Conversation
A grouping key of nullable value type translates to Convert(column, int?) over an int-typed SQL expression. AddGroupByKeySelectorToProjection read the operand as int before converting it back, so the key selector and the key identifier threw "Nullable object must have a value" for the NULL group whenever groupings were assembled on the client: a final GroupBy, and since dotnet#38963 projections that stream a grouping's elements. A key converted to object failed the same way. - The projection binding recorded for a key column is now the nullable read. The key identifier boxes it as is, and its value comparers already handle null. - A Convert to the nullable form of its SQL operand, or to a reference type, converts that nullable read. Only the client-side key reading changes; the generated SQL is the same. A key read as a nullable or reference type gets the value EF projects for it, so a nullable cast over an optional navigation gives a NULL group. A key that stays non-nullable still throws as before. Fixes dotnet#33663 Signed-off-by: QHarshil <harshil_c@hotmail.com>
Contributor
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The reviewed changes have no unresolved blocking issues and include comprehensive regression coverage.
Review effort: Lite
Findings: None
What changed in this PR
Fixes client-side GroupBy failures when nullable value-type keys contain SQL NULL values.
Changes:
- Preserves nullable reads for grouping keys and identifiers.
- Handles nullable and reference-type conversions safely.
- Adds provider-specific regression and compatibility tests.
| File | Summary |
|---|---|
test/EFCore.SqlServer.FunctionalTests/Query/NorthwindGroupByQuerySqlServerTest.cs |
Adds SQL Server GroupBy coverage. |
test/EFCore.SqlServer.FunctionalTests/Query/AdHocMiscellaneousQuerySqlServerTest.cs |
Adds SQL Server regression coverage. |
test/EFCore.Sqlite.FunctionalTests/Query/AdHocMiscellaneousQuerySqliteTest.cs |
Adds SQLite regression coverage. |
test/EFCore.Specification.Tests/Query/NorthwindGroupByQueryTestBase.cs |
Adds nullable-key GroupBy tests. |
test/EFCore.Relational.Specification.Tests/Query/NorthwindGroupByQueryRelationalTestBase.cs |
Adds split-query Include coverage. |
test/EFCore.Relational.Specification.Tests/Query/AdHocMiscellaneousQueryRelationalTestBase.cs |
Adds issue #33663 regression coverage. |
test/EFCore.InMemory.FunctionalTests/Query/NorthwindGroupByQueryInMemoryTest.cs |
Records expected InMemory translation failures. |
src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs |
Implements null-safe GroupBy key projection and conversion handling. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #33663
Summary
When groupings are assembled on the client, a NULL key of nullable value type throws. From #33663:
This happens with a final
GroupByon 8.0, 9.0 and 10.0. On main it also happens for projections that stream a grouping's elements: #38963 moved them onto the same client-side grouping, so the issue'sgroup.Select(x => new { x.Key, C = x.ToArray() }), which works on 10.0, now throws too. #38963 isn't on release/11.0, so without a fix this ships in 12.0.The issue's code on each build (SQLite, one company with no country):
group.ToList()group1.ToList()(new { x.CountryId, x.Id })ArgumentNullExceptionselect2(new { x.Key, C = x.ToArray() })select4The
Sumcases at the end of the issue already work on main. The generated SQL doesn't change for any query.Implementation
For
GroupBy(i => i.Group)over anint?column, the key translates toConvert(column, int?)over anint-typed SQL expression, andAddGroupByKeySelectorToProjectionread the operand asintbefore converting it back. From the compiled query:Key column binding. The binding recorded for a key column is now the nullable read. The key selector still converts it to the column's type, so a key that is non-nullable in C# behaves as before. The key identifier boxes the nullable read as is; its value comparers already compare null to null. This matches how the identifier already reads columns that aren't part of the key.
Convert over a key column. A
Convertto the nullable form of its SQL operand returns that nullable read, and aConvertto a reference type converts it. The second covers keys typedobject, such asGroupBy(e => (object?)e.ReportsTo). AConvertwith a user-defined conversion method is left as it was.Scope
A key read as a nullable or reference type now gets the value EF projects for it.
GroupBy(e => (uint?)e.Manager!.EmployeeID)over an optional navigation gives a NULL group, asSelect(e => (uint?)e.Manager!.EmployeeID)gives null; on main it threw. A key that stays non-nullable, such asGroupBy(e => e.Manager!.EmployeeID), still throws "Nullable object must have a value", as projecting it does.Not covered here:
Select(g => new { g.Key, Count = g.Count(), Items = g.ToList() }), joins the grouping back to its source onkey = key, so the NULL group still comes back empty. GroupBy nullable column followed by First() over group returns null record #29240 is the same join forFirst(). I'm looking at that separately.Concat('Value cannot be null. (Parameter 'collection')' after apply groupBy #33494), client-side grouping is already wrong on main for any key: Stream GroupBy elements instead of joining the source back to itself #38963 turned the crash in 'Value cannot be null. (Parameter 'collection')' after apply groupBy #33494 into a single grouping holding every row. Nullable keys over those sources now behave the way non-nullable keys already do there.Test plan
New tests, each over a NULL key:
Final_GroupBy_nullable_value_type_keyNorthwindGroupByQueryTestBase, FinalGroupByFinal_GroupBy_nullable_cast_over_optional_navigationFinal_GroupBy_anonymous_key_with_nullable_value_typeFinal_GroupBy_composite_key_with_nullable_value_typeFinal_GroupBy_nullable_value_type_key_as_objectGroupBy_nullable_value_type_key_Select_ToListNorthwindGroupByQueryTestBase, GroupByWithoutAggregateGroupBy_nullable_value_type_key_Select_element_projectionGroupBy_nullable_value_type_key_with_element_selector_Select_ToListFinal_GroupBy_nullable_value_type_key_with_split_IncludeNorthwindGroupByQueryRelationalTestBaseGroupBy_nullable_foreign_key_in_anonymous_key(the issue's code)AdHocMiscellaneousQueryRelationalTestBase, region 33663The Northwind tests use
Employee.ReportsTo, which is NULL for the one employee with no manager, and for the split query the highestEmployeeIDover a customer's orders, which is NULL for customers with no orders. All 10 fail on main on SQL Server and SQLite (20 of 20 executions each) and pass with the change. InMemory doesn't support a finalGroupBy, so its overrides assert the existing translation failure; the other tests pass there on both builds. No existing SQL baseline changes.Reverting the key column binding, or the nullable
Convertcase, fails all 10. Reverting only the reference-type condition failsFinal_GroupBy_nullable_value_type_key_as_object.Test results
GroupBy_ResultSelector_Entire_Entity_Where, which fails the same way onmainhere: two LACOR orders share the latestOrderDate, so which oneFirst()picks depends on the plan. The other 12 pass when their classes run on their own, on this branch and onmain(connection-pool timeouts and a server crash under load).EFCore.Tests6,990,EFCore.Relational.Tests1,500,EFCore.SqlServer.Tests1,406,EFCore.Sqlite.Tests896,EFCore.InMemory.Tests39,EFCore.ApiBaseline.Tests14: all passed.To run the new tests: