Repository navigation
Description
Problem
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)becomesWHERE r."MaybeTags" IS NULLand 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).
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-…