From 55ee5ee76e4c6b2f524e7ba8d89b083e24f80513 Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Wed, 22 Apr 2026 17:46:36 +0400 Subject: [PATCH 1/4] Fix: SubqueryFilterChecker stack-empty crash on bare null-check predicates SubqueryFilterChecker.VisitBinary unconditionally called Pop() on its meaningfulLefts/meaningfulRights correlation stacks whenever it saw a "tuple[i] == null" comparison. If the checker visited a FilterProvider whose predicate started with a bare null-check before any correlation equality had pushed anything onto the stacks, Pop() threw InvalidOperationException("Stack empty.") during LINQ translation. This shape surfaces naturally from: Query.All() .GroupBy(p => p.Id) .Select(g => g.Count(x => x.Field == null)) .ToArray(); where VisitAggregate takes the "use grouping AggregateProvider" optimization path and runs SubqueryFilterRemover over an origin data source whose top FilterProvider carries the bare "x.Field == null" predicate. Guard Pop() with a Count==0 check and treat an empty-stack null-check as "not a subquery-correlation filter" (fall through to base.VisitFilter), which is the conservative, behavior-preserving choice. Covered by a new e2e regression test in Orm/Xtensive.Orm.Tests/Linq/SubqueryFilterCheckerTest.cs using an inline model; the test fails with "Stack empty." on the unfixed code and passes with the guard. Made-with: Cursor --- .../Linq/SubqueryFilterCheckerTest.cs | 72 +++++++++++++++++++ .../Linq/Rewriters/SubqueryFilterRemover.cs | 19 ++++- 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 Orm/Xtensive.Orm.Tests/Linq/SubqueryFilterCheckerTest.cs diff --git a/Orm/Xtensive.Orm.Tests/Linq/SubqueryFilterCheckerTest.cs b/Orm/Xtensive.Orm.Tests/Linq/SubqueryFilterCheckerTest.cs new file mode 100644 index 0000000000..f63eba85d0 --- /dev/null +++ b/Orm/Xtensive.Orm.Tests/Linq/SubqueryFilterCheckerTest.cs @@ -0,0 +1,72 @@ +// 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.Linq; +using NUnit.Framework; +using Xtensive.Orm.Configuration; +using Xtensive.Orm.Tests.Linq.SubqueryFilterCheckerTestModel; + +namespace Xtensive.Orm.Tests.Linq.SubqueryFilterCheckerTestModel +{ + [HierarchyRoot] + public class Promotion : Entity + { + [Field, Key] + public long Id { get; private set; } + + [Field(Length = 64)] + public string CampainName { get; set; } + } +} + +namespace Xtensive.Orm.Tests.Linq +{ + /// + /// Regression test for the always-on correctness fix in + /// SubqueryFilterRemover.SubqueryFilterChecker. + /// + /// The legacy checker unconditionally called Pop() on its internal + /// meaningfulLefts/meaningfulRights stacks on every null-constant + /// comparison it saw. When the checker visited a FilterProvider whose + /// predicate started with a bare null-check (e.g. x.Field == null + /// inside g.Count(...) after a GroupBy on a scalar key) nothing + /// had been pushed onto the stacks first, so Pop() threw + /// InvalidOperationException("Stack empty.") during LINQ translation. + /// + /// + [TestFixture] + [Category("Linq")] + public sealed class SubqueryFilterCheckerTest : AutoBuildTest + { + protected override DomainConfiguration BuildConfiguration() + { + var config = base.BuildConfiguration(); + config.Types.Register(typeof(Promotion)); + return config; + } + + /// + /// Minimal reproducer for the "Stack empty." crash: + /// GroupBy(pk).Select(g => g.Count(x => x.NullableField == null)). + /// When the grouping key is the entity Id and the Select projects a + /// single scalar aggregate, Translator.Queryable.VisitAggregate + /// takes the "use grouping AggregateProvider" optimization path and + /// runs over + /// an origin data source whose top FilterProvider carries the bare + /// null-check predicate. With nothing pushed onto the correlation stacks + /// yet, the legacy checker did Pop() on empty and crashed. + /// + [Test] + public void GroupByCountWithBareNullPredicate_DoesNotThrow() + { + using var session = Domain.OpenSession(); + using var tx = session.OpenTransaction(); + + Assert.DoesNotThrow(() => session.Query.All() + .GroupBy(p => p.Id) + .Select(g => g.Count(x => x.CampainName == null)) + .ToArray()); + } + } +} diff --git a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs index 6a7243a4b3..34529a18e8 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs @@ -57,12 +57,25 @@ protected override BinaryExpression VisitBinary(BinaryExpression b) } else if (@continue && rightConstant!=null) { var rightIsNullValue = rightConstant.Value==null; - if (!rightIsNullValue) + if (!rightIsNullValue) { @continue = false; + } else { + // A bare null-check such as `x.Field == null` may appear in a + // FilterProvider predicate that the checker reaches before any + // correlation equality has pushed anything onto the stacks + // (e.g. `GroupBy(k).Select(g => g.Count(x => x.Field == null))`). + // The legacy implementation called Pop() on an empty stack here + // and crashed with InvalidOperationException("Stack empty."). + // Treat it as "not a subquery-correlation filter" instead. var leftIsParameter = leftAccess.Object.NodeType==ExpressionType.Parameter; - var onStackValue = (leftIsParameter) ? meaningfulLefts.Pop() : meaningfulRights.Pop(); - @continue = onStackValue == leftAccess.Object; + var stack = leftIsParameter ? meaningfulLefts : meaningfulRights; + if (stack.Count==0) { + @continue = false; + } + else { + @continue = stack.Pop()==leftAccess.Object; + } } } else { From bf82ea6d12507ca23e804e8e5184682fe120bf8b Mon Sep 17 00:00:00 2001 From: Sergey Naumenko <152863015+snaumenko-st@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:04:08 +0400 Subject: [PATCH 2/4] Update Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs Co-authored-by: Sergei Pavlov --- .../Orm/Linq/Rewriters/SubqueryFilterRemover.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs index 34529a18e8..178b7f34dc 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs @@ -70,12 +70,7 @@ protected override BinaryExpression VisitBinary(BinaryExpression b) // Treat it as "not a subquery-correlation filter" instead. var leftIsParameter = leftAccess.Object.NodeType==ExpressionType.Parameter; var stack = leftIsParameter ? meaningfulLefts : meaningfulRights; - if (stack.Count==0) { - @continue = false; - } - else { - @continue = stack.Pop()==leftAccess.Object; - } + @continue = stack.TryPop(out var top) && top == leftAccess.Object; } } else { From 3ad57991f23568f574220f355c758c6c59cd8014 Mon Sep 17 00:00:00 2001 From: Sergey Naumenko <152863015+snaumenko-st@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:04:58 +0400 Subject: [PATCH 3/4] Fix typo in SubqueryFilterRemover.cs --- Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs index 178b7f34dc..dd137ff7fe 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs @@ -70,7 +70,7 @@ protected override BinaryExpression VisitBinary(BinaryExpression b) // Treat it as "not a subquery-correlation filter" instead. var leftIsParameter = leftAccess.Object.NodeType==ExpressionType.Parameter; var stack = leftIsParameter ? meaningfulLefts : meaningfulRights; - @continue = stack.TryPop(out var top) && top == leftAccess.Object; + @continue = stack.TryPop(out var top) && top == leftAccess.Object; } } else { From 4fb677e4957f68f9ae9dd88c31ee98ff7b21c573 Mon Sep 17 00:00:00 2001 From: Sergey Naumenko <152863015+snaumenko-st@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:10:09 +0400 Subject: [PATCH 4/4] Fix formatting issue in SubqueryFilterRemover.cs --- Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs index dd137ff7fe..f82a98b2ea 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs @@ -70,7 +70,7 @@ protected override BinaryExpression VisitBinary(BinaryExpression b) // Treat it as "not a subquery-correlation filter" instead. var leftIsParameter = leftAccess.Object.NodeType==ExpressionType.Parameter; var stack = leftIsParameter ? meaningfulLefts : meaningfulRights; - @continue = stack.TryPop(out var top) && top == leftAccess.Object; + @continue = stack.TryPop(out var top) && top == leftAccess.Object; } } else {