Repository navigation
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
Description
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()andIsUnboundedEnd()arerange is I…Range<T>and answerfalse;LowerBound()andUpperBound()answernull;LowerBoundInclusive()andUpperBoundInclusive()answerfalse;Overlaps(x)answersfalse;Contains(Empty)andIsContainedBy((,))answertrue;Intersect(x)answersEmpty;Gap(Empty)answersEmpty;Merge((,))answers(,);Union((,))answers a set. - Throw
NullReferenceException.Contains(T), andMerge,GapandExceptfor most operands. - Throw
InvalidOperationException.Unionfor 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:
- This issue (11.0.0): the in-memory rule for the range extensions, documented once in
docs/ranges.md(a null range) anddocs/efcore.md(a NULL column). It also adds<exception cref="ArgumentNullException">on the members that throw, and a discovered convention test over every public range andRangeSetextension that holds each kind's null behaviour. - A predicate over a NULL range, RangeSet or value set column is NULL in SQL, so its negation drops the row EF Core's own translations keep #355 (11.0.0): the SQL half, for ranges,
RangeSetand value sets:x IS NOT NULL AND <predicate>, measured on both EF Core lines. - RangeSet.From accepts a null element: the set holds null, IsEmpty() is false and Contains throws #356:
RangeSet.Fromaccepts a null element, which is howExceptandUnionon a null range build a set holdingnulltoday. - 10.x: a predicate over a null range, RangeSet or value set is false in memory and in SQL (port of the predicate half of #350, #355 and #356) #357 (10.2.0): the predicate half of this issue, A predicate over a NULL range, RangeSet or value set column is NULL in SQL, so its negation drops the row EF Core's own translations keep #355 and RangeSet.From accepts a null element: the set holds null, IsEmpty() is false and Contains throws #356 on
release/10.x. The value-producing members stay as they are there and are documented as undefined on a null range.
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
CaffeinatedCoder commentedon Oct 24, 2026
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.!r.Name.StartsWith("a")r."Name" NOT LIKE 'a%' OR r."Name" IS NULL!r.Name.Contains("a")r."Name" NOT LIKE '%a%' OR r."Name" IS NULL!(r.Name.Length > 3)CASE WHEN length(r."Name")::int > 3 THEN FALSE ELSE TRUE END!(r.Number > 3)CASE WHEN r."Number" > 3 THEN FALSE ELSE TRUE END!r.Maybe.IsEmpty()NOT (isempty(r."Maybe"))!r.Maybe.Overlaps(p)NOT (r."Maybe" && @p)r.Maybe.Contains(Empty)r."Maybe" @> @EmptySo 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 forstringmembers 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 answerfalse(IsEmpty,IsFinite,IsInfinity,IsUnboundedStart,IsUnboundedEnd, the…Inclusivepair,Overlaps); onlyContains(Empty)andIsContainedBy((,))answertrue.That suggests a third option beside (a) and (b):
RangeSetpredicate over a null value isfalsein memory and in a query, and its negation istrue. In SQL only the negated forms change, asx 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)andIsContainedBy((,))returnfalseon null, andContains(T)stops throwingNullReferenceException. The value-producing members (Intersect,Gap,Merge,Union,Except) throwArgumentNullExceptionon a null receiver, where today they return a value, throwNullReferenceExceptionor throwInvalidOperationExceptiondepending on the operand.LowerBound()/UpperBound()keep returningnull, 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 underNOTfor a nullable one.Merge pull request #352 from CaffeinatedCoder/test/349-350-bound-null…
CaffeinatedCoder commentedon Oct 24, 2026
Measured 2026-10-24 for the value-producing half of (c), on
main,DateRangeandLocalDateRangealike:Intersect(null)with a null operand returnsemptyfor a finite, a half-unbounded and the empty receiver, and throwsNullReferenceExceptionfor(,). So today one member answers a value or throws depending on the receiver's shape. That is how #361'sRangeIntersectAggreturnedemptyafter a bounded range and threw after(,). Rule (c) (ArgumentNullException, 11.0.0) covers the null operand as well as the null receiver.fix: a range set refuses a null range instead of storing it (#356)
CaffeinatedCoder commentedon Oct 24, 2026
The predicate half of rule (c) is PR #371 (with #355): the null guards in
RangeExtensionsandRangeSet, and the SQL wrapper. Its seven predicate candidates are removed as fixed. #370 (#356) removedUnion(Infinite).Contains(v), since memory throws for it. Left for this issue's 11.0.0 half: the four value-producing candidatesIntersect(p) != null,Intersect(p) == Empty,Gap(Empty).IsEmpty()andMerge(Infinite) == x, throughArgumentNullExceptionfromIntersect,Gap,Merge,UnionandExcepton a null range or operand.fix: a predicate over a null range, range set or value set is false (#…
Merge pull request #373 from CaffeinatedCoder/fix/357-null-predicates…