Repository navigation
Identify lifted GroupBy groupings by a database-computed rank - #39242
AndriySvyryd merged 2 commits into
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
The provider-wide grouping and SQL-generation changes warrant final human validation despite the focused regression coverage.
0 open findings
What changed in this PR
Fixes lifted relational GroupBy projections by using a database-computed dense rank to preserve database grouping semantics.
Changes:
- Adds
DenseRankExpressionand SQL/nullability handling. - Uses the rank as the client grouping identifier.
- Adds regression coverage and updates SQL baselines.
| File | Description |
|---|---|
src/EFCore.Relational/Query/Internal/DenseRankExpression.cs |
Defines the internal rank expression. |
src/EFCore.Relational/Query/QuerySqlGenerator.cs |
Generates DENSE_RANK SQL. |
src/EFCore.Relational/Query/SqlNullabilityProcessor.cs |
Processes rank ordering expressions. |
src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs |
Uses the rank as the grouping identifier. |
src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs |
Creates ranks for lifted groupings. |
test/EFCore.Relational.Specification.Tests/Query/AdHocMiscellaneousQueryRelationalTestBase.cs |
Adds the collation regression test. |
test/EFCore.SqlServer.FunctionalTests/Query/AdHocMiscellaneousQuerySqlServerTest.cs |
Verifies SQL Server SQL. |
test/EFCore.Sqlite.FunctionalTests/Query/AdHocMiscellaneousQuerySqliteTest.cs |
Verifies SQLite SQL. |
test/EFCore.SqlServer.FunctionalTests/Query/NorthwindGroupByQuerySqlServerTest.cs |
Updates GroupBy SQL baselines. |
test/EFCore.SqlServer.FunctionalTests/Query/PrecompiledQuerySqlServerTest.cs |
Updates the precompiled-query baseline. |
test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsCollectionsQuerySqlServerTest.cs |
Updates navigation-query SQL. |
test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsCollectionsSharedTypeQuerySqlServerTest.cs |
Updates shared-type SQL. |
test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsCollectionsSplitQuerySqlServerTest.cs |
Updates split-query SQL. |
test/EFCore.SqlServer.FunctionalTests/Query/TemporalComplexNavigationsCollectionsQuerySqlServerTest.cs |
Updates temporal-query SQL. |
test/EFCore.SqlServer.FunctionalTests/Query/TemporalComplexNavigationsCollectionsSharedTypeQuerySqlServerTest.cs |
Updates temporal shared-type SQL. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
|
As discussed in the description, there is always the alternative of trying to map a provider's collation and settings to a custom ValueComparer but it seems like a rabbit-hole. Adding the dense_rank() to the generated SQL is ugly but performance-wise it's only a small hit, and its definitely better than reverting to the previous unnecessary GROUP BY. |
|
Please rebase on latest main |
- Project DENSE_RANK() OVER (ORDER BY <grouping terms>) for lifted GroupBy element projections and start a new grouping when it changes, instead of comparing the grouping key with its CLR comparer on the client - Keeps groupings consistent with the database's grouping where CLR and database equality differ, e.g. keys differing only in case under a case-insensitive collation - Add an internal DenseRankExpression, handled by QuerySqlGenerator and SqlNullabilityProcessor - Add a regression test and update baselines of lifted GroupBy projections for the new column Fixes dotnet#39241
c590ec3 to
9398b14
Compare
The rank always ordered by the grouping terms ascending, while the query keeps any existing ordering over those terms (e.g. OrderByDescending on the key), so the database had to sort the rows a second time. The rank now uses the query's orderings, then the remaining grouping terms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AndriySvyryd
left a comment
There was a problem hiding this comment.
Thanks for your contribution!

Fixes #39241
Summary
Since #38963, a projection that only enumerates a grouping's elements drops the
GROUP BY. The rows stream back ordered by the grouping key, andGroupBySingleQueryingEnumerableassembles the groupings on the client, starting a new one whenever the key changes. That decision compared each row's key with the previous row's using the key column'sValueComparer, which is ordinal for an ordinary string column.The database orders by its collation, and the two can disagree. Under SQL Server's default case-insensitive collation,
"Engineering"and"engineering"sort next to each other as one group, but the client compares them ordinally and splits them:GroupBy(e => e.DepartmentName).Select(g => g.Count())(server-sideGROUP BY)GroupBy(e => e.DepartmentName).Select(g => g.Select(e => e.Name).ToList()), beforeThe fix lets the database decide where each group ends. Lifted projections now project
DENSE_RANK() OVER (ORDER BY <grouping terms>), and the client starts a new grouping when that rank changes, compared as an integer, instead of comparing the key columns:Rows the database considers equal tie and share a rank, under the database's own comparison rules. That covers case-insensitive and accent-insensitive collations, SQL Server's trailing-space rule (
'abc' = 'abc ') and value converters, for any key type, including composite keys.g.Keystill comes from the first row of each grouping, the same arbitrary choice aGROUP BYmakes betweenEngineeringandengineering.The query already sorts by the grouping terms, so the rank can be computed on that same sorted stream. The other cost is one extra column per row.
Why
DENSE_RANK()KeyComparercan't be made to match the database. SQL Server gives string key columns aCaseInsensitiveValueComparer(Use case-insensitive string comparisons by default on SQL Server #29955), but that assumes a case-insensitive collation and only covers case. Collations are never interpreted on the client anywhere in EF, and plain columns like the one above use ordinal comparison.MIN(key) OVER (PARTITION BY key)) stops the client splitting a database group, but not merging two: a case-insensitive comparer over a case-sensitive column collapses them.MINalso isn't available for every type, for example SQL Serverbit.LAG(key) OVER (ORDER BY key)relies on the outerORDER BYreturning tied rows in the same order as the window's, which SQL doesn't guarantee.Implementation
DenseRankExpressionis a new SQL expression inMicrosoft.EntityFrameworkCore.Query.Internal. It's deliberately internal, like the providers' ownSqlServerAggregateFunctionExpressionandGlobExpression, so this fix adds no public API.QuerySqlGeneratorandSqlNullabilityProcessorhandle it alongsideRowNumberExpression; it's never nullable. It supports quoting for precompiled queries.TryTranslateGroupingElementProjectionpasses the rank to theSelectExpressionthrough an internalApplyGroupIdentifier.ApplyProjectionthen uses it, instead of the grouping columns' comparers, to build the key identifier for the final GroupBy. The identifier has the same lifetime as_preGroupByIdentifier: it isn't cloned, and a pushdown drops it. Only lifted projections set it, so a plain finalGroupBy(...).ToList()is unchanged.Regenerated baselines
Every test that exercises the lift gains the rank column. Results are unchanged in every case; only the SQL changes:
NorthwindGroupByQuery:GroupBy_select_grouping_list/_array,GroupBy_selecting_grouping_key_list,GroupBy_selecting_grouping_element_list,GroupBy_selecting_grouping_element_array_with_key,GroupBy_with_element_selector_selecting_grouping_element_list,GroupBy_with_result_selector_selecting_grouping_element_list, and the threeGroupBy_nullable_value_type_key_*testsDENSE_RANK() OVER(ORDER BY <key>)addedCollection_projection_over_GroupBy_over_parameter(ComplexNavigationsCollections,…SharedType,…SplitQuery, and the two temporal variants)Final_GroupBy_projecting_grouping_elements(precompiled)GroupBy_nullable_foreign_key_in_anonymous_key(SQL Server and SQLite)DENSE_RANK() OVER(ORDER BY [c].[CountryId], [c].[Id])Testing
GroupBy_elements_with_keys_differing_only_in_caseinAdHocMiscellaneousQueryRelationalTestBaseuses the data from the issue: three employees in"Engineering", one in"engineering", one in"Sales". It asserts that the lifted groupings match the groups of a server-sideGROUP BY, which makes it independent of the provider's collation: SQL Server's default is case-insensitive and SQLite's is case-sensitive. On SQL Server it fails without the fix (Expected: [1, 4],Actual: [1, 1, 3]), in both its sync and async variants.EFCore.ApiBaseline.Testsgreen, with no baseline change sinceDenseRankExpressionis internal