You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Value set Union is declared NULL only for a NULL operand, but array_cat isn't strict: a filter and a projection disagree on a NULL column #360
ValueSetsMethodCallTranslator declares Union (array_cat, behind ValueSetSqlGuard.UnguardedUnion) with argumentsPropagateNullability: [true, true]. That tells EF Core the result is NULL exactly when an operand is. PostgreSQL's array_cat is not strict (pg_proc.proisstrict = false): array_cat(NULL::text[], ARRAY['a']) and array_cat(ARRAY['a'], NULL) both return {a}.
Measured 2026-10-24 on PostgreSQL 16, with a nullable StringSet column that is NULL in rows 1, 5 and 8, and o = {z}:
Where(r => r.MaybeTags.Union(o) == null) becomes WHERE r."MaybeTags" IS NULL and returns rows 1, 5 and 8.
Select(r => r.MaybeTags.Union(o)) returns {z} for rows 1, 5 and 8.
So within SQL, the same expression is NULL in a filter and {z} in a projection. In memory the instance call throws NullReferenceException. Under #350's rule (c), a value-producing member on a null value has no value, so the filter's NULL is the intended answer.
Remove (array_remove) is declared the same way, and it isn't strict in its element argument either: array_remove(ARRAY['a'], NULL) is {a}, while array_remove(NULL::text[], 'a') is NULL. A null element argument is probably unreachable for a value set; that needs checking.
Change
Keep the declaration, and make the SQL match it. For example, Union translates to CASE WHEN a IS NULL OR b IS NULL THEN NULL ELSE array_cat(a, b) END when an operand can be NULL. ValueSetSqlGuard must still see the placeholder, and it must allow the CASE around it (it already follows values through CASE). The alternative, declaring [false, false], would make the filter agree with {z}: a value for a null value set, which rule (c) rules out.
Done when
The context sweep (filter and projection over a nullable value set column) agrees with itself for Union and Remove. The declared nullability of every function the translators emit is checked against PostgreSQL in a test, as for lower/upper (#349).
Measured 2026-10-24 on main, PostgreSQL 16, by the composition sweeps (#375, #376): both halves of this issue are reachable from ordinary queries, and Remove's NULL element is reachable too.
Outer join (OuterJoinProbeTests, Outer-join sweep: range, RangeSet and value set members over the NULL a left join or optional navigation produces from a required column #375): a parent without a child reads the child's required Tags and Name as NULL. c.Tags.Union(p.Labels), c.Tags.Union(@{a}) and p.Labels.Union(c.Tags) project the other operand ({a}, or {} for an empty one) where Where(… == null) selects the row. p.Labels.Remove(c.Name) projects p.Labels unchanged (array_remove(p."Labels", NULL)) where Where(… == null) selects the row. The same through a navigation, DefaultIfEmpty and LeftJoin.
Every one of these is a projection; the filters (== null, != null) agree with rule (c) everywhere. The rows are registered as candidates.json entries against this issue.
It reproduces on 10.x, with a workaround that holds. Measured 2026-10-24 on release/10.x (9af5842), PostgreSQL 16, on EF Core 10.0.0 and on 10.0.13 with Npgsql 10.0.3. A nullable StringSet column is NULL in rows 1 and 3, o = {z}, and the required Tags = {a,b}, with a nullable Label:
Where(r => r.MaybeTags.Union(o) == null) selects rows 1 and 3. Select(r => r.MaybeTags.Union(o)) loads {z} for them.
Select(r => r.Tags.Remove(r.Label)) loads {a,b} where Label is NULL, and the filter … == null selects those rows.
Workaround:r.MaybeTags == null ? null : r.MaybeTags.Union(o) and r.Label == null ? null : r.Tags.Remove(r.Label) load NULL for those rows. EF Core keeps the CASE and ValueSetSqlGuard allows it in the projection. The filter is unchanged.
Deferred from 10.2.0. It is named with that workaround under docs/efcore.md#known-differences-in-1020 (#397 / PR #398). The 10.x port is #395, and it removes the entry.
Committed for 10.3.0 through its port #395, which this issue blocks (#408, widened 2026-10-25). The fix lands here on main first, and #395 cherry-picks it into release/10.x.
Fixed in PR #443: Union and Remove become CASE WHEN a IS NULL OR b IS NULL THEN NULL ELSE f(a, b) END, with the inner array_cat/array_remove declared as propagating nothing (so DeclaredNullabilityTests' static check agrees with pg_proc.proisstrict). EF Core folds the test away for required columns, non-null parameters and constants, so their SQL is unchanged. All 24 candidates registered against this issue reported candidate fixed and are removed. ValueSetNullOperandParityTests holds it and is written to cherry-pick into #395.
Activity
CaffeinatedCoder commentedon Oct 24, 2026
Measured 2026-10-24 on
main, PostgreSQL 16, by the composition sweeps (#375, #376): both halves of this issue are reachable from ordinary queries, andRemove's NULL element is reachable too.OuterJoinProbeTests, Outer-join sweep: range, RangeSet and value set members over the NULL a left join or optional navigation produces from a required column #375): a parent without a child reads the child's requiredTagsandNameas NULL.c.Tags.Union(p.Labels),c.Tags.Union(@{a})andp.Labels.Union(c.Tags)project the other operand ({a}, or{}for an empty one) whereWhere(… == null)selects the row.p.Labels.Remove(c.Name)projectsp.Labelsunchanged (array_remove(p."Labels", NULL)) whereWhere(… == null)selects the row. The same through a navigation,DefaultIfEmptyandLeftJoin.NullableOperandPairProbeTests, Nullable-operand-pair sweep: every binary member over two nullable operands, including CASE and COALESCE operands #376):r.TagsA.Union(r.TagsB), and the same with aCASEorCOALESCEoperand, project the non-NULL side on every (null, value) and (value, null) row.r.TagsA.Remove(r.Label)with a nullablestringcolumn projectsTagsAon the rows whoseLabelis NULL.Every one of these is a projection; the filters (
== null,!= null) agree with rule (c) everywhere. The rows are registered ascandidates.jsonentries against this issue.CaffeinatedCoder commentedon Oct 24, 2026
It reproduces on 10.x, with a workaround that holds. Measured 2026-10-24 on
release/10.x(9af5842), PostgreSQL 16, on EF Core 10.0.0 and on 10.0.13 with Npgsql 10.0.3. A nullableStringSetcolumn is NULL in rows 1 and 3,o = {z}, and the requiredTags = {a,b}, with a nullableLabel:Where(r => r.MaybeTags.Union(o) == null)selects rows 1 and 3.Select(r => r.MaybeTags.Union(o))loads{z}for them.Select(r => r.Tags.Remove(r.Label))loads{a,b}whereLabelis NULL, and the filter… == nullselects those rows.r.MaybeTags == null ? null : r.MaybeTags.Union(o)andr.Label == null ? null : r.Tags.Remove(r.Label)load NULL for those rows. EF Core keeps theCASEandValueSetSqlGuardallows it in the projection. The filter is unchanged.Deferred from 10.2.0. It is named with that workaround under
docs/efcore.md#known-differences-in-1020(#397 / PR #398). The 10.x port is #395, and it removes the entry.CaffeinatedCoder commentedon Oct 25, 2026
Committed for 10.3.0 through its port #395, which this issue blocks (#408, widened 2026-10-25). The fix lands here on
mainfirst, and #395 cherry-picks it intorelease/10.x.CaffeinatedCoder commentedon Oct 25, 2026
Fixed in PR #443:
UnionandRemovebecomeCASE WHEN a IS NULL OR b IS NULL THEN NULL ELSE f(a, b) END, with the innerarray_cat/array_removedeclared as propagating nothing (soDeclaredNullabilityTests' static check agrees withpg_proc.proisstrict). EF Core folds the test away for required columns, non-null parameters and constants, so their SQL is unchanged. All 24 candidates registered against this issue reportedcandidate fixedand are removed.ValueSetNullOperandParityTestsholds it and is written to cherry-pick into #395.Merge pull request #443 from CaffeinatedCoder/fix/360-value-set-null-…
Merge pull request #445 from CaffeinatedCoder/fix/395-value-set-null-…