Skip to content

A null range answers some extensions in memory and throws on others, and the answers it gives disagree with PostgreSQL on a NULL column #350

Open

Description

@CaffeinatedCoder

Problem

On a null receiver the range extensions do one of three things, and which one can depend on the operand:

  • Answer a value. IsEmpty(), IsFinite(), IsInfinity(), IsUnboundedStart() and IsUnboundedEnd() are range is I…Range<T> and answer false; LowerBound() and UpperBound() answer null; LowerBoundInclusive() and UpperBoundInclusive() answer false; Overlaps(x) answers false; Contains(Empty) and IsContainedBy((,)) answer true; Intersect(x) answers Empty; Gap(Empty) answers Empty; Merge((,)) answers (,); Union((,)) answers a set.
  • Throw NullReferenceException. Contains(T), and Merge, Gap and Except for most operands.
  • Throw InvalidOperationException. Union for most operands.

In a query the same call over a NULL column is NULL. So wherever memory answers, a negated or vacuous form disagrees on exactly the NULL rows: !r.Maybe.IsEmpty(), !r.Maybe.IsFinite(), !r.Maybe.IsUnboundedEnd(), !r.Maybe.UpperBoundInclusive() and !r.Maybe.Overlaps(p) keep NULL rows in memory and drop them in SQL, and so do r.Maybe.Contains(Empty), r.Maybe.IsContainedBy((,)), r.Maybe.Intersect(p) != null, r.Maybe.Intersect(p) == Empty, r.Maybe.Gap(Empty).IsEmpty() and r.Maybe.Union((,)).Contains(d); r.Maybe.Merge((,)) == r.Maybe disagrees the other way (EF Core's null compensation keeps the NULL rows, memory drops them). Measured 2026-10-23 with the same probe and versions as #349: these 12 predicate shapes disagree, as parameter and as inlined constant, and every one of their disagreements is a NULL row. On the required column the same shapes agree. The bound comparisons that also disagree on this column (LowerBound() == null becoming r."Maybe" IS NULL) are #349.

#261 settled the comparison case on the assumption that "in memory the call would throw a NullReferenceException on the null receiver". For these members it does not.

Decision (2026-10-24): rule (c), EF Core's convention

EF Core treats a predicate over NULL as false and keeps the row under a negation (measured in the comment below, and in #355 for string[]). The range extensions follow it:

Member kind On a null range (either side) Line
Boolean predicates (state checks, Contains, IsContainedBy, Overlaps, IsAdjacentTo, the position operators) false, in memory and in SQL; the negation is true 11.0.0 and 10.2.0
LowerBound(), UpperBound() null, as today, matching SQL unchanged
Value-producing members (Intersect, Gap, Merge, Union, Except) throw ArgumentNullException 11.0.0 only (breaking)
CompareTo against 0 or ±1, and ordering as documented in docs/ranges.md#sort-ranges (#220, #253, #256, #261): Comparer<T>.Default, a NULL equals null and sorts before every range; CompareTo called on a null throws in memory; ORDER BY puts NULLs where PostgreSQL does (#359) unchanged

The work is split:

Done when

Every range and RangeSet extension follows the table above, held by the convention test and by ExecutedQueryTests.PredicatesOverANullableColumn_SelectTheRowsMemorySelects (widened by #355). docs/efcore.md and docs/ranges.md say what a null range and a NULL column return, as claims. The twelve candidates registered by #352 report candidate fixed and are removed: the seven predicates through #355 and the in-memory changes, the five value-producing forms because memory throws.

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
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    package/coreAffects CodoMetis.ValueRanges
    severity/mediumInconsistency or failure in a narrow scenario
    on Oct 23, 2026
  3. CaffeinatedCoder commented on Oct 24, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Measured 2026-10-24 on main (EF Core 11 RC, Npgsql 11 RC, ToQueryString()): what EF Core itself writes for a negated predicate on a nullable column, next to what this package writes.

    LINQ (nullable column) SQL NULL row
    !r.Name.StartsWith("a") r."Name" NOT LIKE 'a%' OR r."Name" IS NULL kept
    !r.Name.Contains("a") r."Name" NOT LIKE '%a%' OR r."Name" IS NULL kept
    !(r.Name.Length > 3) CASE WHEN length(r."Name")::int > 3 THEN FALSE ELSE TRUE END kept
    !(r.Number > 3) CASE WHEN r."Number" > 3 THEN FALSE ELSE TRUE END kept
    !r.Maybe.IsEmpty() NOT (isempty(r."Maybe")) dropped
    !r.Maybe.Overlaps(p) NOT (r."Maybe" && @p) dropped
    r.Maybe.Contains(Empty) r."Maybe" @> @Empty dropped

    So EF Core does not treat a predicate on a NULL value as unknown. It treats it as false, and compensates the negation so the row comes back, even for string members whose C# call would throw on null. This package's translations are the exception, not EF Core's convention. In memory, the range predicates that answer on a null receiver already answer false (IsEmpty, IsFinite, IsInfinity, IsUnboundedStart, IsUnboundedEnd, the …Inclusive pair, Overlaps); only Contains(Empty) and IsContainedBy((,)) answer true.

    That suggests a third option beside (a) and (b):

    • (c) EF Core's convention. A Boolean range or RangeSet predicate over a null value is false in memory and in a query, and its negation is true. In SQL only the negated forms change, as x IS NULL OR NOT (…), which is how EF Core writes !Name.StartsWith; positive predicates keep their SQL, so index use for && is untouched (not measured). In memory, Contains(Empty) and IsContainedBy((,)) return false on null, and Contains(T) stops throwing NullReferenceException. The value-producing members (Intersect, Gap, Merge, Union, Except) throw ArgumentNullException on a null receiver, where today they return a value, throw NullReferenceException or throw InvalidOperationException depending on the operand. LowerBound()/UpperBound() keep returning null, which matches SQL.

    Not measured yet: whether a translation shape such as x IS NOT NULL AND isempty(x) lets EF Core's own nullability processing drop the check on a required column (so the SQL there stays as it is) and produce the compensation under NOT for a nullable one.

  4. added a commit that references this issue on Oct 24, 2026
  5. CaffeinatedCoder commented on Oct 24, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Measured 2026-10-24 for the value-producing half of (c), on main, DateRange and LocalDateRange alike: Intersect(null) with a null operand returns empty for a finite, a half-unbounded and the empty receiver, and throws NullReferenceException for (,). So today one member answers a value or throws depending on the receiver's shape. That is how #361's RangeIntersectAgg returned empty after a bounded range and threw after (,). Rule (c) (ArgumentNullException, 11.0.0) covers the null operand as well as the null receiver.

  6. CaffeinatedCoder commented on Oct 24, 2026

    @CaffeinatedCoder
    OwnerAuthor

    The predicate half of rule (c) is PR #371 (with #355): the null guards in RangeExtensions and RangeSet, and the SQL wrapper. Its seven predicate candidates are removed as fixed. #370 (#356) removed Union(Infinite).Contains(v), since memory throws for it. Left for this issue's 11.0.0 half: the four value-producing candidates Intersect(p) != null, Intersect(p) == Empty, Gap(Empty).IsEmpty() and Merge(Infinite) == x, through ArgumentNullException from Intersect, Gap, Merge, Union and Except on a null range or operand.

  7. 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/coreAffects CodoMetis.ValueRangespackage/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLseverity/mediumInconsistency or failure in a narrow 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