From 4eba082b77a26c05a7659d978b73b7f38124b89e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 23 Oct 2026 21:18:41 +0200 Subject: [PATCH 1/2] test: register #349 and #350 as ExecutedQueryTests candidates Two sweeps in ExecutedQueryTests now end in Candidates.Verdict, compared with LINQ over the rows as loaded. BoundsComparedWithNull runs LowerBound() and UpperBound() against null, HasValue, ?? in a filter and a projection, and a filter on a projected bound over nine shapes in thirteen range columns; all 117 (operation, column) pairs disagree today, one candidate per operation under nullable DateRange column holding NULL and every shape, one line per row, so only the NULL row is quarantined; one candidate per operation under #350. E70 quotes the bound-null sentence of docs/efcore.md, held by ExecutedQueryTests. With argumentsPropagateNullability: [false] in BoundFunction applied locally, the nine #349 candidates report candidate fixed and nothing else fails; the fix is a separate PR. Closes #351 Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 2c44472fc5c88bf3c1ce3cac7c4c6fa97c372461) --- .../ExecutedQueryTests.cs | 296 ++++++++++++++++++ test/oracles/candidates.json | 147 +++++++++ test/oracles/claims.json | 14 + 3 files changed, 457 insertions(+) diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs index 74374eac..46e485dc 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs @@ -1,4 +1,13 @@ +using System.Data.Common; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using CodoMetis.ValueRanges.Core; +using CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests.Fuzzing; +using CodoMetis.ValueRanges.Oracles; using Microsoft.EntityFrameworkCore; +using NodaTime; +using Npgsql; namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; @@ -6,6 +15,7 @@ namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; /// Executes the translated SQL against live PostgreSQL and asserts that the server's /// answers agree with the in-memory implementations — the core promise of the library. /// Each test seeds its own rows (unique Ids / GroupKeys) so tests can run in parallel. +/// The two sweeps at the end collect their disagreements and end in . /// [TestClass] public class ExecutedQueryTests @@ -622,4 +632,290 @@ await Seed( .SingleAsync(); Assert.AreEqual(periods.Take(2).RangeIntersectAgg(), serverIntersectAgg); } + + /// + /// LowerBound() and UpperBound() compared with , read through + /// HasValue, coalesced with ?? in a filter and a projection, and filtered after being + /// projected, for a range column of every family, against the same expression over the rows as + /// loaded. The bounds are NULL for the empty range and for the unbounded side of a non-null one, so + /// these forms only agree while EF Core knows lower and upper can be NULL for a non-null + /// argument (#349). Each failure line starts with the operation, then the column. Ids run from 2400 + /// to 2659, twenty per family. + /// + [TestMethod] + public async Task BoundsComparedWithNull_SelectTheRowsMemorySelects() + { + ContainerLifecycle.RequireDatabase(); + + var at = new DateTime(2026, 3, 1, 8, 0, 0); + var utc = new DateTimeOffset(at, TimeSpan.Zero); + LocalDate Day(int day) => new(2026, 3, day); + + var failures = new List(); + failures.AddRange(await BoundNullSweep(2400, r => r.Seats, 1, 10, 20, int.MinValue, int.MaxValue)); + failures.AddRange(await BoundNullSweep(2420, r => r.Tickets, 1L, 10L, 20L, long.MinValue, long.MaxValue)); + failures.AddRange(await BoundNullSweep(2440, r => r.Price, 1.5m, 10m, 20.25m, decimal.MinValue, decimal.MaxValue)); + failures.AddRange(await BoundNullSweep(2460, r => r.Period, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 15), + new DateOnly(2026, 3, 31), DateOnly.MinValue, DateOnly.MaxValue)); + failures.AddRange(await BoundNullSweep(2480, r => r.LocalTime, at, at.AddDays(1), at.AddDays(2), DateTime.MinValue, DateTime.MaxValue)); + failures.AddRange(await BoundNullSweep(2500, r => r.InstantTime, utc, utc.AddDays(1), utc.AddDays(2), + DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + failures.AddRange(await BoundNullSweep(2520, r => r.NodaPeriod, Day(1), Day(15), Day(31), LocalDate.MinIsoValue, LocalDate.MaxIsoValue)); + failures.AddRange(await BoundNullSweep(2540, r => r.NodaWallClock, LocalDateTime.FromDateTime(at), LocalDateTime.FromDateTime(at.AddDays(1)), + LocalDateTime.FromDateTime(at.AddDays(2)), LocalDateTime.MinIsoValue, LocalDateTime.MaxIsoValue)); + failures.AddRange(await BoundNullSweep(2560, r => r.NodaWindow, Instant.FromDateTimeOffset(utc), Instant.FromDateTimeOffset(utc.AddDays(1)), + Instant.FromDateTimeOffset(utc.AddDays(2)), Instant.MinValue, Instant.MaxValue)); + // time is microsecond-precise, so TimeOnly.MaxValue would round to 24:00; any fallback the column holds will do. + failures.AddRange(await BoundNullSweep(2580, r => r.OpeningHours, new TimeOnly(9, 0), new TimeOnly(12, 0), new TimeOnly(17, 0), + TimeOnly.MinValue, new TimeOnly(23, 59, 59))); + failures.AddRange(await BoundNullSweep(2600, r => r.Score, 1.5, 10.0, 20.25, double.MinValue, double.MaxValue)); + failures.AddRange(await BoundNullSweep(2620, r => r.Load, 1.5f, 10f, 20.25f, float.MinValue, float.MaxValue)); + failures.AddRange(await BoundNullSweep(2640, r => r.BillingPeriod, new YearMonth(2026, 1), new YearMonth(2026, 6), + new YearMonth(2026, 12), new YearMonth(1, 1), new YearMonth(9999, 12))); + + Candidates.Verdict(nameof(ExecutedQueryTests), failures, + "LowerBound()/UpperBound() against null in a query: PostgreSQL and memory select different rows", + method: nameof(BoundsComparedWithNull_SelectTheRowsMemorySelects)); + } + + /// + /// Seeds one row per shape into — finite across, below and above + /// , unbounded on either side of it, infinite and empty — and runs every bound + /// operation on the server and over the rows as loaded. and + /// are the ?? fallbacks; they must round-trip through the column's + /// element type, or the projection disagrees for that reason alone. + /// + private static async Task> BoundNullSweep( + int firstId, Expression> column, T a, T v, T b, T min, T max) + where TRange : class, IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + (string Name, TRange Range)[] shapes = + [ + ("[a,b]", TRange.CreateFinite(a, b, true, true)), + ("[a,a]", TRange.CreateFinite(a, a, true, true)), + ("[b,b]", TRange.CreateFinite(b, b, true, true)), + ("(,a]", TRange.CreateUnboundedStart(a, true)), + ("(,b]", TRange.CreateUnboundedStart(b, true)), + ("[a,)", TRange.CreateUnboundedEnd(a, true)), + ("[b,)", TRange.CreateUnboundedEnd(b, true)), + ("(,)", TRange.Infinite), + ("empty", TRange.Empty) + ]; + + var property = (PropertyInfo)((MemberExpression)column.Body).Member; + var names = new Dictionary(); + var rows = new List(); + foreach (var (shape, index) in shapes.Select((shape, index) => (shape, index))) + { + var row = new Reservation { Id = firstId + index }; + property.SetValue(row, shape.Range); + rows.Add(row); + names[row.Id] = shape.Name; + } + + await Seed([.. rows]); + + var row0 = column.Parameters[0]; + var label = $"{property.Name} ({typeof(TRange).Name})"; + var lastId = firstId + shapes.Length - 1; + + // The operation written over the range as IRange, spliced onto the column, so the call is + // the one a consumer's r.Column.LowerBound() makes. + Expression> On(Expression, TOut>> operation) => + Expression.Lambda>(FuzzEngine.Splice(operation, row0, column.Body), row0); + + // (bound ?? fallback) op v, with both values captured, so EF Core sends them as parameters. + Expression> Coalesced(ExpressionType comparison, Expression> bound, T fallback) => + Expression.Lambda>( + Expression.MakeBinary(comparison, Expression.Coalesce(bound.Body, Captured(fallback)), Captured(v)), row0); + + (string Name, Expression> Filter)[] filters = + [ + ("LowerBound() == null", On((_, x) => x.LowerBound() == null)), + ("LowerBound() != null", On((_, x) => x.LowerBound() != null)), + ("LowerBound().HasValue", On((_, x) => x.LowerBound().HasValue)), + ("UpperBound() == null", On((_, x) => x.UpperBound() == null)), + ("UpperBound() != null", On((_, x) => x.UpperBound() != null)), + ("(UpperBound() ?? max) >= v", Coalesced(ExpressionType.GreaterThanOrEqual, On((_, x) => x.UpperBound()), max)), + ("(LowerBound() ?? min) <= v", Coalesced(ExpressionType.LessThanOrEqual, On((_, x) => x.LowerBound()), min)) + ]; + + await using var context = new IntegrationDbContext(); + var mine = context.Reservations.Where(r => r.Id >= firstId && r.Id <= lastId); + var loaded = await mine.AsNoTracking().OrderBy(r => r.Id).ToListAsync(); + var failures = new List(); + + foreach (var (name, filter) in filters) + { + var memory = loaded.Where(filter.Compile()).Select(r => r.Id).ToList(); + await Agree(name, () => mine.Where(filter).Select(r => r.Id).OrderBy(id => id).ToListAsync(), memory); + } + + var projection = Expression.Lambda>(Expression.Coalesce(On((_, x) => x.UpperBound()).Body, Captured(max)), row0); + var projected = loaded.Select(projection.Compile()).ToList(); + try + { + var server = await mine.OrderBy(r => r.Id).Select(projection).ToListAsync(); + if (!server.SequenceEqual(projected)) + failures.Add($"Select(UpperBound() ?? max) on {label}: server [{string.Join(", ", server)}], memory [{string.Join(", ", projected)}]"); + } + catch (Exception e) when (e is InvalidOperationException or DbException) + { + failures.Add($"Select(UpperBound() ?? max) on {label}: server threw {e.GetType().Name}: {e.Message}"); + } + + // The filter a grid's "blank" header filter sends: the bound projected first, compared after. + var withUpper = On((r, x) => new { r.Id, To = x.UpperBound() }); + await Agree("Select(new { Id, To = UpperBound() }).Where(To == null)", + () => mine.Select(withUpper).Where(t => t.To == null).Select(t => t.Id).OrderBy(id => id).ToListAsync(), + [.. loaded.Select(withUpper.Compile()).Where(t => t.To == null).Select(t => t.Id)]); + + return failures; + + async Task Agree(string operation, Func>> server, List memory) + { + try + { + var rowsServer = await server(); + if (!rowsServer.SequenceEqual(memory)) + failures.Add($"{operation} on {label}: server rows {Named(rowsServer)}, memory rows {Named(memory)}"); + } + catch (Exception e) when (e is InvalidOperationException or DbException) + { + failures.Add($"{operation} on {label}: server threw {e.GetType().Name}: {e.Message}"); + } + } + + string Named(IEnumerable ids) => $"{{{string.Join(" ", ids.Select(id => names[id]))}}}"; + } + + /// + /// Range predicates over a nullable column holding NULL and every shape, against the same + /// expression over the rows as loaded. On a NULL column every call is NULL in SQL; in memory some + /// extensions answer a value on a null receiver, so their negated and vacuous forms keep or drop the + /// NULL row where SQL does the opposite (#350). A row whose in-memory evaluation throws has no answer + /// to compare and is left out on both sides. The column lives in a database of its own, so the + /// shared schema keeps its required columns. + /// + [TestMethod] + public async Task PredicatesOverANullableColumn_SelectTheRowsMemorySelects() + { + ContainerLifecycle.RequireServer(); + + var connectionString = await NullableRangeContext.CreateDatabase(); + + DateOnly March(int day) => new(2026, 3, day); + (string Name, DateRange? Range)[] shapes = + [ + ("null", null), + ("empty", DateRange.Empty), + ("(,)", DateRange.Infinite), + ("[1,10]", DateRange.CreateFinite(March(1), March(10), true, true)), + ("[20,25]", DateRange.CreateFinite(March(20), March(25), true, true)), + ("(,10]", DateRange.CreateUnboundedStart(March(10), true)), + ("[20,)", DateRange.CreateUnboundedEnd(March(20), true)) + ]; + var names = shapes.Select((shape, index) => (Id: index + 1, shape.Name)).ToDictionary(s => s.Id, s => s.Name); + + await using (var seed = new NullableRangeContext(connectionString)) + { + await seed.Database.EnsureCreatedAsync(); + seed.Rows.AddRange(shapes.Select((shape, index) => new NullableRangeRow { Id = index + 1, Period = shape.Range })); + await seed.SaveChangesAsync(); + } + + var p = DateRange.CreateFinite(March(5), March(22), true, true); + var v = March(7); + + (string Name, Expression> Filter)[] filters = + [ + ("!IsEmpty()", r => !r.Period!.IsEmpty()), + ("!IsFinite()", r => !r.Period!.IsFinite()), + ("!IsUnboundedEnd()", r => !r.Period!.IsUnboundedEnd()), + ("!UpperBoundInclusive()", r => !r.Period!.UpperBoundInclusive()), + ("!Overlaps(p)", r => !r.Period!.Overlaps(p)), + ("Contains(Empty)", r => r.Period!.Contains(DateRange.Empty)), + ("IsContainedBy(Infinite)", r => r.Period!.IsContainedBy(DateRange.Infinite)), + ("Intersect(p) != null", r => r.Period!.Intersect(p) != null), + ("Intersect(p) == Empty", r => r.Period!.Intersect(p) == DateRange.Empty), + ("Gap(Empty).IsEmpty()", r => r.Period!.Gap(DateRange.Empty).IsEmpty()), + ("Union(Infinite).Contains(v)", r => r.Period!.Union(DateRange.Infinite).Contains(v)), + ("Merge(Infinite) == x", r => r.Period!.Merge(DateRange.Infinite) == r.Period) + ]; + + await using var context = new NullableRangeContext(connectionString); + var loaded = await context.Rows.AsNoTracking().OrderBy(r => r.Id).ToListAsync(); + var failures = new List(); + + foreach (var (name, filter) in filters) + { + var test = filter.Compile(); + var judged = new HashSet(); + var memory = new List(); + foreach (var row in loaded) + { + bool holds; + try { holds = test(row); } + catch (Exception e) when (e is NullReferenceException or InvalidOperationException or ArgumentException) { continue; } + + judged.Add(row.Id); + if (holds) memory.Add(row.Id); + } + + // One line per row, so a candidate names the row it quarantines and every other row stays judged. + try + { + var server = (await context.Rows.Where(filter).Select(r => r.Id).ToListAsync()).ToHashSet(); + foreach (var id in judged.Order().Where(id => server.Contains(id) != memory.Contains(id))) + failures.Add($"{name} on DateRange? {names[id]}: server {Selects(server.Contains(id))} the row, memory {Selects(memory.Contains(id))} it"); + } + catch (Exception e) when (e is InvalidOperationException or DbException) + { + failures.Add($"{name} on DateRange?: server threw {e.GetType().Name}: {e.Message}"); + } + } + + Candidates.Verdict(nameof(ExecutedQueryTests), failures, + "Range predicates over a nullable column: PostgreSQL and memory select different rows", + method: nameof(PredicatesOverANullableColumn_SelectTheRowsMemorySelects)); + + static string Selects(bool selected) => selected ? "selects" : "drops"; + } + + /// A captured variable, which EF Core sends as a parameter. + private static MemberExpression Captured(T value) => + Expression.Field(Expression.Constant(new StrongBox(value)), nameof(StrongBox<>.Value)); + + public sealed class NullableRangeRow + { + public int Id { get; set; } + + public DateRange? Period { get; set; } + } + + /// A database of its own, so that a nullable range column does not change the shared schema. + private sealed class NullableRangeContext(string connectionString) : DbContext + { + public DbSet Rows => Set(); + + public static async Task CreateDatabase() + { + var name = $"nullable_range_{Guid.NewGuid():N}"; + await using (var connection = new NpgsqlConnection(ContainerLifecycle.ServerConnectionString)) + { + await connection.OpenAsync(); + await using var command = new NpgsqlCommand($"CREATE DATABASE {name}", connection); + await command.ExecuteNonQueryAsync(); + } + + return new NpgsqlConnectionStringBuilder(ContainerLifecycle.ServerConnectionString) { Database = name }.ConnectionString; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges()); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().Property(r => r.Id).ValueGeneratedNever(); + } } diff --git a/test/oracles/candidates.json b/test/oracles/candidates.json index 0e271757..57ea2d31 100644 --- a/test/oracles/candidates.json +++ b/test/oracles/candidates.json @@ -26,5 +26,152 @@ "family": "(,max] needs max + 1 to canonicalize; PostgreSQL raises 22003", "issue": "#132", "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "ExecutedQueryTests", + "row": "LowerBound() == null on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "LowerBound() != null on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "LowerBound().HasValue on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "UpperBound() == null on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "UpperBound() != null on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "(UpperBound() ?? max) >= v on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "(LowerBound() ?? min) <= v on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "Select(UpperBound() ?? max) on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "Select(new { Id, To = UpperBound() }).Where(To == null) on ", + "family": "lower()/upper() declared NULL only for a NULL argument", + "issue": "#349", + "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "!IsEmpty() on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "!IsFinite() on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "!IsUnboundedEnd() on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "!UpperBoundInclusive() on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "!Overlaps(p) on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "Contains(Empty) on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "IsContainedBy(Infinite) on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "Intersect(p) != null on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "Intersect(p) == Empty on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "Gap(Empty).IsEmpty() on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "Union(Infinite).Contains(v) on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" + }, + { + "oracle": "ExecutedQueryTests", + "row": "Merge(Infinite) == x on DateRange? null:", + "family": "a null receiver answers in memory where SQL answers NULL", + "issue": "#350", + "method": "PredicatesOverANullableColumn_SelectTheRowsMemorySelects" } ] diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 4ed5d975..23775b41 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1121,6 +1121,20 @@ "TimeAndYearMonthIntegrationTests" ] }, + { + "id": "E70-bounds-null-for-unbounded-or-empty-in-sql", + "source": "docs/efcore.md#read-bounds-merge-ranges-and-aggregate", + "quote": "**`LowerBound()` and `UpperBound()` return `T?`.** PostgreSQL's `lower` and `upper` return `NULL` for an unbounded or empty range, and the in-memory methods match that.", + "claim": "In a query over a range column of any family, LowerBound() and UpperBound() are NULL exactly for the empty range and for the unbounded side, as in memory: compared with null, read through HasValue, coalesced with ?? in a filter or a projection, or compared with null after being projected, they select and return what the same expression gives over the rows as loaded.", + "oracle": "differential", + "surfaces": [ + "RangeExtensions.LowerBound", + "RangeExtensions.UpperBound" + ], + "heldBy": [ + "ExecutedQueryTests" + ] + }, { "id": "J01-tostring-is-postgres-literal", "source": "docs/serialization.md#print-a-range-as-a-literal", From 20b9dfb4a3c4e0c6461b1c81d0e5401c0cfa122a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 24 Oct 2026 09:08:09 +0200 Subject: [PATCH 2/2] fix: declare lower() and upper() NULL for a non-null range (#349) BoundFunction declared lower() and upper() with argumentsPropagateNullability [true], which tells EF Core the bound is NULL only when the column is. PostgreSQL also returns NULL for the empty range and for an unbounded side, so EF Core folded LowerBound() == null on a required column to WHERE FALSE, dropped the COALESCE of a ??, and tested a nullable column's bound with the column's own IS NULL. The flag is now [false] for both, on ranges and on RangeSet. The bound sweep in ExecutedQueryTests now runs every operation on the RangeSet column of each family too (13 + 13 columns), and its nine #349 candidates are removed. With the old flag it fails on 234 rows, 117 per kind; with the new one every row agrees. Two translation tests pin the SQL without a database. Claim E70 covers RangeSet's bounds and quotes the efcore.md sentence that now promises the query answer. Found-By: US Defect-Class: SQL Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 89a7a3814f4534698624a925e9514f288444478e) --- CHANGELOG.md | 6 + docs/efcore.md | 4 +- docs/migration.md | 39 ++++ .../CHANGELOG.md | 4 +- .../CHANGELOG.md | 4 +- .../Query/ValueRangesMethodCallTranslator.cs | 7 +- .../ExecutedQueryTests.cs | 170 ++++++++++-------- .../QueryTranslationTests.cs | 22 +++ test/oracles/candidates.json | 63 ------- test/oracles/claims.json | 8 +- 10 files changed, 181 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbac594e..3d583280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ has the rules. ## [10.2.0] +Some calls answer differently: [Changed answers in 10.2.0](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020). + +### Changed answers + +- EF Core: `LowerBound()` and `UpperBound()` in a query are `NULL` for an empty range or set and on an unbounded side, as in memory. So `== null` selects those rows, where it selected none on a required column, and `?? fallback` returns the fallback, where the row was missing ([#354](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/354), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)). + ## [10.1.0] — 2026-10-19 One call answers differently, on the .NET 11 runtime only: [Changed answers in 10.1.0](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1010). diff --git a/docs/efcore.md b/docs/efcore.md index 8332088f..10275c79 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -147,7 +147,9 @@ Good to know: have a gap. PostgreSQL normalizes multiranges the same way this library does, so the equality is exact ([verified against live PostgreSQL](../README.md#verified-against-postgresql)). - **`LowerBound()` and `UpperBound()` return `T?`.** PostgreSQL's `lower` and `upper` return `NULL` - for an unbounded or empty range, and the in-memory methods match that. + for an unbounded or empty range, and the in-memory methods match that. So `== null`, `HasValue` + and `??` on a bound select the same rows in a query as in memory, on a range or a `RangeSet` + column. - **Discrete upper bounds are adjusted for you.** For `int4range`, `int8range` and `daterange`, PostgreSQL normalizes to the half-open form `[lower, upper)`. This library normalizes to the closed form `[lower, upper]`. So `UpperBound()` becomes `upper(x) - 1`, and diff --git a/docs/migration.md b/docs/migration.md index 0a604fb6..775bd3c2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -785,3 +785,42 @@ runtimes. Look at the text instead: - **Say "the end of the day" another way.** Use an unbounded end, `[22:00,)`, or an inclusive `23:59:59.9999999` bound ([TimeRange and the custom timerange type](efcore.md#timerange-and-the-custom-timerange-type)). + +## Changed answers in 10.2.0 + +Some calls return a different value. Your code stays the same, so the compiler can't show you where. +Each section below says how to find the calls whose answer changed. + +### Check bound comparisons with `null` in queries + +Check EF Core queries that call `LowerBound()` or `UpperBound()` on a range or `RangeSet` column and +then test the result for `null`. That covers `== null`, `!= null`, `HasValue` and `??` with a +fallback, in a filter or a projection. It also covers a filter on a bound you projected first, which +is what a data grid sends for a "blank" filter. + +PostgreSQL's `lower` and `upper` return `NULL` for an empty range and for an unbounded side. The +in-memory methods return `null` for the same values. Before 10.2.0, the EF Core package declared +that a bound is `NULL` only when the column is, and EF Core simplified the query on that basis. On a +required column: + +| Query | Before 10.2.0 | Now | +|---|---|---| +| `Where(r => r.Period.LowerBound() == null)` | no rows | the empty rows and the rows with no lower bound | +| `Where(r => r.Period.UpperBound() != null)`, or `HasValue` | every row | the rows with an upper bound | +| `Where(r => (r.Period.UpperBound() ?? DateOnly.MaxValue) >= day)` | the empty rows and the rows with no upper bound are missing | those rows match through the fallback | +| `Select(r => r.Period.UpperBound() ?? DateOnly.MaxValue)` | throws (`InvalidOperationException`) at an empty row or a row with no upper bound | returns the fallback for it | +| `Select(r => new { To = r.Period.UpperBound() }).Where(x => x.To == null)` | no rows | the empty rows and the rows with no upper bound | + +On a nullable column, `LowerBound() == null` ran as the column's own `IS NULL`. It found the `NULL` +rows and missed the empty and unbounded ones. + +This holds for every range type and for `RangeSet`: the .NET and NodaTime ranges, `TimeRange`, the +float ranges and `YearMonthRange`. Code that runs in memory is unchanged. + +- **Search your queries for `LowerBound()` and `UpperBound()`.** Each one followed by `== null`, + `!= null`, `HasValue` or `??` now returns the rows the same code returns in memory. +- **Keep or drop the workarounds you wrote for it.** A query that adds `IsEmpty()`, + `IsUnboundedStart()` or `IsUnboundedEnd()` beside the `null` test returns the same rows as before, + so the extra test is now optional. +- **Check reports built on the old rows.** On a required column, a count of rows with no end date + was 0, and a filter on a date with a `??` fallback left out every open-ended row. diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md index 0fe49ba1..8f73a247 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md @@ -5,7 +5,9 @@ covers all four packages, which share one version number and release together. ## [10.2.0] -No change in this package. +### Changed answers + +- EF Core: `LowerBound()` and `UpperBound()` of a NodaTime range or multirange column, `YearMonthRange` included, are `NULL` in a query for an empty value and on an unbounded side, as in memory. So `== null` selects those rows, where it selected none on a required column, and `?? fallback` returns the fallback, where the row was missing ([#354](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/354), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)). ## [10.1.0] — 2026-10-19 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md index 3bda39b5..51439083 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md @@ -5,7 +5,9 @@ covers all four packages, which share one version number and release together. ## [10.2.0] -No change in this package. +### Changed answers + +- EF Core: `LowerBound()` and `UpperBound()` in a query are `NULL` for an empty range or set and on an unbounded side, as in memory. So `== null` selects those rows, where it selected none on a required column, and `?? fallback` returns the fallback, where the row was missing ([#354](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/354), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)). ## [10.1.0] — 2026-10-19 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs index 95436f9c..fe6341cc 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs @@ -634,14 +634,17 @@ private SqlExpression BoolFunction(string name, SqlExpression argument) /// /// A bound-extraction function (lower/upper) on a range or multirange. /// PostgreSQL returns NULL for an empty or correspondingly unbounded operand, hence the - /// nullable CLR type. + /// nullable CLR type. That NULL comes from a non-null argument, so the argument must not be + /// declared as propagating nullability: EF Core would then take the bound of a required column + /// as never NULL, fold IS NULL to FALSE and drop the COALESCE of a + /// ??, and test a nullable column's bound with the column's own IS NULL. /// private SqlExpression BoundFunction(string name, SqlExpression argument, IRangeTypeDefinition definition) => sqlExpressionFactory.Function( name, [argument], nullable: true, - argumentsPropagateNullability: [true], + argumentsPropagateNullability: [false], typeof(Nullable<>).MakeGenericType(definition.ElementClrType), ElementMapping(definition)); diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs index 46e485dc..4455f43f 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs @@ -636,11 +636,11 @@ await Seed( /// /// LowerBound() and UpperBound() compared with , read through /// HasValue, coalesced with ?? in a filter and a projection, and filtered after being - /// projected, for a range column of every family, against the same expression over the rows as - /// loaded. The bounds are NULL for the empty range and for the unbounded side of a non-null one, so - /// these forms only agree while EF Core knows lower and upper can be NULL for a non-null - /// argument (#349). Each failure line starts with the operation, then the column. Ids run from 2400 - /// to 2659, twenty per family. + /// projected, for a range column and a multirange column of every family, against the same + /// expression over the rows as loaded. The bounds are NULL for the empty range and for the unbounded + /// side of a non-null one, so these forms only agree while EF Core knows lower and + /// upper can be NULL for a non-null argument (#349). Each failure line starts with the + /// operation, then the column. Ids run from 2400 to 2659, twenty per family. /// [TestMethod] public async Task BoundsComparedWithNull_SelectTheRowsMemorySelects() @@ -652,25 +652,29 @@ public async Task BoundsComparedWithNull_SelectTheRowsMemorySelects() LocalDate Day(int day) => new(2026, 3, day); var failures = new List(); - failures.AddRange(await BoundNullSweep(2400, r => r.Seats, 1, 10, 20, int.MinValue, int.MaxValue)); - failures.AddRange(await BoundNullSweep(2420, r => r.Tickets, 1L, 10L, 20L, long.MinValue, long.MaxValue)); - failures.AddRange(await BoundNullSweep(2440, r => r.Price, 1.5m, 10m, 20.25m, decimal.MinValue, decimal.MaxValue)); - failures.AddRange(await BoundNullSweep(2460, r => r.Period, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 15), + failures.AddRange(await BoundNullSweep(2400, r => r.Seats, r => r.SeatBlocks, 1, 10, 20, int.MinValue, int.MaxValue)); + failures.AddRange(await BoundNullSweep(2420, r => r.Tickets, r => r.TicketBlocks, 1L, 10L, 20L, long.MinValue, long.MaxValue)); + failures.AddRange(await BoundNullSweep(2440, r => r.Price, r => r.PriceBands, 1.5m, 10m, 20.25m, decimal.MinValue, decimal.MaxValue)); + failures.AddRange(await BoundNullSweep(2460, r => r.Period, r => r.BlockedDays, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 15), new DateOnly(2026, 3, 31), DateOnly.MinValue, DateOnly.MaxValue)); - failures.AddRange(await BoundNullSweep(2480, r => r.LocalTime, at, at.AddDays(1), at.AddDays(2), DateTime.MinValue, DateTime.MaxValue)); - failures.AddRange(await BoundNullSweep(2500, r => r.InstantTime, utc, utc.AddDays(1), utc.AddDays(2), + failures.AddRange(await BoundNullSweep(2480, r => r.LocalTime, r => r.WallClockWindows, at, at.AddDays(1), at.AddDays(2), + DateTime.MinValue, DateTime.MaxValue)); + failures.AddRange(await BoundNullSweep(2500, r => r.InstantTime, r => r.InstantWindows, utc, utc.AddDays(1), utc.AddDays(2), DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); - failures.AddRange(await BoundNullSweep(2520, r => r.NodaPeriod, Day(1), Day(15), Day(31), LocalDate.MinIsoValue, LocalDate.MaxIsoValue)); - failures.AddRange(await BoundNullSweep(2540, r => r.NodaWallClock, LocalDateTime.FromDateTime(at), LocalDateTime.FromDateTime(at.AddDays(1)), - LocalDateTime.FromDateTime(at.AddDays(2)), LocalDateTime.MinIsoValue, LocalDateTime.MaxIsoValue)); - failures.AddRange(await BoundNullSweep(2560, r => r.NodaWindow, Instant.FromDateTimeOffset(utc), Instant.FromDateTimeOffset(utc.AddDays(1)), - Instant.FromDateTimeOffset(utc.AddDays(2)), Instant.MinValue, Instant.MaxValue)); + failures.AddRange(await BoundNullSweep(2520, r => r.NodaPeriod, r => r.NodaBlockedDays, Day(1), Day(15), Day(31), + LocalDate.MinIsoValue, LocalDate.MaxIsoValue)); + failures.AddRange(await BoundNullSweep(2540, r => r.NodaWallClock, r => r.NodaWallClocks, LocalDateTime.FromDateTime(at), + LocalDateTime.FromDateTime(at.AddDays(1)), LocalDateTime.FromDateTime(at.AddDays(2)), + LocalDateTime.MinIsoValue, LocalDateTime.MaxIsoValue)); + failures.AddRange(await BoundNullSweep(2560, r => r.NodaWindow, r => r.NodaWindows, Instant.FromDateTimeOffset(utc), + Instant.FromDateTimeOffset(utc.AddDays(1)), Instant.FromDateTimeOffset(utc.AddDays(2)), + Instant.MinValue, Instant.MaxValue)); // time is microsecond-precise, so TimeOnly.MaxValue would round to 24:00; any fallback the column holds will do. - failures.AddRange(await BoundNullSweep(2580, r => r.OpeningHours, new TimeOnly(9, 0), new TimeOnly(12, 0), new TimeOnly(17, 0), - TimeOnly.MinValue, new TimeOnly(23, 59, 59))); - failures.AddRange(await BoundNullSweep(2600, r => r.Score, 1.5, 10.0, 20.25, double.MinValue, double.MaxValue)); - failures.AddRange(await BoundNullSweep(2620, r => r.Load, 1.5f, 10f, 20.25f, float.MinValue, float.MaxValue)); - failures.AddRange(await BoundNullSweep(2640, r => r.BillingPeriod, new YearMonth(2026, 1), new YearMonth(2026, 6), + failures.AddRange(await BoundNullSweep(2580, r => r.OpeningHours, r => r.OpeningWindows, new TimeOnly(9, 0), new TimeOnly(12, 0), + new TimeOnly(17, 0), TimeOnly.MinValue, new TimeOnly(23, 59, 59))); + failures.AddRange(await BoundNullSweep(2600, r => r.Score, r => r.ScoreBands, 1.5, 10.0, 20.25, double.MinValue, double.MaxValue)); + failures.AddRange(await BoundNullSweep(2620, r => r.Load, r => r.LoadBands, 1.5f, 10f, 20.25f, float.MinValue, float.MaxValue)); + failures.AddRange(await BoundNullSweep(2640, r => r.BillingPeriod, r => r.BillingPeriods, new YearMonth(2026, 1), new YearMonth(2026, 6), new YearMonth(2026, 12), new YearMonth(1, 1), new YearMonth(9999, 12))); Candidates.Verdict(nameof(ExecutedQueryTests), failures, @@ -679,14 +683,16 @@ public async Task BoundsComparedWithNull_SelectTheRowsMemorySelects() } /// - /// Seeds one row per shape into — finite across, below and above - /// , unbounded on either side of it, infinite and empty — and runs every bound - /// operation on the server and over the rows as loaded. and + /// Seeds one row per shape into , and the same shape as a one-element + /// multirange into — finite across, below and above , + /// unbounded on either side of it, infinite and empty — and runs every bound operation on both + /// columns, on the server and over the rows as loaded. and /// are the ?? fallbacks; they must round-trip through the column's /// element type, or the projection disagrees for that reason alone. /// private static async Task> BoundNullSweep( - int firstId, Expression> column, T a, T v, T b, T min, T max) + int firstId, Expression> column, Expression>> set, + T a, T v, T b, T min, T max) where TRange : class, IRangeFactory, IRange where T : struct, IComparable, IEquatable { @@ -703,77 +709,91 @@ private static async Task> BoundNullSweep( ("empty", TRange.Empty) ]; - var property = (PropertyInfo)((MemberExpression)column.Body).Member; - var names = new Dictionary(); - var rows = new List(); + var property = (PropertyInfo)((MemberExpression)column.Body).Member; + var setProperty = (PropertyInfo)((MemberExpression)set.Body).Member; + var names = new Dictionary(); + var rows = new List(); foreach (var (shape, index) in shapes.Select((shape, index) => (shape, index))) { var row = new Reservation { Id = firstId + index }; property.SetValue(row, shape.Range); + setProperty.SetValue(row, RangeSet.From(shape.Range)); rows.Add(row); names[row.Id] = shape.Name; } await Seed([.. rows]); - var row0 = column.Parameters[0]; - var label = $"{property.Name} ({typeof(TRange).Name})"; - var lastId = firstId + shapes.Length - 1; + var row0 = column.Parameters[0]; + var setColumn = Expression.Property(row0, setProperty); + var lastId = firstId + shapes.Length - 1; - // The operation written over the range as IRange, spliced onto the column, so the call is - // the one a consumer's r.Column.LowerBound() makes. - Expression> On(Expression, TOut>> operation) => - Expression.Lambda>(FuzzEngine.Splice(operation, row0, column.Body), row0); + await using var context = new IntegrationDbContext(); + var mine = context.Reservations.Where(r => r.Id >= firstId && r.Id <= lastId); + var loaded = await mine.AsNoTracking().OrderBy(r => r.Id).ToListAsync(); + var failures = new List(); + + // The range's bounds are the IRange extensions and the set's its own methods, each spliced + // onto its column, so the call is the one a consumer's r.Column.LowerBound() makes. + await Sweep($"{property.Name} ({typeof(TRange).Name})", + Bound>(column.Body, (_, x) => x.LowerBound()), Bound>(column.Body, (_, x) => x.UpperBound())); + await Sweep($"{setProperty.Name} (RangeSet<{typeof(TRange).Name}>)", + Bound>(setColumn, (_, x) => x.LowerBound()), Bound>(setColumn, (_, x) => x.UpperBound())); + + return failures; + + Expression> Bound(Expression on, Expression> bound) => + Expression.Lambda>(FuzzEngine.Splice(bound, row0, on), row0); + + // The operation written over the bound, spliced onto it. + Expression> Over(Expression> bound, Expression> operation) => + Expression.Lambda>(FuzzEngine.Splice(operation, row0, bound.Body), row0); // (bound ?? fallback) op v, with both values captured, so EF Core sends them as parameters. Expression> Coalesced(ExpressionType comparison, Expression> bound, T fallback) => Expression.Lambda>( Expression.MakeBinary(comparison, Expression.Coalesce(bound.Body, Captured(fallback)), Captured(v)), row0); - (string Name, Expression> Filter)[] filters = - [ - ("LowerBound() == null", On((_, x) => x.LowerBound() == null)), - ("LowerBound() != null", On((_, x) => x.LowerBound() != null)), - ("LowerBound().HasValue", On((_, x) => x.LowerBound().HasValue)), - ("UpperBound() == null", On((_, x) => x.UpperBound() == null)), - ("UpperBound() != null", On((_, x) => x.UpperBound() != null)), - ("(UpperBound() ?? max) >= v", Coalesced(ExpressionType.GreaterThanOrEqual, On((_, x) => x.UpperBound()), max)), - ("(LowerBound() ?? min) <= v", Coalesced(ExpressionType.LessThanOrEqual, On((_, x) => x.LowerBound()), min)) - ]; - - await using var context = new IntegrationDbContext(); - var mine = context.Reservations.Where(r => r.Id >= firstId && r.Id <= lastId); - var loaded = await mine.AsNoTracking().OrderBy(r => r.Id).ToListAsync(); - var failures = new List(); - - foreach (var (name, filter) in filters) - { - var memory = loaded.Where(filter.Compile()).Select(r => r.Id).ToList(); - await Agree(name, () => mine.Where(filter).Select(r => r.Id).OrderBy(id => id).ToListAsync(), memory); - } - - var projection = Expression.Lambda>(Expression.Coalesce(On((_, x) => x.UpperBound()).Body, Captured(max)), row0); - var projected = loaded.Select(projection.Compile()).ToList(); - try - { - var server = await mine.OrderBy(r => r.Id).Select(projection).ToListAsync(); - if (!server.SequenceEqual(projected)) - failures.Add($"Select(UpperBound() ?? max) on {label}: server [{string.Join(", ", server)}], memory [{string.Join(", ", projected)}]"); - } - catch (Exception e) when (e is InvalidOperationException or DbException) + async Task Sweep(string label, Expression> lower, Expression> upper) { - failures.Add($"Select(UpperBound() ?? max) on {label}: server threw {e.GetType().Name}: {e.Message}"); - } + (string Name, Expression> Filter)[] filters = + [ + ("LowerBound() == null", Over(lower, (_, x) => x == null)), + ("LowerBound() != null", Over(lower, (_, x) => x != null)), + ("LowerBound().HasValue", Over(lower, (_, x) => x.HasValue)), + ("UpperBound() == null", Over(upper, (_, x) => x == null)), + ("UpperBound() != null", Over(upper, (_, x) => x != null)), + ("(UpperBound() ?? max) >= v", Coalesced(ExpressionType.GreaterThanOrEqual, upper, max)), + ("(LowerBound() ?? min) <= v", Coalesced(ExpressionType.LessThanOrEqual, lower, min)) + ]; + + foreach (var (name, filter) in filters) + { + var memory = loaded.Where(filter.Compile()).Select(r => r.Id).ToList(); + await Agree(label, name, () => mine.Where(filter).Select(r => r.Id).OrderBy(id => id).ToListAsync(), memory); + } - // The filter a grid's "blank" header filter sends: the bound projected first, compared after. - var withUpper = On((r, x) => new { r.Id, To = x.UpperBound() }); - await Agree("Select(new { Id, To = UpperBound() }).Where(To == null)", - () => mine.Select(withUpper).Where(t => t.To == null).Select(t => t.Id).OrderBy(id => id).ToListAsync(), - [.. loaded.Select(withUpper.Compile()).Where(t => t.To == null).Select(t => t.Id)]); + var projection = Expression.Lambda>(Expression.Coalesce(upper.Body, Captured(max)), row0); + var projected = loaded.Select(projection.Compile()).ToList(); + try + { + var server = await mine.OrderBy(r => r.Id).Select(projection).ToListAsync(); + if (!server.SequenceEqual(projected)) + failures.Add($"Select(UpperBound() ?? max) on {label}: server [{string.Join(", ", server)}], memory [{string.Join(", ", projected)}]"); + } + catch (Exception e) when (e is InvalidOperationException or DbException) + { + failures.Add($"Select(UpperBound() ?? max) on {label}: server threw {e.GetType().Name}: {e.Message}"); + } - return failures; + // The filter a grid's "blank" header filter sends: the bound projected first, compared after. + var withUpper = Over(upper, (r, x) => new { r.Id, To = x }); + await Agree(label, "Select(new { Id, To = UpperBound() }).Where(To == null)", + () => mine.Select(withUpper).Where(t => t.To == null).Select(t => t.Id).OrderBy(id => id).ToListAsync(), + [.. loaded.Select(withUpper.Compile()).Where(t => t.To == null).Select(t => t.Id)]); + } - async Task Agree(string operation, Func>> server, List memory) + async Task Agree(string label, string operation, Func>> server, List memory) { try { diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/QueryTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/QueryTranslationTests.cs index 7a50a9e3..13231407 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/QueryTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/QueryTranslationTests.cs @@ -262,6 +262,28 @@ public void LowerBound_InOrderBy() StringAssert.Contains(sql, "ORDER BY lower(b.\"Period\")"); } + /// + /// lower and upper are NULL for an empty or unbounded operand that is not NULL itself, + /// so a required column's bound can still be NULL. Declared as propagating its argument's + /// nullability, EF Core folded these to WHERE FALSE and dropped the COALESCE (#349). + /// ExecutedQueryTests.BoundsComparedWithNull_SelectTheRowsMemorySelects is the authority. + /// + [TestMethod] + public void BoundComparedWithNull_OnARequiredColumn_TestsTheBound() + { + var sql = Sql(db => db.Bookings.Where(b => b.Period.LowerBound() == null || b.BlockedDays.UpperBound() == null)); + StringAssert.Contains(sql, "lower(b.\"Period\") IS NULL"); + StringAssert.Contains(sql, "upper(b.\"BlockedDays\") IS NULL"); + Assert.IsFalse(sql.Contains("FALSE"), sql); + } + + [TestMethod] + public void BoundCoalesced_OnARequiredColumn_KeepsTheCoalesce() + { + var sql = Sql(db => db.Bookings.Where(b => (b.Period.UpperBound() ?? DateOnly.MaxValue) >= Day)); + StringAssert.Contains(sql, "COALESCE(upper(b.\"Period\") - 1, "); + } + [TestMethod] public void UpperBound_Discrete_CompensatesHalfOpenCanonicalization() { diff --git a/test/oracles/candidates.json b/test/oracles/candidates.json index 57ea2d31..d8638340 100644 --- a/test/oracles/candidates.json +++ b/test/oracles/candidates.json @@ -27,69 +27,6 @@ "issue": "#132", "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" }, - { - "oracle": "ExecutedQueryTests", - "row": "LowerBound() == null on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, - { - "oracle": "ExecutedQueryTests", - "row": "LowerBound() != null on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, - { - "oracle": "ExecutedQueryTests", - "row": "LowerBound().HasValue on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, - { - "oracle": "ExecutedQueryTests", - "row": "UpperBound() == null on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, - { - "oracle": "ExecutedQueryTests", - "row": "UpperBound() != null on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, - { - "oracle": "ExecutedQueryTests", - "row": "(UpperBound() ?? max) >= v on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, - { - "oracle": "ExecutedQueryTests", - "row": "(LowerBound() ?? min) <= v on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, - { - "oracle": "ExecutedQueryTests", - "row": "Select(UpperBound() ?? max) on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, - { - "oracle": "ExecutedQueryTests", - "row": "Select(new { Id, To = UpperBound() }).Where(To == null) on ", - "family": "lower()/upper() declared NULL only for a NULL argument", - "issue": "#349", - "method": "BoundsComparedWithNull_SelectTheRowsMemorySelects" - }, { "oracle": "ExecutedQueryTests", "row": "!IsEmpty() on DateRange? null:", diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 23775b41..b90d43e3 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1124,12 +1124,14 @@ { "id": "E70-bounds-null-for-unbounded-or-empty-in-sql", "source": "docs/efcore.md#read-bounds-merge-ranges-and-aggregate", - "quote": "**`LowerBound()` and `UpperBound()` return `T?`.** PostgreSQL's `lower` and `upper` return `NULL` for an unbounded or empty range, and the in-memory methods match that.", - "claim": "In a query over a range column of any family, LowerBound() and UpperBound() are NULL exactly for the empty range and for the unbounded side, as in memory: compared with null, read through HasValue, coalesced with ?? in a filter or a projection, or compared with null after being projected, they select and return what the same expression gives over the rows as loaded.", + "quote": "**`LowerBound()` and `UpperBound()` return `T?`.** PostgreSQL's `lower` and `upper` return `NULL` for an unbounded or empty range, and the in-memory methods match that. So `== null`, `HasValue` and `??` on a bound select the same rows in a query as in memory, on a range or a `RangeSet` column.", + "claim": "In a query over a range or multirange column of any family, LowerBound() and UpperBound() are NULL exactly for the empty range or set and for the unbounded side, as in memory: compared with null, read through HasValue, coalesced with ?? in a filter or a projection, or compared with null after being projected, they select and return what the same expression gives over the rows as loaded.", "oracle": "differential", "surfaces": [ "RangeExtensions.LowerBound", - "RangeExtensions.UpperBound" + "RangeExtensions.UpperBound", + "RangeSet.LowerBound", + "RangeSet.UpperBound" ], "heldBy": [ "ExecutedQueryTests"