diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecutedQueryTests.cs index 74374ea..46e485d 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 0e27175..57ea2d3 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 8ce0a42..ba787ff 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1207,6 +1207,20 @@ "NativeTemporalKeyTests" ] }, + { + "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",