Query: whole non-entity object from a plain (non-aggregated) LeftJoin/DefaultIfEmpty inner is not nulled on no-match
- 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
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