Repository navigation
Translate SelectMany over a grouping by flattening its rows - #39602
Open
benedict-odonovan wants to merge 3 commits into
Open
benedict-odonovan wants to merge 3 commits into
benedict-odonovan wants to merge 3 commits into
Conversation
Fixes dotnet#28002 Part of dotnet#36510 - Let navigation expansion accept a grouping as the SelectMany collection, composing a result selector over the collection as a Select. - Drop the GROUP BY in relational translation, since the elements of all groups are the source rows. A Skip or Take over the grouping filters on ROW_NUMBER() partitioned by the grouping key. - Other operators over the grouping, and SelectMany inside a subquery, keep failing translation. - Decline on InMemory, which threw an internal error rather than failing translation.
- Follow a member of a projected element in navigation expansion, so navigations through e.g. the anonymous element of a result selector are expanded rather than read as null or empty. - Build the per-group row bounds without adding the Skip and Take counts, which overflowed for Take(int.MaxValue), and skip nothing for a negative Skip. - Fail translation for a Skip or Take count that depends on the group. - Keep the element ordering after the grouping terms when the groups are ordered.
- Mark an entity in a set operation's result optional when it's optional in either operand, so a required navigation through it is a LEFT JOIN rather than dropping the rows where it's null. - Check DTO operands of a set operation for compatible includes and query roots, and keep a DTO opaque when the operands initialize different members. - Rebuild a DTO initialized through its members in the include expansion, so that Include applies to the entities in it.
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 #28002
Part of #36510
Summary
Every
SelectManyover a grouping currently fails in navigation expansion, before any provider sees it, including the plainSelectMany(g => g). This translates the shapes that map directly to SQL, the main one being top-N per group:The elements of all groups are the source rows, so the
GROUP BYisn't needed.SelectMany(g => g)becomes a plain read of the source, and aSkiporTakeover the grouping filters onROW_NUMBER()partitioned by the grouping key, the same shape as the group element reads from #39173.The bounds subtract from
ROW_NUMBER(), which is abigint, rather than adding the counts, soTake(int.MaxValue)doesn't overflow. The second comparison keeps a negativeSkipskipping nothing, as in LINQ. With constants the bounds are folded instead, e.g.Skip(1).Take(2)gives[row] > 1 AND [row] <= 3.This is also the first step towards #36510. What's left there is translating aggregates over
gin the element selector as window functions, using the support added in #39240.The change
SelectManyaccepts a grouping as its collection and always produces the overload without a result selector. A result selector can only see the grouping and an element, so it's composed over the collection as aSelect, and nothing downstream has to carry a grouping.x.oafterSelectMany(g => g, (g, o) => new { g.Key, o }). Without this,x.o.Customer.Cityread null andx.o.OrderDetailscame back empty, with no join and no warning.SnapshotExpressionalso keeps aMemberInitExpressioninstead of reducing it to a default, and the include expansion rebuilds such a DTO so thatIncludeapplies to the entities in it, as it already did for anonymous types.GroupBy. The same silent nulls happen on main afterDistinct,Union,Concat,Except,Joinand a plainSelectMany, e.g.Select(o => new { o }).Distinct().Select(x => x.o.Customer.City)returns null for every row. These now get aLEFT JOIN. New tests inNorthwindSetOperationsQueryRelationalTestBasecoverDistinctandUnionwith anonymous and DTO members.Concatwhose second operand has a null entity from a left join read a required navigation with anINNER JOINand dropped those rows. This also fixes the same query with the bare entity, which drops the rows on main. DTO operands are now checked for compatible includes and query roots, as anonymous ones are; when their members differ, the DTO stays opaque as before.TranslateSelectManyOverGroupingmatchesg [OrderBy/ThenBy] [Skip and/or Take] [Select]. It keeps aTake(5).Skip(2)and aSkip(2).Take(5)distinct: the first ends at the group's 5th row, the second at its 7th. The counts must be constants or parameters, sog.Take(g.Count())fails translation rather than in the database.SelectExpression.ApplyRowsPerGroup: removes the grouping, restoring the pre-grouping identifier, and numbers and filters the rows when there's a limit. It sharesRemoveGroupingwithApplyFirstRowPerGroup.ShapedQueryExpression.VisitChildren.Since the result is rows rather than groups, anything composed afterwards works normally, including
Where,OrderBy,Take, aggregates, navigations andInclude. This avoids the root-operator restriction on the lifted grouping projections.What stays out
These keep failing translation, each pinned by a test:
g.Where(...)inside the selectorOrderByover aTakeinside the selectorTakeon the groups before theSelectManySkiporTakewhose count depends on the group, such asg.Keyorg.Count()g.OrderBy(o => g.Count())SelectManyover a grouping in a projected subquery, such asSelect(c => c.Orders.GroupBy(...).SelectMany(g => g).ToList()), or in a predicate subquery, such asWhere(o => orders.GroupBy(...).SelectMany(g => g.Take(1)).Select(x => x.OrderID).Contains(o.OrderID))A
SelectManyover a grouping in the collection selector of a correlatedSelectManydoes translate, withCROSS APPLY, and is tested. SQLite asserts the APPLY error.The rewrite suggested earlier on #28002,
.Select(g => g.Take(n)).SelectMany(x => x), doesn't reach this translation and isn't covered here.Worth reviewing closely
Positional records and constructor DTOs aren't followed. For
new RecO(o, k), nothing linksx.Oto the constructor argument, sox.O.Customer.Citystill reads null, as it does on main forSelect(o => new RecO(o, 1)).Select(x => x.O.Customer.City). This is an existing limitation, not specific to this PR, but it now also applies to the newSelectManyshapes. Making it fail translation instead would change existing queries, so I've left it.The
_subquerycheck inTranslateSelectManyOverGroupingis conservative. The subquery tests fail translation even without it, so it's kept as a guard rather than pinned by a test.Ordering within groups. When the groups are ordered by their key, the element ordering is appended to it, so
GroupBy(...).OrderBy(g => g.Key).SelectMany(g => g.OrderByDescending(o => o.OrderID))givesORDER BY [o].[CustomerID], [o].[OrderID] DESC, as in LINQ to Objects. When the groups aren't ordered, the element ordering only decides which rows a limit keeps, and is otherwise dropped, as forSelectManyover any other subquery. Ordering the result by the grouping terms in that case would also match LINQ, but it would add anORDER BYto every such query, so this is a choice I'd like checked.An ordering before the
GroupBydoesn't choose the rows.OrderByDescending(o => o.OrderID).GroupBy(...).SelectMany(g => g.Take(1))numbers each group by its identifier and logs the row-limiting-without-OrderBywarning, asg.First()already does. The ordering has to go inside the group.g.Keyunder a case-insensitive collation is read from each row, so one group can returnABCandabcas its key whereGROUP BYwould report one value.The navigation expansion change is in core. It runs for every provider. Cosmos doesn't translate
GroupBy, so it should still fail there, and I haven't run the Cosmos tests. A third-party non-relational provider that translatesGroupBywould now see this shape inTranslateSelectMany.Testing
The tests for the new behavior are in
NorthwindGroupByQueryRelationalTestBase, with baselines on SQL Server. The four existing tests which pinned the failure moved there fromNorthwindGroupByQueryTestBase, andProjectedTypebecameprotectedfor that.Select_GroupBy_SelectManyalso translates now, so its comment about entity equality is gone.OwnedQueryRelationalTestBasegains a test with owned navigations.NorthwindGroupByQueryInMemoryTestgains one pinning the translation failure.The new tests cover the review cases: navigations through a projected element,
Take(0),Take(a).Skip(b)past the end,int.MaxValueand negative counts as constants and parameters, twoThenBys, ordered groups with and without a limit, counts and orderings that depend on the group, and a correlatedSelectMany.