Skip to content

Materialize a left-joined non-entity projection as null on no-match (#30915)#38479

Open
ajcvickers wants to merge 11 commits into
dotnet:mainfrom
ajcvickers:fix/30915-defaultifempty-nonentity-null
Open

Materialize a left-joined non-entity projection as null on no-match (#30915)#38479
ajcvickers wants to merge 11 commits into
dotnet:mainfrom
ajcvickers:fix/30915-defaultifempty-nonentity-null

Conversation

@ajcvickers

Copy link
Copy Markdown
Contributor

Summary

A LEFT JOIN — via GroupJoin + DefaultIfEmpty() or the LeftJoin operator — whose inner side is a subquery projecting a non-entity (an anonymous type, a DTO, or a GroupBy-with-aggregate projection), when the whole projected object is selected, threw InvalidOperationException: Nullable object must have a value on a no-match (all-NULL) row. EF constructed the object from all-NULL columns instead of yielding null.

Entities already get this right via key-column null checks; this does the equivalent for non-entity projections.

Addresses #30915 and its duplicate #38055; partially implements the umbrella #22517. This is not a complete fix of that family — several related shapes remain deferred (see the checklist below), each pinned by a characterization test so a future change is detected.

Approach

Mirror the entity path without a structural wrapper (a wrapper around the inner shaper breaks NewExpression/MemberInitExpression member-folding in core ReplacingExpressionVisitor and RelationalSqlTranslatingExpressionVisitor, which would regress member-only consumers):

  1. Inject a synthesized marker column (1 AS marker, a SqlFragmentExpression) into the inner subquery on the nullable side, before pushdown, so the LEFT JOIN nulls it on a no-match row.
  2. Record (inner-shaper-node → marker binding) on the SelectExpression, re-keyed through the transparent-identifier projection-binding pass.
  3. Gate only the final whole-object projection in RelationalProjectionBindingExpressionVisitor: marker == null ? null : new { ... }. Member access folds to a column before reaching the gate, so member-only consumers are untouched.

The marker is injected only for reference-type New/MemberInit inners on the nullable side, and is pruned when unconsumed — so existing entity and member-only queries are byte-identical (verified below).

Testing

Built as a golden-master baseline to prove the change is additive: the characterization tests were committed first on main (asserting current behavior — correct results where it works, asserted-throw / translation-fail where it doesn't), then the fix, then only the tests whose behavior actually changed were flipped to assert the corrected behavior.

  • 36 characterization tests covering the matrix — both join styles; anonymous / DTO / struct / record-struct / ValueTuple / nested / aggregate inners; whole-object vs member-only; post-operators (Distinct, Take, GroupBy-after, second-join-after, Union); server-side null checks; alias collision with a user member named marker; and the matched-row-with-null-data invariant — green on SQLite and SQL Server.
  • The existing functional suites (GroupBy / Join / Select / SelectMany-APPLY / ComplexNavigations / GearsOfWar) on both providers show zero baseline drift.

Follow-up work (deferred; each already pinned by a characterization test)

All are sub-cases of the umbrella #22517 unless noted.

Confirmed working (not follow-ups), from the added coverage

  • Correlated SelectMany + DefaultIfEmpty whole-object works on providers that support OUTER APPLY (e.g. SQL Server); it fails on SQLite only because SQLite has no APPLY (a provider limitation, not this bug).
  • A user projection member literally named marker does not collide with the synthesized marker column — aliases uniquify (marker / marker0); pinned by a test, and the cosmetic outermost-SELECT duplicate (read by ordinal) self-heals under composition.
  • A matched row whose aggregate data is null stays non-null, distinct from a no-match — the invariant that justifies the marker design over member-coalescing; pinned by a test.

Issue housekeeping

#30915, #38055, #22517 and #28248 are the same problem family but currently disconnected — worth cross-linking.

Copilot AI review requested due to automatic review settings June 22, 2026 11:33
@ajcvickers ajcvickers requested a review from a team as a code owner June 22, 2026 11:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a long-standing relational materialization bug where a LEFT JOIN/DefaultIfEmpty() whose nullable side projects a non-entity object (anonymous type/DTO/aggregate projection) could throw InvalidOperationException: Nullable object must have a value on no-match rows, by ensuring the whole projected object becomes null instead of being constructed from all-NULL columns.

Changes:

  • Injects and tracks a synthetic “marker” projection on the nullable side of outer joins inside SelectExpression, and uses it to null-gate whole-object non-entity projections during projection binding.
  • Adds extensive relational characterization coverage for the #30915 shape matrix, with provider-specific SQL baselines for SQLite and SQL Server.
  • Adds provider-specific overrides for the correlated OUTER APPLY case on SQL Server (provider divergence vs. SQLite).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
test/EFCore.SqlServer.FunctionalTests/Query/AdHocMiscellaneousQuerySqlServerTest.cs Adds #30915 seeding and SQL Server-specific SQL baselines/overrides (including OUTER APPLY case).
test/EFCore.Sqlite.FunctionalTests/Query/AdHocMiscellaneousQuerySqliteTest.cs Adds #30915 seeding and SQLite SQL baselines for the fixed shapes.
test/EFCore.Relational.Specification.Tests/Query/AdHocMiscellaneousQueryRelationalTestBase.cs Adds the main characterization test matrix for left-joined non-entity whole-object materialization and deferred follow-ups.
src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs Implements marker injection/aliasing and marker bookkeeping on outer joins to enable null-gating later.
src/EFCore.Relational/Query/Internal/RelationalProjectionBindingExpressionVisitor.cs Applies marker-based gating when projecting whole non-entity objects, while avoiding interference with TransparentIdentifier intermediate projections.

@ajcvickers

Copy link
Copy Markdown
Contributor Author

@AndriySvyryd I did a lot of analysis of similar query shapes and added tests to try to reduce the risk of regressions here. There are still a lot of related cases that don't work--there is a checklist in the PR description.

@roji I know it's not your job, but if you have time it would be good to get your eyes on this.

…net#30915)

A LEFT JOIN (GroupJoin+DefaultIfEmpty or the LeftJoin operator) whose inner
side is a subquery projecting a non-entity (anonymous type, DTO, or a
GroupBy-with-aggregate projection) threw 'Nullable object must have a value'
when the whole projected object was selected and the join produced a no-match
(all-NULL) row: EF constructed the object instead of yielding null.

Entities already handle this via key-column null checks. For non-entity
projections, inject a synthesized marker column (1, aliased 'marker') into the
inner subquery on the nullable side so the LEFT JOIN nulls it on no-match,
record (inner-shaper-node -> marker binding) on the SelectExpression, and gate
whole-object materialization in RelationalProjectionBindingExpressionVisitor so
it yields null when the marker is null. Member access is unaffected (it folds
to a column before reaching the gate), and value-type / transparent-identifier
projections are left untouched.

Covers direct whole-object projection of a left-joined non-entity. Does not yet
cover: a subsequent GroupBy/second-join, a non-pushed-down inner, value-type
structs, or server-side object == null (tracked as follow-ups).
…rojection, alias collision, boundary cases
…30915)

When a user projection has a member named 'marker' beside the projected nullable
object, the synthesized marker uniquifies at every subquery level but leaves a
cosmetic duplicate output alias at the outermost SELECT (which never uniquifies,
for any projection - EF binds by ordinal). Legal SQL; self-heals under composition.
Comment points to the pinning tests so this isn't re-opened as a bug.
TryGetNonEntityNullabilityMarker and RemapNonEntityNullabilityMarker are
query-translation operations on the public SelectExpression type, in the same
category as the existing pubternal ApplyDefaultIfEmpty / MakeProjectionNullable.
Match that house style: public + [EntityFrameworkInternal] + the standard
internal-API doc disclaimer, rather than real 'internal'. (NullabilityMarkerMemberInfo
stays internal: a constant-like shared detail, cf. internal const DiscriminatorColumnAlias.)
…ner, DISTINCT marker, Nullable<T>, ValueTuple, deep member access)
@ajcvickers ajcvickers force-pushed the fix/30915-defaultifempty-nonentity-null branch from 66877b0 to f4e208e Compare June 22, 2026 12:02
…arker column

Left_join_on_entity_with_owned_navigations_complex projects an anonymous
type after grouping.DefaultIfEmpty(), so the dotnet#30915 nullability marker
(1 AS [marker]) is now legitimately emitted in the DISTINCT subquery.
Data assertions already pass on both providers; only the SQL Server
baselines needed updating (SQLite does not baseline this query's SQL).
Copilot AI review requested due to automatic review settings June 22, 2026 12:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@AndriySvyryd AndriySvyryd requested a review from cincuranet June 22, 2026 23:23
// Category A: whole non-entity object projected from the nullable side
// ---------------------------------------------------------------------------------------

[Fact] // 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove numbering comments.
Use regions to group tests instead of just comments

private static bool IsTransparentIdentifierProjection(Expression expression)
=> expression is NewExpression newExpression && IsTransparentIdentifierType(newExpression.Type);

private static bool IsTransparentIdentifierType(Type type)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to TransparentIdentifierFactory, there's probably more code that could use this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants