Skip to content

Query: whole non-entity object from a plain (non-aggregated) LeftJoin/DefaultIfEmpty inner is not nulled on no-match #38608

Description

@ajcvickers

Sub-case of #22517. Prior report: #26873 (closed as duplicate). This one is filed to track the specific plain-inner variant, which — unlike its siblings — cannot be fixed with the narrow relational marker and should be tackled as part of the navigation-expansion rework (#32957 / #37859).

Repro shape

A whole non-entity object projected from the nullable side of a LeftJoin / GroupJoin+DefaultIfEmpty, where the inner is a plain (non-aggregated) Select:

var categories = context.Requests.Select(r => new { r.PickupStatusId, Count = 1 });

var query = context.Statuses
    .LeftJoin(categories, s => s.PickupStatusId, c => c.PickupStatusId, (s, countInfo) => new { s.PickupStatusId, countInfo });

On a no-match row this throws Nullable object must have a value instead of yielding a null countInfo. Same shape via GroupJoin + DefaultIfEmpty.

Root cause

Navigation expansion folds the trivial inner Select into the pending selector and moves the projection forward. So at AddJoin the inner shaper is the raw entity, not a New/MemberInit. The #30915 marker gate is conditioned on the inner shaper being New/MemberInit, so the marker is never injected. Nothing gates the object at materialization.

Why this cannot be fixed in the relational layer

Two relational-layer approaches were tried and rejected:

  1. Inject the marker at AddJoin. The code path never runs — the inner shaper is already a bare entity by the time AddJoin sees it.
  2. Gate at projection-binding time instead. This false-positives on ordinary optional-navigation projections (Select(x => new { x.OptionalNav.Prop })): it nulls the whole object instead of the member, defeating optional-navigation type compensation. Confirmed regression on 8 GearsOfWarQuery tests.

After folding, the whole-DefaultIfEmpty'd-element case is structurally identical to an optional-navigation member access. The distinguishing signal is PendingSelector, which is a core-only concept, invisible to providers, and erased before translation runs. No relational-layer rule can recover it.

Conclusion

The marker gate needs the inner projection to still be a New/MemberInit at translation time; folding removes that precondition. Removing/reworking navigation expansion dissolves the fold — the projection reaches translation as written, the marker fires, and the optional-navigation case stays distinct. This variant belongs in that work, not a narrow relational fix.

The other #22517 sub-cases (struct/record-struct whole object #30915, GroupBy-after-join #28119, value-type left-join default #38555) do not fold and remain fixable narrowly; they are unaffected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions