Query: lift GroupBy aggregates over reference navigations into pre-GroupBy joins#38668
Query: lift GroupBy aggregates over reference navigations into pre-GroupBy joins#38668ducmerida wants to merge 7 commits into
Conversation
…oins Aggregates whose selectors traverse reference navigations currently translate to a correlated scalar subquery re-scanning the source once per group. A new relational preprocessing visitor rewrites them into explicit GroupJoin/DefaultIfEmpty left joins on the pre-GroupBy source (one join per distinct navigation prefix, deduped across aggregates; pair types via TransparentIdentifierFactory), restoring the EF Core 6 single LEFT JOIN + GROUP BY shape. Conservative bail-outs preserve the current translation for composite/shadow FKs, owned types, collections, HAVING, non-aggregate group usages and non-plain query roots (e.g. TemporalAsOf). Fixes dotnet#27933
|
@dotnet-policy-service agree |
There was a problem hiding this comment.
Pull request overview
This PR improves relational query translation performance by rewriting GroupBy(...).Select(g => aggregate(g, x => x.ReferenceNav.Prop)) patterns into pre-GroupBy left joins, avoiding the per-group correlated subquery re-scan regression introduced after EF Core 6 (Fixes #27933).
Changes:
- Introduces a relational-only LINQ rewriter (
GroupByAggregateNavigationLiftingExpressionVisitor) to lift reference-navigation access inside aggregate selectors into explicit pre-GroupByjoins. - Hooks the rewriter into
RelationalQueryTranslationPreprocessor.Process()so it runs before GroupJoin-flattening/normalization. - Updates/adds SQL baselines and new spec tests covering multiple aggregates sharing a navigation, two-level navigation chains, predicate
Count, and intermediate projections.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/EFCore.Relational/Query/RelationalQueryTranslationPreprocessor.cs | Runs the new lifting visitor early in relational preprocessing. |
| src/EFCore.Relational/Query/Internal/GroupByAggregateNavigationLiftingExpressionVisitor.cs | Implements the LINQ-level rewrite to synthesize pre-GroupBy left joins for reference-navigation aggregate selectors. |
| test/EFCore.Specification.Tests/Query/NorthwindGroupByQueryTestBase.cs | Adds new cross-provider spec tests for the lifted-join scenarios. |
| test/EFCore.SqlServer.FunctionalTests/Query/NorthwindGroupByQuerySqlServerTest.cs | Updates SQL baselines and adds SQL Server assertions for the new tests. |
| test/EFCore.SqlServer.FunctionalTests/Query/UdfDbFunctionSqlServerTests.cs | Updates TVF-related GroupBy aggregate SQL baselines to the lifted-join shape. |
| test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs | Updates GearsOfWar SQL baselines affected by the new translation shape. |
| test/EFCore.SqlServer.FunctionalTests/Query/Inheritance/TPTGearsOfWarQuerySqlServerTest.cs | Updates TPT SQL baseline for the lifted-join translation. |
| test/EFCore.SqlServer.FunctionalTests/Query/Inheritance/TPCGearsOfWarQuerySqlServerTest.cs | Updates TPC SQL baseline for the lifted-join translation. |
| test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs | Updates Sqlite SQL baseline for the lifted-join translation. |
Yes, the correlated subquery is born in nav expansion, not in a relational translator. The "expand on the parent" (smitpatel's original plan in #27933) literally means: expand the aggregate's navigation on the pre-GroupBy source, bake the join into the source as a transparent identifier, group over the widened element, and rewrite the aggregate to read the joined TI member. That's exactly what the EF6 shape was. The decisive advantage over the PR's relational pre-pass is reuse of existing, correct machinery instead of re-deriving it, reducing the number of changes siginficantly. The one real downside: this is Core, not RelationalMoving the lift into core is only safe if it's gated so providers don't see synthesized joins if they aren't supported. The existing extensibility point Proposed implementation plan:Step 0 — Gating flag on the extensibility helper
Step 1 — Carry the pre-GroupBy source forwardModify
Modify
Step 2 — The core rewrite in
|
Providers that translate joins natively opt in via a relational extensibility helper; the core default is false so non-relational providers never see synthesized joins.
…e-GroupBy source (dotnet#27933) GroupByNavigationExpansionExpression stashes the pre-GroupBy source and the original key selector; when the result selector only uses g.Key and whitelisted aggregates and at least one aggregate selector traverses a reference navigation, the navigations expand as joins on the parent via the existing expansion machinery and the GroupBy is rebuilt over the widened element, translating the aggregates into the grouped query instead of a correlated subquery per group.
All results-verified. Includes composite-key navigations, query-syntax and GroupBy-in-subquery shapes, TVFs, TPT/TPC hierarchies and temporal tables (AS OF propagated to the joined tables); required navigations now produce INNER JOIN.
…ith and without IgnoreQueryFilters
|
Thanks a lot @AndriySvyryd — plan implemented and pushed. The preprocessing visitor is now deleted (which also moots both earlier Copilot comments), the lift lives in Findings from the implementation: Your reuse argument held up empirically, beyond what I expected:
Steps 3–4 folded away: Owned paths cleanly fall through to today's translation, as the plan specified — the existing correlated baselines ( ValidationFull |
Fixes #27933
Summary
GroupBy aggregates whose selectors traverse reference navigations currently translate to a correlated scalar subquery that re-scans the source once per group. This PR rewrites them into explicit pre-GroupBy left joins, restoring the EF Core 6 single
LEFT JOIN … GROUP BYshape:Real-world motivation: a production P&L report (fact table grouped subsidiary × month, 15 sums, two traversing navigations) went from ~1,500 per-group rescans to one join; all 9 aggregate grand totals are identical digit-for-digit (decimal columns) against the app's previous NHibernate implementation.
Background
The #27931 PR description states the regression and the plan verbatim: "Side-effect above is that joins expanded over the grouping element (due to navigations used on aggregate chain), doesn't translate to aggregate anymore since we need to translate the join on parent query, remove the translated join if the chain didn't end in aggregate and also de-dupe same joins. Filing issue to improve this in future." — that issue is #27933.
It also warns "Due to fragile nature of matching to lift the join, we shouldn't try to lift joins" — that verdict is about matching translated SQL trees in the relational layer (the EF6 lifting removed for the #27132 bugs). This PR never touches translated trees: it synthesizes the join at the LINQ level, before translation, from model metadata, so the pushdown/alias-uniquification fragility that killed relational lifting structurally cannot occur.
One additional finding (verified empirically): the
GroupBy(key, elementSelector)overload does not avoid the subquery either — the element selector is recorded as a pending selector and the navigation re-surfaces inside the per-group clone. There is no LINQ-side workaround short of writing the join manually; that manual explicit-join form is exactly what this PR synthesizes.Implementation
GroupByAggregateNavigationLiftingExpressionVisitor(new,EFCore.Relational, internal) rewritessrc.GroupBy(k).Select(g => …aggregates…)intosrc.GroupJoin(...).SelectMany(p => p.Inners.DefaultIfEmpty(), ...).GroupBy(k′).Select(…)when aggregate selectors traverse reference navigations:
TransparentIdentifierFactory.src.Select(proj).GroupBy(k).Select(aggs)by inlining the one-level projection first.RelationalQueryTranslationPreprocessor.Process— relational-only (Cosmos must never see synthesized joins) and beforeQueryableMethodNormalizingExpressionVisitor, whose existing GroupJoin-flattening turns the synthesized pattern into aLeftJoin. No changes to navigation expansion or the relational translators.Count()and the grouping key are unaffected; the per-group rowset is identical to the one inside today's correlated subquery (which also left-joins), so all aggregate values including null semantics are unchanged. Global query filters on the joined principal land on the synthesized join exactly as they land inside today's subquery, andIgnoreQueryFilters()affects both identically.g.Key(includingg.Select(...).Distinct().Agg()—SUM(DISTINCT)support is a possible follow-up), operators betweenGroupByandSelect(HAVING stays correlated, as today), and sources with non-plain query roots or provider-specific queryable operators — the full-suite run caughtTemporalAsOfsources joining a plain (non-AS-OF) synthesized root and returning wrong rows; theSourceSafetyScannerbail-out now guards that entire class of mismatch.Testing
GroupBy_with_aggregate_through_navigation_propertyupdated to the EF6 shape; across the suites seven baselines changed, all to the lifted single-join shape, all with results verified (GearsOfWarbase/TPT/TPC on SQL Server,GearsOfWaron Sqlite, and twoUdfDbFunctionTVF tests — the temporal GearsOfWar variant intentionally keeps its original translation via the bail-out).Countwith a navigation predicate, aggregate over a navigation hidden in an intermediate projection.EFCore.SqlServer.FunctionalTests50,751 tests andEFCore.Sqlite.FunctionalTests38,330 tests — remaining failures are environment-only (Full-Text Search / memory-optimized / SqlAzure not installed locally; a preview-runtime BCL artifact), zero related to this change.Notes for reviewers
GroupByover complex key generates redundant subquery #30113), group materialization (GroupBy with a projection of the IGrouping ToArray or other collection results in a inefficient sql query #35991). Improves GroupBy on Navigation Property not translating properly #27077 (aggregate-side subquery gone; a duplicate join against key-side expansion may remain — inferred, not reproduced) and GroupBy possibly causes needless duplication of predicate #29670 (predicate duplication gone wherever the fallback is no longer taken).