Repository navigation
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
Description
Problem
A Boolean predicate over a NULL range, RangeSet or value set column is NULL in SQL, so a filter drops the row whether the predicate is negated or not. EF Core's own translations treat a predicate over NULL as false and keep the row under a negation. Measured 2026-10-24 with ToQueryString() on main (EF Core 11 RC, Npgsql 11 RC) and on release/10.x (EF Core 10.0.0, and 10.0.13 with Npgsql 10.0.3), with identical SQL on all three:
| LINQ over a nullable column | SQL | NULL row |
|---|---|---|
!r.Name.StartsWith("a") (EF Core) |
r."Name" NOT LIKE 'a%' OR r."Name" IS NULL |
kept |
!r.Names.Contains("x") (string[], EF Core) |
NOT ('x' = ANY (r."Names") AND 'x' = ANY (r."Names") IS NOT NULL) |
kept |
!r.Names.Any() (string[], EF Core) |
CASE WHEN cardinality(r."Names") > 0 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(DateRange.Empty) |
r."Maybe" @> @Empty |
dropped (memory: true) |
!r.MaybeSet.Overlaps(p) (RangeSet) |
NOT (r."MaybeSet" && @p) |
dropped |
!r.MaybeSet.IsEmpty() (RangeSet) |
NOT (isempty(r."MaybeSet")) |
dropped |
!r.MaybeTags.Contains("x") (StringSet) |
NOT (r."MaybeTags" @> ARRAY['x']::text[]) |
dropped |
!r.MaybeTags.IsEmpty (StringSet) |
cardinality(r."MaybeTags") <> 0 OR r."MaybeTags" IS NULL |
kept |
So the package also disagrees with itself: a value set's !IsEmpty keeps the NULL row (it is a comparison, which EF Core compensates) and its !Contains drops it. In a projection, Select(r => r.Maybe!.IsEmpty()) is isempty(r."Maybe"), NULL for a NULL row; what reading that into bool does was not measured.
For a range, the in-memory side is #350: most extensions answer false on a null range, so the negated forms disagree with memory on the NULL row. For RangeSet and the value sets the members are instance members, so memory throws NullReferenceException and the only reference is EF Core's convention.
Change
Rule (c) from #350: a Boolean predicate with a NULL range, RangeSet or value set on either side is false, and its negation true. The translators write each operand that can be NULL as x IS NOT NULL AND <predicate>, and EF Core's nullability processing does the rest. Measured with that shape written by hand in LINQ (r.X != null && r.X.IsEmpty()), identical on both EF Core lines:
| Operand | Positive | Negated |
|---|---|---|
| required column | isempty(r."Period") (check removed) |
NOT (isempty(r."Period")) |
| nullable column | r."Maybe" IS NOT NULL AND isempty(r."Maybe") |
r."Maybe" IS NULL OR NOT (isempty(r."Maybe")) |
nullable column, && |
r."Maybe" IS NOT NULL AND r."Maybe" && @p |
r."Maybe" IS NULL OR NOT (r."Maybe" && @p) |
| two nullable columns | r."Maybe" IS NULL OR r."Other" IS NULL OR NOT (r."Maybe" && r."Other") |
|
| non-null captured parameter | NOT (r."Period" && @p) (check removed) |
|
| projection, nullable column | r."Maybe" IS NOT NULL AND isempty(r."Maybe") |
The SQL over required columns and non-null parameters is unchanged. A positive filter over a nullable column keeps the operator a GiST index serves, beside an IS NOT NULL (not checked with EXPLAIN). This covers every Boolean predicate the translators emit for ranges, RangeSet and value sets, with two exceptions that keep their documented rules. CompareTo against 0 or ±1 follows Comparer<T>.Default (docs/ranges.md#sort-ranges; #220, #253, #256, #261): a NULL column equals a null range and sorts before every other range, so its rewrite stays as it is. Equality (==, !=) is EF Core's own null semantics. A null test is already allowed on a value derived from array_cat (ValueSetSqlGuard), so Union operands keep working.
Done when
ExecutedQueryTests.PredicatesOverANullableColumn_SelectTheRowsMemorySelectscovers every Boolean predicate, discovered rather than listed, over a nullable column of every range type, aRangeSetand a value set, in a filter (positive and negated) and in a projection. Range rows compare with memory (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).RangeSetand value set rows, whose instance call throws in memory, compare withfalse.- Translation tests pin the required-column SQL as unchanged.
docs/efcore.mdsays what a predicate over a NULL column returns, as a claim held by that sweep (with 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).- The
release/10.xport is its own issue.
Activity
CaffeinatedCoder commentedon Oct 24, 2026
Measured 2026-10-24 on PostgreSQL 16, for the projection row of the table above: today
Select(r => r.Maybe!.IsEmpty()),Select(r => r.Maybe!.Overlaps(DateRange.Infinite))andSelect(r => r.MaybeTags!.Count)over a NULL row throwInvalidOperationException: Cannot read the Value property of a Nullable object that has no valuewhen EF Core reads the NULL intoboolorint. Memory returnsfalseforIsEmpty()(the range extension) and throwsNullReferenceExceptionfor the value set'sCount(an instance member). With thex IS NOT NULL AND …shape the two predicates readfalse.Counthas no rule yet; under (c) it is a value, not a predicate.fix: a predicate over a null range, range set or value set is false (#…
Merge pull request #373 from CaffeinatedCoder/fix/357-null-predicates…