Query: lift GroupBy aggregates over reference navigations into pre-GroupBy joins#38668
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 |
…grouping parameter, add filtered-navigation total test
AndriySvyryd
left a comment
There was a problem hiding this comment.
Thanks for your contribution!
API review baseline changes for
|
Fixes #27933
Fixes #27077
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 expands those navigations onto the pre-GroupBy source inside navigation expansion, 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: the join comes from re-running navigation expansion itself over the pre-GroupBy source, 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; the join this PR produces is the one navigation expansion would have produced for that manual form.Implementation
The lift lives in
NavigationExpandingExpressionVisitor(per review feedback; the earlier preprocessing visitor is deleted), gated by a provider capability flag:INavigationExpansionExtensibilityHelper.SupportsNavigationExpansionJoins— default interface implementation returningfalse; the newRelationalNavigationExpansionExtensibilityHelperimplements it astrue. Non-relational providers (Cosmos, InMemory) keep today's translation untouched.ProcessGroupBystashes the pre-GroupBy parent (NavigationExpansionExpression) and the original key selector onGroupByNavigationExpansionExpression. Operators applied over the grouping sequence clear the stash (UpdateSource), so HAVING-like shapes keep today's translation, as before.ProcessSelect(GroupByNavigationExpansionExpression, …)scans the result selector for aggregates whose selector traverses a reference navigation; when found, it re-expands the key and aggregate selectors on the stashed parent via the existingExpandNavigationsForSource, then rebuildsGroupBy(key).Select(aggregates)over the joined source.IgnoreQueryFilters()clears both identically), and provider query-root semantics — temporalAS OFsources join temporal principals viaSqlServerNavigationExpansionExtensibilityHelper.CreateQueryRoot.Count()and the grouping key are unaffected; the per-group rowset is identical to the one inside today's correlated subquery (which also joins the navigation), so all aggregate values including null semantics are unchanged.g.Key, operators betweenGroupByandSelect, theGroupBy(key, elementSelector)overload, and non-whitelisted aggregates.Testing
GroupBy_with_aggregate_through_navigation_propertyupdated to the EF6 shape. Baselines changed across the suites, all to the lifted single-join shape, all results-verified:GearsOfWarbase/TPT/TPC/Temporal on SQL Server (the temporal variant now lifts withFOR SYSTEM_TIME AS OFpropagated to every joined table),GearsOfWaron Sqlite,Ef6.GroupByquery-syntax samples,NorthwindGroupBy(including a required navigation now producing INNER JOIN), twoUdfDbFunctionTVF tests,AdHocMiscellaneousrepeated aggregates, andComplexNavigationsSharedTypecomposite-key shapes.Countwith a navigation predicate, aggregate over a navigation hidden in an intermediate projection, navigation shared by key and aggregate sharing one join (GroupBy on Navigation Property not translating properly #27077), and filtered navigations — filter on the join, with an additional unfilteredCount()unaffected by it, and withIgnoreQueryFilters().EFCore.SqlServer.FunctionalTests50,801 tests andEFCore.Sqlite.FunctionalTests38,358 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. InMemory unchanged (flag off).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 Redundant inner join when I'm using GroupBy() #28622 (redundant join) and GroupBy possibly causes needless duplication of predicate #29670 (predicate duplication gone wherever the fallback is no longer taken). Possible follow-up: lifting theGroupBy(key, elementSelector)overload.