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",