diff --git a/Orm/Xtensive.Orm.Tests/Linq/SubqueryFilterCheckerTest.cs b/Orm/Xtensive.Orm.Tests/Linq/SubqueryFilterCheckerTest.cs new file mode 100644 index 000000000..f63eba85d --- /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 6a7243a4b..f82a98b2e 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/SubqueryFilterRemover.cs @@ -57,12 +57,20 @@ 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; + @continue = stack.TryPop(out var top) && top == leftAccess.Object; } } else {