Repository navigation
Keep groups and joined elements as the provider produces them - #20899
xperiandri wants to merge 11 commits into
Conversation
Every query test so far runs over AsQueryable() and compares rows, so a tree that only LINQ to Objects can run passes. These scenarios also check the tree: the query must stay on its provider, and must not contain what a C# expression lambda never does - a block, a parameter nothing binds, a call into FSharp.Core, a join nested in a collection selector, a member read from a tuple built without naming its members. A scenario names what is wrong with its tree today. The name has to be removed by the change that makes it untrue, because a scenario fails when its tree is cleaner than it says. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Selecting a struct tuple failed with "unreachable": the tuple was replaced by an anonymous object, but only reference tuples had an anonymous object type to be replaced by, and the way back always built a reference tuple. Both kinds now share the anonymous object of their arity, and the way back builds the kind the query asked for. Taking a struct tuple apart in a lambda failed with a tuple index out of range, because a struct tuple has fields where a reference tuple has properties. Fixes dotnet#15522 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
✅ No release notes required |
Covered casesEF Core SQLite translates 102 of 145 scenarios, 93 without this change. Now translated: SQLProvider 1.5.27 on SQLite, 301 queries: none that works today is lost, and |
There was a problem hiding this comment.
🟡 Changes recommended
Projection fusion can break nested tuple and record reference-identity semantics, while gaps in shape validation allow invalid expression trees to pass tests.
4 open issues
What changed in this PR
Updates FSharp.Core query translation so grouping aggregates and joined elements remain in forms LINQ providers can translate. Builds on #20894’s struct-tuple support and provider expression-tree tests.
Changes:
- Removes redundant projections after joins and additional
forclauses. - Reads grouping keys and aggregates directly from provider groupings, while preserving group identity when used as values.
- Adds query-shape scenarios, updates expression baselines, and documents translation changes.
| File | Description |
|---|---|
| tests/fsharp/core/queriesOverIQueryable/test.fsx | Updates join expression baselines. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryStructureScenarios.fs | Covers loops, subqueries, and left joins. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryShapeTests.fs | Runs parameterized query scenarios. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryScenarioDomain.fs | Supplies shared query entities and data. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryScenario.fs | Compares results and expected tree violations. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryProviderShape.fs | Captures provider expressions and checks their structure. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryProjectionScenarios.fs | Covers tuple, record, and entity projections. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryOperatorScenarios.fs | Covers filtering, ordering, joins, and aggregates. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryMethodSyntaxScenarios.fs | Covers direct LINQ method calls. |
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Linq/QueryKeyScenarios.fs | Covers composite keys, grouped aggregates, and identity. |
| tests/FSharp.Core.UnitTests/FSharp.Core.UnitTests.fsproj | Registers the scenario infrastructure and tests. |
| src/FSharp.Core/QueryExtensions.fs | Simplifies grouping consumers and caches rebuilt groups. |
| src/FSharp.Core/Query.fs | Preserves converted elements and controls final reconstruction. |
| src/FSharp.Core/Linq.fs | Reads struct-tuple elements through fields. |
| docs/release-notes/PENDING_BREAKING_CHANGES.md | Illustrates changed provider expression shapes. |
| docs/release-notes/.FSharp.Core/11.0.200.md | Records struct-tuple and grouping translation fixes. |
Review effort: Balanced
The shape tests followed only the first source of each operator. Each lambda parameter is now tied to the sequence it stands for, so the check also covers the inner input of a join, the second parameter of a join result selector, a collection selector of SelectMany, an identity Select, a chain inside the lambda of another operator and a query captured from earlier. A value scenario now has to end in a Queryable operator that gives the value: a count made over the rows of the provider leaves it. The validator itself is tested on trees built by hand. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
0422d0f to
89a206c
Compare
pcshrosbree
left a comment
There was a problem hiding this comment.
On EF Core 10.0.14 with SQLite, grouping queries that failed on #20894 now translate and return the same rows as LINQ to Objects, but four that ran on #20894 now fail. Those four should keep working alongside the new ones; each returns a group of tuples or records alongside another value:
query { for c in db.Customers do
groupValBy (c.Name, c.Age) c.City into g
let n = g.Count()
select (g, n) }On #20894 this runs. Here it throws "Translation of 'Select' which contains grouping parameter without composition is not supported", because RebuiltGroupings.Of(source: _arg2, ..) passes EF the provider's grouping itself. The same happens after a join, after two fors, and with a record element. Each of these four queries rebuilds the group only once in its projection. Group identity can still be observed across operators and on repeated enumeration, so counting uses within one lambda is not enough to decide when Of is needed.
- The new Impact paragraph in PENDING_BREAKING_CHANGES covers linq2db. These four EF Core queries belong there too.
- This PR also fixes a nested
findthat throwsType mismatch when building 'args'on main:where (c.Orders.Count > 1); select (c.Name, query { for o in c.Orders do for o2 in c.Orders do find (o.Id < o2.Id) }). The existingfindscenario passes without this PR's changes, so no test covers this fix. - A scenario for a group beside a count would guard the case above.
- A nested
query { for (_, a) in g do sumBy a }over a group of tuples still doesn't translate, whileg.Sum(fun (_, a) -> a)does.
T-Gro
left a comment
There was a problem hiding this comment.
🤖🕵️ AI review - verify independently.
A value scenario was accepted when the last execution at the provider was an operator that gives a value. A count made by the client over rows taken from the provider passed whenever a function it ran asked the provider for a count of its own, because that count came last. Now every execution has to be such an operator, and so does the enumeration of a source taken as it is, which is recorded as an execution too. The violations of a tree are compared with every occurrence: a tree with one block was equal to a tree with two. The scenarios that have several of a kind are pinned with their number. The validator tests build a tree with an invoked lambda and one with an unbound parameter, which no scenario has, and the two client counts. The release note names the error a struct tuple gave on main. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…y what they hold The shape tests did not see an unnamed element that passes through a result selector giving one of its parameters back, in SelectMany, Join and Zip, and they skipped the element parameter of Aggregate together with the accumulated value. Both are checked now. A function with a seed takes the accumulated value and an element, and one without a seed takes two elements. A scenario compared an entity by the text it makes of itself, which names its key only: a result with the right key and the wrong name or age passed. An object of a class of the scenarios is now compared by every property that gives a plain value. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A groupBy or groupValBy over tuples or records, or any groupBy after a
join or a second for, rebuilt every group in the middle of the query:
.Select(g => new Grouping(g.Key, g.Select(v => new Tuple(..)))) before
the select that reads g.Key and g.Count(). EF Core failed on it
("Value cannot be null. (Parameter 'key')") and Cosmos rejected it.
Every join also re-projected each pair into a copy of itself.
Both came from the yield of the element itself that the compiler adds
after each join, for and custom operator: it was translated as a new
projection that rebuilt the element. It now passes the elements on
unchanged. A consumer of a group reads its key and elements from the
provider's grouping, and an aggregate over the rebuilt elements -
Count, Sum, Average, Max, Select(..).Sum(), Count(predicate) - takes
the rebuilding function into its own lambda, which EF Core turns into
COUNT, SUM, AVG and MAX.
Only the rebuilding is moved: a projection that does nothing but take
the translation's own anonymous objects apart and put them together
again, into a lambda that reads the members of its element. A Select
the query itself wrote stays where it is, and so does the rebuilding in
front of a lambda that uses its element as a value of its own.
A group the query uses as a value is rebuilt where it is mentioned now,
no longer once for the whole query. The rebuilt groupings are kept by
the grouping they come from, so that a group is still one object in
every operator that mentions it, and the groups of a group are the same
objects each time it is gone through. Where nothing but the result of
a query is converted, a group is built as before.
A nested find gives the element as the query wrote it. It failed on a
type mismatch for an element that is a tuple, and would have for a
group.
Where the conversion of the result is what reads the elements of a
join, it names its parameter 'tupledArg', as the compiler names the
lambda that read them before. SQLProvider knows the elements of a join
by that name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The translation's own rebuilding of the elements of a group was moved into the lambda that reads them only if the lambda used the element for its members alone. A member that is itself a tuple or a record the rebuilding makes counts as a value of its own as well: read twice, it became two objects, and ReferenceEquals(x.Pair, x.Pair) was false. The note on breaking changes names what linq2db loses: a group of tuples that a query uses as a value in the middle of a query is no longer translated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ject, as a use of the whole The rebuilding of the elements of a group moves into the lambda that reads them only if the lambda uses the element for its members alone. Two reads were taken for such a member without being one: - A member of a record that is no field, like member x.Identity = x, is a function of the whole record. Each read of it got a record of its own, and ReferenceEquals(x.Identity, x.Identity) was false. Only the fields of a record, and the items of a tuple, are members of the element now. - A struct that holds a tuple or a record of a class was not an object that the rebuilding makes anew, although the tuple in it is. Read twice, it gave two tuples. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The note no longer credits dotnet#3782: the record with fields out of order is fixed by dotnet#20895. It names the distinct that returned a city once for each id, because the provider applied DISTINCT to both columns of the earlier projection. The note on breaking changes says that the SQL now names its columns, and that a groupBy on an item of a selected tuple needs dotnet#20899. A scenario takes one item of a tuple apart in the next for and applies distinct to it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
89a206c to
0677fc4
Compare
|
Groups used as values: keep Thanks @pcshrosbree, I reproduced the four queries. They run on #20894. On this head EF Core fails them with "Translation of 'Select' which contains grouping parameter without composition is not supported", and linq2db with query { for c in db.Customers do
groupValBy (c.Name, c.Age) c.City into g
let n = g.Count()
select (g, n) }The same happens after a The cause is 1. Keep 2. Build the group where the query uses it. The group is built with The scenarios of the test repository (248, the FSharp.Core of the SDK as the baseline), counted as the ones that translate and return the right rows:
I go with the second: the four queries are plain F# that people write, and only code that tests a group for identity sees the other side. The suggestions below implement it against this head, and the same change is the branch The other points of the review are in this PR: the nested |
xperiandri
left a comment
There was a problem hiding this comment.
Suggestions that make the second way, against this head. They belong together: apply all of them or none.
| /// The groupings rebuilt so far, by the grouping each was rebuilt from. A query that mentions a group in several | ||
| /// places, or goes through a group of groups twice, gets one and the same object for a group every time. | ||
| type internal RebuiltGroupings<'K, 'T> private () = | ||
| static let rebuilt = | ||
| System.Runtime.CompilerServices.ConditionalWeakTable<obj, Grouping<'K, 'T>>() | ||
|
|
||
| static member Of(source: obj, key: 'K, values: seq<'T>) : IGrouping<'K, 'T> = | ||
| match rebuilt.TryGetValue source with | ||
| | true, grouping -> grouping | ||
| | _ -> rebuilt.GetValue(source, fun _ -> Grouping<'K, 'T>(key, values)) | ||
|
|
There was a problem hiding this comment.
The cache of rebuilt groupings goes: the second way builds a group where the query uses it.
| /// The groupings rebuilt so far, by the grouping each was rebuilt from. A query that mentions a group in several | |
| /// places, or goes through a group of groups twice, gets one and the same object for a group every time. | |
| type internal RebuiltGroupings<'K, 'T> private () = | |
| static let rebuilt = | |
| System.Runtime.CompilerServices.ConditionalWeakTable<obj, Grouping<'K, 'T>>() | |
| static member Of(source: obj, key: 'K, values: seq<'T>) : IGrouping<'K, 'T> = | |
| match rebuilt.TryGetValue source with | |
| | true, grouping -> grouping | |
| | _ -> rebuilt.GetValue(source, fun _ -> Grouping<'K, 'T>(key, values)) |
| /// A grouping rebuilt from the provider's grouping of converted elements, where a query mentions the group. | ||
| [<return: Struct>] | ||
| let rec (|RebuiltGrouping|_|) (expr: Expr) = | ||
| match expr with | ||
| | Patterns.Coerce(RebuiltGrouping grouping, _) -> ValueSome grouping | ||
| | Patterns.Call(None, rebuild, [ _; key; elements ]) -> | ||
| match rebuild.DeclaringType with | ||
| | null -> ValueNone | ||
| | owner when | ||
| owner.IsGenericType | ||
| && owner.GetGenericTypeDefinition() = typedefof<RebuiltGroupings<_, _>> | ||
| -> | ||
| ValueSome struct (key, elements) |
There was a problem hiding this comment.
RebuiltGrouping matches the construction of a Grouping again.
| /// A grouping rebuilt from the provider's grouping of converted elements, where a query mentions the group. | |
| [<return: Struct>] | |
| let rec (|RebuiltGrouping|_|) (expr: Expr) = | |
| match expr with | |
| | Patterns.Coerce(RebuiltGrouping grouping, _) -> ValueSome grouping | |
| | Patterns.Call(None, rebuild, [ _; key; elements ]) -> | |
| match rebuild.DeclaringType with | |
| | null -> ValueNone | |
| | owner when | |
| owner.IsGenericType | |
| && owner.GetGenericTypeDefinition() = typedefof<RebuiltGroupings<_, _>> | |
| -> | |
| ValueSome struct (key, elements) | |
| /// A grouping built from the provider's grouping of converted elements, where a query mentions the group. | |
| [<return: Struct>] | |
| let rec (|RebuiltGrouping|_|) (expr: Expr) = | |
| match expr with | |
| | Patterns.Coerce(RebuiltGrouping grouping, _) -> ValueSome grouping | |
| | Patterns.NewObject(constructor, [ key; elements ]) -> | |
| match constructor.DeclaringType with | |
| | null -> ValueNone | |
| | owner when owner.IsGenericType && owner.GetGenericTypeDefinition() = typedefof<Grouping<_, _>> -> ValueSome struct (key, elements) |
| /// A group is rebuilt into the same object each time | ||
| let rebuildsOneObject (projection: Expr) = | ||
| match projection with | ||
| | RebuiltGrouping _ -> true | ||
| | _ -> false |
There was a problem hiding this comment.
A group is no longer rebuilt into one object by itself, so this goes. A group that a lambda uses whole is an object through makesObject.
| /// A group is rebuilt into the same object each time | |
| let rebuildsOneObject (projection: Expr) = | |
| match projection with | |
| | RebuiltGrouping _ -> true | |
| | _ -> false |
| | ("Count" | "LongCount" | "Any" | "All" | "Sum" | "Average" | "Min" | "Max" | "Select" | "SelectMany"), | ||
| [ Patterns.NewDelegate(_, [ consumed ], body) ] when | ||
| Type.op_Equality (consumed.Type, projection.Type) | ||
| && (rebuildsOneObject projection || not (usesWhole consumed projection body)) |
There was a problem hiding this comment.
The fusion is decided by usesWhole alone.
| && (rebuildsOneObject projection || not (usesWhole consumed projection body)) | |
| && (not (usesWhole consumed projection body)) |
| @@ -1170,7 +1175,7 @@ module Query = | |||
| let mutElemTy = mutExpr.Type.GetGenericArguments().[0] | |||
| // Generate "source.Select(fun v -> ...)" (remembering that Select is an extension member, i.e. static) | |||
| let mutVar = new Var("v", mutElemTy) | |||
| let mutToImmutConvExpr = ConvMutableToImmutable conv (Expr.Var mutVar) | |||
| let mutToImmutConvExpr = ConvMutableToImmutableWith ofResult conv (Expr.Var mutVar) | |||
| // Use Queryable.Select when source is IQueryable, Enumerable.Select otherwise | |||
| // This preserves the IQueryable type, enabling query composition and EF Core async ops | |||
| let immutExpr = MakeSelect (CanEliminate.Yes, isIQ, mutExpr, mutVar, mutToImmutConvExpr) | |||
| @@ -1180,22 +1185,44 @@ module Query = | |||
|
|
|||
| assert (mutExpr.Type.GetGenericTypeDefinition() = typedefof<IGrouping<_, _>>) | |||
| let mutElemTy = mutExpr.Type.GetGenericArguments().[1] | |||
| let immutIGroupingTy = typedefof<IGrouping<_, _>>.MakeGenericType [| immutKeyTy; immutElemTy |] | |||
| let immutGroupingTy = typedefof<Grouping<_, _>>.MakeGenericType [| immutKeyTy; immutElemTy |] | |||
| // Generate "source.Select(fun v -> ...)" (remembering that Select is an extension member, i.e. static) | |||
| let var = new Var("v", mutElemTy) | |||
| let convExpr = ConvMutableToImmutable conv (Expr.Var var) | |||
| // The elements of a group are gone through whenever the group is, and a group among them has to be | |||
| // the same object each time | |||
| let convExpr = ConvMutableToImmutableWith false conv (Expr.Var var) | |||
|
|
|||
| // Construct an IGrouping | |||
| let args = | |||
| [ Expr.PropertyGet (mutExpr, mutExpr.Type.GetProperty "Key") | |||
| MakeSelect(CanEliminate.Yes, false, mutExpr, var, convExpr) ] | |||
|
|
|||
| Expr.Coerce (Expr.NewObject (immutGroupingTy.GetConstructors().[0], args), immutIGroupingTy) | |||
| if ofResult then | |||
| let immutIGroupingTy = typedefof<IGrouping<_, _>>.MakeGenericType [| immutKeyTy; immutElemTy |] | |||
| let immutGroupingTy = typedefof<Grouping<_, _>>.MakeGenericType [| immutKeyTy; immutElemTy |] | |||
| Expr.Coerce (Expr.NewObject (immutGroupingTy.GetConstructors().[0], args), immutIGroupingTy) | |||
| else | |||
| // The rebuilt groupings are kept by the grouping they come from | |||
| let rebuiltGroupingsTy = typedefof<RebuiltGroupings<_, _>>.MakeGenericType [| immutKeyTy; immutElemTy |] | |||
| let rebuild = rebuiltGroupingsTy.GetMethod("Of", BindingFlags.Static ||| BindingFlags.Public ||| BindingFlags.NonPublic) | |||
| Expr.Call (rebuild, Expr.Coerce (mutExpr, typeof<obj>) :: args) | |||
|
|
|||
| | NoConv -> | |||
| mutExpr | |||
|
|
|||
| let ConvMutableToImmutable conv mutExpr = ConvMutableToImmutableWith false conv mutExpr | |||
|
|
|||
| /// Whether the expression rebuilds a group where a query mentions it | |||
| let rec MentionsRebuiltGroup (expr: Expr) = | |||
| match expr with | |||
| | Call (None, rebuild, [ _; _; _ ]) when | |||
| (match rebuild.DeclaringType with | |||
| | null -> false | |||
| | owner -> owner.IsGenericType && owner.GetGenericTypeDefinition() = typedefof<RebuiltGroupings<_, _>>) | |||
| -> | |||
| true | |||
| | ExprShape.ShapeVar _ -> false | |||
| | ExprShape.ShapeLambda (_, body) -> MentionsRebuiltGroup body | |||
| | ExprShape.ShapeCombination (_, args) -> List.exists MentionsRebuiltGroup args | |||
| /// Given the expressions for a function (fun immutConsumingVar -> immutConsumingExpr) operating over immutable tuple and record | |||
| /// types, build the expressions for an equivalent function (fun mutConsumingVar -> mutConsumingExpr) which will operate over mutable data, where the given conversion | |||
| /// data says how immutable types have been replaced by mutable types in the type of the input variable. | |||
| @@ -1206,19 +1233,36 @@ module Query = | |||
| /// function will accept mutable tuples. In this case, the function is implemented by replacing | |||
| /// uses of the immutConsumingVar in the body of immutConsumingExpr with a tuple expression built | |||
| /// from the elements of mutConsumingVar, and then simplifying the overall result. | |||
| let ConvertImmutableConsumerToMutableConsumer conv (immutConsumingVar: Var, immutConsumingExpr: Expr) : Var * Expr = | |||
| let ConvertImmutableConsumerToMutableConsumerWith ofResult conv (immutConsumingVar: Var, immutConsumingExpr: Expr) : Var * Expr = | |||
| match conv with | |||
| | NoConv -> (immutConsumingVar, immutConsumingExpr) | |||
| | _ -> | |||
| let mutConsumingVarType = ConvImmutableTypeToMutableType conv immutConsumingVar.Type | |||
| let mutConsumingVar = Var (immutConsumingVar.Name, mutConsumingVarType) | |||
|
|
|||
| let immutConsumingVarReplacementExpr = ConvMutableToImmutable conv (Expr.Var mutConsumingVar) | |||
| let immutConsumingVarReplacementExpr = ConvMutableToImmutableWith ofResult conv (Expr.Var mutConsumingVar) | |||
| let mutConsumingExprBeforeSimplification = immutConsumingExpr.Substitute (fun v -> if v = immutConsumingVar then Some immutConsumingVarReplacementExpr else None) | |||
|
|
|||
| let mutConsumingExpr = SimplifyConsumingExpr mutConsumingExprBeforeSimplification | |||
| mutConsumingVar, mutConsumingExpr | |||
|
|
|||
| let ConvertImmutableConsumerToMutableConsumer conv consumer = | |||
| ConvertImmutableConsumerToMutableConsumerWith false conv consumer | |||
|
|
|||
There was a problem hiding this comment.
The conversion back to immutable tuples and records is the one of #20894 again, without ofResult.
| let rec ConvMutableToImmutable conv mutExpr = | |
| match conv with | |
| | TupleConv (isStruct, convs) -> | |
| let elements = convs |> List.mapi (fun i conv -> ConvMutableToImmutable conv (AnonymousObjectGet (mutExpr, i))) | |
| if isStruct then Expr.NewStructTuple elements else Expr.NewTuple elements | |
| | RecordConv (typ, convs) -> | |
| Expr.NewRecord (typ, convs |> List.mapi (fun i conv -> ConvMutableToImmutable conv (AnonymousObjectGet (mutExpr, i)))) | |
| | SeqConv conv -> | |
| // At this point, we know the input is either an IQueryable or an IEnumerable. | |
| // If it is an IQueryable, we must return an IQueryable. | |
| let isIQ = IsIQueryableTy mutExpr.Type | |
| assert (IsIEnumerableTy mutExpr.Type || IsIQueryableTy mutExpr.Type) | |
| let mutElemTy = mutExpr.Type.GetGenericArguments().[0] | |
| // Generate "source.Select(fun v -> ...)" (remembering that Select is an extension member, i.e. static) | |
| let mutVar = new Var("v", mutElemTy) | |
| let mutToImmutConvExpr = ConvMutableToImmutable conv (Expr.Var mutVar) | |
| // Use Queryable.Select when source is IQueryable, Enumerable.Select otherwise | |
| // This preserves the IQueryable type, enabling query composition and EF Core async ops | |
| let immutExpr = MakeSelect (CanEliminate.Yes, isIQ, mutExpr, mutVar, mutToImmutConvExpr) | |
| immutExpr | |
| | GroupingConv (immutKeyTy, immutElemTy, conv) -> | |
| assert (mutExpr.Type.GetGenericTypeDefinition() = typedefof<IGrouping<_, _>>) | |
| let mutElemTy = mutExpr.Type.GetGenericArguments().[1] | |
| let immutIGroupingTy = typedefof<IGrouping<_, _>>.MakeGenericType [| immutKeyTy; immutElemTy |] | |
| let immutGroupingTy = typedefof<Grouping<_, _>>.MakeGenericType [| immutKeyTy; immutElemTy |] | |
| // Generate "source.Select(fun v -> ...)" (remembering that Select is an extension member, i.e. static) | |
| let var = new Var("v", mutElemTy) | |
| let convExpr = ConvMutableToImmutable conv (Expr.Var var) | |
| // Construct an IGrouping | |
| let args = | |
| [ Expr.PropertyGet (mutExpr, mutExpr.Type.GetProperty "Key") | |
| MakeSelect(CanEliminate.Yes, false, mutExpr, var, convExpr) ] | |
| Expr.Coerce (Expr.NewObject (immutGroupingTy.GetConstructors().[0], args), immutIGroupingTy) | |
| | NoConv -> | |
| mutExpr | |
| /// Given the expressions for a function (fun immutConsumingVar -> immutConsumingExpr) operating over immutable tuple and record | |
| /// types, build the expressions for an equivalent function (fun mutConsumingVar -> mutConsumingExpr) which will operate over mutable data, where the given conversion | |
| /// data says how immutable types have been replaced by mutable types in the type of the input variable. | |
| /// | |
| /// For example, if 'conv' is NoConv, then the input function will be returned unchanged. | |
| /// | |
| /// If 'conv' is a TupleConv, then the input function will accept immutable tuples, and the output | |
| /// function will accept mutable tuples. In this case, the function is implemented by replacing | |
| /// uses of the immutConsumingVar in the body of immutConsumingExpr with a tuple expression built | |
| /// from the elements of mutConsumingVar, and then simplifying the overall result. | |
| let ConvertImmutableConsumerToMutableConsumer conv (immutConsumingVar: Var, immutConsumingExpr: Expr) : Var * Expr = | |
| match conv with | |
| | NoConv -> (immutConsumingVar, immutConsumingExpr) | |
| | _ -> | |
| let mutConsumingVarType = ConvImmutableTypeToMutableType conv immutConsumingVar.Type | |
| let mutConsumingVar = Var (immutConsumingVar.Name, mutConsumingVarType) | |
| let immutConsumingVarReplacementExpr = ConvMutableToImmutable conv (Expr.Var mutConsumingVar) | |
| let mutConsumingExprBeforeSimplification = immutConsumingExpr.Substitute (fun v -> if v = immutConsumingVar then Some immutConsumingVarReplacementExpr else None) | |
| let mutConsumingExpr = SimplifyConsumingExpr mutConsumingExprBeforeSimplification | |
| mutConsumingVar, mutConsumingExpr | |
| |> Seq.map (fun (city, group) -> | ||
| let elements = group |> Seq.map (fun c -> c.Name, c.Age) |> List.ofSeq | ||
| Grouping(city, elements) :> IGrouping<_, _>, elements.Length)) | ||
| |> Scenario.violating rebuiltGroup |
There was a problem hiding this comment.
Pinned with the constructor.
| |> Scenario.violating rebuiltGroup | |
| |> Scenario.violating newGroup |
| |> Seq.map (fun (city, pairs) -> | ||
| let elements = pairs |> Seq.map (fun (c, o) -> c.Name, o.Id) |> List.ofSeq | ||
| Grouping(city, elements) :> IGrouping<_, _>, elements.Length)) | ||
| |> Scenario.violating rebuiltGroup |
There was a problem hiding this comment.
Pinned with the constructor.
| |> Scenario.violating rebuiltGroup | |
| |> Scenario.violating newGroup |
| |> Seq.map (fun (city, pairs) -> | ||
| let elements = pairs |> Seq.map (fun (c, o) -> c.Name, o.Id) |> List.ofSeq | ||
| Grouping(city, elements) :> IGrouping<_, _>, elements.Length)) | ||
| |> Scenario.violating rebuiltGroup |
There was a problem hiding this comment.
Pinned with the constructor.
| |> Scenario.violating rebuiltGroup | |
| |> Scenario.violating newGroup |
| |> Seq.map (fun (city, group) -> | ||
| let elements = group |> Seq.map (fun c -> { PersonName = c.Name; PersonAge = c.Age }) |> List.ofSeq | ||
| Grouping(city, elements) :> IGrouping<_, _>, elements.Length)) | ||
| |> Scenario.violating rebuiltGroup |
There was a problem hiding this comment.
Pinned with the constructor.
| |> Scenario.violating rebuiltGroup | |
| |> Scenario.violating newGroup |
| // Now: ...Join(..).GroupBy(..).Select(g => new AnonymousObject`2(Item1 = g.Key, Item2 = g.Count())) | ||
| ``` | ||
|
|
||
| **Impact:** A group of tuples or records that a query uses as a value in the middle of a query, for example one that is passed on whole to a later `select` or `where`, is rebuilt from the provider's grouping by `RebuiltGroupings.Of`, so that it stays one object in every operator that mentions it. linq2db, which built such a group on the client, cannot be handed the grouping itself and no longer translates that call: such a query no longer translates with linq2db. EF Core cannot translate the call either where the group goes into a `select` beside another value: `groupValBy (c.Name, c.Age) c.City into g; let n = g.Count(); select (g, n)` ran with the groups rebuilt in the middle of the query and now fails with "Translation of 'Select' which contains grouping parameter without composition is not supported". The same holds after a `join`, after two `for`s, and with a record as the element. Reading the key of a group, or counting, summing, averaging or searching it, is not affected: those read the provider's grouping. |
There was a problem hiding this comment.
The paragraph of the impact says what the second way does.
| **Impact:** A group of tuples or records that a query uses as a value in the middle of a query, for example one that is passed on whole to a later `select` or `where`, is rebuilt from the provider's grouping by `RebuiltGroupings.Of`, so that it stays one object in every operator that mentions it. linq2db, which built such a group on the client, cannot be handed the grouping itself and no longer translates that call: such a query no longer translates with linq2db. EF Core cannot translate the call either where the group goes into a `select` beside another value: `groupValBy (c.Name, c.Age) c.City into g; let n = g.Count(); select (g, n)` ran with the groups rebuilt in the middle of the query and now fails with "Translation of 'Select' which contains grouping parameter without composition is not supported". The same holds after a `join`, after two `for`s, and with a record as the element. Reading the key of a group, or counting, summing, averaging or searching it, is not affected: those read the provider's grouping. | |
| **Impact:** A group of tuples or records that a query uses as a value in the middle of a query, for example one that is passed on whole to a later `select` or `where`, is built where the query uses it, as before, so a lambda that mentions the group twice has two objects: `obj.ReferenceEquals(g, g)` is false for it. Reading the key of a group, or counting, summing, averaging or searching it, is not affected: those read the provider's grouping. |
|
The second way looks right to me. The four queries are plain F#, and your results show that neither EF Core nor linq2db translates them with One correction for query { for c in customers.AsQueryable() do
groupValBy (c.Name, c.Age) c.City into g
where (g.Equals g)
select (g.Key, obj.ReferenceEquals(g, g)) }You say the second way makes both |
…for it A group that a query uses as a value was rebuilt through RebuiltGroupings.Of, which hands the provider's grouping itself to the lambda. EF Core cannot translate that where the group goes into a select beside another value (a count, a join, a second for, a record as the element), and linq2db cannot translate it at all. The group is now built with new Grouping at each place where the query mentions it: the reads of its key and the aggregates over it still read the provider's grouping. What it gives up is the identity of a group between its mentions. FSharp.Core 10.1 built the group once, in the Select right after the GroupBy, so over an in-memory IQueryable a query that compares a group with itself now gives other rows. PENDING_BREAKING_CHANGES.md says so, and the three scenarios that check identity stay, pinned to the rows that the build at each mention gives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @pcshrosbree, you are right: identity is not as before. I ran your query with FSharp.Core 10.1.402 and on #20894. Both build the group once, in the d8a4360 applies the second way as one commit, with your two points:
The commit differs from the suggestions in these two points and in formatting (Fantomas splits the |


Stacked on #20894
A
groupByorgroupValByover tuples or records, and anygroupByafter a join or a secondfor, no longer rebuilds every group in the middle of the query, which EF Core failed on with "Value cannot be null. (Parameter 'key')".Details
yieldof the element itself that the compiler adds after each join,forand custom operator was translated as a projection rebuilding the element. The elements now pass through unchanged.Count,Any,All,Sum,Average,Min,Max,SelectorSelectManyover a group, reads the provider's grouping. EF Core turns these intoCOUNT,SUM,AVGandMAX.Selectthe query itself wrote stays where it is.new Grouping(key, elements)at each place where the query mentions it, sogroupValBy (c.Name, c.Age) c.City into g; let n = g.Count(); select (g, n), also after ajoin, after twofors and with a record as the element, runs on EF Core and linq2db as on Check query trees for LINQ providers and translate struct tuples #20894. Two mentions of a group are two objects: FSharp.Core 10.1 built the group once, in theSelectright after theGroupBy, so over an in-memoryIQueryablea query that compares a group with itself now gives other rows.PENDING_BREAKING_CHANGES.mdlists them.tupledArg, the name the compiler gave the lambda that read them before. SQLProvider recognizes joined elements by that name.🤖 Generated with Claude Code