From 4bd2b7b9d278df0ba932f9949c806253d21fb1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 26 Oct 2026 17:50:25 +0100 Subject: [PATCH 1/2] fix: follow a text set operation through a scalar subquery in the guard's rewrite (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard's walker treats a scalar subquery that projects a text Union, Add, Intersect or Except as derived, and so lets ==, != and Count over it through, trusting the expander to write them as mutual containment and count(DISTINCT). The expander's IsDerived had no arm for a scalar subquery, so the raw = and cardinality reached the server and compared the unsorted array_cat: {a,c} ∪ {a,b} == {a,b,c} dropped the row, and (… ?? subquery).Count counted {a,c,a,b} as 4. 10.3.0 refused the query. IsDerived now judges a scalar subquery by its one projection, as the walker does. UnionCanonicalityTests runs ==, != and Count over COALESCE through a correlated scalar subquery, and Add, Intersect and Except the same way, against memory over the loaded rows; with the arm removed both new tests fail on the answer. Found-By: RV Defect-Class: SQL Co-Authored-By: Claude Opus 5.5 --- .../Query/ValueSetSqlGuard.cs | 4 ++ .../UnionCanonicalityTests.cs | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs index c6ea1ae..5a2e977 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs @@ -645,6 +645,10 @@ private SqlExpression Subquery( CaseExpression conditional => conditional.WhenClauses.Any(clause => IsDerived(clause.Result)) || IsDerived(conditional.ElseResult), SqlUnaryExpression { OperatorType: ExpressionType.Convert } convert when IsSet(convert) => IsDerived(convert.Operand), ColumnExpression column => derivedColumns.Contains((column.TableAlias, column.Name)), + + // The walker lets ==, != and Count through over a scalar subquery that projects one, so this + // must find it too, or the raw = and cardinality reach the server (#505). + ScalarSubqueryExpression { Subquery.Projection: [var only] } => IsDerived(only.Expression), _ => false }; diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/UnionCanonicalityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/UnionCanonicalityTests.cs index bbd79bf..31171d8 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/UnionCanonicalityTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/UnionCanonicalityTests.cs @@ -152,6 +152,44 @@ public async Task EqualityOnAConditionalOrCoalescedUnion_AnswersAsMemoryDoes() await FirstRow(context).CountAsync(r => (r.OptionalTags ?? r.Tags.Union(Operand)) == Target)); } + [TestMethod] + public async Task EqualityOnAUnionFromAScalarSubquery_AnswersAsMemoryDoes() + { + await using var context = await Context(); + var rows = await Loaded(context); + + // The row's own union, read back through a correlated scalar subquery, which EF Core keeps a + // subquery in the comparison. Was (10.4.0 before #505): (SELECT array_cat(…) … LIMIT 1) = @target, 0 rows; 10.3.0 refused it. + Assert.AreEqual(rows.Count(r => r.Tags.Union(Operand) == Target), + await Rows(context).CountAsync(r => context.Reservations.Where(o => o.Id == r.Id).Select(o => o.Tags.Union(Operand)).FirstOrDefault() == Target)); + Assert.AreEqual(rows.Count(r => r.Tags.Union(Operand) != Target), + await Rows(context).CountAsync(r => context.Reservations.Where(o => o.Id == r.Id).Select(o => o.Tags.Union(Operand)).FirstOrDefault() != Target)); + Assert.AreEqual(rows.Count(r => (r.OptionalTags ?? r.Tags.Union(Operand)) == Target), + await Rows(context).CountAsync(r => (r.OptionalTags ?? context.Reservations.Where(o => o.Id == r.Id).Select(o => o.Tags.Union(Operand)).FirstOrDefault()) == Target)); + + // The other text operations take the same path: {a,c} + a, {a,c} ∩ {a,b,c}, {a,c} ∖ {z}. + var repeated = StringSet.From("a", "b", "c"); + var absent = StringSet.From("z"); + Assert.AreEqual(rows.Count(r => r.Tags.Add("a") == r.Tags), + await Rows(context).CountAsync(r => context.Reservations.Where(o => o.Id == r.Id).Select(o => o.Tags.Add("a")).FirstOrDefault() == r.Tags)); + Assert.AreEqual(rows.Count(r => r.Tags.Intersect(repeated) == r.Tags), + await Rows(context).CountAsync(r => context.Reservations.Where(o => o.Id == r.Id).Select(o => o.Tags.Intersect(repeated)).FirstOrDefault() == r.Tags)); + Assert.AreEqual(rows.Count(r => r.Tags.Except(absent) == r.Tags), + await Rows(context).CountAsync(r => context.Reservations.Where(o => o.Id == r.Id).Select(o => o.Tags.Except(absent)).FirstOrDefault() == r.Tags)); + } + + [TestMethod] + public async Task CountOverAUnionFromAScalarSubquery_CountsTheDistinctElements_AsMemoryDoes() + { + await using var context = await Context(); + var rows = await Loaded(context); + + // EF Core moves a bare .Count into the subquery; behind a COALESCE it stays outside. + // Was (10.4.0 before #505): cardinality(COALESCE((SELECT array_cat(…) …), …)) = 3 → 4 for {a,c,a,b}. + Assert.AreEqual(rows.Count(r => (r.OptionalTags ?? r.Tags.Union(Operand)).Count == 3), + await Rows(context).CountAsync(r => (r.OptionalTags ?? context.Reservations.Where(o => o.Id == r.Id).Select(o => o.Tags.Union(Operand)).FirstOrDefault())!.Count == 3)); + } + [TestMethod] public async Task EqualityOnARemovedUnion_AnswersAsMemoryDoes() { From a0209a826471417df3536d28372904c0e3cab9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 26 Oct 2026 17:50:56 +0100 Subject: [PATCH 2/2] test: seed the guard's rewrite missing a scalar subquery (#505) Co-Authored-By: Claude Opus 5.5 --- ...ard-rewrite-misses-a-scalar-subquery.patch | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 test/oracles/seeds/guard-rewrite-misses-a-scalar-subquery.patch diff --git a/test/oracles/seeds/guard-rewrite-misses-a-scalar-subquery.patch b/test/oracles/seeds/guard-rewrite-misses-a-scalar-subquery.patch new file mode 100644 index 0000000..704367c --- /dev/null +++ b/test/oracles/seeds/guard-rewrite-misses-a-scalar-subquery.patch @@ -0,0 +1,20 @@ +Seed: the guard's rewrite misses a scalar subquery projecting a text set operation, so == and Count over it compare the unsorted array_cat +Oracles: UnionCanonicalityTests +Fails: EqualityOnAUnionFromAScalarSubquery and CountOverAUnionFromAScalarSubquery: the server drops {a,c} ∪ {a,b} == {a,b,c} and counts {a,c,a,b} as 4 +History: introduced by #485 (11.0.0-preview.1, and 10.4.0 before its tag), fixed by #505 + +diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs +index 5a2e977..c6ea1ae 100644 +--- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs ++++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs +@@ -645,10 +645,6 @@ internal static class ValueSetSqlGuard + CaseExpression conditional => conditional.WhenClauses.Any(clause => IsDerived(clause.Result)) || IsDerived(conditional.ElseResult), + SqlUnaryExpression { OperatorType: ExpressionType.Convert } convert when IsSet(convert) => IsDerived(convert.Operand), + ColumnExpression column => derivedColumns.Contains((column.TableAlias, column.Name)), +- +- // The walker lets ==, != and Count through over a scalar subquery that projects one, so this +- // must find it too, or the raw = and cardinality reach the server (#505). +- ScalarSubqueryExpression { Subquery.Projection: [var only] } => IsDerived(only.Expression), + _ => false + }; +