Skip to content

Whitelist Any() and All() for GroupBy aggregate navigation lifting - #38829

Open
benedict-odonovan wants to merge 1 commit into
dotnet:mainfrom
benedict-odonovan:whitelist-any-for-groupby-aggregate-navigation-lifting
Open

Whitelist Any() and All() for GroupBy aggregate navigation lifting#38829
benedict-odonovan wants to merge 1 commit into
dotnet:mainfrom
benedict-odonovan:whitelist-any-for-groupby-aggregate-navigation-lifting

Conversation

@benedict-odonovan

Copy link
Copy Markdown

Fixes #38816

  • I've read the guidelines for contributing and seen the walkthrough
  • I've posted a comment on an issue with a detailed description of how I am planning to contribute and got approval from a member of the team
  • The code builds and tests pass locally (also verified by our automated build checks)
  • Commit messages follow this format
  • Tests for the changes have been added (for bug fixes / features)
  • Code follows the same patterns and style as existing code in this repo

Summary

#38668 lifts GroupBy aggregates whose selectors traverse a reference navigation onto a shared pre-GroupBy join. GroupingAggregateScanner only allows the grouping parameter to appear as g.Key or as the source of a whitelisted aggregate; Any and All were not on that whitelist, so any quantifier in the result selector marked the grouping parameter as an unsupported usage and disabled the lift for every aggregate in the group, not just for itself.

The trigger is the presence of the quantifier, not anything it reads — a bare g.Any() or an All whose predicate touches no navigation demotes its siblings just the same. The workaround was to write g.Count(...) > 0 instead of g.Any(...), which shows the shape is otherwise handled.

This PR adds Any and All to PredicateAggregateMethodNames.

ctx.Orders
    .GroupBy(o => o.EmployeeID)
    .Select(g => new
    {
        g.Key,
        Region = g.Max(o => o.Customer.Region),
        AnyLondon = g.Any(o => o.Customer.City == "London"),
        Count = g.Count()
    });
-- before: Max over the navigation demoted to a correlated subquery
SELECT [o].[EmployeeID] AS [Key], (
    SELECT MAX([c].[Region])
    FROM [Orders] AS [o0]
    LEFT JOIN [Customers] AS [c] ON [o0].[CustomerID] = [c].[CustomerID]
    WHERE [o].[EmployeeID] = [o0].[EmployeeID] OR ([o].[EmployeeID] IS NULL AND [o0].[EmployeeID] IS NULL)) AS [Region], CASE
    WHEN EXISTS (
        SELECT 1
        FROM [Orders] AS [o1]
        LEFT JOIN [Customers] AS [c0] ON [o1].[CustomerID] = [c0].[CustomerID]
        WHERE ([o].[EmployeeID] = [o1].[EmployeeID] OR ([o].[EmployeeID] IS NULL AND [o1].[EmployeeID] IS NULL)) AND [c0].[City] = N'London') THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END AS [AnyLondon], COUNT(*) AS [Count]
FROM [Orders] AS [o]
GROUP BY [o].[EmployeeID]

-- after: Max reads the shared join; Any keeps its EXISTS
SELECT [o].[EmployeeID] AS [Key], MAX([c].[Region]) AS [Region], CASE
    WHEN EXISTS (
        SELECT 1
        FROM [Orders] AS [o0]
        LEFT JOIN [Customers] AS [c0] ON [o0].[CustomerID] = [c0].[CustomerID]
        WHERE ([o].[EmployeeID] = [o0].[EmployeeID] OR ([o].[EmployeeID] IS NULL AND [o0].[EmployeeID] IS NULL)) AND [c0].[City] = N'London') THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END AS [AnyLondon], COUNT(*) AS [Count]
FROM [Orders] AS [o]
LEFT JOIN [Customers] AS [c] ON [o].[CustomerID] = [c].[CustomerID]
GROUP BY [o].[EmployeeID]

Any and All themselves still translate to a correlated EXISTS / NOT EXISTS, which is the right shape for them; what changes is that they no longer demote their siblings.

Implementation

One-line change: Any and All join Count and LongCount in GroupingAggregateScanner.PredicateAggregateMethodNames.

Everything else already works for them:

  • TryMatchAggregate accepts 1 or 2 arguments for predicate aggregates, so both g.Any() and g.Any(predicate) match; All has no parameterless overload so it always takes the predicate path.
  • RebuildLiftedAggregate rebuilds the call against the widened element type from the generic method definition, quoting the lambda for Queryable overloads — no per-method handling.
  • The lift is unchanged in kind, so the correctness argument from Query: lift GroupBy aggregates over reference navigations into pre-GroupBy joins #38668 carries over verbatim: the navigation join is row-preserving by construction (FK → unique principal key ⇒ at most one match per row), so the per-group rowset the quantifier sees is identical to the one inside today's correlated subquery.

Testing

New specification tests (all providers, SQL Server baselines) mirroring the shapes #38668 added for the already-whitelisted aggregates:

Test Mirrors
GroupBy_Any_with_predicate_through_navigation_property GroupBy_Count_with_predicate_through_navigation_property
GroupBy_All_with_predicate_through_navigation_property
GroupBy_Queryable_Any_with_predicate_through_navigation_property AsQueryable() source path
GroupBy_Queryable_All_with_predicate_through_navigation_property
GroupBy_Any_and_aggregate_through_navigation_property bare Any() alongside a lifted aggregate
GroupBy_All_and_aggregate_through_navigation_property All predicate reading no navigation, alongside a lifted aggregate
GroupBy_multiple_aggregates_with_Any_and_All_sharing_same_navigation GroupBy_multiple_aggregates_sharing_same_navigation
GroupBy_Any_through_two_level_navigation GroupBy_aggregate_through_two_level_navigation
GroupBy_key_and_Any_through_same_navigation GroupBy_key_and_aggregate_through_same_navigation
GroupBy_Any_through_navigation_in_intermediate_projection GroupBy_aggregate_through_navigation_in_intermediate_projection
GroupBy_Any_through_filtered_navigation GroupBy_aggregate_through_filtered_navigation
GroupBy_Any_through_filtered_navigation_with_total GroupBy_aggregate_through_filtered_navigation_with_total
GroupBy_Any_through_filtered_navigation_ignore_query_filters GroupBy_aggregate_through_filtered_navigation_ignore_query_filters

Copilot AI lite review requested due to automatic review settings August 19, 2026 10:38
@benedict-odonovan
benedict-odonovan requested a review from a team as a code owner August 19, 2026 10:38
@benedict-odonovan

Copy link
Copy Markdown
Author

@dotnet-policy-service agree

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

This PR fixes an EF Core 11 regression in GroupBy aggregate navigation lifting where the presence of Any()/All() in a GroupBy result selector caused all sibling aggregates to stop being lifted into the shared pre-GroupBy navigation join (falling back to correlated subqueries). The change expands the allowed “predicate aggregate” whitelist so quantifiers no longer demote unrelated aggregates.

Changes:

  • Whitelist Enumerable.Any and Enumerable.All in GroupingAggregateScanner.PredicateAggregateMethodNames so GroupBy result selectors containing quantifiers don’t mark the grouping parameter as unsupported usage.
  • Add new specification tests covering Any/All (Enumerable and Queryable forms) through navigations and alongside other aggregates.
  • Add/extend SQL Server baselines for the new tests, including query-filtered navigation scenarios.

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
src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs Adds Any/All to the predicate-aggregate whitelist used by GroupBy aggregate lifting eligibility checks.
test/EFCore.Specification.Tests/Query/NorthwindGroupByQueryTestBase.cs Adds spec tests for Any/All GroupBy quantifiers through navigations and in combination with lifted aggregates.
test/EFCore.SqlServer.FunctionalTests/Query/NorthwindGroupByQuerySqlServerTest.cs Adds SQL Server expected SQL for the new Northwind GroupBy Any/All scenarios.
test/EFCore.Specification.Tests/Query/NorthwindQueryFiltersQueryTestBase.cs Adds spec tests for GroupBy Any through a filtered navigation, including IgnoreQueryFilters and “with total” variants.
test/EFCore.SqlServer.FunctionalTests/Query/NorthwindQueryFiltersQuerySqlServerTest.cs Adds SQL Server expected SQL baselines for the filtered-navigation Any GroupBy scenarios.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

@AndriySvyryd AndriySvyryd self-assigned this Aug 19, 2026
@AndriySvyryd AndriySvyryd added this to the 12.0.0 milestone Aug 19, 2026
- Add Any and All to PredicateAggregateMethodNames in GroupingAggregateScanner
  so a quantifier in the result selector no longer counts as an unsupported use
  of the grouping parameter and blocks the lift for every sibling aggregate
- Aggregates traversing reference navigations are lifted into the shared
  pre-GroupBy join again; Any/All keep translating to EXISTS/NOT EXISTS
- Add specification tests mirroring the shapes covered by dotnet#38668: predicate
  through a navigation, Enumerable and Queryable sources, quantifier alongside
  a lifted aggregate, multiple aggregates sharing one navigation, two-level
  navigation, navigation shared by key and aggregate, navigation in an
  intermediate projection, and filtered navigations with and without
  IgnoreQueryFilters
- Add the corresponding SQL Server baselines

Fixes dotnet#38816
Copilot AI review requested due to automatic review settings August 20, 2026 10:23
@benedict-odonovan
benedict-odonovan force-pushed the whitelist-any-for-groupby-aggregate-navigation-lifting branch from 6c681bb to bff0805 Compare August 20, 2026 10:23

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 5 out of 5 changed files in this pull request and generated no new comments.

@benedict-odonovan

Copy link
Copy Markdown
Author

Rebased the PR on main to solve the nullability warnings/failures on the CI tests 😄

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.

GroupBy with Any() or All() prevents aggregates over reference navigations from being lifted into a join

3 participants