Skip to content

Commit e27f055

Browse files
committed
Fixed planning phase crash when inline fragments reference types not included in union or interface definitions
1 parent 5de13df commit e27f055

3 files changed

Lines changed: 247 additions & 7 deletions

File tree

RELEASE_NOTES.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,8 @@
238238
* Added `map` function for Relay `Connection` and `Edge` types
239239
* Excluded `GraphQLWebSocketMiddleware` from exception stack trace if request not a Web Socket
240240
* Changed `GraphQLOptionsDefaults.WebSocketConnectionInitTimeoutInMs` const type to `double`
241-
* Improved Relay XML documentation comments
241+
* Improved Relay XML documentation comments
242+
243+
### 3.1.1 - Unreleased
244+
245+
* Fixed planning phase crash when inline fragments reference types not included in union or interface definitions

src/FSharp.Data.GraphQL.Server/Planning.fs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,9 @@ let rec private abstractionInfo (ctx : PlanningContext) (parentDef : AbstractDef
118118
| ValueSome (Abstract abstractDef) ->
119119
abstractionInfo ctx abstractDef field ValueNone includer
120120
| _ ->
121-
let pname = parentDef :?> NamedDef
122-
Debug.Fail "Must be prevented by validation"
123-
failwith $"There is no object type named '%s{typeName}' that is a possible type of '%s{pname.Name}'"
121+
// Type condition doesn't match any possible types of the abstract type.
122+
// This is valid and should return an empty map (no fields for this type condition).
123+
Map.empty
124124

125125
let private directiveIncluder (directive: Directive) : Includer =
126126
fun variables ->
@@ -333,9 +333,9 @@ and private planAbstraction (ctx:PlanningContext) (selectionSet: Selection list)
333333
// Filter out already existing fields
334334
Map.merge (fun _ -> deepMerge) fields fragmentFields
335335
) Map.empty
336-
if Map.isEmpty plannedTypeFields
337-
then { info with Kind = ResolveDeferred info }
338-
else { info with Kind = ResolveAbstraction plannedTypeFields }
336+
// Always return ResolveAbstraction kind, even for empty maps.
337+
// An empty map is a valid state representing "no fields selected for this type condition."
338+
{ info with Kind = ResolveAbstraction plannedTypeFields }
339339

340340
let private planVariables (schema: ISchema) (operation: OperationDefinition) =
341341
operation.VariableDefinitions

tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,239 @@ let ``Planning must work with unions``() =
230230
"Person",
231231
[ ("name", upcast UNamed, upcast StringType)
232232
("age", upcast UNamed, upcast IntType) ] ])
233+
234+
[<Fact>]
235+
let ``Planning must handle inline fragment with non-matching type condition in unions``() =
236+
// ═══════════════════════════════════════════════════════════════════════════
237+
// REGRESSION TEST for Planning_ResolveDeferred_Bug
238+
// ═══════════════════════════════════════════════════════════════════════════
239+
//
240+
// GraphQL SCENARIO:
241+
// =================
242+
// In GraphQL, inline fragments with type conditions are used to query fields
243+
// specific to certain types in a union or interface:
244+
//
245+
// query {
246+
// items { # Union of [Animal, Person]
247+
// ... on Animal { } # ✓ Valid – Animal is in union
248+
// ... on Person { } # ✓ Valid – Person is in union
249+
// ... on Robot { } # ✓ Valid by spec! Robot is not in union
250+
// }
251+
// }
252+
//
253+
// THE PROBLEM:
254+
// ============
255+
// When an inline fragment's type condition does NOT match any type in the union,
256+
// the GraphQL spec says this is VALID – the fragment simply never matches.
257+
//
258+
// Example: Querying for Robot fields on a Person|Animal union
259+
// Expected: Empty result for Robot (no runtime error during planning)
260+
// Bug: Runtime error "Expected an Abstraction!" during query planning phase
261+
//
262+
// WHY IT MATTERS:
263+
// ===============
264+
// This commonly happens in real-world scenarios:
265+
// - Generic queries across multiple schema types
266+
// - Schema evolution (type removed from union, old queries still reference it)
267+
// - Client doesn't know exact union composition
268+
//
269+
// According to GraphQL spec, this must NOT fail – it should gracefully
270+
// produce an empty result set for non-matching fragments.
271+
// ═══════════════════════════════════════════════════════════════════════════
272+
273+
// Create a third type that is NOT part of UNamed union
274+
let Robot =
275+
Define.Object(
276+
name = "Robot",
277+
fields =
278+
[ Define.Field("modelNumber", StringType, fun _ (robot: string) -> robot)
279+
Define.Field("name", StringType, fun _ _ -> "Robot") ])
280+
281+
let Query = Define.Object("Query", [ Define.Field("names", ListOf UNamed, fun _ () -> []) ])
282+
let schema = Schema(query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; Robot ] })
283+
let schemaProcessor = Executor(schema)
284+
285+
// GraphQL Query:
286+
// UNamed union = Person | Animal (Robot is NOT in this union)
287+
// The "... on Robot" fragment below will never match any objects
288+
let query = """query Example {
289+
names {
290+
... on Animal {
291+
name
292+
species
293+
}
294+
... on Person {
295+
name
296+
age
297+
}
298+
... on Robot {
299+
modelNumber
300+
}
301+
}
302+
}"""
303+
304+
// TEST ASSERTION:
305+
// This must succeed per GraphQL spec – non-matching fragments are valid
306+
// Bug would cause: "Expected an Abstraction!" runtime error during planning
307+
let plan = schemaProcessor.CreateExecutionPlanOrFail(query)
308+
309+
// Verify the execution plan structure
310+
equals 1 plan.Fields.Length
311+
let listInfo = plan.Fields.Head
312+
let UNamedList : ListOfDef<Named, Named list> = ListOf UNamed
313+
listInfo.Identifier |> equals "names"
314+
listInfo.ReturnDef |> equals (upcast UNamedList)
315+
let (ResolveCollection(info)) = listInfo.Kind
316+
info.ParentDef |> equals (upcast UNamedList)
317+
info.ReturnDef |> equals (upcast UNamed)
318+
319+
// Must successfully extract abstraction info
320+
// Bug would fail here with wrong execution info kind
321+
let (ResolveAbstraction(innerFields)) = info.Kind
322+
323+
// Result: Only Animal and Person fields (Robot is filtered out)
324+
// This is correct GraphQL behavior – non-matching fragments produce no fields
325+
innerFields
326+
|> Map.map (fun typeName fields -> fields |> List.map (fun i -> (i.Identifier, i.ParentDef, i.ReturnDef)))
327+
|> equals (Map.ofList [ "Animal",
328+
[ ("name", upcast UNamed, upcast StringType)
329+
("species", upcast UNamed, upcast StringType) ]
330+
"Person",
331+
[ ("name", upcast UNamed, upcast StringType)
332+
("age", upcast UNamed, upcast IntType) ] ])
333+
334+
[<Fact>]
335+
let ``Planning must handle nested inline fragments with non-matching type conditions``() =
336+
// REGRESSION TEST for Planning_ResolveDeferred_Bug (nested scenario)
337+
//
338+
// GraphQL SCENARIO:
339+
// =================
340+
// Same issue as above, but with nested structure:
341+
//
342+
// query {
343+
// container {
344+
// nested { # Union of [Animal, Person]
345+
// ... on Robot { } # ✓ Valid – just never matches
346+
// }
347+
// }
348+
// }
349+
//
350+
// Tests that deeply nested queries with non-matching fragments work correctly.
351+
// This is common in production with complex schema hierarchies.
352+
353+
// Define Robot type (not part of UNamed union)
354+
let RobotType =
355+
Define.Object(
356+
name = "Robot",
357+
fields =
358+
[ Define.Field("modelNumber", StringType, fun _ (robot: string) -> robot)
359+
Define.Field("name", StringType, fun _ _ -> "Robot") ])
360+
361+
// Container type with nested union list – creates deeper nesting
362+
let ContainerType =
363+
Define.Object<unit>(
364+
name = "Container",
365+
fields = [ Define.Field("nested", ListOf UNamed, fun _ () -> []) ])
366+
367+
let Query =
368+
Define.Object(
369+
"Query",
370+
[ Define.Field("container", ContainerType, fun _ () -> ()) ])
371+
372+
let schema = Schema(query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; RobotType ] })
373+
let schemaProcessor = Executor(schema)
374+
375+
// Nested query with non-matching fragment
376+
let query = """query Example {
377+
container {
378+
nested {
379+
... on Animal {
380+
name
381+
species
382+
}
383+
... on Person {
384+
name
385+
age
386+
}
387+
... on Robot {
388+
modelNumber
389+
}
390+
}
391+
}
392+
}"""
393+
394+
// Must succeed – nested non-matching fragments are valid per GraphQL spec
395+
let plan = schemaProcessor.CreateExecutionPlanOrFail(query)
396+
397+
// Verify the plan structure is correct
398+
equals 1 plan.Fields.Length
399+
plan.Fields.Head.Identifier |> equals "container"
400+
401+
[<Fact>]
402+
let ``Planning must return ResolveAbstraction even when all fragments are non-matching``() =
403+
// REGRESSION TEST for Planning_ResolveDeferred_Bug (extreme case)
404+
//
405+
// GraphQL SCENARIO – EDGE CASE:
406+
// ==============================
407+
// What if ALL inline fragments in a query don't match the union?
408+
//
409+
// query {
410+
// items { # Union of [Animal, Person]
411+
// ... on Robot { } # Doesn't match
412+
// }
413+
// }
414+
//
415+
// THE PROBLEM:
416+
// ============
417+
// With only non-matching fragments, the planner has zero fields to plan.
418+
// This is the MOST EXTREME case of the bug.
419+
//
420+
// Expected GraphQL behavior: Valid query, returns empty result set
421+
// Bug behavior: Runtime crash during planning with "Expected an Abstraction!"
422+
//
423+
// WHY THIS HAPPENS:
424+
// =================
425+
// In real-world scenarios:
426+
// - Client queries for types that were removed from union
427+
// - Conditional fragments based on client-side logic
428+
// - Generic queries against multiple schema versions
429+
//
430+
// Per GraphQL spec: This MUST work – it's just a query that matches nothing.
431+
432+
// Robot is NOT in UNamed union
433+
let RobotType =
434+
Define.Object(
435+
name = "Robot",
436+
fields = [ Define.Field("modelNumber", StringType, fun _ (robot: string) -> robot) ])
437+
438+
let Query = Define.Object("Query", [ Define.Field("names", ListOf UNamed, fun _ () -> []) ])
439+
let schema = Schema(query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; RobotType ] })
440+
let schemaProcessor = Executor(schema)
441+
442+
// GraphQL Query – ONLY non-matching fragment!
443+
// UNamed union = Person | Animal (NOT Robot)
444+
// This query will match zero objects at runtime
445+
let query = """query Example {
446+
names {
447+
... on Robot {
448+
modelNumber
449+
}
450+
}
451+
}"""
452+
453+
// TEST ASSERTION:
454+
// Must succeed per GraphQL spec – empty result is valid, not an error
455+
// Bug would cause: Runtime crash "Expected an Abstraction!" during planning
456+
let plan = schemaProcessor.CreateExecutionPlanOrFail(query)
457+
458+
// Verify the plan was created successfully
459+
equals 1 plan.Fields.Length
460+
let listInfo = plan.Fields.Head
461+
let (ResolveCollection(info)) = listInfo.Kind
462+
463+
// Must successfully extract abstraction info
464+
let (ResolveAbstraction(innerFields)) = info.Kind
465+
466+
// Result: Empty map – no matching types
467+
// This is CORRECT per GraphQL spec – valid query, just matches nothing
468+
innerFields |> Map.isEmpty |> equals true

0 commit comments

Comments
 (0)