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() { 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 + }; +