Skip to content

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

Closed

Description

@CaffeinatedCoder

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) 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).

Activity

  1. added this to the 11.0.0 milestone on Oct 24, 2026
  2. added
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    area/value-setsValue set families and wrapper arities
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    severity/lowCosmetic, message or documentation-level difference
    on Oct 24, 2026
  3. CaffeinatedCoder commented on Oct 24, 2026

    @CaffeinatedCoder
    OwnerAuthor

    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.

    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.

  4. CaffeinatedCoder commented on Oct 24, 2026

    @CaffeinatedCoder
    OwnerAuthor

    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.

  5. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    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.

  6. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    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.

  7. added 2 commits that reference this issue on Oct 25, 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/value-setsValue set families and wrapper aritiesbugSomething isn't workingfound-by/oracleFound by the generated-oracle machinery (claims × API surfaces)package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLseverity/lowCosmetic, message or documentation-level differencesilentAnswers 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