Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Kept the first filter of a path filtered differently under different …
…types

ObjectListFilterMiddleware walks a shared plan once per path, so a later walk
of the same plan under the path is skipped, and the last filter it assigned
depended on which walks were skipped. The first filter of a path never comes
from a skipped walk, so keeping it gives the same result as walking the plan
as a tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
xperiandri and claude committed Oct 10, 2026
commit 270377146f05fd5294dd4358e9cba17e3833442f
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,14 @@ type internal ObjectListFilterMiddleware<'ObjectType, 'ListType> (reportToMetada
|> splitSeqErrorsList
|> Result.map (Seq.vchoose id >> Seq.toList)
// The filters of the list fields of the operation by path; the filters of the fields of a list field's items are
// not collected, and a path filtered differently under different types of an abstract field keeps the last
// filter.
// not collected.
//
// A plan shares the plans of the selection sets that are planned the same way, so walking it field by field
// would walk a shared plan once for every path through the plans to it: exponentially often with the nesting of
// abstract fields. A shared plan is therefore walked once per path of response names, which the plans sharing it
// have in common.
// have in common. A path filtered differently under different types of an abstract field keeps its first filter:
// the walk of a plan skipped under a path repeats an earlier one, so it never holds the first filter of a path,
// while it could hold a later one.
let filters = Dictionary<SelectionPath, ObjectListFilter> (HashIdentity.Reference)
// The fields selected under a field, those of every possible type of an abstract field included
let selectedFields (kind : ExecutionInfoKind) =
Expand All @@ -231,7 +232,7 @@ type internal ObjectListFilterMiddleware<'ObjectType, 'ListType> (reportToMetada
| ResolveCollection item ->
let path = parent.Child field.Ast.AliasOrName
fieldFilters item
|> Result.map (List.iter (fun filter -> filters[path] <- filter))
|> Result.map (List.iter (fun filter -> filters.TryAdd (path, filter) |> ignore))
| _ -> Ok ()
match collected with
| Error errs -> errors <- ValueSome errs
Expand Down
28 changes: 16 additions & 12 deletions tests/FSharp.Data.GraphQL.Tests/PlanningDoSTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -327,18 +327,22 @@ let ``Object list filter middleware collects many filters nested deeply quickly`
filters.Count |> equals 24_000

[<Fact>]
let ``Object list filter middleware reports one filter for a list field filtered differently under different types`` () =
// The filters are reported by path, and the field has the same path under both types
let executor = Executor (nodeSchema (), [ Define.ObjectListFilterMiddleware<Node, Node>(true) ])
let plan =
planIsolated executor "{ node { ... on A { items(filter: { id: 1 }) { id } } ... on B { items(filter: { id: 2 }) { id } } } }"
let result = executeIsolated executor plan noVariables
ensureDirect result <| fun _ errors -> empty errors
result.Metadata.TryFind<ObjectListFilters> "filters"
|> wantValueSome
|> Seq.map (fun (KeyValue (path, _)) -> path |> List.map string)
|> List.ofSeq
|> equals [ [ "node"; "items" ] ]
let ``Object list filter middleware reports the first filter of a list field filtered differently under different types`` () =
// The filters are reported by path, and the field has the same path under both types. In the second document, the
// plan of A's child is reached again under C after the merged plan of B's child, whose last filter is the second one
let filtersOf (query : string) =
let executor = Executor (nodeSchema (), [ Define.ObjectListFilterMiddleware<Node, Node>(true) ])
let result = executeIsolated executor (planIsolated executor query) noVariables
ensureDirect result <| fun _ errors -> empty errors
result.Metadata.TryFind<ObjectListFilters> "filters"
|> wantValueSome
|> Seq.map (fun (KeyValue (path, filter)) -> path |> List.map string, filter)
|> List.ofSeq
filtersOf "{ node { ... on A { items(filter: { id: 1 }) { id } } ... on B { items(filter: { id: 2 }) { id } } } }"
|> equals [ [ "node"; "items" ], Equals ({ FieldName = "id"; Value = 1L }, null) ]
filtersOf
"{ node { child { ... on A { items(filter: { id: 1 }) { id } } } ... on B { child { ... on C { items(filter: { id: 2 }) { id } } } } } }"
|> equals [ [ "node"; "child"; "items" ], Equals ({ FieldName = "id"; Value = 1L }, null) ]

[<Fact>]
let ``Object list filter middleware accepts a deferred fragment at the root`` () =
Expand Down