Skip to content

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

Closed

Description

@CaffeinatedCoder

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

  1. added this to the 11.0.0 milestone on Oct 23, 2026
  2. added
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    area/rangesRange unions, variants and the shape engines
    area/range-setRangeSet and multirange algebra
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    package/efcore-nodatimeAffects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime
    severity/highWrong results, crash or failed writes in a realistic scenario
    on Oct 23, 2026
  3. added a commit that references this issue on Oct 24, 2026
  4. added 2 commits that reference this issue on Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/range-setRangeSet and multirange algebraarea/rangesRange unions, variants and the shape enginesbreakingChanges an answer or a signature — needs a majorbugSomething isn't workingpackage/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLpackage/efcore-nodatimeAffects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTimeseverity/highWrong results, crash or failed writes in a realistic scenariosilentAnswers wrongly without throwing — fixes go in the next major, never deferred

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions