Skip to content

Consume the elements a query translation produces - #20901

Open
xperiandri wants to merge 14 commits into
dotnet:mainfrom
xperiandri:fix/query-consume-translated
Open

xperiandri wants to merge 14 commits into
dotnet:mainfrom
xperiandri:fix/query-consume-translated

Conversation

@xperiandri

@xperiandri xperiandri commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #20894

exists, all, count, sumBy, averageBy, minBy, maxBy and their Nullable variants after two fors, a join, or a tuple or record select now run on the query's own elements instead of on tuples and records rebuilt by a final Select, which EF Core evaluated on the client or rejected.

Details
  • A function that uses an element as a value of its own gets the elements as they are: a tuple or record, a struct that holds one, and a sequence of them, such as a nested query the function mentions twice.
  • A member of a record that is no field, and a member of a tuple that is no item, gets the record or tuple itself.

🤖 Generated with Claude Code

xperiandri and others added 3 commits November 1, 2026 22:03
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>
Copilot AI balanced review requested due to automatic review settings November 1, 2026 21:16
@xperiandri
xperiandri requested a review from a team as a code owner November 1, 2026 21:16
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

✅ Release notes checked


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`src/FSharp.Core` docs/release-notes/.FSharp.Core/11.0.200.md

@xperiandri

Copy link
Copy Markdown
Contributor Author

Covered cases

EF Core SQLite translates 100 of 137 scenarios, 92 without this change. Now translated: exists, all, sumBy and minByNullable over two fors, averageBy and sumByNullable after a join, maxBy and exists after a record select.

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.

🟡 Changes recommended

The consumer rewrite reconstructs a selected record separately at each use, causing obj.ReferenceEquals(p, p) to return false and breaking reference-identity semantics on LINQ to Objects.

3 open issues
What changed in this PR

This PR updates FSharp.Core query translation so terminal predicates and aggregates consume translated elements without first rebuilding tuples or records. It builds on #20894’s struct-tuple support and provider-shape tests.

Changes:

  • Rewrite terminal consumers and count to use translated element types.
  • Support struct-tuple construction and field access in query expression trees.
  • Add result and expression-shape scenarios, test registration, and release notes.
File Description
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryStructureScenarios.fs Covers loops, joins, subqueries, and terminal consumers.
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryShapeTests.fs Runs scenario groups as parameterized tests.
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryScenarioDomain.fs Supplies shared entities and sample data.
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryScenario.fs Checks results, provider retention, and expression shapes.
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryProviderShape.fs Captures provider expressions and detects shape violations.
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryProjectionScenarios.fs Covers tuple, record, and other projections.
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryOperatorScenarios.fs Covers query operators and aggregates.
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryMethodSyntaxScenarios.fs Covers LINQ method-syntax expression trees.
tests/​FSharp.Core.UnitTests/​FSharp.Core/​Microsoft.FSharp.Linq/​QueryKeyScenarios.fs Covers join and grouping keys.
tests/​FSharp.Core.UnitTests/​FSharp.Core.UnitTests.fsproj Registers the new test files.
src/​FSharp.Core/​QueryExtensions.fs Tracks struct-tuple conversions through anonymous objects.
src/​FSharp.Core/​Query.fs Rewrites terminal consumers and restores tuple kinds.
src/​FSharp.Core/​Linq.fs Translates struct-tuple element access into field reads.
docs/​release-notes/​.FSharp.Core/​11.0.200.md Documents struct-tuple and terminal-consumer changes.

Review effort: Balanced

Comment thread src/FSharp.Core/Query.fs Resolved
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>

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 🕵️ AI review — verify independently.

Comment thread src/FSharp.Core/Query.fs Resolved
Comment thread src/FSharp.Core/Query.fs Outdated
/// is asked for
let IsRebuiltObject (ty: Type) =
not ty.IsValueType
&& (Microsoft.FSharp.Reflection.FSharpType.IsTuple ty || isPartiallyImmutableRecord ty)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 🕵️ Repeated uses of the same nested query get different wrapper objects — result changes from true on the base to false here; reconstructed sequences also need whole-element preservation.

open System.Linq

let result =
    query {
        for n in [1; 2].AsQueryable() do
        let s =
            query {
                for m in [n].AsQueryable() do
                select (m, m)
            }
        exists (obj.ReferenceEquals(s, s))
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96ae948. A sequence of tuples or records is selected anew at each mention, as a tuple or record is built, so it counts as an object of its own too, and the function gets the elements as they are. The scenario is exists with a nested query, the nested query compared with itself. The tree is the one main builds, with the unnamed read of the tuple that it has; that is pinned.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ A nested query that returns groupings still gets two wrappers: true on the base, false at 96ae948c4d.

open System.Linq

let result =
    query {
        for n in [1; 2].AsQueryable() do
        let groups =
            query {
                for m in [n].AsQueryable() do
                groupValBy (m, m) m
            }
        exists (obj.ReferenceEquals(groups, groups))
    }

IsRebuiltObject does not recognize the IGrouping<_, _> element, although GroupingConv rebuilds it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 998b564. IsRebuiltObject now asks whether the elements of a sequence are converted at all (IsConvertedElement): tuples and records of a class or a struct, groups of such elements, and sequences of those. A nested query of groups, or of struct tuples, that a function mentions twice is one sequence again.

Scenarios: exists with a nested query of groups, the nested query compared with itself (the example above) and the same for struct tuples. Both fail without the change.

@T-Gro
T-Gro self-requested a review November 2, 2026 12:51
@T-Gro T-Gro added the AI-reviewed PR reviewed by AI review council label Nov 2, 2026
xperiandri and others added 2 commits November 2, 2026 14:24
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>
@xperiandri
xperiandri force-pushed the fix/query-consume-translated branch from 67a2c46 to 96ae948 Compare November 2, 2026 18:57
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Build Failure Analysis

Summary - All 16 primary Windows build binlogs fail while compiling FSharp.Core.UnitTests.fsproj; the 16 companion binlogs compile successfully and show no independent failed-target or process failure.

Root cause: string.Contains overload is unavailable on the Windows net472 target

QueryShapeTests.fs:461 calls failure.Contains("rows: expected", StringComparison.Ordinal). On the failing target, that string.Contains(string, StringComparison) overload is unavailable, so F# resolves toward the generic sequence Contains shape and reports string/char and StringComparison/IEqualityComparer<char> mismatches. The compatible repository pattern is failure.IndexOf("rows: expected", StringComparison.Ordinal) >= 0.

Affected errors

  • FS0001 - expected char, but received string at line 461, column 88.
  • FS0001 - StringComparison is incompatible with IEqualityComparer<char> at line 461, column 106.
  • FS0193 - the same comparer type-constraint mismatch at line 461, column 106.

Fix attempt - blocked

Delivery - none

No code was published. This is a contributor-fork PR, and the required Windows net472 validation cannot run on this Linux worker because the .NET Framework 4.7.2 reference assemblies are unavailable. The playbook prohibits publishing an unvalidated patch.

Checks

  • ./build.sh -c Release - exit 0; prepared the repository bootstrap.
  • ./eng/common/dotnet.sh build tests/FSharp.Core.UnitTests/FSharp.Core.UnitTests.fsproj -c Release --no-restore - exit 0 for the Linux net11.0 target; it does not exercise the failing API surface.
  • BUILDING_USING_DOTNET=false OS=Windows_NT ./eng/common/dotnet.sh build tests/FSharp.Core.UnitTests/FSharp.Core.UnitTests.fsproj -c Release -f net472 --no-restore - exit 1 with MSB3644 before compilation because .NETFramework,Version=v4.7.2 reference assemblies are unavailable.
  • A forced netstandard2.0 project build also could not reach this file: it exits 1 earlier in FSharp.Test.Utilities on unsupported AppDomain APIs.
  • Remaining analyzed build errors: the three line-461 diagnostics above in every primary binlog; no compiler errors in companion binlogs.
Build evidence

The primary binlogs report failed Build.proj/solution targets rooted in tests/FSharp.Core.UnitTests/FSharp.Core.UnitTests.fsproj CoreCompile/Fsc. Each reports 4 errors (the three structured F# diagnostics plus Build failed) and one warning. The companion binlogs report Build: SUCCEEDED, 0 errors, and no failed projects. Analysis run: https://github.com/dotnet/fsharp/actions/runs/41513458000

Structured data:

{
  "artifact_kind": "analysis",
  "fix_delivery": "none",
  "fix_status": "blocked",
  "workflow_artifact": "build-failure-analysis"
}

Warning

Firewall blocked 82 domains

The following domains were blocked by the firewall during workflow execution:

  • 0t3vsblobprodcus362.vsblob.vsassets.io
  • 11vvsblobprodcus336.vsblob.vsassets.io
  • 1javsblobprodcus364.vsblob.vsassets.io
  • 1k9vsblobprodcus379.vsblob.vsassets.io
  • 1oavsblobprodcus350.vsblob.vsassets.io
  • 1p7vsblobprodcus324.vsblob.vsassets.io
  • 1s1vsblobprodcus386.vsblob.vsassets.io
  • 2kmvsblobprodcus39.vsblob.vsassets.io
  • 2zrvsblobprodcus388.vsblob.vsassets.io
  • 37bvsblobprodcus311.vsblob.vsassets.io
  • 4myvsblobprodcus32.vsblob.vsassets.io
  • 4vyvsblobprodcus361.vsblob.vsassets.io
  • 51yvsblobprodcus36.vsblob.vsassets.io
  • 5dkvsblobprodcus355.vsblob.vsassets.io
  • 5rqvsblobprodcus385.vsblob.vsassets.io
  • 6s7vsblobprodcus313.vsblob.vsassets.io
  • 6yfvsblobprodwus2121.vsblob.vsassets.io
  • 7devsblobprodcus323.vsblob.vsassets.io
  • 7k6vsblobprodcus337.vsblob.vsassets.io
  • 8xbvsblobprodcus382.vsblob.vsassets.io
  • ajhvsblobprodcus363.vsblob.vsassets.io
  • bcnvsblobprodcus378.vsblob.vsassets.io
  • builds.dotnet.microsoft.com
  • c50vsblobprodcus330.vsblob.vsassets.io
  • c78vsblobprodcus322.vsblob.vsassets.io
  • cflvsblobprodcus383.vsblob.vsassets.io
  • ci.dot.net
  • ckzvsblobprodcus347.vsblob.vsassets.io
  • data.nuget.org
  • dlbvsblobprodcus316.vsblob.vsassets.io
  • fdpvsblobprodcus345.vsblob.vsassets.io
  • frdvsblobprodcus327.vsblob.vsassets.io
  • gbsvsblobprodcus365.vsblob.vsassets.io
  • gervsblobprodcus329.vsblob.vsassets.io
  • h6tvsblobprodcus346.vsblob.vsassets.io
  • i1qvsblobprodcus353.vsblob.vsassets.io
  • ibzvsblobprodcus369.vsblob.vsassets.io
  • imzvsblobprodcus368.vsblob.vsassets.io
  • jd4vsblobprodcus366.vsblob.vsassets.io
  • josvsblobprodcus372.vsblob.vsassets.io
  • jrqvsblobprodcus343.vsblob.vsassets.io
  • k0ivsblobprodcus356.vsblob.vsassets.io
  • k4kvsblobprodcus344.vsblob.vsassets.io
  • kgfvsblobprodcus314.vsblob.vsassets.io
  • kh4vsblobprodcus325.vsblob.vsassets.io
  • kijvsblobprodcus387.vsblob.vsassets.io
  • kmuvsblobprodcus389.vsblob.vsassets.io
  • kxqvsblobprodcus376.vsblob.vsassets.io
  • l49vsblobprodcus358.vsblob.vsassets.io
  • ljcvsblobprodcus317.vsblob.vsassets.io
  • m8dvsblobprodcus37.vsblob.vsassets.io
  • mfjvsblobprodcus373.vsblob.vsassets.io
  • n3kvsblobprodcus335.vsblob.vsassets.io
  • nn8vsblobprodcus340.vsblob.vsassets.io
  • o3svsblobprodcus318.vsblob.vsassets.io
  • ofvvsblobprodcus315.vsblob.vsassets.io
  • p2ovsblobprodcus312.vsblob.vsassets.io
  • pc2vsblobprodcus360.vsblob.vsassets.io
  • pdfvsblobprodcus380.vsblob.vsassets.io
  • pe3vsblobprodcus354.vsblob.vsassets.io
  • pe4vsblobprodcus351.vsblob.vsassets.io
  • pkgs.dev.azure.com
  • rcxvsblobprodcus328.vsblob.vsassets.io
  • s4uvsblobprodcus326.vsblob.vsassets.io
  • sc4vsblobprodcus331.vsblob.vsassets.io
  • se1vsblobprodcus349.vsblob.vsassets.io
  • southcentralus0.in.applicationinsights.azure.com
  • sqdvsblobprodcus333.vsblob.vsassets.io
  • st8vsblobprodcus339.vsblob.vsassets.io
  • tphvsblobprodcus375.vsblob.vsassets.io
  • u3hvsblobprodcus371.vsblob.vsassets.io
  • u6ovsblobprodcus377.vsblob.vsassets.io
  • ukkvsblobprodcus352.vsblob.vsassets.io
  • v53vsblobprodcus320.vsblob.vsassets.io
  • vb4vsblobprodcus33.vsblob.vsassets.io
  • vwvvsblobprodcus334.vsblob.vsassets.io
  • wztvsblobprodwus2155.vsblob.vsassets.io
  • x3yvsblobprodcus370.vsblob.vsassets.io
  • xupvsblobprodcus332.vsblob.vsassets.io
  • yluvsblobprodcus367.vsblob.vsassets.io
  • yttvsblobprodcus357.vsblob.vsassets.io
  • ytvvsblobprodcus310.vsblob.vsassets.io

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "0t3vsblobprodcus362.vsblob.vsassets.io"
    - "11vvsblobprodcus336.vsblob.vsassets.io"
    - "1javsblobprodcus364.vsblob.vsassets.io"
    - "1k9vsblobprodcus379.vsblob.vsassets.io"
    - "1oavsblobprodcus350.vsblob.vsassets.io"
    - "1p7vsblobprodcus324.vsblob.vsassets.io"
    - "1s1vsblobprodcus386.vsblob.vsassets.io"
    - "2kmvsblobprodcus39.vsblob.vsassets.io"
    - "2zrvsblobprodcus388.vsblob.vsassets.io"
    - "37bvsblobprodcus311.vsblob.vsassets.io"
    - "4myvsblobprodcus32.vsblob.vsassets.io"
    - "4vyvsblobprodcus361.vsblob.vsassets.io"
    - "51yvsblobprodcus36.vsblob.vsassets.io"
    - "5dkvsblobprodcus355.vsblob.vsassets.io"
    - "5rqvsblobprodcus385.vsblob.vsassets.io"
    - "6s7vsblobprodcus313.vsblob.vsassets.io"
    - "6yfvsblobprodwus2121.vsblob.vsassets.io"
    - "7devsblobprodcus323.vsblob.vsassets.io"
    - "7k6vsblobprodcus337.vsblob.vsassets.io"
    - "8xbvsblobprodcus382.vsblob.vsassets.io"
    - "ajhvsblobprodcus363.vsblob.vsassets.io"
    - "bcnvsblobprodcus378.vsblob.vsassets.io"
    - "builds.dotnet.microsoft.com"
    - "c50vsblobprodcus330.vsblob.vsassets.io"
    - "c78vsblobprodcus322.vsblob.vsassets.io"
    - "cflvsblobprodcus383.vsblob.vsassets.io"
    - "ci.dot.net"
    - "ckzvsblobprodcus347.vsblob.vsassets.io"
    - "data.nuget.org"
    - "dlbvsblobprodcus316.vsblob.vsassets.io"
    - "fdpvsblobprodcus345.vsblob.vsassets.io"
    - "frdvsblobprodcus327.vsblob.vsassets.io"
    - "gbsvsblobprodcus365.vsblob.vsassets.io"
    - "gervsblobprodcus329.vsblob.vsassets.io"
    - "h6tvsblobprodcus346.vsblob.vsassets.io"
    - "i1qvsblobprodcus353.vsblob.vsassets.io"
    - "ibzvsblobprodcus369.vsblob.vsassets.io"
    - "imzvsblobprodcus368.vsblob.vsassets.io"
    - "jd4vsblobprodcus366.vsblob.vsassets.io"
    - "josvsblobprodcus372.vsblob.vsassets.io"
    - "jrqvsblobprodcus343.vsblob.vsassets.io"
    - "k0ivsblobprodcus356.vsblob.vsassets.io"
    - "k4kvsblobprodcus344.vsblob.vsassets.io"
    - "kgfvsblobprodcus314.vsblob.vsassets.io"
    - "kh4vsblobprodcus325.vsblob.vsassets.io"
    - "kijvsblobprodcus387.vsblob.vsassets.io"
    - "kmuvsblobprodcus389.vsblob.vsassets.io"
    - "kxqvsblobprodcus376.vsblob.vsassets.io"
    - "l49vsblobprodcus358.vsblob.vsassets.io"
    - "ljcvsblobprodcus317.vsblob.vsassets.io"
    - "m8dvsblobprodcus37.vsblob.vsassets.io"
    - "mfjvsblobprodcus373.vsblob.vsassets.io"
    - "n3kvsblobprodcus335.vsblob.vsassets.io"
    - "nn8vsblobprodcus340.vsblob.vsassets.io"
    - "o3svsblobprodcus318.vsblob.vsassets.io"
    - "ofvvsblobprodcus315.vsblob.vsassets.io"
    - "p2ovsblobprodcus312.vsblob.vsassets.io"
    - "pc2vsblobprodcus360.vsblob.vsassets.io"
    - "pdfvsblobprodcus380.vsblob.vsassets.io"
    - "pe3vsblobprodcus354.vsblob.vsassets.io"
    - "pe4vsblobprodcus351.vsblob.vsassets.io"
    - "pkgs.dev.azure.com"
    - "rcxvsblobprodcus328.vsblob.vsassets.io"
    - "s4uvsblobprodcus326.vsblob.vsassets.io"
    - "sc4vsblobprodcus331.vsblob.vsassets.io"
    - "se1vsblobprodcus349.vsblob.vsassets.io"
    - "southcentralus0.in.applicationinsights.azure.com"
    - "sqdvsblobprodcus333.vsblob.vsassets.io"
    - "st8vsblobprodcus339.vsblob.vsassets.io"
    - "tphvsblobprodcus375.vsblob.vsassets.io"
    - "u3hvsblobprodcus371.vsblob.vsassets.io"
    - "u6ovsblobprodcus377.vsblob.vsassets.io"
    - "ukkvsblobprodcus352.vsblob.vsassets.io"
    - "v53vsblobprodcus320.vsblob.vsassets.io"
    - "vb4vsblobprodcus33.vsblob.vsassets.io"
    - "vwvvsblobprodcus334.vsblob.vsassets.io"
    - "wztvsblobprodwus2155.vsblob.vsassets.io"
    - "x3yvsblobprodcus370.vsblob.vsassets.io"
    - "xupvsblobprodcus332.vsblob.vsassets.io"
    - "yluvsblobprodcus367.vsblob.vsassets.io"
    - "yttvsblobprodcus357.vsblob.vsassets.io"
    - "ytvvsblobprodcus310.vsblob.vsassets.io"

See Network Configuration for more information.

Automated content by GitHub Copilot. Generated by the Build Failure Analysis workflow. · copilot · gpt56 · 228.9 AIC · ⌖ 8.66 AIC · ⊞ 22K | [History]( · ◷)

@T-Gro T-Gro left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 🕵️ Re-reviewed all recent changes as of 96ae948; 1 finding

Comment thread src/FSharp.Core/Query.fs Outdated
else
Microsoft.FSharp.Reflection.FSharpType.IsTuple ty
|| isPartiallyImmutableRecord ty
|| ((IsIEnumerableTy ty || IsIQueryableTy ty) && IsRebuiltObject (ty.GetGenericArguments().[0]))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 🕵️ Stack overflow before even an empty query can run over a recursive struct.

open System.Linq

[<Struct>]
type Node = { Children: seq<Node> }

let result =
    query {
        for n in Seq.empty<Node>.AsQueryable() do
        exists (Seq.isEmpty n.Children)
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 998b564. The check no longer goes from a sequence into the fields of its element: it follows only the type arguments of sequences and groups, so a struct that holds a sequence of itself ends the search.

Test: exists over a struct that holds a sequence of itself (an empty source gives false, one element with an empty Children gives true). Without the change the test host dies with a stack overflow.

xperiandri and others added 7 commits November 4, 2026 10:23
String.Contains(string, StringComparison) does not exist on .NET Framework, so
the test project failed to compile for net472 and all the Windows legs of the CI
went red. IndexOf with an ordinal comparison does the same on both.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Reflection promises no order of the properties of a class. On .NET Framework one
scenario listed the properties of the first order it rendered in another order
than those of the three after it, so the expected and the actual rows of the
same orders differed as text. The names decide the order now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
exists, all, sumBy, averageBy, minBy, maxBy and count after two fors,
a join, or a tuple or record select took the translated query, turned
every element back into an F# tuple or record with a final Select, and
ran the consumer over the rebuilt value. A LINQ provider then saw a
tuple built at the end of the query and a lambda reading an unnamed
member of it, which EF Core evaluates on the client or rejects.

The consumers now read the anonymous objects the translation already
uses, the same way the next operator in a chain does, and count counts
them without the conversion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The consumer rewrite put a separate tuple or record in the place of every
mention of the element, so a function that used the element, or a tuple or
record in it, as a value of its own got a different object for each mention:
ReferenceEquals(p, p) was false. A function like that is evaluated over the
elements as the query produces them, as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ject, as a use of the whole

A function over the elements of a query is rewritten over the elements as the
query translation produces them, unless it uses an element, or a tuple or
record in it, as a value of its own. Two reads were taken for a member of the
element without being one:

- A member of a record that is no field, like member x.Identity = x, is a
  function of the whole record: the rewrite gave each read a record of its
  own, and ReferenceEquals(p.Identity, p.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 counted, although
  the tuple in it is built anew each time the struct is.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A function that consumes the elements of a query was rewritten over the elements
the translation produces whenever it read only parts of an element. A part that
is a nested query was then selected anew at every mention, so a function that
mentioned it twice got two sequences where the element held one. A sequence of
tuples or records now counts as an object of its own, like a tuple or a record,
and the function gets the elements as they are.

The scenarios add that nested query compared with itself, and a struct passed
twice whose record is changed through one copy and read through the other.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…erts

IsRebuiltObject asked of the element of a sequence whether it is itself a rebuilt
object, which a group is not, and a struct tuple or record without a rebuilt part
is not either. A nested query that returns groups, or struct tuples, was taken for
a sequence the translation leaves alone, so a function that mentioned it twice
got two sequences, where ReferenceEquals gave true on the code before.

The sequence is rebuilt when its elements are converted at all: tuples and
records of a class or a struct, groups of such elements, and sequences of those.
Only the type arguments of the sequences and the groups are followed, which also
ends the search for a struct that holds a sequence of itself. That search ran
without end and overflowed the stack on the first query over such a struct.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the fix/query-consume-translated branch from 96ae948 to 998b564 Compare November 4, 2026 19:54

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI-reviewed PR reviewed by AI review council

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

3 participants