Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
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;

/// <summary>
/// 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 <see cref="Candidates.Verdict"/>.
/// </summary>
[TestClass]
public class ExecutedQueryTests
Expand Down Expand Up @@ -622,4 +632,290 @@ await Seed(
.SingleAsync();
Assert.AreEqual(periods.Take(2).RangeIntersectAgg(), serverIntersectAgg);
}

/// <summary>
/// <c>LowerBound()</c> and <c>UpperBound()</c> compared with <see langword="null"/>, read through
/// <c>HasValue</c>, coalesced with <c>??</c> 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 <c>lower</c> and <c>upper</c> 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.
/// </summary>
[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<string>();
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));
}

/// <summary>
/// Seeds one row per shape into <paramref name="column"/> — finite across, below and above
/// <paramref name="v"/>, unbounded on either side of it, infinite and empty — and runs every bound
/// operation on the server and over the rows as loaded. <paramref name="min"/> and
/// <paramref name="max"/> are the <c>??</c> fallbacks; they must round-trip through the column's
/// element type, or the projection disagrees for that reason alone.
/// </summary>
private static async Task<List<string>> BoundNullSweep<TRange, T>(
int firstId, Expression<Func<Reservation, TRange>> column, T a, T v, T b, T min, T max)
where TRange : class, IRangeFactory<TRange, T>, IRange<T>
where T : struct, IComparable<T>, IEquatable<T>
{
(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<int, string>();
var rows = new List<Reservation>();
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<T>, spliced onto the column, so the call is
// the one a consumer's r.Column.LowerBound() makes.
Expression<Func<Reservation, TOut>> On<TOut>(Expression<Func<Reservation, IRange<T>, TOut>> operation) =>
Expression.Lambda<Func<Reservation, TOut>>(FuzzEngine.Splice(operation, row0, column.Body), row0);

// (bound ?? fallback) op v, with both values captured, so EF Core sends them as parameters.
Expression<Func<Reservation, bool>> Coalesced(ExpressionType comparison, Expression<Func<Reservation, T?>> bound, T fallback) =>
Expression.Lambda<Func<Reservation, bool>>(
Expression.MakeBinary(comparison, Expression.Coalesce(bound.Body, Captured(fallback)), Captured(v)), row0);

(string Name, Expression<Func<Reservation, bool>> 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<string>();

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<Func<Reservation, T>>(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<Task<List<int>>> server, List<int> 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<int> ids) => $"{{{string.Join(" ", ids.Select(id => names[id]))}}}";
}

/// <summary>
/// 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.
/// </summary>
[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<Func<NullableRangeRow, bool>> 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<string>();

foreach (var (name, filter) in filters)
{
var test = filter.Compile();
var judged = new HashSet<int>();
var memory = new List<int>();
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";
}

/// <summary>A captured variable, which EF Core sends as a parameter.</summary>
private static MemberExpression Captured<T>(T value) =>
Expression.Field(Expression.Constant(new StrongBox<T>(value)), nameof(StrongBox<>.Value));

public sealed class NullableRangeRow
{
public int Id { get; set; }

public DateRange? Period { get; set; }
}

/// <summary>A database of its own, so that a nullable range column does not change the shared schema.</summary>
private sealed class NullableRangeContext(string connectionString) : DbContext
{
public DbSet<NullableRangeRow> Rows => Set<NullableRangeRow>();

public static async Task<string> 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<NullableRangeRow>().Property(r => r.Id).ValueGeneratedNever();
}
}
Loading
Loading