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 000000000..213a28b52 --- /dev/null +++ b/Orm/Xtensive.Orm.Tests/Linq/Optimization/AggregateFusionTest.cs @@ -0,0 +1,632 @@ +// 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.Providers; +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)); + } + + /// + /// 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. + /// + [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)); + } + + /// + /// 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.Tests/Linq/Optimization/Model.cs b/Orm/Xtensive.Orm.Tests/Linq/Optimization/Model.cs new file mode 100644 index 000000000..83d821439 --- /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 000000000..be96efd80 --- /dev/null +++ b/Orm/Xtensive.Orm.Tests/Linq/Optimization/OptimizationTestBase.cs @@ -0,0 +1,112 @@ +// 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. + /// + /// + public abstract class OptimizationTestBase : AutoBuildTest + { + 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)); + 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 3dad38e87..a9f9ec98f 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(ref source, argument, ref aggregateType, expressionPart); var originProjection = origin.Item1; var originColumnIndex = origin.Item2; @@ -908,24 +908,80 @@ private CompilableProvider ChooseSourceForAggregate(CompilableProvider left, Com return null; } - private (ProjectionExpression, ColNum) VisitAggregateSource(Expression source, LambdaExpression aggregateParameter, - AggregateType aggregateType, Expression visitedExpression) + 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. - // This effectively substitutes source.Count(filter) -> source.Where(filter).Count() - // and source.Sum(selector) -> source.Select(selector).Sum() + // 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.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. - // 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; + // 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; + // 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) { + // 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 = aggregateParameter != null ? VisitWhere(source, aggregateParameter) : VisitSequence(source); + 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; sourceProjection = VisitSequence(source); @@ -964,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) {