Repository navigation
LowerBound() and UpperBound() in a query: == null becomes WHERE FALSE and ?? max loses its COALESCE, because lower() and upper() are declared NULL only for a NULL argument #349
Description
Problem
BoundFunction (ValueRangesMethodCallTranslator.cs:639; release/10.x the same) declares lower() and upper() with argumentsPropagateNullability: [true]. EF Core reads that as "NULL only when the argument is NULL" and simplifies on it. PostgreSQL's lower and upper also return NULL for the empty range and for the unbounded side of a non-null range, which docs/efcore.md states and the in-memory LowerBound() and UpperBound() match. So over a required column EF Core treats the bound as never null:
Query (required LocalDateRange Period) |
SQL | rows, PostgreSQL / memory |
|---|---|---|
r.Period.LowerBound() == null |
WHERE FALSE |
{} / {3,5,6} |
r.Period.UpperBound() != null, r.Period.LowerBound().HasValue |
no WHERE |
all 10 / 7 |
(r.Period.UpperBound() ?? LocalDate.MaxIsoValue) >= @d |
upper(r."Period") - 1 >= @d |
every open end drops out |
(r.Period.LowerBound() ?? LocalDate.MinIsoValue) <= @d |
lower(r."Period") <= @d |
every open start drops out |
Select(r => r.Period.UpperBound() ?? LocalDate.MaxIsoValue) |
no COALESCE |
throws Nullable object must have a value |
nullable column: r.Maybe.LowerBound() == null |
r."Maybe" IS NULL |
misses the empty and unbounded rows |
InstantRange behaves the same (r.Window.LowerBound() == null and UpperBound() == null both give WHERE FALSE), so this is every range type, not a discrete-type adjustment. RangeSet's bounds go through the same helper (ValueRangesMethodCallTranslator.cs:428) and disagree the same way: measured since in #353 on the RangeSet column of every family.
A filter on a projected bound, which is what a DevExtreme grid sends for a "blank" header filter, gives the same: Select(r => new { To = r.Period.UpperBound() }).Where(x => x.To == null) is WHERE FALSE, while the projection alone reads the NULLs correctly.
Measured 2026-10-23 with 10.1.0 from nuget.org, EF Core 10.0.13, Npgsql 10.0.3 and PostgreSQL 18.4, over rows of every shape including MinIsoValue and MaxIsoValue bounds, as parameter and as inlined constant. Found by a consumer's differential probe (EF query against LINQ to objects on the same rows). Nothing in the suite compares a bound with null inside a query: ExecutedQueryTests.MergeAndBoundAccessors_MatchInMemory only projects them, and the projection is right.
Change
argumentsPropagateNullability: [false] for lower and upper in BoundFunction, keeping nullable: true. Checked with a stand-in DbFunction that translates to lower() under each flag: [true] gives WHERE FALSE; [false] keeps lower(r."Period") IS NULL (rows {3,5,6}, as memory) and COALESCE(lower(r."Period"), @MinIsoValue) <= @cutoff. The patched source has since been built and run (2026-10-23, main, PostgreSQL 16): with the flag flipped, ExecutedQueryTests.BoundsComparedWithNull_SelectTheRowsMemorySelects (#352) agrees with memory on all nine operations over 13 range columns (every BCL, NodaTime and custom range type), so its nine candidates report candidate fixed; nothing else in the class changes.
isempty, lower_inf, upper_inf and range_merge propagate correctly: they are NULL only for a NULL input (range_merge('{}'::datemultirange) is empty, not NULL).
Release
silent: answers change, so it ships as a minor on both lines with a ### Changed answers row. release/10.x (10.1.0, what consumers on .NET 10 run) has the same declaration; its port is #354, which waits for a 10.x minor (#331). The disagreement is registered as nine candidates (one per operation) in ExecutedQueryTests by #352; the fix, #353 (stacked on #352), removes them, and with its flag reverted the bound sweep fails on 234 rows, 117 on the range columns and 117 on the RangeSet columns. On main it is listed under ### Breaking in [11.0.0-preview.1] with docs/migration.md#check-bound-comparisons-with-null-in-queries, since a preview is a major's.
Activity
Merge pull request #352 from CaffeinatedCoder/test/349-350-bound-null…
Merge pull request #367 from CaffeinatedCoder/fix/354-bound-null-prop…
docs: ordering by a bound puts the rows without it where PostgreSQL p…