dotnet / dotnet/efcore

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

Open
#38,608 0 comments 0 reactions 0 assignees View on GitHub
area-query
Dominant language
C#
Stars
14.8k
Forks
3.4k
PR merge metrics
PR metrics pending

Description

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`**:

```csharp
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.

Contributor guide

Open the contributing guide

Research direction

Start with the navigation-expansion rework in #32957/#37859 and trace how PendingSelector is folded before AddJoin. Reproduce the plain non-aggregated Select with LeftJoin or GroupJoin plus DefaultIfEmpty, then compare it with the optional-navigation and other #22517 cases. Done means the no-match whole object is null without regressing optional-navigation compensation.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
databases
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.