From 41ad72ea25109c5b4b545fb2adc480dff3dc8e4a Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Thu, 23 Apr 2026 16:30:12 +0400 Subject: [PATCH 1/3] Fuse Count(predicate) over GroupBy into a single aggregate Count(predicate) evaluated on a grouping parameter produced a per-group correlated subquery because the Where introduced by the predicate wrapped the grouping's AggregateProvider with a FilterProvider that ChooseSourceForAggregate cannot fuse. For queries like GroupBy(k).Select(g => new { g.Key, A = g.Count(p1), B = g.Count(p2) }) this yielded one extra SELECT per aggregate per group (GroupBy N+1). In VisitAggregateSource, when the source is a grouping-parameter bound to an AggregateProvider, rewrite Count(predicate) into Sum(predicate ? 1 : 0). The conditional becomes a CalculateProvider, which ChooseSourceForAggregate does fuse, collapsing all aggregates into a single SELECT ... GROUP BY. aggregateType is passed by ref so the caller sees the Count -> Sum switch. Parameterless Count(), Count(predicate) outside a fusable grouping, and explicit Sum(predicate ? 1 : 0) keep their existing codepaths. Add Orm.Tests.Linq.Optimization fixture with shared test base, small model, and end-to-end tests covering fusion shape, multi-aggregate fusion, predicate composition, the already-working Sum(CASE) form, root-level Count preservation, and regression guards that a group with zero matching rows still materializes 0 (not NULL) for both Count and LongCount. Made-with: Cursor --- .../Linq/Optimization/AggregateFusionTest.cs | 286 ++++++++++++++++++ .../Linq/Optimization/Model.cs | 84 +++++ .../Linq/Optimization/OptimizationTestBase.cs | 122 ++++++++ .../Orm/Linq/Translator.Queryable.cs | 39 ++- 4 files changed, 521 insertions(+), 10 deletions(-) create mode 100644 Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs create mode 100644 Orm/Xtensive.Orm.Tests/Linq/Optimization/Model.cs create mode 100644 Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs diff --git a/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs b/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs new file mode 100644 index 0000000000..d70c14513f --- /dev/null +++ b/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs @@ -0,0 +1,286 @@ +// Copyright (C) 2026 Xtensive LLC. +// This code is distributed under MIT license terms. +// See the License.txt file in the project root for more information. + +using System; +using System.Linq; +using NUnit.Framework; +using Xtensive.Orm.Tests.Linq.Optimization.Model; + +namespace Xtensive.Orm.Tests.Linq.Optimization +{ + /// + /// Aggregate fusion for Count(predicate) and + /// Sum(predicate ? 1 : 0) inside a GroupBy: the aggregate must + /// fuse into a single SELECT ... GROUP BY instead of being emitted as + /// a per-group correlated subquery. + /// + [TestFixture] + [Category("Linq")] + public sealed class AggregateFusionTest : OptimizationTestBase + { + protected override void PopulateData() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var active = new Customer { Name = "A", IsActive = true }; + var inactive = new Customer { Name = "B", IsActive = false }; + _ = new Customer { Name = "C", IsActive = true }; + + _ = new Order { Code = "P1", IsActive = true, PublishedOn = new DateTime(2024, 1, 1), Customer = active }; + _ = new Order { Code = "D1", IsActive = true, PublishedOn = null, Customer = active }; + _ = new Order { Code = "D2", IsActive = false, PublishedOn = null, Customer = inactive }; + _ = new Order { Code = "P2", IsActive = true, PublishedOn = new DateTime(2024, 3, 1), Customer = inactive }; + _ = new Order { Code = "D3", IsActive = false, PublishedOn = null, Customer = active }; + + tx.Complete(); + } + + /// + /// GroupBy(k).Select(g => g.Count(x => predicate)) on a scalar + /// key must emit a single SELECT ... GROUP BY with no per-group + /// correlated subquery. + /// + [Test] + public void GroupByCountPredicate_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Count(x => x.PublishedOn == null), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Count(x => x.PublishedOn == null), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.Drafts)) + .ToArray(); + + var actual = query.ToArray().Select(r => (r.Active, r.Drafts)).ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Multiple predicated counts in the same projection all fuse into a single + /// SELECT ... GROUP BY. + /// + [Test] + public void GroupByMultipleCountPredicates_FuseIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Count(x => x.PublishedOn == null), + Published = g.Count(x => x.PublishedOn != null), + Total = g.Count(), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Count(x => x.PublishedOn == null), + Published = g.Count(x => x.PublishedOn != null), + Total = g.Count(), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.Drafts, r.Published, r.Total)) + .ToArray(); + + var actual = query.ToArray() + .Select(r => (r.Active, r.Drafts, r.Published, r.Total)) + .ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Compound predicate (AndAlso) inside Count fuses too. + /// + [Test] + public void GroupByCountAndAlsoPredicate_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Count(x => x.Code.StartsWith("D") && x.PublishedOn == null), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Count(x => x.Code.StartsWith("D") && x.PublishedOn == null), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.Drafts)) + .ToArray(); + + var actual = query.ToArray() + .Select(r => (r.Active, r.Drafts)) + .ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Sum(x => condition ? 1 : 0) must also fuse without any + /// correlated subquery. + /// + [Test] + public void GroupBySumOfCondition_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Sum(x => x.PublishedOn == null ? 1 : 0), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + AssertCount(sql, "(SELECT SUM", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Sum(x => x.PublishedOn == null ? 1 : 0), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.Drafts)) + .ToArray(); + + var actual = query.ToArray() + .Select(r => (r.Active, r.Drafts)) + .ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Regression: when the Count -> Sum(CASE) rewrite fires and a group + /// contains zero rows matching the predicate, the materialized value must + /// be 0 (Count's contract) — not NULL, and the pipeline + /// must not throw on the int coercion. The inactive group in the seed + /// data contains only unpublished orders, so the predicate + /// PublishedOn != null matches zero rows there. + /// + [Test] + public void GroupByCountPredicate_ZeroMatchingRows_ReturnsZero() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Published = g.Count(x => x.PublishedOn != null), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + + // Materialization itself is part of the regression guard: if SUM returns + // NULL for a group with no matching rows, the int coercion would throw. + var rows = query.ToArray(); + + var inactive = rows.Single(r => !r.Active); + Assert.That(inactive.Published, Is.EqualTo(0), + "Count(predicate) in a grouping where no rows match must materialize as 0, not NULL."); + + var active = rows.Single(r => r.Active); + Assert.That(active.Published, Is.EqualTo(2), + "Sanity: the non-empty group must still count correctly after the rewrite."); + } + + /// + /// Same regression guard as + /// but exercises the LongCount path (rewrite emits long + /// literals and the result column must coerce SUM-of-zero-rows to + /// 0L). + /// + [Test] + public void GroupByLongCountPredicate_ZeroMatchingRows_ReturnsZero() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Published = g.LongCount(x => x.PublishedOn != null), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + + // Materialization itself is part of the regression guard: if SUM returns + // NULL for a group with no matching rows, the long coercion would throw. + var rows = query.ToArray(); + + var inactive = rows.Single(r => !r.Active); + Assert.That(inactive.Published, Is.EqualTo(0L), + "LongCount(predicate) in a grouping where no rows match must materialize as 0L, not NULL."); + + var active = rows.Single(r => r.Active); + Assert.That(active.Published, Is.EqualTo(2L), + "Sanity: the non-empty group must still count correctly after the rewrite."); + } + + /// + /// Root-level Count(predicate) must still return 0 on an + /// empty match set (not NULL); the rewrite must not fire here. + /// + [Test] + public void RootLevelCountPredicate_StillReturnsZeroOnEmpty() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var count = session.Query.All().Count(o => o.Code == "no-such-code"); + + Assert.That(count, Is.EqualTo(0)); + } + } +} diff --git a/Orm/Xtensive.Orm.Tests/Linq/Optimization/Model.cs b/Orm/Xtensive.Orm.Tests/Linq/Optimization/Model.cs new file mode 100644 index 0000000000..83d8214394 --- /dev/null +++ b/Orm/Xtensive.Orm.Tests/Linq/Optimization/Model.cs @@ -0,0 +1,84 @@ +// Copyright (C) 2026 Xtensive LLC. +// This code is distributed under MIT license terms. +// See the License.txt file in the project root for more information. + +using System; +using Xtensive.Orm; + +namespace Xtensive.Orm.Tests.Linq.Optimization.Model +{ + /// + /// A small self-contained fixture re-used by every translator-optimization test. + /// The model intentionally exposes both required and nullable navigation + /// properties so tests can exercise INNER/LEFT JOIN paths. + /// + [HierarchyRoot] + public class Customer : Entity + { + [Field, Key] + public long Id { get; private set; } + + [Field(Length = 128)] + public string Name { get; set; } + + [Field] + public bool IsActive { get; set; } + } + + [HierarchyRoot] + public class Workflow : Entity + { + [Field, Key] + public long Id { get; private set; } + + [Field(Length = 64)] + public string Name { get; set; } + } + + [HierarchyRoot] + public class Order : Entity + { + [Field, Key] + public long Id { get; private set; } + + [Field(Length = 64)] + public string Code { get; set; } + + [Field] + public bool IsActive { get; set; } + + [Field] + public DateTime? PublishedOn { get; set; } + + /// Nullable navigation — exercises LEFT JOIN. + [Field] + public Customer Customer { get; set; } + + /// Nullable navigation — exercises LEFT JOIN. + [Field] + public Workflow Workflow { get; set; } + + [Field] + [Association(PairTo = nameof(OrderItem.Order))] + public EntitySet Items { get; private set; } + } + + [HierarchyRoot] + public class OrderItem : Entity + { + [Field, Key] + public long Id { get; private set; } + + [Field] + public Order Order { get; set; } + + [Field(Length = 128)] + public string Name { get; set; } + + [Field] + public int Quantity { get; set; } + + [Field] + public decimal Price { get; set; } + } +} diff --git a/Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs b/Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs new file mode 100644 index 0000000000..4e12ff8963 --- /dev/null +++ b/Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs @@ -0,0 +1,122 @@ +// Copyright (C) 2026 Xtensive LLC. +// This code is distributed under MIT license terms. +// See the License.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Xtensive.Orm.Configuration; +using Xtensive.Orm.Services; +using Xtensive.Orm.Tests.Linq.Optimization.Model; + +namespace Xtensive.Orm.Tests.Linq.Optimization +{ + /// + /// Shared harness for every translator-optimization test. Subclasses get: + /// + /// A registered /// model. + /// that returns the SQL a query will actually produce. + /// Shape assertions (, ) for the generated SQL string. + /// to pair every shape assertion with a correctness check. + /// + /// Subclasses that need to opt into a specific + /// flag override . + /// + public abstract class OptimizationTestBase : AutoBuildTest + { + /// + /// Flags to apply to . + /// Default keeps the legacy translator behavior so this base class is a no-op + /// for tests that only care about correctness. + /// + protected virtual TranslatorOptimizations Optimizations => TranslatorOptimizations.Default; + + protected override DomainConfiguration BuildConfiguration() + { + var configuration = base.BuildConfiguration(); + configuration.Types.Register(typeof(Customer)); + configuration.Types.Register(typeof(Workflow)); + configuration.Types.Register(typeof(Order)); + configuration.Types.Register(typeof(OrderItem)); + configuration.TranslatorOptimizations = Optimizations; + return configuration; + } + + /// + /// Returns the SQL string that would execute against + /// the configured storage provider. Mirrors the pattern used by + /// : session.Services.Demand<QueryFormatter>().ToSqlString(q). + /// + protected static string Sql(Session session, IQueryable query) + { + var formatter = session.Services.Demand(); + return formatter.ToSqlString(query); + } + + /// + /// Asserts that none of appears in + /// (case-insensitive). Useful for "must not emit OUTER APPLY / LEFT JOIN / ..." shape tests. + /// + protected static void AssertNotContains(string sql, params string[] fragments) + { + ArgumentNullException.ThrowIfNull(sql); + ArgumentNullException.ThrowIfNull(fragments); + foreach (var fragment in fragments) { + Assert.That( + sql.IndexOf(fragment, StringComparison.OrdinalIgnoreCase), + Is.LessThan(0), + () => $"SQL unexpectedly contains '{fragment}':\n{sql}"); + } + } + + /// + /// Asserts that appears exactly + /// times in (case-insensitive). Use for counting subqueries, + /// joins, OR-branches, etc. + /// + protected static void AssertCount(string sql, string fragment, int expected) + { + ArgumentNullException.ThrowIfNull(sql); + ArgumentException.ThrowIfNullOrEmpty(fragment); + var actual = CountOccurrences(sql, fragment); + Assert.That( + actual, + Is.EqualTo(expected), + () => $"Expected '{fragment}' to occur {expected} times, but it occurred {actual} times:\n{sql}"); + } + + /// + /// Runs both queries to materialized lists and compares them element-wise. Every + /// shape assertion in a test should be paired with a call to this method so + /// the optimization never silently changes semantics. + /// + protected static void AssertResultsEqual(IQueryable actual, IQueryable expected) + { + var actualList = actual.ToList(); + var expectedList = expected.ToList(); + Assert.That(actualList, Is.EquivalentTo(expectedList)); + } + + /// + /// Runs both sequences and compares them element-wise. Useful when the + /// "baseline" side is an in-memory computed by + /// the test (e.g. LINQ-to-objects fallback). + /// + protected static void AssertResultsEqual(IQueryable actual, IEnumerable expected) + { + Assert.That(actual.ToList(), Is.EquivalentTo(expected.ToList())); + } + + private static int CountOccurrences(string source, string fragment) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(fragment, index, StringComparison.OrdinalIgnoreCase)) >= 0) { + count++; + index += fragment.Length; + } + return count; + } + } +} diff --git a/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs b/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs index 3dad38e871..d28537a173 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs @@ -775,8 +775,8 @@ private Expression VisitAggregate(Expression source, MethodInfo method, LambdaEx MethodCallExpression expressionPart) { var aggregateType = ExtractAggregateType(expressionPart); - - var origin = VisitAggregateSource(source, argument, aggregateType, expressionPart); + + var origin = VisitAggregateSource(source, argument, ref aggregateType, expressionPart); var originProjection = origin.Item1; var originColumnIndex = origin.Item2; @@ -909,22 +909,41 @@ private CompilableProvider ChooseSourceForAggregate(CompilableProvider left, Com } private (ProjectionExpression, ColNum) VisitAggregateSource(Expression source, LambdaExpression aggregateParameter, - AggregateType aggregateType, Expression visitedExpression) + ref AggregateType aggregateType, Expression visitedExpression) { // Process any selectors or filters specified via parameter to aggregating method. - // This effectively substitutes source.Count(filter) -> source.Where(filter).Count() - // and source.Sum(selector) -> source.Select(selector).Sum() + // Substitutions applied: + // source.Count(filter) -> source.Select(filter ? 1 : 0).Sum() + // (only for a grouping-parameter source; enables aggregate fusion) + // source.Count(filter) -> source.Where(filter).Count() + // source.Sum(selector) -> source.Select(selector).Sum() // If parameterless method is called this method simply processes source. - // This method returns project for source expression and index of a column in RSE provider - // to which aggregate function should be applied. + // Returns source projection and the column index to aggregate over; + // aggregateType is updated when the Count->Sum rewrite is applied. ProjectionExpression sourceProjection; ColNum aggregatedColumnIndex; if (aggregateType == AggregateType.Count) { - aggregatedColumnIndex = 0; - sourceProjection = aggregateParameter != null ? VisitWhere(source, aggregateParameter) : VisitSequence(source); - return (sourceProjection, aggregatedColumnIndex); + // Rewrite Count(predicate) over a grouping into Sum(predicate ? 1 : 0) + // so the aggregate can fuse with the parent grouping AggregateProvider + // instead of being emitted as a per-group correlated subquery. + if (aggregateParameter != null + && source is ParameterExpression fusableGroupingParameter + && context.Bindings.TryGetValue(fusableGroupingParameter, out var fusableGroupingProjection) + && fusableGroupingProjection.ItemProjector.DataSource is AggregateProvider + && fusableGroupingProjection.ItemProjector.Item.StripMarkers().IsGroupingExpression()) { + aggregateParameter = FastExpression.Lambda( + Expression.Condition(aggregateParameter.Body, Expression.Constant(1), Expression.Constant(0)), + aggregateParameter.Parameters[0]); + aggregateType = AggregateType.Sum; + } + else { + aggregatedColumnIndex = 0; + sourceProjection = aggregateParameter != null + ? VisitWhere(source, aggregateParameter) : VisitSequence(source); + return (sourceProjection, aggregatedColumnIndex); + } } IReadOnlyList columnList = null; From 910fa69dcb28b95227589a0b438713a2ee217ff0 Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Thu, 23 Apr 2026 16:56:33 +0400 Subject: [PATCH 2/3] Fuse g.Where(p).Count() over GroupBy the same as g.Count(p) g.Where(p).Count() on a grouping parameter is the LINQ identity of g.Count(p), but the translator saw the outer Count as parameterless and the inner Where as just another provider, so the fusion rewrite introduced for g.Count(p) never fired and the query still produced a per-group correlated subquery. In VisitAggregateSource, before the Count -> Sum(CASE) rewrite, peel Queryable.Where / Enumerable.Where calls off the source and combine their predicates with AndAlso (rebasing each peeled lambda onto the outer predicate's parameter via ExpressionReplacer). Chained Wheres collapse into a single AndAlso-combined predicate, so g.Where(p1).Where(p2).Count() fuses the same way. Add e2e tests covering the single Where, chained Wheres, and LongCount variants. Each new test asserts the fused shape by requiring zero occurrences of both "(SELECT COUNT" and "(SELECT SUM" in the emitted SQL, catching the subtler case where the rewrite applies but the aggregate still lands in a correlated subselect. Made-with: Cursor --- .../Linq/Optimization/AggregateFusionTest.cs | 115 ++++++++++++++++++ .../Linq/Optimization/OptimizationTestBase.cs | 10 -- .../Orm/Linq/Translator.Queryable.cs | 30 ++++- 3 files changed, 142 insertions(+), 13 deletions(-) diff --git a/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs b/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs index d70c14513f..ad138beea7 100644 --- a/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs +++ b/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs @@ -74,6 +74,121 @@ public void GroupByCountPredicate_FusesIntoSingleAggregate() Assert.That(actual, Is.EqualTo(expected)); } + /// + /// g.Where(p).Count() on a grouping parameter is semantically identical + /// to g.Count(p); the translator must recognize the shape and fuse it + /// into the parent GROUP BY the same way as the direct form. + /// + [Test] + public void GroupByWhereCount_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Where(x => x.PublishedOn == null).Count(), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + AssertCount(sql, "(SELECT SUM", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Where(x => x.PublishedOn == null).Count(), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.Drafts)) + .ToArray(); + + var actual = query.ToArray().Select(r => (r.Active, r.Drafts)).ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Chained Wheres followed by Count() on a grouping parameter + /// must fuse as a single aggregate; the translator combines the predicates + /// with AndAlso and applies the same Count -> Sum(CASE) + /// rewrite as for the direct form. + /// + [Test] + public void GroupByChainedWhereCount_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + DraftsD = g.Where(x => x.PublishedOn == null).Where(x => x.Code.StartsWith("D")).Count(), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + AssertCount(sql, "(SELECT SUM", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + DraftsD = g.Where(x => x.PublishedOn == null).Where(x => x.Code.StartsWith("D")).Count(), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.DraftsD)) + .ToArray(); + + var actual = query.ToArray().Select(r => (r.Active, r.DraftsD)).ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Variant of for + /// the LongCount path; the collapsed form must still produce the + /// correct long result and fuse. + /// + [Test] + public void GroupByWhereLongCount_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Where(x => x.PublishedOn == null).LongCount(), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + AssertCount(sql, "(SELECT SUM", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + Drafts = g.Where(x => x.PublishedOn == null).LongCount(), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.Drafts)) + .ToArray(); + + var actual = query.ToArray().Select(r => (r.Active, r.Drafts)).ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + /// /// Multiple predicated counts in the same projection all fuse into a single /// SELECT ... GROUP BY. diff --git a/Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs b/Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs index 4e12ff8963..be96efd807 100644 --- a/Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs +++ b/Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs @@ -20,18 +20,9 @@ namespace Xtensive.Orm.Tests.Linq.Optimization /// Shape assertions (, ) for the generated SQL string. /// to pair every shape assertion with a correctness check. /// - /// Subclasses that need to opt into a specific - /// flag override . /// public abstract class OptimizationTestBase : AutoBuildTest { - /// - /// Flags to apply to . - /// Default keeps the legacy translator behavior so this base class is a no-op - /// for tests that only care about correctness. - /// - protected virtual TranslatorOptimizations Optimizations => TranslatorOptimizations.Default; - protected override DomainConfiguration BuildConfiguration() { var configuration = base.BuildConfiguration(); @@ -39,7 +30,6 @@ protected override DomainConfiguration BuildConfiguration() configuration.Types.Register(typeof(Workflow)); configuration.Types.Register(typeof(Order)); configuration.Types.Register(typeof(OrderItem)); - configuration.TranslatorOptimizations = Optimizations; return configuration; } diff --git a/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs b/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs index d28537a173..a10873ecfc 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs @@ -776,7 +776,7 @@ private Expression VisitAggregate(Expression source, MethodInfo method, LambdaEx { var aggregateType = ExtractAggregateType(expressionPart); - var origin = VisitAggregateSource(source, argument, ref aggregateType, expressionPart); + var origin = VisitAggregateSource(ref source, argument, ref aggregateType, expressionPart); var originProjection = origin.Item1; var originColumnIndex = origin.Item2; @@ -908,23 +908,47 @@ private CompilableProvider ChooseSourceForAggregate(CompilableProvider left, Com return null; } - private (ProjectionExpression, ColNum) VisitAggregateSource(Expression source, LambdaExpression aggregateParameter, + private (ProjectionExpression, ColNum) VisitAggregateSource(ref Expression source, LambdaExpression aggregateParameter, ref AggregateType aggregateType, Expression visitedExpression) { // Process any selectors or filters specified via parameter to aggregating method. // Substitutions applied: + // source.Where(f1)...Where(fn)[.Count(f)] -> source.Count(f1 AND ... AND fn [AND f]) + // (peels Where chains so the rewrite below can see the grouping parameter) // source.Count(filter) -> source.Select(filter ? 1 : 0).Sum() // (only for a grouping-parameter source; enables aggregate fusion) // source.Count(filter) -> source.Where(filter).Count() // source.Sum(selector) -> source.Select(selector).Sum() // If parameterless method is called this method simply processes source. // Returns source projection and the column index to aggregate over; - // aggregateType is updated when the Count->Sum rewrite is applied. + // source and aggregateType are updated when the Where-peel / Count->Sum + // rewrites are applied so the caller sees the post-rewrite shape. ProjectionExpression sourceProjection; ColNum aggregatedColumnIndex; if (aggregateType == AggregateType.Count) { + // Collapse Where chains into a single Count(predicate) so the fusion + // rewrite below can match a grouping-parameter source uniformly for + // both g.Count(p) and g.Where(p).Count() shapes. + while (source is MethodCallExpression whereCall + && QueryableVisitor.GetQueryableMethod(whereCall) == QueryableMethodKind.Where + && whereCall.Arguments.Count == 2) { + var wherePredicate = (LambdaExpression) whereCall.Arguments[1].StripQuotes(); + if (aggregateParameter == null) { + aggregateParameter = wherePredicate; + } + else { + var outerParam = aggregateParameter.Parameters[0]; + var rebasedBody = ExpressionReplacer.Replace( + wherePredicate.Body, wherePredicate.Parameters[0], outerParam); + aggregateParameter = FastExpression.Lambda( + Expression.AndAlso(rebasedBody, aggregateParameter.Body), + outerParam); + } + source = whereCall.Arguments[0]; + } + // Rewrite Count(predicate) over a grouping into Sum(predicate ? 1 : 0) // so the aggregate can fuse with the parent grouping AggregateProvider // instead of being emitted as a per-group correlated subquery. From 5f5531c71bb38db478548fa31132271235f515ee Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Thu, 23 Apr 2026 22:18:38 +0400 Subject: [PATCH 3/3] Generalize aggregate fusion to Where(p).Sum/Min/Max/Avg(selector) Extends the existing Count(predicate) GroupBy-fusion rewrite to Sum, Min, Max and Average by pulling a peeled Where chain (plus the call's own predicate where applicable) into the aggregate selector as a CASE: g.Where(p).Sum(s) -> g.Sum(x => p(x) ? s(x) : 0) g.Where(p).Min/Max/Avg(s)-> g.Min/Max/Avg(x => p(x) ? s(x) : null) Non-nullable Sum uses 0 in the ELSE branch to preserve LINQ's empty-set=0 contract; Min/Max/Avg and nullable Sum use NULL so SQL's NULL-skipping semantics match LINQ. Refactors VisitAggregateSource to share the Where-peel, fusability check and fused-selector construction between Count and Sum/Min/Max/Avg via PeelWhereChain, CombinePredicates, IsFusableGroupingSource and BuildFusedAggregateSelector helpers. Guards PeelWhereChain against the indexed Where overload (Func) - its position binding would silently change if AND- combined with another predicate - and extends the non-fusable Where collapse to Sum/Min/Max/Avg so root-level q.Where(f1).Where(f2).Sum(...) materialises one FilterProvider instead of a stack, matching the non-fusable Count path. Adds end-to-end fusion tests for each new operator, a zero-matching-rows regression for Sum, a guard test for indexed Where, and a root-level Where-chain collapse test for Sum. Made-with: Cursor --- .../Linq/Optimization/AggregateFusionTest.cs | 231 ++++++++++++++++++ .../Orm/Linq/Translator.Queryable.cs | 212 +++++++++++++--- 2 files changed, 403 insertions(+), 40 deletions(-) diff --git a/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs b/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs index ad138beea7..213a28b52b 100644 --- a/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs +++ b/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using NUnit.Framework; +using Xtensive.Orm.Providers; using Xtensive.Orm.Tests.Linq.Optimization.Model; namespace Xtensive.Orm.Tests.Linq.Optimization @@ -397,5 +398,235 @@ public void RootLevelCountPredicate_StillReturnsZeroOnEmpty() Assert.That(count, Is.EqualTo(0)); } + + /// + /// Generalized fusion: g.Where(p).Sum(selector) on a grouping + /// parameter must be pulled into the aggregate selector as + /// g.Sum(x => p(x) ? selector(x) : 0) (0 ELSE branch for a + /// non-nullable numeric selector preserves the LINQ "empty set = 0" + /// contract for Sum) so it fuses with the parent GROUP BY + /// instead of emitting a correlated subquery. + /// + [Test] + public void GroupByWhereSum_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + PublishedIdSum = g.Where(x => x.PublishedOn != null).Sum(x => x.Id), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT COUNT", 0); + AssertCount(sql, "(SELECT SUM", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + PublishedIdSum = g.Where(x => x.PublishedOn != null).Sum(x => x.Id), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.PublishedIdSum)) + .ToArray(); + + var actual = query.ToArray().Select(r => (r.Active, r.PublishedIdSum)).ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Generalized fusion: g.Where(p).Min(selector) must fuse via + /// g.Min(x => p(x) ? (T?)selector(x) : null); SQL MIN + /// ignores NULLs so the rewrite is semantically equivalent. + /// + [Test] + public void GroupByWhereMin_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + MinPublishedId = g.Where(x => x.PublishedOn != null).Min(x => (long?) x.Id), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT MIN", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + MinPublishedId = g.Where(x => x.PublishedOn != null).Min(x => (long?) x.Id), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.MinPublishedId)) + .ToArray(); + + var actual = query.ToArray().Select(r => (r.Active, r.MinPublishedId)).ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Generalized fusion: g.Where(p).Max(selector) must fuse via the + /// same NULL-in-ELSE trick as . + /// + [Test] + public void GroupByWhereMax_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + MaxPublishedId = g.Where(x => x.PublishedOn != null).Max(x => (long?) x.Id), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT MAX", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + MaxPublishedId = g.Where(x => x.PublishedOn != null).Max(x => (long?) x.Id), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.MaxPublishedId)) + .ToArray(); + + var actual = query.ToArray().Select(r => (r.Active, r.MaxPublishedId)).ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Generalized fusion: g.Where(p).Average(selector) must fuse via + /// NULL-in-ELSE; SQL AVG ignores NULLs, matching + /// LINQ's "average over passing rows" contract. + /// + [Test] + public void GroupByWhereAverage_FusesIntoSingleAggregate() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + AvgPublishedId = g.Where(x => x.PublishedOn != null).Average(x => (double?) x.Id), + }) + .OrderBy(r => r.Active); + + var sql = Sql(session, query); + TestContext.WriteLine(sql); + AssertCount(sql, "(SELECT AVG", 0); + + var expected = session.Query.All().ToArray() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + AvgPublishedId = g.Where(x => x.PublishedOn != null).Average(x => (double?) x.Id), + }) + .OrderBy(r => r.Active) + .Select(r => (r.Active, r.AvgPublishedId)) + .ToArray(); + + var actual = query.ToArray().Select(r => (r.Active, r.AvgPublishedId)).ToArray(); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// + /// Regression: g.Where(p).Sum(selector) where no rows match the + /// predicate in a group must materialize as 0 (LINQ Sum's + /// empty-sequence contract for non-nullable numeric selectors) and not + /// as NULL. The rewrite must use 0 — not NULL — in + /// the ELSE branch when the selector result type is non-nullable. + /// + [Test] + public void GroupByWhereSum_ZeroMatchingRows_ReturnsZero() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .GroupBy(o => o.IsActive) + .Select(g => new { + Active = g.Key, + PublishedIdSum = g.Where(x => x.PublishedOn != null).Sum(x => x.Id), + }) + .OrderBy(r => r.Active); + + var rows = query.ToArray(); + var inactive = rows.Single(r => !r.Active); + Assert.That(inactive.PublishedIdSum, Is.EqualTo(0L), + "Sum(selector) over an empty filter in a grouping must materialize as 0, not NULL, for a non-nullable selector."); + } + + /// + /// Guard: + /// — the indexed Where overload — must not be folded into a + /// combined predicate by PeelWhereChain. The index + /// parameter refers to the row's position in the current sequence; AND- + /// combining an indexed Where with another predicate would + /// silently change its semantics and produce an expression tree with an + /// unbound parameter. The correct behaviour is to stop peeling at the + /// indexed call and let VisitWhere handle it normally. + /// + [Test] + public void IndexedWhereChainBeforeCount_IsNotCollapsed() + { + Require.AllFeaturesSupported(ProviderFeatures.RowNumber); + + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var query = session.Query.All() + .OrderBy(o => o.Id) + .Where((o, i) => i >= 0) + .Count(o => o.PublishedOn == null); + + Assert.That(query, Is.EqualTo(3)); + } + + /// + /// Root-level (non-fusable) Sum with a Where chain must collapse into a + /// single WHERE in the emitted SQL — one FilterProvider instead of + /// stacked ones — and produce the same numeric result as the in-memory + /// reference. Verifies the PeelWhereChain rebuild path for + /// Sum/Min/Max/Avg when fusion does not apply. + /// + [Test] + public void RootLevelWhereChainSum_CollapsesAndMatchesReference() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + var actual = session.Query.All() + .Where(o => o.IsActive) + .Where(o => o.PublishedOn != null) + .Sum(o => o.Id); + + var expected = session.Query.All().ToArray() + .Where(o => o.IsActive) + .Where(o => o.PublishedOn != null) + .Sum(o => o.Id); + + Assert.That(actual, Is.EqualTo(expected)); + } } } diff --git a/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs b/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs index a10873ecfc..a9f9ec98f1 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Translator.Queryable.cs @@ -917,6 +917,12 @@ private CompilableProvider ChooseSourceForAggregate(CompilableProvider left, Com // (peels Where chains so the rewrite below can see the grouping parameter) // source.Count(filter) -> source.Select(filter ? 1 : 0).Sum() // (only for a grouping-parameter source; enables aggregate fusion) + // source.Where(f1)...Where(fn).Sum/Min/Max/Avg(selector) + // -> source.Sum/Min/Max/Avg(x => (f1(x) AND ... AND fn(x)) ? selector(x) : else) + // where `else` is 0 for a non-nullable Sum selector (preserves the + // LINQ "empty set = 0" contract) and NULL otherwise (SQL + // Sum/Min/Max/Avg ignore NULLs). Fires only for a grouping-parameter + // source; enables aggregate fusion with the parent GROUP BY. // source.Count(filter) -> source.Where(filter).Count() // source.Sum(selector) -> source.Select(selector).Sum() // If parameterless method is called this method simply processes source. @@ -927,47 +933,54 @@ private CompilableProvider ChooseSourceForAggregate(CompilableProvider left, Com ProjectionExpression sourceProjection; ColNum aggregatedColumnIndex; + // Unified Where-peel + grouping-aggregate fusion. Builds a single + // predicate lambda (`fusionPredicate`) from any peeled Where chain plus, + // for Count, the call's own predicate. When the peeled source is a + // fusable grouping parameter the aggregate is rewritten to fuse with + // the parent GROUP BY: + // + // Count(p) -> Sum(x => p(x) ? 1 : 0) + // Where(p).Sum(s) -> Sum(x => p(x) ? s(x) : 0 | NULL) + // Where(p).Min/Max/Avg(s) -> Min/Max/Avg(x => p(x) ? s(x) : NULL) + // + // In the non-fusable case the peeled Where chain is collapsed into a + // single predicate passed to VisitWhere, yielding one FilterProvider + // with an AndAlso-tree body instead of stacked FilterProviders. SQL + // translation produces the same WHERE clause either way; this just + // saves the RSE pipeline a few redundant nodes. + var fusionPredicate = PeelWhereChain(ref source, aggregateParameter?.Parameters[0]); + var fusionSelector = aggregateParameter; if (aggregateType == AggregateType.Count) { - // Collapse Where chains into a single Count(predicate) so the fusion - // rewrite below can match a grouping-parameter source uniformly for - // both g.Count(p) and g.Where(p).Count() shapes. - while (source is MethodCallExpression whereCall - && QueryableVisitor.GetQueryableMethod(whereCall) == QueryableMethodKind.Where - && whereCall.Arguments.Count == 2) { - var wherePredicate = (LambdaExpression) whereCall.Arguments[1].StripQuotes(); - if (aggregateParameter == null) { - aggregateParameter = wherePredicate; - } - else { - var outerParam = aggregateParameter.Parameters[0]; - var rebasedBody = ExpressionReplacer.Replace( - wherePredicate.Body, wherePredicate.Parameters[0], outerParam); - aggregateParameter = FastExpression.Lambda( - Expression.AndAlso(rebasedBody, aggregateParameter.Body), - outerParam); - } - source = whereCall.Arguments[0]; - } - - // Rewrite Count(predicate) over a grouping into Sum(predicate ? 1 : 0) - // so the aggregate can fuse with the parent grouping AggregateProvider - // instead of being emitted as a per-group correlated subquery. - if (aggregateParameter != null - && source is ParameterExpression fusableGroupingParameter - && context.Bindings.TryGetValue(fusableGroupingParameter, out var fusableGroupingProjection) - && fusableGroupingProjection.ItemProjector.DataSource is AggregateProvider - && fusableGroupingProjection.ItemProjector.Item.StripMarkers().IsGroupingExpression()) { - aggregateParameter = FastExpression.Lambda( - Expression.Condition(aggregateParameter.Body, Expression.Constant(1), Expression.Constant(0)), - aggregateParameter.Parameters[0]); - aggregateType = AggregateType.Sum; - } - else { - aggregatedColumnIndex = 0; - sourceProjection = aggregateParameter != null - ? VisitWhere(source, aggregateParameter) : VisitSequence(source); - return (sourceProjection, aggregatedColumnIndex); - } + // Count's own predicate participates as part of the fusion predicate, + // not as a selector — the selector for Count-as-Sum is the constant 1. + fusionPredicate = CombinePredicates(fusionPredicate, aggregateParameter); + fusionSelector = null; + } + + if (fusionPredicate != null && IsFusableGroupingSource(source)) { + var fusedType = aggregateType == AggregateType.Count ? AggregateType.Sum : aggregateType; + aggregateParameter = BuildFusedAggregateSelector(fusedType, fusionPredicate, fusionSelector); + aggregateType = fusedType; + } + else if (aggregateType == AggregateType.Count) { + // Non-fusable Count: source.Where(f1)...Where(fn).Count([p]) becomes + // one VisitWhere(source, f1 AND ... AND fn [AND p]) + Count(*). + aggregatedColumnIndex = 0; + sourceProjection = fusionPredicate != null + ? VisitWhere(source, fusionPredicate) : VisitSequence(source); + return (sourceProjection, aggregatedColumnIndex); + } + else if (fusionPredicate != null) { + // Non-fusable Sum/Min/Max/Avg with peeled Where(s): rebuild source as + // a single Queryable.Where(source, fusionPredicate) so the primary + // aggregate-selector path below sees one FilterProvider instead of + // stacked ones. Equivalent SQL in either form (both emit + // WHERE f1 AND ... AND fn on the same SELECT), but keeps the RSE + // tree smaller and consistent with the non-fusable Count path. + source = Expression.Call(WellKnownMembers.Queryable.Where.CachedMakeGenericMethod( + fusionPredicate.Parameters[0].Type), + source, + Expression.Quote(fusionPredicate)); } IReadOnlyList columnList = null; @@ -1007,6 +1020,125 @@ private CompilableProvider ChooseSourceForAggregate(CompilableProvider left, Com return (sourceProjection, aggregatedColumnIndex); } + /// + /// Walks Queryable.Where calls off and + /// combines their predicates with AndAlso into a single lambda. + /// Advances past each consumed call; leaves it + /// untouched when no Where is found. + /// + /// + /// Optional lambda parameter to rebase every peeled predicate onto. When + /// the first peeled predicate's own parameter is + /// adopted; subsequent predicates are rebased onto it. + /// + /// + /// The combined predicate, or when no Where + /// call was peeled. + /// + private static LambdaExpression PeelWhereChain(ref Expression source, ParameterExpression initialParameter) + { + LambdaExpression combined = null; + var param = initialParameter; + while (source is MethodCallExpression whereCall + && QueryableVisitor.GetQueryableMethod(whereCall) == QueryableMethodKind.Where + && whereCall.Arguments.Count == 2) { + var predicate = (LambdaExpression) whereCall.Arguments[1].StripQuotes(); + // Queryable exposes both Where(source, Func) and the indexed + // Where(source, Func) under the same QueryableMethodKind. + // Only the 1-parameter overload is safe to AND-combine: the indexed + // variant binds the element's position in the *current* sequence, so + // collapsing it into another Where would change the index semantics. + // Bail out and let the caller leave the indexed Where in place for + // the normal VisitWhere path to handle. + if (predicate.Parameters.Count != 1) { + break; + } + param ??= predicate.Parameters[0]; + var rebased = ExpressionReplacer.Replace(predicate.Body, predicate.Parameters[0], param); + combined = combined == null + ? FastExpression.Lambda(rebased, param) + : FastExpression.Lambda(Expression.AndAlso(rebased, combined.Body), param); + source = whereCall.Arguments[0]; + } + return combined; + } + + /// + /// Combines two predicates with AndAlso, rebasing + /// onto 's parameter. + /// Returns whichever input is non-null when the other is, or + /// when both are. + /// + private static LambdaExpression CombinePredicates(LambdaExpression primary, LambdaExpression extra) + { + if (extra == null) { + return primary; + } + if (primary == null) { + return extra; + } + var param = primary.Parameters[0]; + var rebased = ExpressionReplacer.Replace(extra.Body, extra.Parameters[0], param); + return FastExpression.Lambda(Expression.AndAlso(rebased, primary.Body), param); + } + + /// + /// True when is a parameter bound to a grouping + /// projection whose data source is a bare — + /// the shape produced by GroupBy on a scalar/tuple key. A caller + /// recognising this shape may fuse an additional aggregate into that + /// provider's aggregate columns instead of emitting a per-group + /// correlated subquery. + /// + private bool IsFusableGroupingSource(Expression source) + { + return source is ParameterExpression groupingParameter + && context.Bindings.TryGetValue(groupingParameter, out var groupingProjection) + && groupingProjection.ItemProjector.DataSource is AggregateProvider + && groupingProjection.ItemProjector.Item.StripMarkers().IsGroupingExpression(); + } + + /// + /// Builds a x => predicate(x) ? selector(x) : else lambda used to + /// fuse a filtered aggregate into the parent GROUP BY. When + /// is the selector is + /// the constant 1 (Count-as-Sum rewrite). The ELSE value is + /// 0 for a non-nullable selector — + /// preserves the LINQ "empty set = 0" contract; substituting NULL would + /// leak through as a nullable aggregate result — and NULL + /// otherwise (SQL Sum/Min/Max/Avg ignore NULLs, and LINQ's empty-set + /// semantics for Min/Max/Avg/nullable-Sum also yield NULL). + /// + private static LambdaExpression BuildFusedAggregateSelector( + AggregateType targetType, LambdaExpression predicate, LambdaExpression selector) + { + var param = predicate.Parameters[0]; + var selectorBody = selector == null + ? (Expression) Expression.Constant(1) + : ExpressionReplacer.Replace(selector.Body, selector.Parameters[0], param); + + var selectorType = selectorBody.Type; + Type caseType; + Expression elseValue; + if (targetType == AggregateType.Sum && selectorType.IsValueType && !selectorType.IsNullable()) { + caseType = selectorType; + elseValue = Expression.Constant(System.Activator.CreateInstance(selectorType), selectorType); + } + else { + caseType = selectorType.IsValueType && !selectorType.IsNullable() + ? typeof(Nullable<>).MakeGenericType(selectorType) + : selectorType; + elseValue = Expression.Constant(null, caseType); + } + + var thenValue = selectorBody.Type != caseType + ? (Expression) Expression.Convert(selectorBody, caseType) + : selectorBody; + + return FastExpression.Lambda( + Expression.Condition(predicate.Body, thenValue, elseValue), param); + } + private static void EnsureAggregateIsPossible(Type type, AggregateType aggregateType, Expression visitedExpression) { switch (aggregateType) {