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) {