From 4a7ba1af86eb6a7ac71c02bfa227a11da54d47c0 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 22 Aug 2025 00:32:36 +0300 Subject: [PATCH 01/79] add defer definition --- v2/pkg/asttransform/base.graphql | 204 +++++++++++++++ v2/pkg/asttransform/baseschema.go | 231 ++--------------- v2/pkg/asttransform/baseschema_test.go | 27 +- v2/pkg/asttransform/defer.graphql | 17 ++ v2/pkg/asttransform/defer_internal.graphql | 18 ++ v2/pkg/asttransform/fixtures/complete.golden | 10 +- .../fixtures/custom_query_name.golden | 10 +- .../fixtures/mutation_only.golden | 8 + .../mutation_only_internal_defer.golden | 232 ++++++++++++++++++ .../fixtures/schema_missing.golden | 10 +- v2/pkg/asttransform/fixtures/simple.golden | 10 +- .../fixtures/subscription_only.golden | 8 + .../fixtures/subscription_renamed.golden | 8 + .../with_mutation_subscription.golden | 10 +- v2/pkg/engine/plan/analyze_plan_kind.go | 1 + 15 files changed, 583 insertions(+), 221 deletions(-) create mode 100644 v2/pkg/asttransform/base.graphql create mode 100644 v2/pkg/asttransform/defer.graphql create mode 100644 v2/pkg/asttransform/defer_internal.graphql create mode 100644 v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden diff --git a/v2/pkg/asttransform/base.graphql b/v2/pkg/asttransform/base.graphql new file mode 100644 index 0000000000..410606e525 --- /dev/null +++ b/v2/pkg/asttransform/base.graphql @@ -0,0 +1,204 @@ +"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +scalar Int +"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +scalar Float +"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +scalar String +"The 'Boolean' scalar type represents 'true' or 'false' ." +scalar Boolean +"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +scalar ID +"Directs the executor to include this field or fragment only when the argument is true." +directive @include( + "Included when true." + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +"Directs the executor to skip this field or fragment when the argument is true." +directive @skip( + "Skipped when true." + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +"Marks an element of a GraphQL schema as no longer supported." +directive @deprecated( + """ + Explains why this element was deprecated, usually also including a suggestion + for how to access supported similar data. Formatted in + [Markdown](https://daringfireball.net/projects/markdown/). + """ + reason: String = "No longer supported" +) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE + +directive @specifiedBy(url: String!) on SCALAR + +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + +""" +A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. +In some cases, you need to provide options to alter GraphQL's execution behavior +in ways field arguments will not suffice, such as conditionally including or +skipping a field. Directives provide this by describing additional information +to the executor. +""" +type __Directive { + name: String! + description: String + locations: [__DirectiveLocation!]! + args(includeDeprecated: Boolean = false): [__InputValue!]! + isRepeatable: Boolean! +} + +""" +A Directive can be adjacent to many parts of the GraphQL language, a +__DirectiveLocation describes one such possible adjacencies. +""" +enum __DirectiveLocation { + "Location adjacent to a query operation." + QUERY + "Location adjacent to a mutation operation." + MUTATION + "Location adjacent to a subscription operation." + SUBSCRIPTION + "Location adjacent to a field." + FIELD + "Location adjacent to a fragment definition." + FRAGMENT_DEFINITION + "Location adjacent to a fragment spread." + FRAGMENT_SPREAD + "Location adjacent to an inline fragment." + INLINE_FRAGMENT + "Location adjacent to a variable definition" + VARIABLE_DEFINITION + "Location adjacent to a schema definition." + SCHEMA + "Location adjacent to a scalar definition." + SCALAR + "Location adjacent to an object type definition." + OBJECT + "Location adjacent to a field definition." + FIELD_DEFINITION + "Location adjacent to an argument definition." + ARGUMENT_DEFINITION + "Location adjacent to an interface definition." + INTERFACE + "Location adjacent to a union definition." + UNION + "Location adjacent to an enum definition." + ENUM + "Location adjacent to an enum value definition." + ENUM_VALUE + "Location adjacent to an input object type definition." + INPUT_OBJECT + "Location adjacent to an input object field definition." + INPUT_FIELD_DEFINITION +} +""" +One possible value for a given Enum. Enum values are unique values, not a +placeholder for a string or numeric value. However an Enum value is returned in +a JSON response as a string. +""" +type __EnumValue { + name: String! + description: String + isDeprecated: Boolean! + deprecationReason: String +} + +""" +Object and Interface types are described by a list of Fields, each of which has +a name, potentially a list of arguments, and a return type. +""" +type __Field { + name: String! + description: String + args(includeDeprecated: Boolean = false): [__InputValue!]! + type: __Type! + isDeprecated: Boolean! + deprecationReason: String +} + +"""Arguments provided to Fields or Directives and the input fields of an +InputObject are represented as Input Values which describe their type and +optionally a default value. +""" +type __InputValue { + name: String! + description: String + type: __Type! + defaultValue: String + isDeprecated: Boolean! + deprecationReason: String +} + +""" +A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all +available types and directives on the server, as well as the entry points for +query, mutation, and subscription operations. +""" +type __Schema { + description: String + "A list of all types supported by this server." + types: [__Type!]! + "The type that query operations will be rooted at." + queryType: __Type! + "If this server supports mutation, the type that mutation operations will be rooted at." + mutationType: __Type + "If this server support subscription, the type that subscription operations will be rooted at." + subscriptionType: __Type + "A list of all directives supported by this server." + directives: [__Directive!]! +} + +""" +The fundamental unit of any GraphQL Schema is the type. There are many kinds of +types in GraphQL as represented by the '__TypeKind' enum. + +Depending on the kind of a type, certain fields describe information about that +type. Scalar types provide no information beyond a name and description, while +Enum types provide their values. Object and Interface types provide the fields +they describe. Abstract types, Union and Interface, provide the Object types +possible at runtime. List and NonNull types compose other types. +""" +type __Type { + kind: __TypeKind! + name: String + description: String + # must be non-null for OBJECT and INTERFACE, otherwise null. + fields(includeDeprecated: Boolean = false): [__Field!] + # must be non-null for OBJECT and INTERFACE, otherwise null. + interfaces: [__Type!] + # must be non-null for INTERFACE and UNION, otherwise null. + possibleTypes: [__Type!] + # must be non-null for ENUM, otherwise null. + enumValues(includeDeprecated: Boolean = false): [__EnumValue!] + # must be non-null for INPUT_OBJECT, otherwise null. + inputFields(includeDeprecated: Boolean = false): [__InputValue!] + # must be non-null for NON_NULL and LIST, otherwise null. + ofType: __Type + # may be non-null for custom SCALAR, otherwise null. + specifiedByURL: String +} + +"An enum describing what kind of type a given '__Type' is." +enum __TypeKind { + "Indicates this type is a scalar." + SCALAR + "Indicates this type is an object. 'fields' and 'interfaces' are valid fields." + OBJECT + "Indicates this type is an interface. 'fields' ' and ' 'possibleTypes' are valid fields." + INTERFACE + "Indicates this type is a union. 'possibleTypes' is a valid field." + UNION + "Indicates this type is an enum. 'enumValues' is a valid field." + ENUM + "Indicates this type is an input object. 'inputFields' is a valid field." + INPUT_OBJECT + "Indicates this type is a list. 'ofType' is a valid field." + LIST + "Indicates this type is a non-null. 'ofType' is a valid field." + NON_NULL +} \ No newline at end of file diff --git a/v2/pkg/asttransform/baseschema.go b/v2/pkg/asttransform/baseschema.go index 48a0a3dd9a..1b7c827fa2 100644 --- a/v2/pkg/asttransform/baseschema.go +++ b/v2/pkg/asttransform/baseschema.go @@ -2,14 +2,40 @@ package asttransform import ( "bytes" + _ "embed" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) +var ( + //go:embed base.graphql + baseSchema []byte + + //go:embed defer_internal.graphql + deferInternal []byte + + //go:embed defer.graphql + deferRegular []byte +) + +type Options struct { + InternalDefer bool +} + func MergeDefinitionWithBaseSchema(definition *ast.Document) error { + return MergeDefinitionWithBaseSchemaWithOptions(definition, Options{}) +} + +func MergeDefinitionWithBaseSchemaWithOptions(definition *ast.Document, options Options) error { definition.Input.AppendInputBytes(baseSchema) + if options.InternalDefer { + definition.Input.AppendInputBytes(deferInternal) + } else { + definition.Input.AppendInputBytes(deferRegular) + } + parser := astparser.NewParser() report := operationreport.Report{} parser.Parse(definition, &report) @@ -135,208 +161,3 @@ func findQueryNode(definition *ast.Document) (queryNode ast.Node, ok bool) { return queryNode, ok } - -var baseSchema = []byte(`"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." -scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." -scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." -scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." -scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." -scalar ID -"Directs the executor to include this field or fragment only when the argument is true." -directive @include( - "Included when true." - if: Boolean! -) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -"Directs the executor to skip this field or fragment when the argument is true." -directive @skip( - "Skipped when true." - if: Boolean! -) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -"Marks an element of a GraphQL schema as no longer supported." -directive @deprecated( - """ - Explains why this element was deprecated, usually also including a suggestion - for how to access supported similar data. Formatted in - [Markdown](https://daringfireball.net/projects/markdown/). - """ - reason: String = "No longer supported" -) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE - -directive @specifiedBy(url: String!) on SCALAR - -""" -The @oneOf built-in directive marks an input object as a OneOf Input Object. -Exactly one field must be provided and its value must be non-null at runtime. -All fields defined within a @oneOf input must be nullable in the schema. -""" -directive @oneOf on INPUT_OBJECT - -""" -A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. -In some cases, you need to provide options to alter GraphQL's execution behavior -in ways field arguments will not suffice, such as conditionally including or -skipping a field. Directives provide this by describing additional information -to the executor. -""" -type __Directive { - name: String! - description: String - locations: [__DirectiveLocation!]! - args(includeDeprecated: Boolean = false): [__InputValue!]! - isRepeatable: Boolean! -} - -""" -A Directive can be adjacent to many parts of the GraphQL language, a -__DirectiveLocation describes one such possible adjacencies. -""" -enum __DirectiveLocation { - "Location adjacent to a query operation." - QUERY - "Location adjacent to a mutation operation." - MUTATION - "Location adjacent to a subscription operation." - SUBSCRIPTION - "Location adjacent to a field." - FIELD - "Location adjacent to a fragment definition." - FRAGMENT_DEFINITION - "Location adjacent to a fragment spread." - FRAGMENT_SPREAD - "Location adjacent to an inline fragment." - INLINE_FRAGMENT - "Location adjacent to a variable definition" - VARIABLE_DEFINITION - "Location adjacent to a schema definition." - SCHEMA - "Location adjacent to a scalar definition." - SCALAR - "Location adjacent to an object type definition." - OBJECT - "Location adjacent to a field definition." - FIELD_DEFINITION - "Location adjacent to an argument definition." - ARGUMENT_DEFINITION - "Location adjacent to an interface definition." - INTERFACE - "Location adjacent to a union definition." - UNION - "Location adjacent to an enum definition." - ENUM - "Location adjacent to an enum value definition." - ENUM_VALUE - "Location adjacent to an input object type definition." - INPUT_OBJECT - "Location adjacent to an input object field definition." - INPUT_FIELD_DEFINITION -} -""" -One possible value for a given Enum. Enum values are unique values, not a -placeholder for a string or numeric value. However an Enum value is returned in -a JSON response as a string. -""" -type __EnumValue { - name: String! - description: String - isDeprecated: Boolean! - deprecationReason: String -} - -""" -Object and Interface types are described by a list of Fields, each of which has -a name, potentially a list of arguments, and a return type. -""" -type __Field { - name: String! - description: String - args(includeDeprecated: Boolean = false): [__InputValue!]! - type: __Type! - isDeprecated: Boolean! - deprecationReason: String -} - -"""Arguments provided to Fields or Directives and the input fields of an -InputObject are represented as Input Values which describe their type and -optionally a default value. -""" -type __InputValue { - name: String! - description: String - type: __Type! - defaultValue: String - isDeprecated: Boolean! - deprecationReason: String -} - -""" -A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all -available types and directives on the server, as well as the entry points for -query, mutation, and subscription operations. -""" -type __Schema { - description: String - "A list of all types supported by this server." - types: [__Type!]! - "The type that query operations will be rooted at." - queryType: __Type! - "If this server supports mutation, the type that mutation operations will be rooted at." - mutationType: __Type - "If this server support subscription, the type that subscription operations will be rooted at." - subscriptionType: __Type - "A list of all directives supported by this server." - directives: [__Directive!]! -} - -""" -The fundamental unit of any GraphQL Schema is the type. There are many kinds of -types in GraphQL as represented by the '__TypeKind' enum. - -Depending on the kind of a type, certain fields describe information about that -type. Scalar types provide no information beyond a name and description, while -Enum types provide their values. Object and Interface types provide the fields -they describe. Abstract types, Union and Interface, provide the Object types -possible at runtime. List and NonNull types compose other types. -""" -type __Type { - kind: __TypeKind! - name: String - description: String - # must be non-null for OBJECT and INTERFACE, otherwise null. - fields(includeDeprecated: Boolean = false): [__Field!] - # must be non-null for OBJECT and INTERFACE, otherwise null. - interfaces: [__Type!] - # must be non-null for INTERFACE and UNION, otherwise null. - possibleTypes: [__Type!] - # must be non-null for ENUM, otherwise null. - enumValues(includeDeprecated: Boolean = false): [__EnumValue!] - # must be non-null for INPUT_OBJECT, otherwise null. - inputFields(includeDeprecated: Boolean = false): [__InputValue!] - # must be non-null for NON_NULL and LIST, otherwise null. - ofType: __Type - # may be non-null for custom SCALAR, otherwise null. - specifiedByURL: String -} - -"An enum describing what kind of type a given '__Type' is." -enum __TypeKind { - "Indicates this type is a scalar." - SCALAR - "Indicates this type is an object. 'fields' and 'interfaces' are valid fields." - OBJECT - "Indicates this type is an interface. 'fields' ' and ' 'possibleTypes' are valid fields." - INTERFACE - "Indicates this type is a union. 'possibleTypes' is a valid field." - UNION - "Indicates this type is an enum. 'enumValues' is a valid field." - ENUM - "Indicates this type is an input object. 'inputFields' is a valid field." - INPUT_OBJECT - "Indicates this type is a list. 'ofType' is a valid field." - LIST - "Indicates this type is a non-null. 'ofType' is a valid field." - NON_NULL -}`) diff --git a/v2/pkg/asttransform/baseschema_test.go b/v2/pkg/asttransform/baseschema_test.go index 7a856ea677..2f0baa38ef 100644 --- a/v2/pkg/asttransform/baseschema_test.go +++ b/v2/pkg/asttransform/baseschema_test.go @@ -2,11 +2,8 @@ package asttransform_test import ( "bytes" - "os" "testing" - "github.com/jensneuse/diffview" - "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" @@ -14,9 +11,19 @@ import ( ) func runTestMerge(definition, fixtureName string) func(t *testing.T) { + return runTestMergeWithDefer(definition, fixtureName, false) +} + +func runTestMergeWithDefer(definition, fixtureName string, internalDefer bool) func(t *testing.T) { return func(t *testing.T) { doc := unsafeparser.ParseGraphqlDocumentString(definition) - err := asttransform.MergeDefinitionWithBaseSchema(&doc) + var err error + if internalDefer { + err = asttransform.MergeDefinitionWithBaseSchemaWithOptions(&doc, asttransform.Options{InternalDefer: true}) + } else { + err = asttransform.MergeDefinitionWithBaseSchema(&doc) + } + if err != nil { panic(err) } @@ -27,13 +34,6 @@ func runTestMerge(definition, fixtureName string) func(t *testing.T) { } got := buf.Bytes() goldie.Assert(t, fixtureName, got) - if t.Failed() { - want, err := os.ReadFile("./fixtures/" + fixtureName + ".golden") - if err != nil { - panic(err) - } - diffview.NewGoland().DiffViewBytes(fixtureName, want, got) - } } } @@ -56,6 +56,11 @@ func TestMergeDefinitionWithBaseSchema(t *testing.T) { m: String! } `, "mutation_only")) + t.Run("mutation only - internal defer", runTestMergeWithDefer(` + type Mutation { + m: String! + } + `, "mutation_only_internal_defer", true)) t.Run("schema with mutation", runTestMerge(` schema { mutation: Mutation diff --git a/v2/pkg/asttransform/defer.graphql b/v2/pkg/asttransform/defer.graphql new file mode 100644 index 0000000000..e697e35ba7 --- /dev/null +++ b/v2/pkg/asttransform/defer.graphql @@ -0,0 +1,17 @@ +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +#"Directs the executor to stream this array field when the if argument is true or undefined." +#directive @stream( +# "A unique identifier for the results." +# label: String +# "Controls streaming, usually via a variable." +# if: Boolean! = true +# "The number of results to include in the initial (non-streamed) response." +# initialCount: Int = 0 +#) on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/defer_internal.graphql b/v2/pkg/asttransform/defer_internal.graphql new file mode 100644 index 0000000000..828c2ddb9a --- /dev/null +++ b/v2/pkg/asttransform/defer_internal.graphql @@ -0,0 +1,18 @@ +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer_internal( + id: String! + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) repeatable on FIELD + +#"Directs the executor to stream this array field when the if argument is true or undefined." +#directive @stream( +# "A unique identifier for the results." +# label: String +# "Controls streaming, usually via a variable." +# if: Boolean! = true +# "The number of results to include in the initial (non-streamed) response." +# initialCount: Int = 0 +#) on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/complete.golden b/v2/pkg/asttransform/fixtures/complete.golden index fa69f656e6..a528465a30 100644 --- a/v2/pkg/asttransform/fixtures/complete.golden +++ b/v2/pkg/asttransform/fixtures/complete.golden @@ -229,4 +229,12 @@ enum __TypeKind { LIST "Indicates this type is a non-null. 'ofType' is a valid field." NON_NULL -} \ No newline at end of file +} + +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/custom_query_name.golden b/v2/pkg/asttransform/fixtures/custom_query_name.golden index b1b8ff8c13..1fb2f6d0c9 100644 --- a/v2/pkg/asttransform/fixtures/custom_query_name.golden +++ b/v2/pkg/asttransform/fixtures/custom_query_name.golden @@ -229,4 +229,12 @@ enum __TypeKind { LIST "Indicates this type is a non-null. 'ofType' is a valid field." NON_NULL -} \ No newline at end of file +} + +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/mutation_only.golden b/v2/pkg/asttransform/fixtures/mutation_only.golden index bfe7dab3d3..3e3e194b13 100644 --- a/v2/pkg/asttransform/fixtures/mutation_only.golden +++ b/v2/pkg/asttransform/fixtures/mutation_only.golden @@ -223,6 +223,14 @@ enum __TypeKind { NON_NULL } +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + type Query { __schema: __Schema! __type(name: String!): __Type diff --git a/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden b/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden new file mode 100644 index 0000000000..ca38127511 --- /dev/null +++ b/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden @@ -0,0 +1,232 @@ +schema { + mutation: Mutation + query: Query +} + +type Mutation { + m: String! + __typename: String! +} + +"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +scalar Int + +"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +scalar Float + +"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +scalar String + +"The 'Boolean' scalar type represents 'true' or 'false' ." +scalar Boolean + +"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +scalar ID + +"Directs the executor to include this field or fragment only when the argument is true." +directive @include( + "Included when true." + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +"Directs the executor to skip this field or fragment when the argument is true." +directive @skip( + "Skipped when true." + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +"Marks an element of a GraphQL schema as no longer supported." +directive @deprecated( + """ + Explains why this element was deprecated, usually also including a suggestion + for how to access supported similar data. Formatted in + [Markdown](https://daringfireball.net/projects/markdown/). + """ + reason: String = "No longer supported" +) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + +directive @specifiedBy( + url: String! +) on SCALAR + +""" +A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. +In some cases, you need to provide options to alter GraphQL's execution behavior +in ways field arguments will not suffice, such as conditionally including or +skipping a field. Directives provide this by describing additional information +to the executor. +""" +type __Directive { + name: String! + description: String + locations: [__DirectiveLocation!]! + args(includeDeprecated: Boolean = false): [__InputValue!]! + isRepeatable: Boolean! + __typename: String! +} + +""" +A Directive can be adjacent to many parts of the GraphQL language, a +__DirectiveLocation describes one such possible adjacencies. +""" +enum __DirectiveLocation { + "Location adjacent to a query operation." + QUERY + "Location adjacent to a mutation operation." + MUTATION + "Location adjacent to a subscription operation." + SUBSCRIPTION + "Location adjacent to a field." + FIELD + "Location adjacent to a fragment definition." + FRAGMENT_DEFINITION + "Location adjacent to a fragment spread." + FRAGMENT_SPREAD + "Location adjacent to an inline fragment." + INLINE_FRAGMENT + "Location adjacent to a variable definition" + VARIABLE_DEFINITION + "Location adjacent to a schema definition." + SCHEMA + "Location adjacent to a scalar definition." + SCALAR + "Location adjacent to an object type definition." + OBJECT + "Location adjacent to a field definition." + FIELD_DEFINITION + "Location adjacent to an argument definition." + ARGUMENT_DEFINITION + "Location adjacent to an interface definition." + INTERFACE + "Location adjacent to a union definition." + UNION + "Location adjacent to an enum definition." + ENUM + "Location adjacent to an enum value definition." + ENUM_VALUE + "Location adjacent to an input object type definition." + INPUT_OBJECT + "Location adjacent to an input object field definition." + INPUT_FIELD_DEFINITION +} + +""" +One possible value for a given Enum. Enum values are unique values, not a +placeholder for a string or numeric value. However an Enum value is returned in +a JSON response as a string. +""" +type __EnumValue { + name: String! + description: String + isDeprecated: Boolean! + deprecationReason: String + __typename: String! +} + +""" +Object and Interface types are described by a list of Fields, each of which has +a name, potentially a list of arguments, and a return type. +""" +type __Field { + name: String! + description: String + args(includeDeprecated: Boolean = false): [__InputValue!]! + type: __Type! + isDeprecated: Boolean! + deprecationReason: String + __typename: String! +} + +""" +Arguments provided to Fields or Directives and the input fields of an +InputObject are represented as Input Values which describe their type and +optionally a default value. +""" +type __InputValue { + name: String! + description: String + type: __Type! + defaultValue: String + isDeprecated: Boolean! + deprecationReason: String + __typename: String! +} + +""" +A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all +available types and directives on the server, as well as the entry points for +query, mutation, and subscription operations. +""" +type __Schema { + description: String + "A list of all types supported by this server." + types: [__Type!]! + "The type that query operations will be rooted at." + queryType: __Type! + "If this server supports mutation, the type that mutation operations will be rooted at." + mutationType: __Type + "If this server support subscription, the type that subscription operations will be rooted at." + subscriptionType: __Type + "A list of all directives supported by this server." + directives: [__Directive!]! + __typename: String! +} + +""" +The fundamental unit of any GraphQL Schema is the type. There are many kinds of +types in GraphQL as represented by the '__TypeKind' enum. + +Depending on the kind of a type, certain fields describe information about that +type. Scalar types provide no information beyond a name and description, while +Enum types provide their values. Object and Interface types provide the fields +they describe. Abstract types, Union and Interface, provide the Object types +possible at runtime. List and NonNull types compose other types. +""" +type __Type { + kind: __TypeKind! + name: String + description: String + fields(includeDeprecated: Boolean = false): [__Field!] + interfaces: [__Type!] + possibleTypes: [__Type!] + enumValues(includeDeprecated: Boolean = false): [__EnumValue!] + inputFields(includeDeprecated: Boolean = false): [__InputValue!] + ofType: __Type + specifiedByURL: String + __typename: String! +} + +"An enum describing what kind of type a given '__Type' is." +enum __TypeKind { + "Indicates this type is a scalar." + SCALAR + "Indicates this type is an object. 'fields' and 'interfaces' are valid fields." + OBJECT + "Indicates this type is an interface. 'fields' ' and ' 'possibleTypes' are valid fields." + INTERFACE + "Indicates this type is a union. 'possibleTypes' is a valid field." + UNION + "Indicates this type is an enum. 'enumValues' is a valid field." + ENUM + "Indicates this type is an input object. 'inputFields' is a valid field." + INPUT_OBJECT + "Indicates this type is a list. 'ofType' is a valid field." + LIST + "Indicates this type is a non-null. 'ofType' is a valid field." + NON_NULL +} + +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer_internal( + id: String! + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) repeatable on FIELD + +type Query { + __schema: __Schema! + __type(name: String!): __Type + __typename: String! +} \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/schema_missing.golden b/v2/pkg/asttransform/fixtures/schema_missing.golden index fa69f656e6..a528465a30 100644 --- a/v2/pkg/asttransform/fixtures/schema_missing.golden +++ b/v2/pkg/asttransform/fixtures/schema_missing.golden @@ -229,4 +229,12 @@ enum __TypeKind { LIST "Indicates this type is a non-null. 'ofType' is a valid field." NON_NULL -} \ No newline at end of file +} + +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/simple.golden b/v2/pkg/asttransform/fixtures/simple.golden index fa69f656e6..a528465a30 100644 --- a/v2/pkg/asttransform/fixtures/simple.golden +++ b/v2/pkg/asttransform/fixtures/simple.golden @@ -229,4 +229,12 @@ enum __TypeKind { LIST "Indicates this type is a non-null. 'ofType' is a valid field." NON_NULL -} \ No newline at end of file +} + +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/subscription_only.golden b/v2/pkg/asttransform/fixtures/subscription_only.golden index 923037d7ff..8fae3d3ef6 100644 --- a/v2/pkg/asttransform/fixtures/subscription_only.golden +++ b/v2/pkg/asttransform/fixtures/subscription_only.golden @@ -222,6 +222,14 @@ enum __TypeKind { NON_NULL } +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + type Query { __schema: __Schema! __type(name: String!): __Type diff --git a/v2/pkg/asttransform/fixtures/subscription_renamed.golden b/v2/pkg/asttransform/fixtures/subscription_renamed.golden index 21a6637642..10f8497a4e 100644 --- a/v2/pkg/asttransform/fixtures/subscription_renamed.golden +++ b/v2/pkg/asttransform/fixtures/subscription_renamed.golden @@ -222,6 +222,14 @@ enum __TypeKind { NON_NULL } +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + type Query { __schema: __Schema! __type(name: String!): __Type diff --git a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden index 709ad78ac1..d0a5175ba3 100644 --- a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden +++ b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden @@ -240,4 +240,12 @@ enum __TypeKind { LIST "Indicates this type is a non-null. 'ofType' is a valid field." NON_NULL -} \ No newline at end of file +} + +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file diff --git a/v2/pkg/engine/plan/analyze_plan_kind.go b/v2/pkg/engine/plan/analyze_plan_kind.go index 9d949884af..5dc08e4cc2 100644 --- a/v2/pkg/engine/plan/analyze_plan_kind.go +++ b/v2/pkg/engine/plan/analyze_plan_kind.go @@ -23,6 +23,7 @@ func AnalyzePlanKind(operation, definition *ast.Document, operationName string) return ast.OperationTypeUnknown, false, report } operationType = visitor.operationType + // TODO: this should be done differently streaming = visitor.hasDeferDirective || visitor.hasStreamDirective return } From e0f49c46001369c543c289499e836896e9d1a4ca Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 26 Aug 2025 20:41:39 +0300 Subject: [PATCH 02/79] add normalization rule to expand defer into inline defer per field --- v2/pkg/ast/ast_directive.go | 9 + v2/pkg/ast/ast_inline_fragment.go | 8 + .../astnormalization/astnormalization_test.go | 21 ++- .../fragment_spread_inlining_test.go | 4 +- .../inline_fragment_expand_defer.go | 167 ++++++++++++++++++ .../inline_fragment_expand_defer_test.go | 58 ++++++ ...e_selections_from_inline_fragments_test.go | 32 ++++ v2/pkg/lexer/literal/literal.go | 1 + 8 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 v2/pkg/astnormalization/inline_fragment_expand_defer.go create mode 100644 v2/pkg/astnormalization/inline_fragment_expand_defer_test.go diff --git a/v2/pkg/ast/ast_directive.go b/v2/pkg/ast/ast_directive.go index 9b70b521ab..658417b312 100644 --- a/v2/pkg/ast/ast_directive.go +++ b/v2/pkg/ast/ast_directive.go @@ -28,6 +28,15 @@ func (l *DirectiveList) HasDirectiveByName(document *Document, name string) bool return false } +func (l *DirectiveList) HasDirectiveByNameBytes(document *Document, directiveName ByteSlice) (directiveRef int, exists bool) { + for i := range l.Refs { + if bytes.Equal(directiveName, document.DirectiveNameBytes(l.Refs[i])) { + return l.Refs[i], true + } + } + return InvalidRef, false +} + func (l *DirectiveList) RemoveDirectiveByName(document *Document, name string) { for i := range l.Refs { if document.DirectiveNameString(l.Refs[i]) == name { diff --git a/v2/pkg/ast/ast_inline_fragment.go b/v2/pkg/ast/ast_inline_fragment.go index db110a8714..9c0d5e4d36 100644 --- a/v2/pkg/ast/ast_inline_fragment.go +++ b/v2/pkg/ast/ast_inline_fragment.go @@ -86,3 +86,11 @@ func (d *Document) InlineFragmentSelectionSet(ref int) (selectionSetRef int, ok func (d *Document) InlineFragmentDirectives(ref int) []int { return d.InlineFragments[ref].Directives.Refs } + +func (d *Document) InlineFragmentDirectiveByName(inlineFragmentRef int, directiveName ByteSlice) (ref int, exists bool) { + if !d.InlineFragments[inlineFragmentRef].HasDirectives { + return InvalidRef, false + } + + return d.InlineFragments[inlineFragmentRef].Directives.HasDirectiveByNameBytes(d, directiveName) +} diff --git a/v2/pkg/astnormalization/astnormalization_test.go b/v2/pkg/astnormalization/astnormalization_test.go index 2bc22e84ac..d56f307c20 100644 --- a/v2/pkg/astnormalization/astnormalization_test.go +++ b/v2/pkg/astnormalization/astnormalization_test.go @@ -1243,7 +1243,24 @@ var runWithVariables = func(t *testing.T, normalizeFunc registerNormalizeFunc, d assert.Equal(t, want, got) } -var run = func(t *testing.T, normalizeFunc registerNormalizeFunc, definition, operation, expectedOutput string, indent ...bool) { +type runOptions struct { + indent bool + withInternalDefer bool +} + +var runWithOptions = func(t *testing.T, normalizeFunc registerNormalizeFunc, definition, operation, expectedOutput string, options runOptions) { + t.Helper() + run(t, normalizeFunc, definition, operation, expectedOutput, options) +} + +var run = func(t *testing.T, normalizeFunc registerNormalizeFunc, definition, operation, expectedOutput string, options ...runOptions) { + t.Helper() + + var opts runOptions + + if len(options) > 0 { + opts = options[0] + } definitionDocument := unsafeparser.ParseGraphqlDocumentString(definition) err := asttransform.MergeDefinitionWithBaseSchema(&definitionDocument) @@ -1265,7 +1282,7 @@ var run = func(t *testing.T, normalizeFunc registerNormalizeFunc, definition, op } var got, want string - if len(indent) > 0 && indent[0] { + if opts.indent { got = mustString(astprinter.PrintStringIndent(&operationDocument, " ")) want = mustString(astprinter.PrintStringIndent(&expectedOutputDocument, " ")) } else { diff --git a/v2/pkg/astnormalization/fragment_spread_inlining_test.go b/v2/pkg/astnormalization/fragment_spread_inlining_test.go index b08fc67449..170c7ea5d8 100644 --- a/v2/pkg/astnormalization/fragment_spread_inlining_test.go +++ b/v2/pkg/astnormalization/fragment_spread_inlining_test.go @@ -628,7 +628,7 @@ func TestInlineFragments(t *testing.T) { }`) }) t.Run("non intersecting interfaces shouldn't merge", func(t *testing.T) { - run(t, fragmentSpreadInline, testDefinition, ` + runWithOptions(t, fragmentSpreadInline, testDefinition, ` { dog { ...nonIntersectingInterfaces @@ -652,7 +652,7 @@ func TestInlineFragments(t *testing.T) { } fragment sentientFragment on Sentient { name - }`, true) + }`, runOptions{indent: true}) }) t.Run("implicitly intersecting interfaces should merge", func(t *testing.T) { run(t, fragmentSpreadInline, ` diff --git a/v2/pkg/astnormalization/inline_fragment_expand_defer.go b/v2/pkg/astnormalization/inline_fragment_expand_defer.go new file mode 100644 index 0000000000..793d30ecc3 --- /dev/null +++ b/v2/pkg/astnormalization/inline_fragment_expand_defer.go @@ -0,0 +1,167 @@ +package astnormalization + +import ( + "fmt" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" +) + +// InlineFragmentExpandDefer registers a visitor that +// applies the defer directive to every nested field +func InlineFragmentExpandDefer(walker *astvisitor.Walker) { + visitor := inlineFragmentExpandDeferVisitor{ + Walker: walker, + } + walker.RegisterEnterDocumentVisitor(&visitor) + walker.RegisterInlineFragmentVisitor(&visitor) + walker.RegisterEnterSelectionSetVisitor(&visitor) +} + +type inlineFragmentExpandDeferVisitor struct { + *astvisitor.Walker + operation *ast.Document + defers []deferInfo + currentDeferId int +} + +type deferInfo struct { + parentDeferId string + id string + label string + fragmentRef int +} + +func (f *inlineFragmentExpandDeferVisitor) EnterDocument(operation, _ *ast.Document) { + f.operation = operation +} + +func (f *inlineFragmentExpandDeferVisitor) EnterInlineFragment(ref int) { + if !f.operation.InlineFragmentHasDirectives(ref) { + return + } + + // has defer directive? + directiveRef, exists := f.operation.InlineFragmentDirectiveByName(ref, literal.DEFER) + if !exists { + return + } + + // check if defer is enabled + enabled := true + ifValue, hasIf := f.operation.DirectiveArgumentValueByName(directiveRef, literal.IF) + if hasIf { + enabled = bool(f.operation.BooleanValue(ifValue.Ref)) + } + + // remove defer directive from the inline fragment + // as it will be applied to every nested field + f.operation.RemoveDirectiveFromNode(ast.Node{ + Kind: ast.NodeKindInlineFragment, + Ref: ref, + }, directiveRef) + + if !enabled { + return + } + + selectionSetRef, ok := f.operation.InlineFragmentSelectionSet(ref) + if !ok { + return + } + + if len(f.operation.SelectionSetFieldSelections(selectionSetRef)) == 0 { + // if a deferred fragment has no fields, it should be ignored + return + } + + // get label argument if any + labelValue, hasLabel := f.operation.DirectiveArgumentValueByName(directiveRef, literal.LABEL) + label := "" + if hasLabel { + label = f.operation.StringValueContentString(labelValue.Ref) + } + + f.currentDeferId++ + + parentDeferId := "" + if len(f.defers) > 0 { + parentDeferId = f.defers[len(f.defers)-1].id + } + + deferInfo := deferInfo{ + parentDeferId: parentDeferId, + id: fmt.Sprintf("%d", f.currentDeferId), + label: label, + fragmentRef: ref, + } + + f.defers = append(f.defers, deferInfo) +} + +func (f *inlineFragmentExpandDeferVisitor) LeaveInlineFragment(ref int) { + if len(f.defers) == 0 { + return + } + + if f.defers[len(f.defers)-1].fragmentRef == ref { + f.defers = f.defers[:len(f.defers)-1] + } +} + +func (f *inlineFragmentExpandDeferVisitor) EnterSelectionSet(ref int) { + // if there are no active defers, nothing to do + if len(f.defers) == 0 { + return + } + + fieldSelectionRefs := f.operation.SelectionSetFieldSelections(ref) + // if there are no fields in the current selection set, nothing to do + if len(fieldSelectionRefs) == 0 { + return + } + + // apply the internal defer directive to every field in the current selection set + for _, fieldSelectionRef := range fieldSelectionRefs { + f.addInternalDeferDirective(f.operation.Selections[fieldSelectionRef].Ref) + } +} + +func (f *inlineFragmentExpandDeferVisitor) addInternalDeferDirective(fieldRef int) { + var args []int + + deferInfo := f.defers[len(f.defers)-1] + + if deferInfo.id != "" { + args = append(args, f.addStringArgument("id", deferInfo.id)) + } + + if deferInfo.parentDeferId != "" { + args = append(args, f.addStringArgument("parentDeferId", deferInfo.parentDeferId)) + } + + if deferInfo.label != "" { + args = append(args, f.addStringArgument("label", deferInfo.label)) + } + + directiveRef := f.operation.ImportDirective("defer_internal", args) + + f.operation.AddDirectiveToNode(directiveRef, ast.Node{ + Kind: ast.NodeKindField, + Ref: fieldRef, + }) +} + +func (f *inlineFragmentExpandDeferVisitor) addStringArgument(name, value string) int { + strValueRef := f.operation.AddStringValue(ast.StringValue{ + Content: f.operation.Input.AppendInputString(value), + }) + + arg := ast.Argument{ + Name: f.operation.Input.AppendInputString(name), + Value: ast.Value{Kind: ast.ValueKindString, Ref: strValueRef}, + } + + return f.operation.AddArgument(arg) +} diff --git a/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go b/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go new file mode 100644 index 0000000000..e9706d9802 --- /dev/null +++ b/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go @@ -0,0 +1,58 @@ +package astnormalization + +import "testing" + +func TestInlineFragmentExpandDefer(t *testing.T) { + t.Run("simple", func(t *testing.T) { + run(t, InlineFragmentExpandDefer, testDefinition, ` + query dog { + dog { + ... @defer { + name + } + } + }`, + ` + query dog { + dog { + ... { + name @defer_internal(id: "1") + } + } + }`) + }) + t.Run("with interface type", func(t *testing.T) { + run(t, InlineFragmentExpandDefer, testDefinition, ` + query pet { + pet { + ... on Dog @defer { + name + nickname + ... @defer { + barkVolume + } + } + ... on Cat @defer { + name + meowVolume + } + } + }`, + ` + query pet { + pet { + ... on Dog { + name @defer_internal(id: "1") + nickname @defer_internal(id: "1") + ... { + barkVolume @defer_internal(id: "2", parentDeferId: "1") + } + } + ... on Cat { + name @defer_internal(id: "3") + meowVolume @defer_internal(id: "3") + } + } + }`) + }) +} diff --git a/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go b/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go index c74425e3ff..f8f522b411 100644 --- a/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go +++ b/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go @@ -96,4 +96,36 @@ func TestResolveInlineFragments(t *testing.T) { }`) }) + t.Run("with internal defer", func(t *testing.T) { + run(t, inlineSelectionsFromInlineFragments, testDefinition, ` + query pet { + pet { + ... on Dog { + name @defer_internal(id: "1") + nickname @defer_internal(id: "1") + ... { + barkVolume @defer_internal(id: "2", parentDeferId: "1") + } + } + ... on Cat { + name @defer_internal(id: "3") + meowVolume @defer_internal(id: "3") + } + } + }`, + ` + query pet { + pet { + ... on Dog { + name @defer_internal(id: "1") + nickname @defer_internal(id: "1") + barkVolume @defer_internal(id: "2", parentDeferId: "1") + } + ... on Cat { + name @defer_internal(id: "3") + meowVolume @defer_internal(id: "3") + } + } + }`) + }) } diff --git a/v2/pkg/lexer/literal/literal.go b/v2/pkg/lexer/literal/literal.go index 8c57db74c2..23e141c882 100644 --- a/v2/pkg/lexer/literal/literal.go +++ b/v2/pkg/lexer/literal/literal.go @@ -66,6 +66,7 @@ var ( IF = []byte("if") SKIP = []byte("skip") DEFER = []byte("defer") + LABEL = []byte("label") STREAM = []byte("stream") SCHEMA = []byte("schema") EXTEND = []byte("extend") From 06dd534d83ded178b5f564e65555eda95be7f9ba Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 27 Aug 2025 18:55:06 +0300 Subject: [PATCH 03/79] allow to merge non scalar fields with defer, allow to merge fields with same directives in different order --- v2/pkg/ast/ast_directive.go | 85 ++++++++++++++++++- .../inline_fragment_selection_merging.go | 38 +++++++++ .../inline_fragment_selection_merging_test.go | 73 ++++++++++++++++ v2/pkg/lexer/literal/literal.go | 1 + 4 files changed, 193 insertions(+), 4 deletions(-) diff --git a/v2/pkg/ast/ast_directive.go b/v2/pkg/ast/ast_directive.go index 658417b312..03bb2ad3fa 100644 --- a/v2/pkg/ast/ast_directive.go +++ b/v2/pkg/ast/ast_directive.go @@ -50,6 +50,19 @@ func (l *DirectiveList) RemoveDirectiveByName(document *Document, name string) { } } +func (l *DirectiveList) RemoveDirectiveByRef(directiveRef int) { + for i := range l.Refs { + if l.Refs[i] == directiveRef { + if i < len(l.Refs)-1 { + l.Refs = append(l.Refs[:i], l.Refs[i+1:]...) + } else { + l.Refs = l.Refs[:i] + } + return + } + } +} + func (d *Document) CopyDirective(ref int) int { var arguments ArgumentList if d.Directives[ref].HasArguments { @@ -127,15 +140,79 @@ func (d *Document) DirectivesAreEqual(left, right int) bool { } func (d *Document) DirectiveSetsAreEqual(left, right []int) bool { - if len(left) != len(right) { - return false + if len(left) == 0 && len(right) == 0 { + return true + } + + // if left has no directives and right has only the defer directives, we consider them equal + if len(left) == 0 && len(right) > 0 { + for i := 0; i < len(right); i++ { + if !bytes.Equal(d.DirectiveNameBytes(right[i]), literal.DEFER_INTERNAL) { + return false + } + } + return true + } + + // if right has no directives and left has only the defer directives, we consider them equal + if len(left) > 0 && len(right) == 0 { + for i := 0; i < len(left); i++ { + if !bytes.Equal(d.DirectiveNameBytes(left[i]), literal.DEFER_INTERNAL) { + return false + } + } + return true } + + // check that every non-defer directive in the left has an equal in the right for i := 0; i < len(left); i++ { - leftDirective, rightDirective := left[i], right[i] - if !d.DirectivesAreEqual(leftDirective, rightDirective) { + leftDirective := left[i] + + if bytes.Equal(d.DirectiveNameBytes(leftDirective), literal.DEFER_INTERNAL) { + continue + } + + hasRightEqual := false + for j := 0; j < len(right); j++ { + rightDirective := right[j] + + if bytes.Equal(d.DirectiveNameBytes(rightDirective), literal.DEFER_INTERNAL) { + continue + } + + if d.DirectivesAreEqual(leftDirective, rightDirective) { + hasRightEqual = true + break + } + } + if !hasRightEqual { return false } } + + // check that every non-defer directive in the right has an equal in the left + for i := 0; i < len(right); i++ { + rightDirective := right[i] + if bytes.Equal(d.DirectiveNameBytes(rightDirective), literal.DEFER_INTERNAL) { + continue + } + + hasLeftEqual := false + for j := 0; j < len(left); j++ { + leftDirective := left[j] + if bytes.Equal(d.DirectiveNameBytes(leftDirective), literal.DEFER_INTERNAL) { + continue + } + if d.DirectivesAreEqual(leftDirective, rightDirective) { + hasLeftEqual = true + break + } + } + if !hasLeftEqual { + return false + } + } + return true } diff --git a/v2/pkg/astnormalization/inline_fragment_selection_merging.go b/v2/pkg/astnormalization/inline_fragment_selection_merging.go index 118c9eb53c..4a9e9f95d6 100644 --- a/v2/pkg/astnormalization/inline_fragment_selection_merging.go +++ b/v2/pkg/astnormalization/inline_fragment_selection_merging.go @@ -2,9 +2,11 @@ package astnormalization import ( "bytes" + "strconv" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" ) // mergeInlineFragmentSelections registers a visitor that @@ -88,10 +90,45 @@ func (f *inlineFragmentSelectionMergeVisitor) mergeFields(left, right int) (ok b return false } + f.mergeFieldsDefer(left, right) + f.operation.AppendSelectionSet(leftSet, rightSet) return true } +func (f *inlineFragmentSelectionMergeVisitor) mergeFieldsDefer(left, right int) { + leftDeferDirectiveRef, leftDeferExists := f.operation.Fields[left].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) + rightDeferDirectiveRef, rightDeferExists := f.operation.Fields[right].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) + + switch { + case !leftDeferExists && !rightDeferExists: + // do nothing + case leftDeferExists && !rightDeferExists: + f.operation.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef) + case !leftDeferExists: + // do nothing, right will be discarded + default: + // both have the defer, wins defer will smaller id + leftDeferIdValue, _ := f.operation.DirectiveArgumentValueByName(leftDeferDirectiveRef, []byte("id")) + rightDeferIdValue, _ := f.operation.DirectiveArgumentValueByName(rightDeferDirectiveRef, []byte("id")) + + leftId, _ := strconv.Atoi(f.operation.StringValueContentString(leftDeferIdValue.Ref)) + rightId, _ := strconv.Atoi(f.operation.StringValueContentString(rightDeferIdValue.Ref)) + + switch { + case leftId == rightId: + // do nothing, they are equal + case leftId < rightId: + // left wins, remove right + case leftId > rightId: + f.operation.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef) + // append a right defer to the left + // no need to import as a right will be discarded + f.operation.Fields[left].Directives.Refs = append(f.operation.Fields[left].Directives.Refs, rightDeferDirectiveRef) + } + } +} + func (f *inlineFragmentSelectionMergeVisitor) EnterSelectionSet(ref int) { selectionRefs := f.operation.SelectionSets[ref].SelectionRefs if len(selectionRefs) < 2 { @@ -119,6 +156,7 @@ func (f *inlineFragmentSelectionMergeVisitor) EnterSelectionSet(ref int) { if !f.fragmentsCanBeMerged(leftRef, rightRef) { continue } + if f.mergeInlineFragments(leftRef, rightRef) { f.operation.RemoveFromSelectionSet(ref, j) f.RevisitNode() diff --git a/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go b/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go index ffb9f7359f..2b9634c2fa 100644 --- a/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go +++ b/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go @@ -216,6 +216,24 @@ func TestMergeInlineFragmentFieldSelections(t *testing.T) { } }`) }) + + t.Run("fields with the same directives but in different order", func(t *testing.T) { + run(t, mergeInlineFragmentSelections, testDefinition, ` + { + field @skip(if: $foo) @include(if: $foo) { + subfieldA + } + field @include(if: $foo) @skip(if: $foo) { + subfieldB + } + }`, ` + { + field @skip(if: $foo) @include(if: $foo) { + subfieldA + subfieldB + } + }`) + }) }) t.Run("fragments and fields", func(t *testing.T) { t.Run("field field fragment", func(t *testing.T) { @@ -331,5 +349,60 @@ func TestMergeInlineFragmentFieldSelections(t *testing.T) { }`) }) + t.Run("with internal defer", func(t *testing.T) { + runWithOptions(t, mergeInlineFragmentSelections, testDefinition, ` + query pet { + pet { + ... on Dog { + name @defer_internal(id: "1") + nickname @defer_internal(id: "1") + nickname @defer_internal(id: "2", parentDeferId: "1") + barkVolume @defer_internal(id: "2", parentDeferId: "1") + } + ... on Cat { + name + extra { + bool + } + } + ... on Cat { + name @defer_internal(id: "3") + meowVolume @defer_internal(id: "3") + extra @defer_internal(id: "3") { + bool @defer_internal(id: "3") + } + } + ... on Cat { + name @defer_internal(id: "4") + nickname @defer_internal(id: "4") + meowVolume @defer_internal(id: "4") + } + } + }`, + ` + query pet { + pet { + ... on Dog { + name @defer_internal(id: "1") + nickname @defer_internal(id: "1") + nickname @defer_internal(id: "2", parentDeferId: "1") + barkVolume @defer_internal(id: "2", parentDeferId: "1") + } + ... on Cat { + name + extra { + bool + bool @defer_internal(id: "3") + } + name @defer_internal(id: "3") + meowVolume @defer_internal(id: "3") + name @defer_internal(id: "4") + nickname @defer_internal(id: "4") + meowVolume @defer_internal(id: "4") + } + } + }`, runOptions{indent: true}) + }) + }) } diff --git a/v2/pkg/lexer/literal/literal.go b/v2/pkg/lexer/literal/literal.go index 23e141c882..d8aa26fcc4 100644 --- a/v2/pkg/lexer/literal/literal.go +++ b/v2/pkg/lexer/literal/literal.go @@ -66,6 +66,7 @@ var ( IF = []byte("if") SKIP = []byte("skip") DEFER = []byte("defer") + DEFER_INTERNAL = []byte("defer_internal") LABEL = []byte("label") STREAM = []byte("stream") SCHEMA = []byte("schema") From 10cf6acea042aba71f5fa4a8ca16c514eedf70ef Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 27 Aug 2025 19:21:22 +0300 Subject: [PATCH 04/79] proper merge scalar fields with internal defer --- v2/pkg/ast/ast_field.go | 36 ++++++++++++++++ .../astnormalization/field_deduplication.go | 3 +- .../field_deduplication_test.go | 43 +++++++++++++++++++ .../inline_fragment_selection_merging.go | 37 +--------------- 4 files changed, 82 insertions(+), 37 deletions(-) diff --git a/v2/pkg/ast/ast_field.go b/v2/pkg/ast/ast_field.go index f92e2bde52..1e660847d9 100644 --- a/v2/pkg/ast/ast_field.go +++ b/v2/pkg/ast/ast_field.go @@ -2,8 +2,10 @@ package ast import ( "bytes" + "strconv" "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafebytes" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/position" ) @@ -178,3 +180,37 @@ func (d *Document) FieldTypeNode(fieldName []byte, enclosingNode Node) (node Nod return node, true } + +func (d *Document) MergeFieldsDefer(left, right int) { + leftDeferDirectiveRef, leftDeferExists := d.Fields[left].Directives.HasDirectiveByNameBytes(d, literal.DEFER_INTERNAL) + rightDeferDirectiveRef, rightDeferExists := d.Fields[right].Directives.HasDirectiveByNameBytes(d, literal.DEFER_INTERNAL) + + switch { + case !leftDeferExists && !rightDeferExists: + // do nothing + case leftDeferExists && !rightDeferExists: + d.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef) + d.Fields[left].HasDirectives = len(d.Fields[left].Directives.Refs) > 0 + case !leftDeferExists: + // do nothing, right will be discarded + default: + // both have the defer, wins defer will smaller id + leftDeferIdValue, _ := d.DirectiveArgumentValueByName(leftDeferDirectiveRef, []byte("id")) + rightDeferIdValue, _ := d.DirectiveArgumentValueByName(rightDeferDirectiveRef, []byte("id")) + + leftId, _ := strconv.Atoi(d.StringValueContentString(leftDeferIdValue.Ref)) + rightId, _ := strconv.Atoi(d.StringValueContentString(rightDeferIdValue.Ref)) + + switch { + case leftId == rightId: + // do nothing, they are equal + case leftId < rightId: + // left wins, remove right + case leftId > rightId: + d.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef) + // append a right defer to the left + // no need to import as a right will be discarded + d.Fields[left].Directives.Refs = append(d.Fields[left].Directives.Refs, rightDeferDirectiveRef) + } + } +} diff --git a/v2/pkg/astnormalization/field_deduplication.go b/v2/pkg/astnormalization/field_deduplication.go index b57a8a0633..68afcbcec0 100644 --- a/v2/pkg/astnormalization/field_deduplication.go +++ b/v2/pkg/astnormalization/field_deduplication.go @@ -52,8 +52,9 @@ func (d *deduplicateFieldsVisitor) EnterSelectionSet(ref int) { continue } // here we will check full directive equality if they are not equal we won't deduplicate - // it means that even directives order matters + // order of directives doesn't matter if they are fully equal if d.operation.FieldsAreEqualFlat(left, right, true) { + d.operation.MergeFieldsDefer(left, right) d.operation.RemoveFromSelectionSet(ref, b) d.RevisitNode() return diff --git a/v2/pkg/astnormalization/field_deduplication_test.go b/v2/pkg/astnormalization/field_deduplication_test.go index 8cfd76f6db..40d5f467ef 100644 --- a/v2/pkg/astnormalization/field_deduplication_test.go +++ b/v2/pkg/astnormalization/field_deduplication_test.go @@ -35,4 +35,47 @@ func TestDeDuplicateFields(t *testing.T) { doesKnowCommand(dogCommand: 0) }`) }) + + t.Run("with internal defer", func(t *testing.T) { + run(t, deduplicateFields, testDefinition, ` + query pet { + pet { + ... on Dog { + name @defer_internal(id: "1") + nickname @defer_internal(id: "2", parentDeferId: "1") + nickname @defer_internal(id: "1") + barkVolume @defer_internal(id: "2", parentDeferId: "1") + } + ... on Cat { + name @defer_internal(id: "4") + name @defer_internal(id: "3") + name + extra { + bool + bool @defer_internal(id: "3") + } + meowVolume @defer_internal(id: "4") + meowVolume @defer_internal(id: "3") + nickname @defer_internal(id: "4") + } + } + }`, ` + query pet { + pet { + ... on Dog { + name @defer_internal(id: "1") + nickname @defer_internal(id: "1") + barkVolume @defer_internal(id: "2", parentDeferId: "1") + } + ... on Cat { + name + extra { + bool + } + meowVolume @defer_internal(id: "3") + nickname @defer_internal(id: "4") + } + } + }`, runOptions{indent: true}) + }) } diff --git a/v2/pkg/astnormalization/inline_fragment_selection_merging.go b/v2/pkg/astnormalization/inline_fragment_selection_merging.go index 4a9e9f95d6..36d54dc9a0 100644 --- a/v2/pkg/astnormalization/inline_fragment_selection_merging.go +++ b/v2/pkg/astnormalization/inline_fragment_selection_merging.go @@ -2,11 +2,9 @@ package astnormalization import ( "bytes" - "strconv" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" - "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" ) // mergeInlineFragmentSelections registers a visitor that @@ -90,45 +88,12 @@ func (f *inlineFragmentSelectionMergeVisitor) mergeFields(left, right int) (ok b return false } - f.mergeFieldsDefer(left, right) + f.operation.MergeFieldsDefer(left, right) f.operation.AppendSelectionSet(leftSet, rightSet) return true } -func (f *inlineFragmentSelectionMergeVisitor) mergeFieldsDefer(left, right int) { - leftDeferDirectiveRef, leftDeferExists := f.operation.Fields[left].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) - rightDeferDirectiveRef, rightDeferExists := f.operation.Fields[right].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) - - switch { - case !leftDeferExists && !rightDeferExists: - // do nothing - case leftDeferExists && !rightDeferExists: - f.operation.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef) - case !leftDeferExists: - // do nothing, right will be discarded - default: - // both have the defer, wins defer will smaller id - leftDeferIdValue, _ := f.operation.DirectiveArgumentValueByName(leftDeferDirectiveRef, []byte("id")) - rightDeferIdValue, _ := f.operation.DirectiveArgumentValueByName(rightDeferDirectiveRef, []byte("id")) - - leftId, _ := strconv.Atoi(f.operation.StringValueContentString(leftDeferIdValue.Ref)) - rightId, _ := strconv.Atoi(f.operation.StringValueContentString(rightDeferIdValue.Ref)) - - switch { - case leftId == rightId: - // do nothing, they are equal - case leftId < rightId: - // left wins, remove right - case leftId > rightId: - f.operation.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef) - // append a right defer to the left - // no need to import as a right will be discarded - f.operation.Fields[left].Directives.Refs = append(f.operation.Fields[left].Directives.Refs, rightDeferDirectiveRef) - } - } -} - func (f *inlineFragmentSelectionMergeVisitor) EnterSelectionSet(ref int) { selectionRefs := f.operation.SelectionSets[ref].SelectionRefs if len(selectionRefs) < 2 { From 89268271f24b6300aaa8ce91509042a79dea10b4 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 27 Aug 2025 19:34:58 +0300 Subject: [PATCH 05/79] add tests with different defer on the same non scalar field --- .../inline_fragment_expand_defer_test.go | 34 ++++++++++++++-- .../inline_fragment_selection_merging_test.go | 40 +++++++++++++------ ...e_selections_from_inline_fragments_test.go | 30 ++++++++++++-- 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go b/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go index e9706d9802..937f6b0d9f 100644 --- a/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go +++ b/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go @@ -22,7 +22,7 @@ func TestInlineFragmentExpandDefer(t *testing.T) { }`) }) t.Run("with interface type", func(t *testing.T) { - run(t, InlineFragmentExpandDefer, testDefinition, ` + runWithOptions(t, InlineFragmentExpandDefer, testDefinition, ` query pet { pet { ... on Dog @defer { @@ -32,6 +32,19 @@ func TestInlineFragmentExpandDefer(t *testing.T) { barkVolume } } + ... on Dog { + ... @defer { + extra { + noString + } + } + ... @defer { + extra { + string + noString + } + } + } ... on Cat @defer { name meowVolume @@ -48,11 +61,24 @@ func TestInlineFragmentExpandDefer(t *testing.T) { barkVolume @defer_internal(id: "2", parentDeferId: "1") } } + ... on Dog { + ... { + extra @defer_internal(id: "3") { + noString @defer_internal(id: "3") + } + } + ... { + extra @defer_internal(id: "4") { + string @defer_internal(id: "4") + noString @defer_internal(id: "4") + } + } + } ... on Cat { - name @defer_internal(id: "3") - meowVolume @defer_internal(id: "3") + name @defer_internal(id: "5") + meowVolume @defer_internal(id: "5") } } - }`) + }`, runOptions{indent: true}) }) } diff --git a/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go b/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go index 2b9634c2fa..016d3bbb1d 100644 --- a/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go +++ b/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go @@ -359,6 +359,15 @@ func TestMergeInlineFragmentFieldSelections(t *testing.T) { nickname @defer_internal(id: "2", parentDeferId: "1") barkVolume @defer_internal(id: "2", parentDeferId: "1") } + ... on Dog { + extra @defer_internal(id: "3") { + noString @defer_internal(id: "3") + } + extra @defer_internal(id: "4") { + string @defer_internal(id: "4") + noString @defer_internal(id: "4") + } + } ... on Cat { name extra { @@ -366,16 +375,16 @@ func TestMergeInlineFragmentFieldSelections(t *testing.T) { } } ... on Cat { - name @defer_internal(id: "3") - meowVolume @defer_internal(id: "3") - extra @defer_internal(id: "3") { - bool @defer_internal(id: "3") + name @defer_internal(id: "5") + meowVolume @defer_internal(id: "5") + extra @defer_internal(id: "5") { + bool @defer_internal(id: "5") } } ... on Cat { - name @defer_internal(id: "4") - nickname @defer_internal(id: "4") - meowVolume @defer_internal(id: "4") + name @defer_internal(id: "6") + nickname @defer_internal(id: "6") + meowVolume @defer_internal(id: "6") } } }`, @@ -387,18 +396,23 @@ func TestMergeInlineFragmentFieldSelections(t *testing.T) { nickname @defer_internal(id: "1") nickname @defer_internal(id: "2", parentDeferId: "1") barkVolume @defer_internal(id: "2", parentDeferId: "1") + extra @defer_internal(id: "3") { + noString @defer_internal(id: "3") + string @defer_internal(id: "4") + noString @defer_internal(id: "4") + } } ... on Cat { name extra { bool - bool @defer_internal(id: "3") + bool @defer_internal(id: "5") } - name @defer_internal(id: "3") - meowVolume @defer_internal(id: "3") - name @defer_internal(id: "4") - nickname @defer_internal(id: "4") - meowVolume @defer_internal(id: "4") + name @defer_internal(id: "5") + meowVolume @defer_internal(id: "5") + name @defer_internal(id: "6") + nickname @defer_internal(id: "6") + meowVolume @defer_internal(id: "6") } } }`, runOptions{indent: true}) diff --git a/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go b/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go index f8f522b411..0e887ac518 100644 --- a/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go +++ b/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go @@ -107,9 +107,22 @@ func TestResolveInlineFragments(t *testing.T) { barkVolume @defer_internal(id: "2", parentDeferId: "1") } } + ... on Dog { + ... { + extra @defer_internal(id: "3") { + noString @defer_internal(id: "3") + } + } + ... { + extra @defer_internal(id: "4") { + string @defer_internal(id: "4") + noString @defer_internal(id: "4") + } + } + } ... on Cat { - name @defer_internal(id: "3") - meowVolume @defer_internal(id: "3") + name @defer_internal(id: "5") + meowVolume @defer_internal(id: "5") } } }`, @@ -121,9 +134,18 @@ func TestResolveInlineFragments(t *testing.T) { nickname @defer_internal(id: "1") barkVolume @defer_internal(id: "2", parentDeferId: "1") } + ... on Dog { + extra @defer_internal(id: "3") { + noString @defer_internal(id: "3") + } + extra @defer_internal(id: "4") { + string @defer_internal(id: "4") + noString @defer_internal(id: "4") + } + } ... on Cat { - name @defer_internal(id: "3") - meowVolume @defer_internal(id: "3") + name @defer_internal(id: "5") + meowVolume @defer_internal(id: "5") } } }`) From 2dc88221d70bf8ab36d9f516207ab4b3c56a5b96 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 27 Aug 2025 20:46:01 +0300 Subject: [PATCH 06/79] add inline defer to operation normalizer --- v2/pkg/astnormalization/astnormalization.go | 16 +++++ .../astnormalization/astnormalization_test.go | 71 ++++++++++++++++++- .../inline_fragment_expand_defer.go | 4 +- .../inline_fragment_expand_defer_test.go | 4 +- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/v2/pkg/astnormalization/astnormalization.go b/v2/pkg/astnormalization/astnormalization.go index 04631f8398..9609c0c6b6 100644 --- a/v2/pkg/astnormalization/astnormalization.go +++ b/v2/pkg/astnormalization/astnormalization.go @@ -151,6 +151,7 @@ type options struct { removeNotMatchingOperationDefinitions bool normalizeDefinition bool ignoreSkipInclude bool + inlineDefer bool } type Option func(options *options) @@ -161,6 +162,12 @@ func WithExtractVariables() Option { } } +func WithInlineDefer() Option { + return func(options *options) { + options.inlineDefer = true + } +} + func WithRemoveFragmentDefinitions() Option { return func(options *options) { options.removeFragmentDefinitions = true @@ -243,6 +250,15 @@ func (o *OperationNormalizer) setupOperationWalkers() { }) } + if o.options.inlineDefer { + inlineDefer := astvisitor.NewWalker(8) + inlineFragmentExpandDefer(&inlineDefer) + o.operationWalkers = append(o.operationWalkers, walkerStage{ + name: "inlineDefer", + walker: &inlineDefer, + }) + } + if o.options.extractVariables { extractVariablesWalker := astvisitor.NewWalkerWithID(8, "ExtractVariables") extractVariables(&extractVariablesWalker) diff --git a/v2/pkg/astnormalization/astnormalization_test.go b/v2/pkg/astnormalization/astnormalization_test.go index d56f307c20..60257c9f4b 100644 --- a/v2/pkg/astnormalization/astnormalization_test.go +++ b/v2/pkg/astnormalization/astnormalization_test.go @@ -41,6 +41,7 @@ func TestNormalizeOperation(t *testing.T) { WithRemoveFragmentDefinitions(), WithRemoveUnusedVariables(), WithNormalizeDefinition(), + WithInlineDefer(), ) normalizer.NormalizeOperation(&operationDocument, &definitionDocument, &report) @@ -48,8 +49,8 @@ func TestNormalizeOperation(t *testing.T) { t.Fatal(report.Error()) } - got := mustString(astprinter.PrintString(&operationDocument)) - want := mustString(astprinter.PrintString(&expectedOutputDocument)) + got := mustString(astprinter.PrintStringIndent(&operationDocument, " ")) + want := mustString(astprinter.PrintStringIndent(&expectedOutputDocument, " ")) assert.Equal(t, want, got) assert.Equal(t, expectedVariables, string(operationDocument.Input.Variables)) @@ -510,6 +511,72 @@ func TestNormalizeOperation(t *testing.T) { }`, ``, ``) }) + t.Run("defer", func(t *testing.T) { + run(t, testDefinition, ` + query pet { + pet { + ... on Dog @defer { + name + nickname + ... @defer { + barkVolume + } + } + ... on Dog { + ... @defer { + extra { + noString + } + } + ... @defer { + extra { + string + noString + } + } + } + ... on Cat { + name + extra { + bool + } + } + ... on Cat @defer { + name + meowVolume + extra { + bool + } + } + ... on Cat @defer { + name + nickname + meowVolume + } + } + }`, ` + query pet { + pet { + ... on Dog { + name @defer_internal(id: "1") + nickname @defer_internal(id: "1") + barkVolume @defer_internal(id: "2", parentDeferId: "1") + extra @defer_internal(id: "3") { + noString @defer_internal(id: "3") + string @defer_internal(id: "4") + } + } + ... on Cat { + name + extra { + bool + } + meowVolume @defer_internal(id: "5") + nickname @defer_internal(id: "6") + } + } + }`, ``, ``) + }) } func TestOperationNormalizer_NormalizeOperation(t *testing.T) { diff --git a/v2/pkg/astnormalization/inline_fragment_expand_defer.go b/v2/pkg/astnormalization/inline_fragment_expand_defer.go index 793d30ecc3..705aef3797 100644 --- a/v2/pkg/astnormalization/inline_fragment_expand_defer.go +++ b/v2/pkg/astnormalization/inline_fragment_expand_defer.go @@ -8,9 +8,9 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" ) -// InlineFragmentExpandDefer registers a visitor that +// inlineFragmentExpandDefer registers a visitor that // applies the defer directive to every nested field -func InlineFragmentExpandDefer(walker *astvisitor.Walker) { +func inlineFragmentExpandDefer(walker *astvisitor.Walker) { visitor := inlineFragmentExpandDeferVisitor{ Walker: walker, } diff --git a/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go b/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go index 937f6b0d9f..e806e46e73 100644 --- a/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go +++ b/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go @@ -4,7 +4,7 @@ import "testing" func TestInlineFragmentExpandDefer(t *testing.T) { t.Run("simple", func(t *testing.T) { - run(t, InlineFragmentExpandDefer, testDefinition, ` + run(t, inlineFragmentExpandDefer, testDefinition, ` query dog { dog { ... @defer { @@ -22,7 +22,7 @@ func TestInlineFragmentExpandDefer(t *testing.T) { }`) }) t.Run("with interface type", func(t *testing.T) { - runWithOptions(t, InlineFragmentExpandDefer, testDefinition, ` + runWithOptions(t, inlineFragmentExpandDefer, testDefinition, ` query pet { pet { ... on Dog @defer { From 136478069b3d6abf7479a91d6f7aa22a2de2953c Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 28 Aug 2025 20:28:34 +0300 Subject: [PATCH 07/79] mark defer paths in node suggestions add todos --- v2/pkg/ast/ast_field.go | 2 + v2/pkg/asttransform/defer_internal.graphql | 1 + ...datasource_filter_collect_nodes_visitor.go | 28 ++++++- .../datasource_filter_node_suggestions.go | 75 +++++++++++++++++++ v2/pkg/engine/plan/node_selection_builder.go | 2 + 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/v2/pkg/ast/ast_field.go b/v2/pkg/ast/ast_field.go index 1e660847d9..1ff394de74 100644 --- a/v2/pkg/ast/ast_field.go +++ b/v2/pkg/ast/ast_field.go @@ -201,6 +201,8 @@ func (d *Document) MergeFieldsDefer(left, right int) { leftId, _ := strconv.Atoi(d.StringValueContentString(leftDeferIdValue.Ref)) rightId, _ := strconv.Atoi(d.StringValueContentString(rightDeferIdValue.Ref)) + // TODO: need to handle parent id too + switch { case leftId == rightId: // do nothing, they are equal diff --git a/v2/pkg/asttransform/defer_internal.graphql b/v2/pkg/asttransform/defer_internal.graphql index 828c2ddb9a..3e68af9f44 100644 --- a/v2/pkg/asttransform/defer_internal.graphql +++ b/v2/pkg/asttransform/defer_internal.graphql @@ -1,6 +1,7 @@ "Directs the executor to defer this fragment when the if argument is true or undefined." directive @defer_internal( id: String! + parentDeferId: String "A unique identifier for the results." label: String "Controls whether the fragment will be deferred, usually via a variable." diff --git a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go index 82f05fddea..1834b25a20 100644 --- a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go +++ b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go @@ -8,6 +8,7 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) @@ -575,6 +576,7 @@ type fieldInfo struct { possibleTypeNames []string currentPathWithoutFragments string enclosingTypeDefinition ast.Node + deferInfo *DeferInfo } func (f *treeBuilderVisitor) collectFieldInfo(fieldRef int) { @@ -610,6 +612,30 @@ func (f *treeBuilderVisitor) collectFieldInfo(fieldRef int) { parentPathWithoutFragment: parentPathWithoutFragment, currentPathWithoutFragments: currentPathWithoutFragments, isTypeName: isTypeName, - enclosingTypeDefinition: f.walker.EnclosingTypeDefinition, + deferInfo: f.deferInfo(ref), } } + +func (f *fieldInfoVisitor) deferInfo(fieldRef int) *DeferInfo { + deferDirectiveRef, exists := f.operation.Fields[fieldRef].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) + if !exists { + return nil + } + + info := &DeferInfo{} + + idValue, _ := f.operation.DirectiveArgumentValueByName(deferDirectiveRef, []byte("id")) + info.ID = f.operation.StringValueContentString(idValue.Ref) + + parentIdValue, exists := f.operation.DirectiveArgumentValueByName(deferDirectiveRef, []byte("parentDeferId")) + if exists { + info.ParentID = f.operation.StringValueContentString(parentIdValue.Ref) + } + + labelValue, exists := f.operation.DirectiveArgumentValueByName(deferDirectiveRef, []byte("label")) + if exists { + info.Label = f.operation.StringValueContentString(labelValue.Ref) + } + + return info +} diff --git a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go index 71ea3ebfc8..c09e34ab8c 100644 --- a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go +++ b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "iter" + "slices" "github.com/kingledion/go-tools/tree" "github.com/phf/go-queue/queue" @@ -37,9 +38,19 @@ type NodeSuggestion struct { treeNodeId uint possibleTypeNames []string + deferInfo *DeferInfo + deferParentPath bool + deferIDs []string + requiresKey *SourceConnection } +type DeferInfo struct { + ID string + Label string + ParentID string +} + func (n *NodeSuggestion) treeNodeID() uint { return TreeNodeID(n.FieldRef) } @@ -138,6 +149,70 @@ func NewNodeSuggestionsWithSize(size int) *NodeSuggestions { } } +func (f *NodeSuggestions) ProcessDefer() { + for i := range f.items { + if !f.items[i].Selected { + continue + } + + if f.items[i].deferInfo == nil { + continue + } + + // TODO: node should not be deffered in case it is a dependency for not deffered node or another defer on the same level? + + f.propagateDeferParentsUpToRootNode(i) + } +} + +func (f *NodeSuggestions) propagateDeferParentsUpToRootNode(i int) { + if f.items[i].IsRootNode { + return + } + + parentIndexesToAddDeferID := make([]int, 0, 2) + current := i + for { + treeNode := f.treeNode(current) + parentNodeIndexes := treeNode.GetParent().GetData() + + parentIdToUpdate := -1 + for _, parentIdx := range parentNodeIndexes { + if f.items[parentIdx].DataSourceHash != f.items[current].DataSourceHash { + continue + } + + if slices.Contains(f.items[parentIdx].deferIDs, f.items[i].deferInfo.ID) { + // no need to update + return + } else { + parentIdToUpdate = parentIdx + } + } + + if parentIdToUpdate == -1 { + // should not happen + return + } + + parentIndexesToAddDeferID = append(parentIndexesToAddDeferID, parentIdToUpdate) + + if f.items[parentIdToUpdate].IsRootNode { + break + } + + current = parentIdToUpdate + } + + for _, parentIdx := range parentIndexesToAddDeferID { + f.items[parentIdx].deferParentPath = true + + if !slices.Contains(f.items[parentIdx].deferIDs, f.items[i].deferInfo.ID) { + f.items[parentIdx].deferIDs = append(f.items[parentIdx].deferIDs, f.items[i].deferInfo.ID) + } + } +} + func (f *NodeSuggestions) AddItems(items ...*NodeSuggestion) { f.items = append(f.items, items...) f.populateHasSuggestions() diff --git a/v2/pkg/engine/plan/node_selection_builder.go b/v2/pkg/engine/plan/node_selection_builder.go index b60363c289..573550662e 100644 --- a/v2/pkg/engine/plan/node_selection_builder.go +++ b/v2/pkg/engine/plan/node_selection_builder.go @@ -194,6 +194,8 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, } } + p.nodeSelectionsVisitor.nodeSuggestions.ProcessDefer() + return &NodeSelectionResult{ dataSources: p.nodeSelectionsVisitor.dataSources, nodeSuggestions: p.nodeSelectionsVisitor.nodeSuggestions, From e14ac40a1df8e37569a980a7d92ccb9de38ddde3 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 29 Aug 2025 17:59:28 +0300 Subject: [PATCH 08/79] add initial split of the fetches for defer --- v2/pkg/asttransform/baseschema.go | 3 +- .../graphql_datasource_defer_test.go | 516 ++++++++++++++++++ .../datasourcetesting/datasourcetesting.go | 29 +- ...datasource_filter_collect_nodes_visitor.go | 1 + v2/pkg/engine/plan/path_builder_visitor.go | 215 +++++--- v2/pkg/engine/plan/planner_configuration.go | 9 +- 6 files changed, 697 insertions(+), 76 deletions(-) create mode 100644 v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go diff --git a/v2/pkg/asttransform/baseschema.go b/v2/pkg/asttransform/baseschema.go index 1b7c827fa2..a7aa52a45e 100644 --- a/v2/pkg/asttransform/baseschema.go +++ b/v2/pkg/asttransform/baseschema.go @@ -30,10 +30,9 @@ func MergeDefinitionWithBaseSchema(definition *ast.Document) error { func MergeDefinitionWithBaseSchemaWithOptions(definition *ast.Document, options Options) error { definition.Input.AppendInputBytes(baseSchema) + definition.Input.AppendInputBytes(deferRegular) if options.InternalDefer { definition.Input.AppendInputBytes(deferInternal) - } else { - definition.Input.AppendInputBytes(deferRegular) } parser := astparser.NewParser() diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go new file mode 100644 index 0000000000..a3cf93a441 --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go @@ -0,0 +1,516 @@ +package graphql_datasource + +import ( + "testing" + + . "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasourcetesting" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +func TestGraphQLDataSourceDefer(t *testing.T) { + t.Run("basic", func(t *testing.T) { + t.Run("on root query node", func(t *testing.T) { + definition := ` + type User { + id: ID! + name: String! + title: String! + } + + type Query { + user: User! + } + ` + + firstSubgraphSDL := ` + type User { + id: ID! + name: String! + title: String! + } + + type Query { + user: User + } + ` + + firstDatasourceConfiguration := mustDataSourceConfiguration( + t, + "first-service", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"user"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "title", "name"}, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://first.service", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: firstSubgraphSDL, + }, + firstSubgraphSDL, + ), + }, + ), + ) + + planConfiguration := plan.Configuration{ + DataSources: []plan.DataSource{ + firstDatasourceConfiguration, + }, + DisableResolveFieldPositions: true, + Debug: plan.DebugConfiguration{ + PrintQueryPlans: true, + PrintPlanningPaths: true, + }, + } + + t.Run("defer User.title", func(t *testing.T) { + RunWithPermutations( + t, + definition, + ` + query User { + user { + name + ... @defer { + title + } + } + }`, + "User", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {__typename id}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename hostedImageWithProvides {image {__typename id}}}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + PostProcessing: SingleEntityPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user", resolve.ObjectPath("user")), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + DependsOnFetchIDs: []int{1}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://fourth.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Image {__typename cdnUrl}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("Image")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("Image")}, + }, + }, + }), + }, + }, + PostProcessing: SingleEntityPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user.hostedImageWithProvides.image", resolve.ObjectPath("user"), resolve.ObjectPath("hostedImageWithProvides"), resolve.ObjectPath("image")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("hostedImageWithProvides"), + Value: &resolve.Object{ + Path: []string{"hostedImageWithProvides"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "HostedImage": {}, + }, + TypeName: "HostedImage", + Fields: []*resolve.Field{ + { + Name: []byte("image"), + Value: &resolve.Object{ + Path: []string{"image"}, + PossibleTypes: map[string]struct{}{ + "Image": {}, + }, + TypeName: "Image", + Fields: []*resolve.Field{ + { + Name: []byte("cdnUrl"), + Value: &resolve.String{ + Path: []string{"cdnUrl"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + WithDefer(), + ) + }) + }) + + t.Run("on entity from other subgraph", func(t *testing.T) { + definition := ` + type User { + id: ID! + title: String! + firstName: String! + lastName: String! + } + + type Query { + user: User! + } + ` + + firstSubgraphSDL := ` + type User @key(fields: "id") { + id: ID! + title: String! + } + + type Query { + user: User + } + ` + + firstDatasourceConfiguration := mustDataSourceConfiguration( + t, + "first-service", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"user"}, + }, + { + TypeName: "User", + FieldNames: []string{"id", "title"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://first.service", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: firstSubgraphSDL, + }, + firstSubgraphSDL, + ), + }, + ), + ) + + secondSubgraphSDL := ` + type User @key(fields: "id") { + id: ID! + firstName: String! + lastName: String! + } + ` + + secondDatasourceConfiguration := mustDataSourceConfiguration( + t, + "second-service", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "firstName", "lastName"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://second.service", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: secondSubgraphSDL, + }, + secondSubgraphSDL, + ), + }, + ), + ) + + planConfiguration := plan.Configuration{ + DataSources: []plan.DataSource{ + firstDatasourceConfiguration, + secondDatasourceConfiguration, + }, + DisableResolveFieldPositions: true, + Debug: plan.DebugConfiguration{ + PrintQueryPlans: true, + PrintPlanningPaths: true, + }, + } + + t.Run("defer User.lastName", func(t *testing.T) { + RunWithPermutations( + t, + definition, + ` + query User { + user { + title + firstName + ... @defer { + lastName + } + } + }`, + "User", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {__typename id}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename hostedImageWithProvides {image {__typename id}}}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + PostProcessing: SingleEntityPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user", resolve.ObjectPath("user")), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + DependsOnFetchIDs: []int{1}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://fourth.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Image {__typename cdnUrl}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("Image")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("Image")}, + }, + }, + }), + }, + }, + PostProcessing: SingleEntityPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user.hostedImageWithProvides.image", resolve.ObjectPath("user"), resolve.ObjectPath("hostedImageWithProvides"), resolve.ObjectPath("image")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("hostedImageWithProvides"), + Value: &resolve.Object{ + Path: []string{"hostedImageWithProvides"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "HostedImage": {}, + }, + TypeName: "HostedImage", + Fields: []*resolve.Field{ + { + Name: []byte("image"), + Value: &resolve.Object{ + Path: []string{"image"}, + PossibleTypes: map[string]struct{}{ + "Image": {}, + }, + TypeName: "Image", + Fields: []*resolve.Field{ + { + Name: []byte("cdnUrl"), + Value: &resolve.String{ + Path: []string{"cdnUrl"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + WithDefer(), + ) + }) + }) + }) +} diff --git a/v2/pkg/engine/datasourcetesting/datasourcetesting.go b/v2/pkg/engine/datasourcetesting/datasourcetesting.go index ec9c8907f3..d8fb9e8bfe 100644 --- a/v2/pkg/engine/datasourcetesting/datasourcetesting.go +++ b/v2/pkg/engine/datasourcetesting/datasourcetesting.go @@ -35,6 +35,13 @@ type testOptions struct { withFieldDependencies bool withFetchReasons bool validationOptions []astvalidation.Option + withDefer bool +} + +func WithDefer() func(*testOptions) { + return func(o *testOptions) { + o.withDefer = true + } } func WithPostProcessors(postProcessors ...*postprocess.Processor) func(*testOptions) { @@ -177,11 +184,29 @@ func RunTestWithVariables(definition, operation, operationName, variables string if variables != "" { op.Input.Variables = []byte(variables) } - err := asttransform.MergeDefinitionWithBaseSchema(&def) + + transformOptions := asttransform.Options{} + if opts.withDefer { + transformOptions.InternalDefer = true + } + + err := asttransform.MergeDefinitionWithBaseSchemaWithOptions(&def, transformOptions) if err != nil { t.Fatal(err) } - norm := astnormalization.NewWithOpts(astnormalization.WithExtractVariables(), astnormalization.WithInlineFragmentSpreads(), astnormalization.WithRemoveFragmentDefinitions(), astnormalization.WithRemoveUnusedVariables()) + + normalizationOptions := []astnormalization.Option{ + astnormalization.WithExtractVariables(), + astnormalization.WithInlineFragmentSpreads(), + astnormalization.WithRemoveFragmentDefinitions(), + astnormalization.WithRemoveUnusedVariables(), + } + + if opts.withDefer { + normalizationOptions = append(normalizationOptions, astnormalization.WithInlineDefer()) + } + + norm := astnormalization.NewWithOpts(normalizationOptions...) var report operationreport.Report norm.NormalizeOperation(&op, &def, &report) diff --git a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go index 1834b25a20..c16f8f402d 100644 --- a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go +++ b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go @@ -522,6 +522,7 @@ func (f *collectNodesDSVisitor) EnterField(fieldRef int, itemIds []int, treeNode IsLeaf: isLeaf, isTypeName: info.isTypeName, treeNodeId: treeNodeId, + deferInfo: info.deferInfo, } f.localSuggestions = append(f.localSuggestions, &node) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index b66b41375a..afd9e24e5a 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -124,6 +124,19 @@ type objectFetchConfiguration struct { operationType ast.OperationType } +type currentFieldInfo struct { + fieldRef int + typeName string + fieldName string + currentPath string + parentPath string + precedingParentPath string + suggestion *NodeSuggestion + ds DataSource + shareable bool + defferID string +} + func (c *pathBuilderVisitor) currentSelectionSetInfo() (info selectionSetTypeInfo, ok bool) { if len(c.selectionSetRefs) == 0 { return selectionSetTypeInfo{ref: ast.InvalidRef}, false @@ -464,6 +477,17 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { suggestions := c.nodeSuggestions.SuggestionsForPath(typeName, fieldName, currentPath) shareable := len(suggestions) > 1 + + field := ¤tFieldInfo{ + fieldRef: fieldRef, + typeName: typeName, + fieldName: fieldName, + currentPath: currentPath, + parentPath: parentPath, + precedingParentPath: precedingParentPath, + shareable: shareable, + } + for _, suggestion := range suggestions { if idx := slices.IndexFunc(c.skipDS, func(skip DSSkip) bool { return skip.DSHash == suggestion.DataSourceHash @@ -481,7 +505,7 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { ds := c.dataSources[dsIdx] if !c.couldPlanField(fieldRef, ds.Hash()) { - c.handleMissingPath(false, typeName, fieldName, currentPath, shareable) + c.handleMissingPath(false, field) /* if we could not plan the field, we should skip planning children on the same datasource @@ -519,7 +543,28 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { continue } - c.handlePlanningField(fieldRef, typeName, fieldName, currentPath, parentPath, precedingParentPath, suggestion, ds, shareable) + field.ds = ds + field.suggestion = suggestion + + switch { + case len(suggestion.deferIDs) > 0: + for _, deferID := range suggestion.deferIDs { + field.defferID = deferID + // defer parent path planning - should be planned as a deferred path + c.handlePlanningField(field) + } + // and as a normal path + field.defferID = "" + c.handlePlanningField(field) + case suggestion.deferInfo != nil: + field.defferID = suggestion.deferInfo.ID + // should be planned only as a deferred path + c.handlePlanningField(field) + default: + // normal field planning + field.defferID = "" + c.handlePlanningField(field) + } } c.addArrayField(fieldRef, currentPath) @@ -547,11 +592,24 @@ func (c *pathBuilderVisitor) LeaveField(ref int) { }) } -func (c *pathBuilderVisitor) handlePlanningField(fieldRef int, typeName, fieldName, currentPath, parentPath, precedingParentPath string, suggestion *NodeSuggestion, ds DataSource, shareable bool) { - plannedOnPlannerIds := c.fieldsPlannedOn[fieldRef] +type currentFieldInfo struct { + fieldRef int + typeName string + fieldName string + currentPath string + parentPath string + precedingParentPath string + suggestion *NodeSuggestion + ds DataSource + shareable bool + defferID string +} + +func (c *pathBuilderVisitor) handlePlanningField(field *currentFieldInfo) { + plannedOnPlannerIds := c.fieldsPlannedOn[field.fieldRef] if slices.ContainsFunc(plannedOnPlannerIds, func(plannerIdx int) bool { - return c.planners[plannerIdx].DataSourceConfiguration().Hash() == ds.Hash() + return c.planners[plannerIdx].DataSourceConfiguration().Hash() == field.ds.Hash() && c.planners[plannerIdx].DeferID() == field.defferID }) { // when we have already planned the field on the same datasource as was suggested // we do not need to try to plan it again @@ -559,29 +617,31 @@ func (c *pathBuilderVisitor) handlePlanningField(fieldRef int, typeName, fieldNa return } - isMutationRoot := c.isMutationRoot(currentPath) + isMutationRoot := c.isMutationRoot(field.currentPath) var ( plannerIdx int planned bool ) + // mutation root fields should always be planned on a new planner + // because mutations must be executed sequentially if isMutationRoot { - plannerIdx, planned = c.addNewPlanner(fieldRef, typeName, fieldName, currentPath, parentPath, isMutationRoot, ds) + plannerIdx, planned = c.addNewPlanner(field, isMutationRoot) } else { - plannerIdx, planned = c.planWithExistingPlanners(fieldRef, typeName, fieldName, currentPath, parentPath, precedingParentPath, suggestion) + plannerIdx, planned = c.planWithExistingPlanners(field) if !planned { - plannerIdx, planned = c.addNewPlanner(fieldRef, typeName, fieldName, currentPath, parentPath, isMutationRoot, ds) + plannerIdx, planned = c.addNewPlanner(field, isMutationRoot) } } if planned { - c.recordFieldPlannedOn(fieldRef, plannerIdx) - c.addFieldDependencies(fieldRef, typeName, fieldName, plannerIdx) - c.addRootField(fieldRef, plannerIdx) + c.recordFieldPlannedOn(field.fieldRef, plannerIdx) + c.addFieldDependencies(field, plannerIdx) + c.addRootField(field.fieldRef, plannerIdx) } - c.handleMissingPath(planned, typeName, fieldName, currentPath, shareable) + c.handleMissingPath(planned, field) } func (c *pathBuilderVisitor) couldPlanField(fieldRef int, dsHash DSHash) (ok bool) { @@ -675,9 +735,9 @@ func (c *pathBuilderVisitor) hasFieldsWaitingForDependency() bool { // in case current field has @requires directive, and we were able to plan it - it means that all fields from requires selection set was planned before that. // So we need to notify planner of current fieldRef about dependencies on those other fields // we know where fields were planned, because we record planner id of each planned field -func (c *pathBuilderVisitor) addFieldDependencies(fieldRef int, typeName, fieldName string, currentPlannerIdx int) { +func (c *pathBuilderVisitor) addFieldDependencies(field *currentFieldInfo, currentPlannerIdx int) { dsHash := c.planners[currentPlannerIdx].DataSourceConfiguration().Hash() - fieldKey := fieldIndexKey{fieldRef, dsHash} + fieldKey := fieldIndexKey{field.fieldRef, dsHash} fieldRefs, mappingExists := c.fieldDependsOn[fieldKey] if !mappingExists { @@ -687,7 +747,7 @@ func (c *pathBuilderVisitor) addFieldDependencies(fieldRef int, typeName, fieldN requiresConfigurations, ok := c.fieldRequirementsConfigs[fieldKey] if !ok { - c.walker.StopWithInternalErr(fmt.Errorf("missing field requirements configuration for field %s.%s fieldRef %d", typeName, fieldName, fieldRef)) + c.walker.StopWithInternalErr(fmt.Errorf("missing field requirements configuration for field %s.%s fieldRef %d", field.typeName, field.fieldName, field.fieldRef)) } for _, requiresConfiguration := range requiresConfigurations { // add required fields to the current planner to pass it in the representation variables @@ -747,32 +807,38 @@ func (c *pathBuilderVisitor) isPlannerDependenciesAllowsToPlanField(fieldRef int return true } -func (c *pathBuilderVisitor) planWithExistingPlanners(fieldRef int, typeName, fieldName, currentPath, parentPath, precedingParentPath string, suggestion *NodeSuggestion) (plannerIdx int, planned bool) { +func (c *pathBuilderVisitor) planWithExistingPlanners(field *currentFieldInfo) (plannerIdx int, planned bool) { for plannerIdx, plannerConfig := range c.planners { dsConfiguration := plannerConfig.DataSourceConfiguration() planningBehaviour := dsConfiguration.PlanningBehavior() currentPlannerDSHash := dsConfiguration.Hash() - hasSuggestion := suggestion != nil - if !hasSuggestion { + if field.suggestion.DataSourceHash != currentPlannerDSHash { + continue + } + + if plannerConfig.DeferID() != "" && field.defferID == "" { + // do not plan a non-deferred field on a deferred planner continue } - if suggestion.DataSourceHash != currentPlannerDSHash { + if field.defferID != "" && plannerConfig.DeferID() != field.defferID { + // do not plan a deferred field on a planner with different defer id + // or not a deferred planner continue } - isProvided := suggestion.IsProvided - isRootNode := suggestion.IsRootNode + isProvided := field.suggestion.IsProvided + isRootNode := field.suggestion.IsRootNode isChildNode := !isRootNode - if c.secondaryRun && plannerConfig.HasPath(currentPath) { + if c.secondaryRun && plannerConfig.HasPath(field.currentPath) { // on the secondary run we need to process only new fields added by the first run return plannerIdx, true } dsHash := dsConfiguration.Hash() - fieldKey := fieldIndexKey{fieldRef, dsHash} + fieldKey := fieldIndexKey{field.fieldRef, dsHash} requiresConfigurations := c.fieldRequirementsConfigs[fieldKey] fieldHasRequiresDirective := slices.ContainsFunc(requiresConfigurations, func(config FederationFieldConfiguration) bool { return config.FieldName != "" @@ -782,23 +848,23 @@ func (c *pathBuilderVisitor) planWithExistingPlanners(fieldRef int, typeName, fi // we should not plan fields with requires on the same planner as its dependencies, // because field with requires always will need an additional fetch before could be planned. // or the current planner provides dependencies for one of the requires dependency - if !c.isPlannerDependenciesAllowsToPlanField(fieldRef, plannerIdx) { + if !c.isPlannerDependenciesAllowsToPlanField(field.fieldRef, plannerIdx) { continue } } - if plannerConfig.HasPath(parentPath) || plannerConfig.HasPath(precedingParentPath) { - if pathAdded := c.addPlannerPathForTypename(plannerIdx, currentPath, parentPath, fieldRef, fieldName, typeName, planningBehaviour); pathAdded { + if plannerConfig.HasPath(field.parentPath) || plannerConfig.HasPath(field.precedingParentPath) { + if pathAdded := c.addPlannerPathForTypename(field, plannerIdx, planningBehaviour); pathAdded { return plannerIdx, true } if isProvided || (isRootNode && planningBehaviour.MergeAliasedRootNodes) || isChildNode { c.addPath(plannerIdx, pathConfiguration{ - parentPath: parentPath, - path: currentPath, + parentPath: field.parentPath, + path: field.currentPath, shouldWalkFields: true, - typeName: typeName, - fieldRef: fieldRef, + typeName: field.typeName, + fieldRef: field.fieldRef, fragmentRef: ast.InvalidRef, enclosingNode: c.walker.EnclosingTypeDefinition, dsHash: currentPlannerDSHash, @@ -818,9 +884,9 @@ func (c *pathBuilderVisitor) isParentPathIsRootOperationPath(parentPath string) return parentPath == "query" || parentPath == "mutation" || parentPath == "subscription" } -func (c *pathBuilderVisitor) allowNewPlannerForTypenameField(fieldName string, typeName string, parentPath string, dsCfg DataSource) bool { - fedCfg := dsCfg.FederationConfiguration() - isEntityInterface := fedCfg.HasEntityInterface(typeName) +func (c *pathBuilderVisitor) allowNewPlannerForTypenameField(field *currentFieldInfo) bool { + fedCfg := field.ds.FederationConfiguration() + isEntityInterface := fedCfg.HasEntityInterface(field.typeName) if isEntityInterface { return true @@ -829,29 +895,29 @@ func (c *pathBuilderVisitor) allowNewPlannerForTypenameField(fieldName string, t // we should handle a new planner for a __typename // only when it is the first field on a query, // or we are on the entity interface object - return c.isParentPathIsRootOperationPath(parentPath) + return c.isParentPathIsRootOperationPath(field.parentPath) } -func (c *pathBuilderVisitor) addNewPlanner(fieldRef int, typeName, fieldName, currentPath, parentPath string, isMutationRoot bool, dsConfig DataSource) (plannerIdx int, planned bool) { - if !dsConfig.HasRootNode(typeName, fieldName) { - if fieldName != typeNameField { +func (c *pathBuilderVisitor) addNewPlanner(field *currentFieldInfo, isMutationRoot bool) (plannerIdx int, planned bool) { + if !field.ds.HasRootNode(field.typeName, field.fieldName) { + if field.fieldName != typeNameField { return -1, false } - if !c.allowNewPlannerForTypenameField(fieldName, typeName, parentPath, dsConfig) { + if !c.allowNewPlannerForTypenameField(field) { return -1, false } } currentPathConfiguration := pathConfiguration{ - parentPath: parentPath, - path: currentPath, + parentPath: field.parentPath, + path: field.currentPath, shouldWalkFields: true, - typeName: typeName, - fieldRef: fieldRef, + typeName: field.typeName, + fieldRef: field.fieldRef, fragmentRef: ast.InvalidRef, enclosingNode: c.walker.EnclosingTypeDefinition, - dsHash: dsConfig.Hash(), + dsHash: field.ds.Hash(), isRootNode: true, pathType: PathTypeField, } @@ -875,9 +941,9 @@ func (c *pathBuilderVisitor) addNewPlanner(fieldRef int, typeName, fieldName, cu // so we'd miss the selection sets and inline fragments in the root paths = append([]pathConfiguration{ { - path: parentPath, + path: field.parentPath, shouldWalkFields: false, - dsHash: dsConfig.Hash(), + dsHash: field.ds.Hash(), fieldRef: ast.InvalidRef, fragmentRef: fragmentRef, pathType: PathTypeFragment, @@ -893,9 +959,9 @@ func (c *pathBuilderVisitor) addNewPlanner(fieldRef int, typeName, fieldName, cu // this could happen when the parent is a fragment and we walking nested selection sets paths = append([]pathConfiguration{ { - path: parentPath, + path: field.parentPath, shouldWalkFields: true, - dsHash: dsConfig.Hash(), + dsHash: field.ds.Hash(), fieldRef: ast.InvalidRef, fragmentRef: fragmentRef, pathType: pathType, @@ -903,7 +969,7 @@ func (c *pathBuilderVisitor) addNewPlanner(fieldRef int, typeName, fieldName, cu }, paths...) } - plannerPath := parentPath + plannerPath := field.parentPath if isParentFragment { precedingFragmentPath := c.walker.Path[:len(c.walker.Path)-1].DotDelimitedString() @@ -913,7 +979,7 @@ func (c *pathBuilderVisitor) addNewPlanner(fieldRef int, typeName, fieldName, cu { path: precedingFragmentPath, shouldWalkFields: false, - dsHash: dsConfig.Hash(), + dsHash: field.ds.Hash(), fieldRef: ast.InvalidRef, fragmentRef: ast.InvalidRef, pathType: PathTypeParent, @@ -925,7 +991,7 @@ func (c *pathBuilderVisitor) addNewPlanner(fieldRef int, typeName, fieldName, cu plannerPath = precedingFragmentPath } - fieldDefinition, ok := c.walker.FieldDefinition(fieldRef) + fieldDefinition, ok := c.walker.FieldDefinition(field.fieldRef) if !ok { return -1, false } @@ -934,20 +1000,20 @@ func (c *pathBuilderVisitor) addNewPlanner(fieldRef int, typeName, fieldName, cu fetchID := len(c.planners) // the filter needs access to fieldRef to retrieve the field argument variable - c.fieldRef = fieldRef + c.fieldRef = field.fieldRef - isSubscription := c.isSubscriptionRoot(currentPath) + isSubscription := c.isSubscriptionRoot(field.currentPath) fetchConfiguration := &objectFetchConfiguration{ isSubscription: isSubscription, - fieldRef: fieldRef, + fieldRef: field.fieldRef, fieldDefinitionRef: fieldDefinition, fetchID: fetchID, fetchItem: c.fetchItem(), - sourceID: dsConfig.Id(), - sourceName: dsConfig.Name(), - operationType: c.resolveRootFieldOperationType(typeName), - filter: c.resolveSubscriptionFilterCondition(typeName, fieldName), + sourceID: field.ds.Id(), + sourceName: field.ds.Name(), + operationType: c.resolveRootFieldOperationType(field.typeName), + filter: c.resolveSubscriptionFilterCondition(field.typeName, field.fieldName), } if isMutationRoot { @@ -964,9 +1030,10 @@ func (c *pathBuilderVisitor) addNewPlanner(fieldRef int, typeName, fieldName, cu plannerPath, c.plannerPathType(plannerPath), paths, + field.defferID, ) - plannerConfig := dsConfig.CreatePlannerConfiguration(c.logger, fetchConfiguration, plannerPathConfig, c.plannerConfiguration) + plannerConfig := field.ds.CreatePlannerConfiguration(c.logger, fetchConfiguration, plannerPathConfig, c.plannerConfiguration) c.planners = append(c.planners, plannerConfig) @@ -1205,8 +1272,8 @@ func (c *pathBuilderVisitor) resolveRootFieldOperationType(typeName string) ast. } // handleMissingPath - records missing path for the case when we don't yet have a planner for the field -func (c *pathBuilderVisitor) handleMissingPath(planned bool, typeName string, fieldName string, currentPath string, shareable bool) { - suggestions := c.nodeSuggestions.SuggestionsForPath(typeName, fieldName, currentPath) +func (c *pathBuilderVisitor) handleMissingPath(planned bool, field *currentFieldInfo) { + suggestions := c.nodeSuggestions.SuggestionsForPath(field.typeName, field.fieldName, field.currentPath) if len(suggestions) <= 1 { if planned { @@ -1215,9 +1282,9 @@ func (c *pathBuilderVisitor) handleMissingPath(planned bool, typeName string, fi } if c.plannerConfiguration.Debug.PrintPlanningPaths { - fmt.Println("Found potentially missing path", currentPath) + fmt.Println("Found potentially missing path", field.currentPath) } - c.potentiallyMissingPathTracker[currentPath] = struct{}{} + c.potentiallyMissingPathTracker[field.currentPath] = struct{}{} } allSuggestionsPlanned := true @@ -1228,7 +1295,7 @@ func (c *pathBuilderVisitor) handleMissingPath(planned bool, typeName string, fi if c.planners[i].DataSourceConfiguration().Hash() != suggestion.DataSourceHash { continue } - if c.planners[i].HasPath(currentPath) { + if c.planners[i].HasPath(field.currentPath) { hasPlannedSuggestion = true break } @@ -1243,33 +1310,39 @@ func (c *pathBuilderVisitor) handleMissingPath(planned bool, typeName string, fi // all suggestions were planned, so we should not record a missing path return } + + // todo: cleanup + if !field.shareable { + c.walker.SkipNode() + } } // addPlannerPathForTypename adds a path for the __typename field. func (c *pathBuilderVisitor) addPlannerPathForTypename( - plannerIndex int, currentPath string, parentPath string, fieldRef int, fieldName string, typeName string, + field *currentFieldInfo, + plannerIndex int, planningBehaviour DataSourcePlanningBehavior, ) (pathAdded bool) { // Adding __typename should happen only if particular planner has parent path, // otherwise it will be added to all planners and will cause visiting of incorrect selection sets. - if fieldName != typeNameField { + if field.fieldName != typeNameField { return false } if !planningBehaviour.AllowPlanningTypeName { return false } - if c.planners[plannerIndex].HasPath(currentPath) { + if c.planners[plannerIndex].HasPath(field.currentPath) { // do not add a path for __typename if it already exists return true } c.addPath(plannerIndex, pathConfiguration{ - parentPath: parentPath, - path: currentPath, + parentPath: field.parentPath, + path: field.currentPath, shouldWalkFields: true, - typeName: typeName, - fieldRef: fieldRef, + typeName: field.typeName, + fieldRef: field.fieldRef, fragmentRef: ast.InvalidRef, dsHash: c.planners[plannerIndex].DataSourceConfiguration().Hash(), pathType: PathTypeField, diff --git a/v2/pkg/engine/plan/planner_configuration.go b/v2/pkg/engine/plan/planner_configuration.go index 7bc5614d66..59b0a6142e 100644 --- a/v2/pkg/engine/plan/planner_configuration.go +++ b/v2/pkg/engine/plan/planner_configuration.go @@ -85,10 +85,12 @@ type PlannerPathConfiguration interface { HasFragmentPath(fragmentRef int) bool ShouldWalkFieldsOnPath(path string, typeName string) bool HasParent(parent string) bool + DeferID() string } -func newPlannerPathsConfiguration(parentPath string, parentPathType PlannerPathType, paths []pathConfiguration) *plannerPathsConfiguration { +func newPlannerPathsConfiguration(parentPath string, parentPathType PlannerPathType, paths []pathConfiguration, deferID string) *plannerPathsConfiguration { p := &plannerPathsConfiguration{ + deferID: deferID, parentPath: parentPath, parentPathType: parentPathType, index: make(map[string][]int), @@ -108,6 +110,7 @@ type plannerPathsConfiguration struct { parentPath string parentPathType PlannerPathType paths []pathConfiguration + deferID string // indexes @@ -117,6 +120,10 @@ type plannerPathsConfiguration struct { nonLeafPaths map[string]struct{} } +func (p *plannerPathsConfiguration) DeferID() string { + return p.deferID +} + func (p *plannerPathsConfiguration) ForEachPath(callback func(*pathConfiguration) (shouldNathanDreak bool)) { for i := range p.paths { if _, exists := p.index[p.paths[i].path]; !exists { From 48068c165c7b041bad3cbf4e7af0d1716ae5174f Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 8 Jan 2026 21:12:37 +0200 Subject: [PATCH 09/79] chore: cleanup after rebase --- .../datasource_filter_collect_nodes_visitor.go | 5 +++-- v2/pkg/engine/plan/path_builder_visitor.go | 18 ------------------ 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go index c16f8f402d..c56c6ce368 100644 --- a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go +++ b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go @@ -613,11 +613,12 @@ func (f *treeBuilderVisitor) collectFieldInfo(fieldRef int) { parentPathWithoutFragment: parentPathWithoutFragment, currentPathWithoutFragments: currentPathWithoutFragments, isTypeName: isTypeName, - deferInfo: f.deferInfo(ref), + enclosingTypeDefinition: f.walker.EnclosingTypeDefinition, + deferInfo: f.deferInfo(fieldRef), } } -func (f *fieldInfoVisitor) deferInfo(fieldRef int) *DeferInfo { +func (f *treeBuilderVisitor) deferInfo(fieldRef int) *DeferInfo { deferDirectiveRef, exists := f.operation.Fields[fieldRef].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) if !exists { return nil diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index afd9e24e5a..2a941bcacf 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -592,19 +592,6 @@ func (c *pathBuilderVisitor) LeaveField(ref int) { }) } -type currentFieldInfo struct { - fieldRef int - typeName string - fieldName string - currentPath string - parentPath string - precedingParentPath string - suggestion *NodeSuggestion - ds DataSource - shareable bool - defferID string -} - func (c *pathBuilderVisitor) handlePlanningField(field *currentFieldInfo) { plannedOnPlannerIds := c.fieldsPlannedOn[field.fieldRef] @@ -1310,11 +1297,6 @@ func (c *pathBuilderVisitor) handleMissingPath(planned bool, field *currentField // all suggestions were planned, so we should not record a missing path return } - - // todo: cleanup - if !field.shareable { - c.walker.SkipNode() - } } // addPlannerPathForTypename adds a path for the __typename field. From 31508a60daa8cb608178ccab99ed9b2fc56dbc45 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Sun, 11 Jan 2026 14:14:33 +0200 Subject: [PATCH 10/79] add comments --- v2/pkg/engine/plan/node_selection_builder.go | 3 +++ v2/pkg/engine/plan/path_builder_visitor.go | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/v2/pkg/engine/plan/node_selection_builder.go b/v2/pkg/engine/plan/node_selection_builder.go index 573550662e..29a2428e69 100644 --- a/v2/pkg/engine/plan/node_selection_builder.go +++ b/v2/pkg/engine/plan/node_selection_builder.go @@ -194,6 +194,9 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, } } + // NOTE: this is not enough + // If the deffered field is on the entity and entity is jumpable to itself, we need to request keys? + p.nodeSelectionsVisitor.nodeSuggestions.ProcessDefer() return &NodeSelectionResult{ diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 2a941bcacf..af92358196 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -554,6 +554,10 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { c.handlePlanningField(field) } // and as a normal path + + // NOTE: when all child fields was deferred - we should not plan normal path? + // where to detect it? + field.defferID = "" c.handlePlanningField(field) case suggestion.deferInfo != nil: From 5d838feecf2e5c8b7897896d93c68521023b4211 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Sun, 11 Jan 2026 16:02:05 +0200 Subject: [PATCH 11/79] pass defer id via fetch configuration --- v2/pkg/engine/plan/datasource_configuration.go | 1 - v2/pkg/engine/plan/path_builder_visitor.go | 3 ++- v2/pkg/engine/plan/planner_configuration.go | 15 ++++++--------- v2/pkg/engine/plan/visitor.go | 1 + v2/pkg/engine/resolve/fetch.go | 1 + 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/v2/pkg/engine/plan/datasource_configuration.go b/v2/pkg/engine/plan/datasource_configuration.go index 0601e3abfb..d10d3a62d2 100644 --- a/v2/pkg/engine/plan/datasource_configuration.go +++ b/v2/pkg/engine/plan/datasource_configuration.go @@ -360,7 +360,6 @@ type DataSourcePlannerConfiguration struct { PathType PlannerPathType IsNested bool Options plannerConfigurationOptions - FetchID int } type PlannerPathType int diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index af92358196..dc2a0d63bb 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -122,6 +122,7 @@ type objectFetchConfiguration struct { dependsOnFetchIDs []int rootFields []resolve.GraphCoordinate operationType ast.OperationType + deferID string } type currentFieldInfo struct { @@ -1000,6 +1001,7 @@ func (c *pathBuilderVisitor) addNewPlanner(field *currentFieldInfo, isMutationRo fieldRef: field.fieldRef, fieldDefinitionRef: fieldDefinition, fetchID: fetchID, + deferID: field.defferID, fetchItem: c.fetchItem(), sourceID: field.ds.Id(), sourceName: field.ds.Name(), @@ -1021,7 +1023,6 @@ func (c *pathBuilderVisitor) addNewPlanner(field *currentFieldInfo, isMutationRo plannerPath, c.plannerPathType(plannerPath), paths, - field.defferID, ) plannerConfig := field.ds.CreatePlannerConfiguration(c.logger, fetchConfiguration, plannerPathConfig, c.plannerConfiguration) diff --git a/v2/pkg/engine/plan/planner_configuration.go b/v2/pkg/engine/plan/planner_configuration.go index 59b0a6142e..72c41ead9b 100644 --- a/v2/pkg/engine/plan/planner_configuration.go +++ b/v2/pkg/engine/plan/planner_configuration.go @@ -28,6 +28,7 @@ type PlannerConfiguration interface { ObjectFetchConfiguration() *objectFetchConfiguration DataSourceConfiguration() DataSource + DeferID() string RequiredFields() *FederationFieldConfigurations @@ -42,7 +43,6 @@ func (p *plannerConfiguration[T]) Register(visitor *Visitor) error { ParentPath: p.parentPath, PathType: p.parentPathType, IsNested: p.IsNestedPlanner(), - FetchID: p.objectFetchConfiguration.fetchID, Options: p.options, } @@ -62,6 +62,10 @@ func (p *plannerConfiguration[T]) ObjectFetchConfiguration() *objectFetchConfigu return p.objectFetchConfiguration } +func (p *plannerConfiguration[T]) DeferID() string { + return p.objectFetchConfiguration.deferID +} + func (p *plannerConfiguration[T]) DownstreamResponseFieldAlias(downstreamFieldRef int) (alias string, exists bool) { return p.planner.DownstreamResponseFieldAlias(downstreamFieldRef) } @@ -85,12 +89,10 @@ type PlannerPathConfiguration interface { HasFragmentPath(fragmentRef int) bool ShouldWalkFieldsOnPath(path string, typeName string) bool HasParent(parent string) bool - DeferID() string } -func newPlannerPathsConfiguration(parentPath string, parentPathType PlannerPathType, paths []pathConfiguration, deferID string) *plannerPathsConfiguration { +func newPlannerPathsConfiguration(parentPath string, parentPathType PlannerPathType, paths []pathConfiguration) *plannerPathsConfiguration { p := &plannerPathsConfiguration{ - deferID: deferID, parentPath: parentPath, parentPathType: parentPathType, index: make(map[string][]int), @@ -110,7 +112,6 @@ type plannerPathsConfiguration struct { parentPath string parentPathType PlannerPathType paths []pathConfiguration - deferID string // indexes @@ -120,10 +121,6 @@ type plannerPathsConfiguration struct { nonLeafPaths map[string]struct{} } -func (p *plannerPathsConfiguration) DeferID() string { - return p.deferID -} - func (p *plannerPathsConfiguration) ForEachPath(callback func(*pathConfiguration) (shouldNathanDreak bool)) { for i := range p.paths { if _, exists := p.index[p.paths[i].path]; !exists { diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 69faf9ecd4..eab1fe673f 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -1337,6 +1337,7 @@ func (v *Visitor) configureFetch(internal *objectFetchConfiguration, external re FetchDependencies: resolve.FetchDependencies{ FetchID: internal.fetchID, DependsOnFetchIDs: internal.dependsOnFetchIDs, + DeferID: internal.deferID, }, DataSourceIdentifier: []byte(dataSourceType), } diff --git a/v2/pkg/engine/resolve/fetch.go b/v2/pkg/engine/resolve/fetch.go index 622e731c4b..ee9032c1d3 100644 --- a/v2/pkg/engine/resolve/fetch.go +++ b/v2/pkg/engine/resolve/fetch.go @@ -110,6 +110,7 @@ func (s *SingleFetch) FetchInfo() *FetchInfo { type FetchDependencies struct { FetchID int DependsOnFetchIDs []int + DeferID string } type PostProcessingConfiguration struct { From 23ef688a27856c7e3f5f5cd8df15cc802c228255 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 26 Jan 2026 18:33:11 +0200 Subject: [PATCH 12/79] add defer to path configuration --- v2/pkg/engine/plan/path_builder_visitor.go | 27 ++++++++++++++------- v2/pkg/engine/plan/planner_configuration.go | 3 +++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index dc2a0d63bb..5bf05393ca 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -135,7 +135,8 @@ type currentFieldInfo struct { suggestion *NodeSuggestion ds DataSource shareable bool - defferID string + deferID string + deferField bool } func (c *pathBuilderVisitor) currentSelectionSetInfo() (info selectionSetTypeInfo, ok bool) { @@ -550,7 +551,8 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { switch { case len(suggestion.deferIDs) > 0: for _, deferID := range suggestion.deferIDs { - field.defferID = deferID + field.deferID = deferID + field.deferField = false // defer parent path planning - should be planned as a deferred path c.handlePlanningField(field) } @@ -559,15 +561,18 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { // NOTE: when all child fields was deferred - we should not plan normal path? // where to detect it? - field.defferID = "" + field.deferID = "" + field.deferField = false c.handlePlanningField(field) case suggestion.deferInfo != nil: - field.defferID = suggestion.deferInfo.ID + field.deferID = suggestion.deferInfo.ID + field.deferField = true // should be planned only as a deferred path c.handlePlanningField(field) default: // normal field planning - field.defferID = "" + field.deferID = "" + field.deferField = false c.handlePlanningField(field) } } @@ -601,7 +606,7 @@ func (c *pathBuilderVisitor) handlePlanningField(field *currentFieldInfo) { plannedOnPlannerIds := c.fieldsPlannedOn[field.fieldRef] if slices.ContainsFunc(plannedOnPlannerIds, func(plannerIdx int) bool { - return c.planners[plannerIdx].DataSourceConfiguration().Hash() == field.ds.Hash() && c.planners[plannerIdx].DeferID() == field.defferID + return c.planners[plannerIdx].DataSourceConfiguration().Hash() == field.ds.Hash() && c.planners[plannerIdx].DeferID() == field.deferID }) { // when we have already planned the field on the same datasource as was suggested // we do not need to try to plan it again @@ -809,12 +814,12 @@ func (c *pathBuilderVisitor) planWithExistingPlanners(field *currentFieldInfo) ( continue } - if plannerConfig.DeferID() != "" && field.defferID == "" { + if plannerConfig.DeferID() != "" && field.deferID == "" { // do not plan a non-deferred field on a deferred planner continue } - if field.defferID != "" && plannerConfig.DeferID() != field.defferID { + if field.deferID != "" && plannerConfig.DeferID() != field.deferID { // do not plan a deferred field on a planner with different defer id // or not a deferred planner continue @@ -862,6 +867,8 @@ func (c *pathBuilderVisitor) planWithExistingPlanners(field *currentFieldInfo) ( dsHash: currentPlannerDSHash, isRootNode: isRootNode, pathType: PathTypeField, + deferID: field.deferID, + deferredField: field.deferField, }) return plannerIdx, true @@ -912,6 +919,8 @@ func (c *pathBuilderVisitor) addNewPlanner(field *currentFieldInfo, isMutationRo dsHash: field.ds.Hash(), isRootNode: true, pathType: PathTypeField, + deferID: field.deferID, + deferredField: field.deferField, } paths := []pathConfiguration{ @@ -1001,7 +1010,7 @@ func (c *pathBuilderVisitor) addNewPlanner(field *currentFieldInfo, isMutationRo fieldRef: field.fieldRef, fieldDefinitionRef: fieldDefinition, fetchID: fetchID, - deferID: field.defferID, + deferID: field.deferID, fetchItem: c.fetchItem(), sourceID: field.ds.Id(), sourceName: field.ds.Name(), diff --git a/v2/pkg/engine/plan/planner_configuration.go b/v2/pkg/engine/plan/planner_configuration.go index 72c41ead9b..41e1b0a2db 100644 --- a/v2/pkg/engine/plan/planner_configuration.go +++ b/v2/pkg/engine/plan/planner_configuration.go @@ -241,6 +241,9 @@ type pathConfiguration struct { dsHash DSHash isRootNode bool pathType PathType + + deferredField bool + deferID string } type PathType int From 6199f95e8527d7d16a2331b5bb61e32d3ef80957 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 26 Jan 2026 18:41:30 +0200 Subject: [PATCH 13/79] cleanup skip include leftovers --- v2/pkg/engine/plan/visitor.go | 44 +---------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index eab1fe673f..5e7582b35c 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -50,7 +50,6 @@ type Visitor struct { fieldRefDependants map[int][]int // inverse of fieldRefDependsOnFieldRefs fieldConfigs map[int]*FieldConfiguration exportedVariables map[string]struct{} - skipIncludeOnFragments map[int]skipIncludeInfo disableResolveFieldPositions bool includeQueryPlans bool indirectInterfaceFields map[int]indirectInterfaceField @@ -133,13 +132,6 @@ func (v *Visitor) debugPrint(args ...interface{}) { fmt.Println(allArgs...) } -type skipIncludeInfo struct { - skip bool - skipVariableName string - include bool - includeVariableName string -} - type objectFields struct { popOnField int fields *[]*resolve.Field @@ -326,23 +318,6 @@ func (v *Visitor) EnterInlineFragment(ref int) { } v.indirectInterfaceFields[v.Operation.InlineFragments[ref].SelectionSet] = field } - - directives := v.Operation.InlineFragments[ref].Directives.Refs - skipVariableName, skip := v.Operation.ResolveSkipDirectiveVariable(directives) - includeVariableName, include := v.Operation.ResolveIncludeDirectiveVariable(directives) - setRef := v.Operation.InlineFragments[ref].SelectionSet - if setRef == ast.InvalidRef { - return - } - - if skip || include { - v.skipIncludeOnFragments[ref] = skipIncludeInfo{ - skip: skip, - skipVariableName: skipVariableName, - include: include, - includeVariableName: includeVariableName, - } - } } func (v *Visitor) LeaveInlineFragment(ref int) { @@ -513,24 +488,6 @@ func (v *Visitor) resolveFieldPosition(ref int) resolve.Position { } } -func (v *Visitor) resolveSkipIncludeOnParent() (info skipIncludeInfo, ok bool) { - if len(v.skipIncludeOnFragments) == 0 { - return skipIncludeInfo{}, false - } - - for i := len(v.Walker.Ancestors) - 1; i >= 0; i-- { - ancestor := v.Walker.Ancestors[i] - if ancestor.Kind != ast.NodeKindInlineFragment { - continue - } - if info, ok := v.skipIncludeOnFragments[ancestor.Ref]; ok { - return info, true - } - } - - return skipIncludeInfo{}, false -} - func (v *Visitor) resolveOnTypeNames(fieldRef int, fieldName ast.ByteSlice) (onTypeNames [][]byte) { if len(v.Walker.Ancestors) < 2 { return nil @@ -881,6 +838,7 @@ func (v *Visitor) resolveFieldValue(fieldRef, typeRef int, nullable bool, path [ } v.objects = append(v.objects, object) + v.Walker.DefferOnEnterField(func() { v.currentFields = append(v.currentFields, objectFields{ popOnField: fieldRef, From 8ecdbaa701bed835a50e1d0db961e73de0cfda28 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 26 Jan 2026 18:46:09 +0200 Subject: [PATCH 14/79] cleanup old defer directives --- v2/pkg/engine/plan/visitor.go | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 5e7582b35c..8fa41e37cd 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -278,34 +278,6 @@ func (v *Visitor) currentFullPath(skipFragments bool) string { } func (v *Visitor) EnterDirective(ref int) { - directiveName := v.Operation.DirectiveNameString(ref) - ancestor := v.Walker.Ancestors[len(v.Walker.Ancestors)-1] - switch ancestor.Kind { - case ast.NodeKindOperationDefinition: - switch directiveName { - case "flushInterval": - if value, ok := v.Operation.DirectiveArgumentValueByName(ref, literal.MILLISECONDS); ok { - if value.Kind == ast.ValueKindInteger { - v.plan.SetFlushInterval(v.Operation.IntValueAsInt(value.Ref)) - } - } - } - case ast.NodeKindField: - switch directiveName { - case "stream": - initialBatchSize := 0 - if value, ok := v.Operation.DirectiveArgumentValueByName(ref, literal.INITIAL_BATCH_SIZE); ok { - if value.Kind == ast.ValueKindInteger { - initialBatchSize = int(v.Operation.IntValueAsInt32(value.Ref)) - } - } - v.currentField.Stream = &resolve.StreamField{ - InitialBatchSize: initialBatchSize, - } - case "defer": - v.currentField.Defer = &resolve.DeferField{} - } - } } func (v *Visitor) EnterInlineFragment(ref int) { From 3fccb96cf40e5725aa9f995bf08d3a122f80567b Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 26 Jan 2026 20:09:26 +0200 Subject: [PATCH 15/79] pass defer id to the resolve.Field --- .../graphql_datasource_defer_test.go | 188 +++++------------- .../datasourcetesting/datasourcetesting.go | 38 ++-- v2/pkg/engine/plan/configuration.go | 4 + .../engine/plan/datasource_configuration.go | 1 + v2/pkg/engine/plan/planner_configuration.go | 14 +- v2/pkg/engine/plan/visitor.go | 136 +++++-------- v2/pkg/engine/resolve/node_object.go | 4 +- 7 files changed, 150 insertions(+), 235 deletions(-) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go index a3cf93a441..bd2cc4289b 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go @@ -76,6 +76,8 @@ func TestGraphQLDataSourceDefer(t *testing.T) { Debug: plan.DebugConfiguration{ PrintQueryPlans: true, PrintPlanningPaths: true, + + PlanningVisitor: true, }, } @@ -97,87 +99,28 @@ func TestGraphQLDataSourceDefer(t *testing.T) { Response: &resolve.GraphQLResponse{ Fetches: resolve.Sequence( resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + DeferID: "1", + }, FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {__typename id}}"}}`, + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, DataSource: &Source{}, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), }), - resolve.SingleWithPath(&resolve.SingleFetch{ + resolve.Single(&resolve.SingleFetch{ FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - DependsOnFetchIDs: []int{0}, - }, FetchConfiguration: resolve.FetchConfiguration{ - RequiresEntityBatchFetch: false, - RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename hostedImageWithProvides {image {__typename id}}}}}","variables":{"representations":[$$0$$]}}}`, - DataSource: &Source{}, - SetTemplateOutputToNullOnVariableNull: true, - Variables: []resolve.Variable{ - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - { - Name: []byte("id"), - Value: &resolve.Scalar{ - Path: []string{"id"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - }, - }), - }, - }, - PostProcessing: SingleEntityPostProcessingConfiguration, + FetchID: 1, }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, "user", resolve.ObjectPath("user")), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 2, - DependsOnFetchIDs: []int{1}, - }, FetchConfiguration: resolve.FetchConfiguration{ - RequiresEntityBatchFetch: false, - RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://fourth.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Image {__typename cdnUrl}}}","variables":{"representations":[$$0$$]}}}`, - DataSource: &Source{}, - SetTemplateOutputToNullOnVariableNull: true, - Variables: []resolve.Variable{ - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("Image")}, - }, - { - Name: []byte("id"), - Value: &resolve.Scalar{ - Path: []string{"id"}, - }, - OnTypeNames: [][]byte{[]byte("Image")}, - }, - }, - }), - }, - }, - PostProcessing: SingleEntityPostProcessingConfiguration, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {name}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, "user.hostedImageWithProvides.image", resolve.ObjectPath("user"), resolve.ObjectPath("hostedImageWithProvides"), resolve.ObjectPath("image")), + }), ), Data: &resolve.Object{ Fields: []*resolve.Field{ @@ -192,34 +135,18 @@ func TestGraphQLDataSourceDefer(t *testing.T) { TypeName: "User", Fields: []*resolve.Field{ { - Name: []byte("hostedImageWithProvides"), - Value: &resolve.Object{ - Path: []string{"hostedImageWithProvides"}, - Nullable: false, - PossibleTypes: map[string]struct{}{ - "HostedImage": {}, - }, - TypeName: "HostedImage", - Fields: []*resolve.Field{ - { - Name: []byte("image"), - Value: &resolve.Object{ - Path: []string{"image"}, - PossibleTypes: map[string]struct{}{ - "Image": {}, - }, - TypeName: "Image", - Fields: []*resolve.Field{ - { - Name: []byte("cdnUrl"), - Value: &resolve.String{ - Path: []string{"cdnUrl"}, - }, - }, - }, - }, - }, - }, + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("title"), + Defer: &resolve.DeferField{ + DeferID: "1", + }, + Value: &resolve.String{ + Path: []string{"title"}, }, }, }, @@ -232,6 +159,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { planConfiguration, WithDefaultPostProcessor(), WithDefer(), + WithCalculateFieldDependencies(), ) }) }) @@ -375,7 +303,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { Fetches: resolve.Sequence( resolve.Single(&resolve.SingleFetch{ FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {__typename id}}"}}`, + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title __typename id}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, DataSource: &Source{}, }, @@ -388,7 +316,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, FetchConfiguration: resolve.FetchConfiguration{ RequiresEntityBatchFetch: false, RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename hostedImageWithProvides {image {__typename id}}}}}","variables":{"representations":[$$0$$]}}}`, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename firstName}}}","variables":{"representations":[$$0$$]}}}`, DataSource: &Source{}, SetTemplateOutputToNullOnVariableNull: true, Variables: []resolve.Variable{ @@ -421,11 +349,12 @@ func TestGraphQLDataSourceDefer(t *testing.T) { resolve.SingleWithPath(&resolve.SingleFetch{ FetchDependencies: resolve.FetchDependencies{ FetchID: 2, - DependsOnFetchIDs: []int{1}, + DependsOnFetchIDs: []int{0}, + DeferID: "1", }, FetchConfiguration: resolve.FetchConfiguration{ RequiresEntityBatchFetch: false, RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://fourth.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Image {__typename cdnUrl}}}","variables":{"representations":[$$0$$]}}}`, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename lastName}}}","variables":{"representations":[$$0$$]}}}`, DataSource: &Source{}, SetTemplateOutputToNullOnVariableNull: true, Variables: []resolve.Variable{ @@ -438,14 +367,14 @@ func TestGraphQLDataSourceDefer(t *testing.T) { Value: &resolve.String{ Path: []string{"__typename"}, }, - OnTypeNames: [][]byte{[]byte("Image")}, + OnTypeNames: [][]byte{[]byte("User")}, }, { Name: []byte("id"), Value: &resolve.Scalar{ Path: []string{"id"}, }, - OnTypeNames: [][]byte{[]byte("Image")}, + OnTypeNames: [][]byte{[]byte("User")}, }, }, }), @@ -454,7 +383,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { PostProcessing: SingleEntityPostProcessingConfiguration, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, "user.hostedImageWithProvides.image", resolve.ObjectPath("user"), resolve.ObjectPath("hostedImageWithProvides"), resolve.ObjectPath("image")), + }, "user", resolve.ObjectPath("user")), ), Data: &resolve.Object{ Fields: []*resolve.Field{ @@ -469,34 +398,24 @@ func TestGraphQLDataSourceDefer(t *testing.T) { TypeName: "User", Fields: []*resolve.Field{ { - Name: []byte("hostedImageWithProvides"), - Value: &resolve.Object{ - Path: []string{"hostedImageWithProvides"}, - Nullable: false, - PossibleTypes: map[string]struct{}{ - "HostedImage": {}, - }, - TypeName: "HostedImage", - Fields: []*resolve.Field{ - { - Name: []byte("image"), - Value: &resolve.Object{ - Path: []string{"image"}, - PossibleTypes: map[string]struct{}{ - "Image": {}, - }, - TypeName: "Image", - Fields: []*resolve.Field{ - { - Name: []byte("cdnUrl"), - Value: &resolve.String{ - Path: []string{"cdnUrl"}, - }, - }, - }, - }, - }, - }, + Name: []byte("title"), + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + { + Name: []byte("firstName"), + Value: &resolve.String{ + Path: []string{"firstName"}, + }, + }, + { + Name: []byte("lastName"), + Defer: &resolve.DeferField{ + DeferID: "1", + }, + Value: &resolve.String{ + Path: []string{"lastName"}, }, }, }, @@ -509,6 +428,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { planConfiguration, WithDefaultPostProcessor(), WithDefer(), + WithCalculateFieldDependencies(), ) }) }) diff --git a/v2/pkg/engine/datasourcetesting/datasourcetesting.go b/v2/pkg/engine/datasourcetesting/datasourcetesting.go index d8fb9e8bfe..ad6c1311cb 100644 --- a/v2/pkg/engine/datasourcetesting/datasourcetesting.go +++ b/v2/pkg/engine/datasourcetesting/datasourcetesting.go @@ -28,14 +28,15 @@ import ( ) type testOptions struct { - postProcessors []*postprocess.Processor - skipReason string - withFieldInfo bool - withPrintPlan bool - withFieldDependencies bool - withFetchReasons bool - validationOptions []astvalidation.Option - withDefer bool + postProcessors []*postprocess.Processor + skipReason string + withFieldInfo bool + withPrintPlan bool + withIncludeFieldDependencies bool + withFetchReasons bool + withDefer bool + validationOptions []astvalidation.Option + withCalculateFieldDependencies bool } func WithDefer() func(*testOptions) { @@ -77,17 +78,25 @@ func WithPrintPlan() func(*testOptions) { } } -func WithFieldDependencies() func(*testOptions) { +func WithIncludeFieldDependencies() func(*testOptions) { return func(o *testOptions) { o.withFieldInfo = true - o.withFieldDependencies = true + o.withIncludeFieldDependencies = true + o.withCalculateFieldDependencies = true + } +} + +func WithCalculateFieldDependencies() func(*testOptions) { + return func(o *testOptions) { + o.withCalculateFieldDependencies = true } } func WithFetchReasons() func(*testOptions) { return func(o *testOptions) { o.withFieldInfo = true - o.withFieldDependencies = true + o.withIncludeFieldDependencies = true + o.withCalculateFieldDependencies = true o.withFetchReasons = true } } @@ -157,6 +166,7 @@ func RunTestWithVariables(definition, operation, operationName, variables string // by default, we don't want to have field info in the tests because it's too verbose config.DisableIncludeInfo = true config.DisableIncludeFieldDependencies = true + config.DisableCalculateFieldDependencies = true opts := &testOptions{} for _, o := range options { @@ -167,10 +177,14 @@ func RunTestWithVariables(definition, operation, operationName, variables string config.DisableIncludeInfo = false } - if opts.withFieldDependencies { + if opts.withIncludeFieldDependencies { config.DisableIncludeFieldDependencies = false } + if opts.withCalculateFieldDependencies { + config.DisableCalculateFieldDependencies = false + } + if opts.withFetchReasons { config.BuildFetchReasons = true } diff --git a/v2/pkg/engine/plan/configuration.go b/v2/pkg/engine/plan/configuration.go index eebd9df352..489ebecc88 100644 --- a/v2/pkg/engine/plan/configuration.go +++ b/v2/pkg/engine/plan/configuration.go @@ -35,6 +35,10 @@ type Configuration struct { // It requires DisableIncludeInfo set to false. DisableIncludeFieldDependencies bool + // DisableCalculateFieldDependencies controls whether the planner calculates + // field dependencies at all. + DisableCalculateFieldDependencies bool + // BuildFetchReasons allows generating the FetchReasons structure for all the fields. // It may be enabled by some other components of the engine. // It requires DisableIncludeInfo and DisableIncludeFieldDependencies set to false. diff --git a/v2/pkg/engine/plan/datasource_configuration.go b/v2/pkg/engine/plan/datasource_configuration.go index d10d3a62d2..1ad0b1d8d9 100644 --- a/v2/pkg/engine/plan/datasource_configuration.go +++ b/v2/pkg/engine/plan/datasource_configuration.go @@ -435,6 +435,7 @@ type DataSourcePlanningBehavior struct { // } // When true expected response will be { "rootField": ..., "alias": ... } // When false expected response will be { "rootField": ..., "original": ... } + // Deprecated: has no effect anymore OverrideFieldPathFromAlias bool // AllowPlanningTypeName set to true will allow the planner to plan __typename fields. diff --git a/v2/pkg/engine/plan/planner_configuration.go b/v2/pkg/engine/plan/planner_configuration.go index 41e1b0a2db..4f49992101 100644 --- a/v2/pkg/engine/plan/planner_configuration.go +++ b/v2/pkg/engine/plan/planner_configuration.go @@ -86,6 +86,7 @@ type PlannerPathConfiguration interface { IsNestedPlanner() bool HasPath(path string) bool HasPathWithFieldRef(fieldRef int) bool + PathWithFieldRef(fieldRef int) (*pathConfiguration, bool) HasFragmentPath(fragmentRef int) bool ShouldWalkFieldsOnPath(path string, typeName string) bool HasParent(parent string) bool @@ -96,7 +97,7 @@ func newPlannerPathsConfiguration(parentPath string, parentPathType PlannerPathT parentPath: parentPath, parentPathType: parentPathType, index: make(map[string][]int), - indexByFieldRef: make(map[int]struct{}), + indexByFieldRef: make(map[int]*pathConfiguration), fragmentPaths: make(map[pathConfiguration]struct{}), nonLeafPaths: make(map[string]struct{}), } @@ -116,7 +117,7 @@ type plannerPathsConfiguration struct { // indexes index map[string][]int - indexByFieldRef map[int]struct{} + indexByFieldRef map[int]*pathConfiguration fragmentPaths map[pathConfiguration]struct{} nonLeafPaths map[string]struct{} } @@ -150,7 +151,7 @@ func (p *plannerPathsConfiguration) AddPath(configuration pathConfiguration) { p.fragmentPaths[configuration] = struct{}{} } if configuration.pathType == PathTypeField { - p.indexByFieldRef[configuration.fieldRef] = struct{}{} + p.indexByFieldRef[configuration.fieldRef] = &configuration } } @@ -170,6 +171,11 @@ func (p *plannerPathsConfiguration) HasPathWithFieldRef(fieldRef int) bool { return ok } +func (p *plannerPathsConfiguration) PathWithFieldRef(fieldRef int) (*pathConfiguration, bool) { + path, ok := p.indexByFieldRef[fieldRef] + return path, ok +} + func (p *plannerPathsConfiguration) HasFragmentPath(fragmentRef int) bool { for path := range p.fragmentPaths { if path.fragmentRef == fragmentRef { @@ -257,7 +263,7 @@ const ( func (p *pathConfiguration) String() string { switch p.pathType { case PathTypeField: - return fmt.Sprintf(`{"ds":%d,"path":"%s","fieldRef":%3d,"typeName":"%s","shouldWalkFields":%t,"isRootNode":%t,"pathType":"field"}`, p.dsHash, p.path, p.fieldRef, p.typeName, p.shouldWalkFields, p.isRootNode) + return fmt.Sprintf(`{"ds":%d,"path":"%s","fieldRef":%3d,"typeName":"%s","shouldWalkFields":%t,"isRootNode":%t,"pathType":"field","deferID":"%s"}`, p.dsHash, p.path, p.fieldRef, p.typeName, p.shouldWalkFields, p.isRootNode, p.deferID) case PathTypeFragment: return fmt.Sprintf(`{"ds":%d,"path":"%s","fragmentRef":%3d,"shouldWalkFields":%t,"pathType":"fragment"}`, p.dsHash, p.path, p.fragmentRef, p.shouldWalkFields) case PathTypeParent: diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 8fa41e37cd..2c2b7768db 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -41,8 +41,9 @@ type Visitor struct { OperationName string operationDefinitionRef int objects []*resolve.Object - currentFields []objectFields + currentObjectFields []objectFields currentField *resolve.Field + currentFields []*resolve.Field planners []PlannerConfiguration skipFieldsRefs []int fieldRefDependsOnFieldRefs map[int][]int @@ -217,7 +218,7 @@ func (v *Visitor) AllowVisitor(kind astvisitor.VisitorKind, ref int, visitor any } } - if !v.Config.DisableIncludeFieldDependencies && kind == astvisitor.LeaveField { + if !v.Config.DisableCalculateFieldDependencies && kind == astvisitor.LeaveField { // we don't need to do this twice, so we only do it on leave // store which fields are planned on which planners @@ -363,7 +364,10 @@ func (v *Visitor) EnterField(ref int) { } // append the field to the current object - *v.currentFields[len(v.currentFields)-1].fields = append(*v.currentFields[len(v.currentFields)-1].fields, v.currentField) + *v.currentObjectFields[len(v.currentObjectFields)-1].fields = append(*v.currentObjectFields[len(v.currentObjectFields)-1].fields, v.currentField) + + // append the current field to the list of current fields + v.currentFields = append(v.currentFields, v.currentField) v.mapFieldConfig(ref) } @@ -423,6 +427,12 @@ func (v *Visitor) resolveFieldInfo(ref, typeRef int, onTypeNames [][]byte) *reso sourceNames = append(sourceNames, v.planners[i].DataSourceConfiguration().Name()) } } + // deduplicate + slices.Sort(sourceIDs) + sourceIDs = slices.Compact(sourceIDs) + slices.Sort(sourceNames) + sourceNames = slices.Compact(sourceNames) + fieldInfo := &resolve.FieldInfo{ Name: fieldName, NamedType: typeName, @@ -569,8 +579,14 @@ func (v *Visitor) LeaveField(fieldRef int) { return } - if v.currentFields[len(v.currentFields)-1].popOnField == fieldRef { - v.currentFields = v.currentFields[:len(v.currentFields)-1] + v.assignDefer(fieldRef) + + // remove the current field from the current fields stack + v.currentFields = v.currentFields[:len(v.currentFields)-1] + + // remove the current field from the list of current object fields if they belong to this field + if v.currentObjectFields[len(v.currentObjectFields)-1].popOnField == fieldRef { + v.currentObjectFields = v.currentObjectFields[:len(v.currentObjectFields)-1] } fieldDefinitionRef, ok := v.Walker.FieldDefinition(fieldRef) if !ok { @@ -583,6 +599,31 @@ func (v *Visitor) LeaveField(fieldRef int) { } } +func (v *Visitor) assignDefer(fieldRef int) { + currentField := v.currentFields[len(v.currentFields)-1] + + // ignore existence check - we should always have planners for the field + plannerIds, _ := v.fieldPlanners[fieldRef] + + for _, plannerId := range plannerIds { + planner := v.planners[plannerId] + + fieldPathConfiguration, ok := planner.PathWithFieldRef(fieldRef) + if !ok { + continue + } + + if fieldPathConfiguration.deferredField { + currentField.Defer = &resolve.DeferField{ + DeferID: fieldPathConfiguration.deferID, + } + + // after the normalization we should have only one planner per deferred field + break + } + } +} + // skipField returns true if the field was added by the query planner as a dependency. // For another field and should not be included in the response. // If it returns false, the user requests the field. @@ -811,8 +852,12 @@ func (v *Visitor) resolveFieldValue(fieldRef, typeRef int, nullable bool, path [ v.objects = append(v.objects, object) + // When the current field has an object type, we need to push its fields slice to the stack. + // However, we can do that only after the field, which we are currently creating, will be added to the parent object fields. + // So we defer this action to be executed right after the current field is added to the parent object fields slice. + // This is more simple than analyzing resolve.Node, because this object could be nested in a list. v.Walker.DefferOnEnterField(func() { - v.currentFields = append(v.currentFields, objectFields{ + v.currentObjectFields = append(v.currentObjectFields, objectFields{ popOnField: fieldRef, fields: &object.Fields, }) @@ -933,7 +978,7 @@ func (v *Visitor) EnterOperationDefinition(opRef int) { } v.objects = append(v.objects, rootObject) - v.currentFields = append(v.currentFields, objectFields{ + v.currentObjectFields = append(v.currentObjectFields, objectFields{ fields: &rootObject.Fields, popOnField: -1, }) @@ -970,46 +1015,6 @@ func (v *Visitor) EnterOperationDefinition(opRef int) { } } -// TODO: cleanup - field alias override logic is disabled -func (v *Visitor) resolveFieldPath(ref int) []string { - typeName := v.Walker.EnclosingTypeDefinition.NameString(v.Definition) - fieldName := v.Operation.FieldNameUnsafeString(ref) - plannerConfig := v.currentOrParentPlannerConfiguration(ref) - - aliasOverride := false - if plannerConfig != nil && plannerConfig.Planner() != nil { - behavior := plannerConfig.DataSourceConfiguration().PlanningBehavior() - aliasOverride = behavior.OverrideFieldPathFromAlias - } - - for i := range v.Config.Fields { - if v.Config.Fields[i].TypeName == typeName && v.Config.Fields[i].FieldName == fieldName { - if aliasOverride { - override, exists := plannerConfig.DownstreamResponseFieldAlias(ref) - if exists { - return []string{override} - } - } - if aliasOverride && v.Operation.FieldAliasIsDefined(ref) { - return []string{v.Operation.FieldAliasString(ref)} - } - if v.Config.Fields[i].DisableDefaultMapping { - return nil - } - if len(v.Config.Fields[i].Path) != 0 { - return v.Config.Fields[i].Path - } - return []string{fieldName} - } - } - - if aliasOverride { - return []string{v.Operation.FieldAliasOrNameString(ref)} - } - - return []string{fieldName} -} - func (v *Visitor) EnterDocument(operation, definition *ast.Document) { v.Operation, v.Definition = operation, definition } @@ -1029,43 +1034,6 @@ var ( selectorRegex = regexp.MustCompile(`{{\s*\.(.*?)\s*}}`) ) -func (v *Visitor) currentOrParentPlannerConfiguration(fieldRef int) PlannerConfiguration { - // TODO: this method should be dropped it is unnecessary expensive - - const none = -1 - currentPath := v.currentFullPath(false) - plannerIndex := none - plannerPathDeepness := none - - for i := range v.planners { - v.planners[i].ForEachPath(func(plannerPath *pathConfiguration) bool { - if v.isCurrentOrParentPath(currentPath, plannerPath.path) { - currentPlannerPathDeepness := v.pathDeepness(plannerPath.path) - if currentPlannerPathDeepness > plannerPathDeepness { - plannerPathDeepness = currentPlannerPathDeepness - plannerIndex = i - return true - } - } - return false - }) - } - - if plannerIndex != none { - return v.planners[plannerIndex] - } - - return nil -} - -func (v *Visitor) isCurrentOrParentPath(currentPath string, parentPath string) bool { - return strings.HasPrefix(currentPath, parentPath) -} - -func (v *Visitor) pathDeepness(path string) int { - return strings.Count(path, ".") -} - func (v *Visitor) resolveInputTemplates(config *objectFetchConfiguration, input *string, variables *resolve.Variables) { *input = templateRegex.ReplaceAllStringFunc(*input, func(s string) string { selectors := selectorRegex.FindStringSubmatch(s) diff --git a/v2/pkg/engine/resolve/node_object.go b/v2/pkg/engine/resolve/node_object.go index 7f5e94a4c6..a59a3970a0 100644 --- a/v2/pkg/engine/resolve/node_object.go +++ b/v2/pkg/engine/resolve/node_object.go @@ -179,4 +179,6 @@ type StreamField struct { InitialBatchSize int } -type DeferField struct{} +type DeferField struct { + DeferID string +} From 1fb48f6e9dc58a6aab01ed2306387da8e8d91196 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 29 Jan 2026 18:23:57 +0200 Subject: [PATCH 16/79] setup postprocess structure for defer add defer plan kind --- v2/pkg/engine/plan/analyze_plan_kind.go | 65 ------- v2/pkg/engine/plan/analyze_plan_kind_test.go | 186 ------------------- v2/pkg/engine/plan/plan.go | 16 ++ v2/pkg/engine/plan/visitor.go | 50 +++-- v2/pkg/engine/postprocess/defer.go | 13 ++ v2/pkg/engine/postprocess/postprocess.go | 123 ++++++++---- v2/pkg/engine/resolve/response.go | 10 + 7 files changed, 162 insertions(+), 301 deletions(-) delete mode 100644 v2/pkg/engine/plan/analyze_plan_kind.go delete mode 100644 v2/pkg/engine/plan/analyze_plan_kind_test.go create mode 100644 v2/pkg/engine/postprocess/defer.go diff --git a/v2/pkg/engine/plan/analyze_plan_kind.go b/v2/pkg/engine/plan/analyze_plan_kind.go deleted file mode 100644 index 5dc08e4cc2..0000000000 --- a/v2/pkg/engine/plan/analyze_plan_kind.go +++ /dev/null @@ -1,65 +0,0 @@ -package plan - -import ( - "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" - "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" -) - -func AnalyzePlanKind(operation, definition *ast.Document, operationName string) (operationType ast.OperationType, streaming bool, error error) { - walker := astvisitor.NewWalkerWithID(48, "PlanKindVisitor") - visitor := &planKindVisitor{ - Walker: &walker, - operationName: operationName, - } - - walker.RegisterEnterDocumentVisitor(visitor) - walker.RegisterEnterOperationVisitor(visitor) - walker.RegisterEnterDirectiveVisitor(visitor) - - var report operationreport.Report - walker.Walk(operation, definition, &report) - if report.HasErrors() { - return ast.OperationTypeUnknown, false, report - } - operationType = visitor.operationType - // TODO: this should be done differently - streaming = visitor.hasDeferDirective || visitor.hasStreamDirective - return -} - -type planKindVisitor struct { - *astvisitor.Walker - - operation, definition *ast.Document - operationName string - hasStreamDirective, hasDeferDirective bool - operationType ast.OperationType -} - -func (p *planKindVisitor) EnterDirective(ref int) { - directiveName := p.operation.DirectiveNameString(ref) - ancestor := p.Ancestors[len(p.Ancestors)-1] - switch ancestor.Kind { - case ast.NodeKindField: - switch directiveName { - case "defer": - p.hasDeferDirective = true - case "stream": - p.hasStreamDirective = true - } - } -} - -func (p *planKindVisitor) EnterOperationDefinition(ref int) { - name := p.operation.OperationDefinitionNameString(ref) - if p.operationName != name { - p.SkipNode() - return - } - p.operationType = p.operation.OperationDefinitions[ref].OperationType -} - -func (p *planKindVisitor) EnterDocument(operation, definition *ast.Document) { - p.operation, p.definition = operation, definition -} diff --git a/v2/pkg/engine/plan/analyze_plan_kind_test.go b/v2/pkg/engine/plan/analyze_plan_kind_test.go deleted file mode 100644 index f489b10ca5..0000000000 --- a/v2/pkg/engine/plan/analyze_plan_kind_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package plan - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" - "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" -) - -type expectation func(t *testing.T, operationKind ast.OperationType, streaming bool, err error) - -func mustNotErr() expectation { - return func(t *testing.T, operationKind ast.OperationType, streaming bool, err error) { - assert.NoError(t, err) - } -} - -func mustSubscription(expect bool) expectation { - return func(t *testing.T, operationKind ast.OperationType, streaming bool, err error) { - if expect { - assert.Equal(t, ast.OperationTypeSubscription, operationKind) - } else { - assert.NotEqual(t, ast.OperationTypeSubscription, operationKind) - } - } -} - -func mustStreaming(expectStreaming bool) expectation { - return func(t *testing.T, operationKind ast.OperationType, streaming bool, err error) { - assert.Equal(t, expectStreaming, streaming) - } -} - -func TestAnalyzePlanKind(t *testing.T) { - run := func(definition, operation, operationName string, expectations ...expectation) func(t *testing.T) { - return func(t *testing.T) { - def := unsafeparser.ParseGraphqlDocumentString(definition) - op := unsafeparser.ParseGraphqlDocumentString(operation) - err := asttransform.MergeDefinitionWithBaseSchema(&def) - if err != nil { - t.Fatal(err) - } - operationKind, streaming, err := AnalyzePlanKind(&op, &def, operationName) - for i := range expectations { - expectations[i](t, operationKind, streaming, err) - } - } - } - - t.Run("query", run(testDefinition, ` - query MyQuery($id: ID!) { - droid(id: $id){ - name - friends { - name - } - friends { - name - } - primaryFunction - favoriteEpisode - } - }`, - "MyQuery", - mustNotErr(), - mustStreaming(false), - mustSubscription(false), - )) - t.Run("query stream", run(testDefinition, ` - query MyQuery($id: ID!) { - droid(id: $id){ - name - friends @stream { - name - } - friends { - name - } - primaryFunction - favoriteEpisode - } - }`, - "MyQuery", - mustNotErr(), - mustStreaming(true), - mustSubscription(false), - )) - t.Run("query defer", run(testDefinition, ` - query MyQuery($id: ID!) { - droid(id: $id){ - name - friends { - name - } - friends { - name - } - primaryFunction - favoriteEpisode @defer - } - }`, - "MyQuery", - mustNotErr(), - mustStreaming(true), - mustSubscription(false), - )) - t.Run("query defer", run(testDefinition, ` - query MyQuery($id: ID!) { - droid(id: $id){ - name - friends { - name - } - friends { - name - } - primaryFunction - favoriteEpisode - } - } - query OtherDeferredQuery { - droid(id: $id){ - name - friends @stream { - name - } - } - }`, - "MyQuery", - mustNotErr(), - mustStreaming(false), - mustSubscription(false), - )) - t.Run("query defer different name", run(testDefinition, ` - query MyQuery($id: ID!) { - droid(id: $id){ - name - friends { - name - } - friends { - name - } - primaryFunction - favoriteEpisode @defer - } - }`, - "OperationNameNotExists", - mustNotErr(), - mustStreaming(false), - mustSubscription(false), - )) - t.Run("subscription", run(testDefinition, ` - subscription RemainingJedis { - remainingJedis - }`, - "RemainingJedis", - mustNotErr(), - mustStreaming(false), - mustSubscription(true), - )) - t.Run("subscription with streaming", run(testDefinition, ` - subscription NewReviews { - newReviews { - id - stars @defer - } - }`, - "NewReviews", - mustNotErr(), - mustStreaming(true), - mustSubscription(true), - )) - t.Run("subscription name not exists", run(testDefinition, ` - subscription RemainingJedis { - remainingJedis - }`, - "OperationNameNotExists", - mustNotErr(), - mustStreaming(false), - mustSubscription(false), - )) -} diff --git a/v2/pkg/engine/plan/plan.go b/v2/pkg/engine/plan/plan.go index 8674f3a0a8..2d28a11573 100644 --- a/v2/pkg/engine/plan/plan.go +++ b/v2/pkg/engine/plan/plan.go @@ -9,6 +9,7 @@ type Kind int const ( SynchronousResponseKind Kind = iota + 1 SubscriptionResponseKind + DeferResponsePlanKind ) type Plan interface { @@ -61,3 +62,18 @@ func (s *SubscriptionResponsePlan) GetCostCalculator() *CostCalculator { func (s *SubscriptionResponsePlan) SetCostCalculator(c *CostCalculator) { s.CostCalculator = c } + +type DeferResponsePlan struct { + RawResponse *resolve.GraphQLResponse + InitialResponse *resolve.GraphQLResponse + DeferResponses []*resolve.DeferGraphQLResponse + FlushInterval int64 +} + +func (d DeferResponsePlan) PlanKind() Kind { + return DeferResponsePlanKind +} + +func (d DeferResponsePlan) SetFlushInterval(interval int64) { + d.FlushInterval = interval +} diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 2c2b7768db..25551e4b92 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -205,7 +205,7 @@ func (v *Visitor) AllowVisitor(kind astvisitor.VisitorKind, ref int, visitor any } shouldWalkFieldsOnPath := - // check if the field path has type condition and matches the enclosing type + // check if the field path has type condition and matches the enclosing type config.ShouldWalkFieldsOnPath(path, enclosingTypeName) || // check if the planner has path without type condition // this could happen in case of union type @@ -983,23 +983,35 @@ func (v *Visitor) EnterOperationDefinition(opRef int) { popOnField: -1, }) - operationKind, _, err := AnalyzePlanKind(v.Operation, v.Definition, v.OperationName) - if err != nil { - v.Walker.StopWithInternalErr(err) - return + isSubscription := false + isDefer := false + + for i := range v.planners { + if v.planners[i].ObjectFetchConfiguration().isSubscription { + isSubscription = true + break + } + + if v.planners[i].DeferID() != "" { + isDefer = true + break + } } v.response = &resolve.GraphQLResponse{ Data: rootObject, RawFetches: make([]*resolve.FetchItem, 0, len(v.planners)), } + if !v.Config.DisableIncludeInfo { + operationType := v.Operation.OperationDefinitions[0].OperationType v.response.Info = &resolve.GraphQLResponseInfo{ - OperationType: operationKind, + OperationType: operationType, } } - if operationKind == ast.OperationTypeSubscription { + switch { + case isSubscription: v.subscription = &resolve.GraphQLSubscription{ Response: v.response, } @@ -1007,11 +1019,27 @@ func (v *Visitor) EnterOperationDefinition(opRef int) { FlushInterval: v.Config.DefaultFlushIntervalMillis, Response: v.subscription, } - return - } + case isDefer: + if !v.Config.DisableIncludeInfo { + v.response.Info = &resolve.GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + } + } + + v.plan = &DeferResponsePlan{ + RawResponse: v.response, + } + default: + if !v.Config.DisableIncludeInfo { + v.response.Info = &resolve.GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + } + } + + v.plan = &SynchronousResponsePlan{ + Response: v.response, + } - v.plan = &SynchronousResponsePlan{ - Response: v.response, } } diff --git a/v2/pkg/engine/postprocess/defer.go b/v2/pkg/engine/postprocess/defer.go new file mode 100644 index 0000000000..255d81abeb --- /dev/null +++ b/v2/pkg/engine/postprocess/defer.go @@ -0,0 +1,13 @@ +package postprocess + +import ( + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" +) + +type deferProcessor struct { + disable bool +} + +func (d *deferProcessor) Process(deferPlan *plan.DeferResponsePlan) { + +} \ No newline at end of file diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index a98f9f16a5..9288ec8a71 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -21,11 +21,23 @@ type FetchTreeProcessor interface { type Processor struct { disableExtractFetches bool collectDataSourceInfo bool - resolveInputTemplates *resolveInputTemplates - appendFetchID *fetchIDAppender - dedupe *deduplicateSingleFetches - processResponseTree []ResponseTreeProcessor - processFetchTree []FetchTreeProcessor + processFetchTree *FetchTreeProcessors + processResponseTree *ResponseTreeProcessors + deferProcessor *deferProcessor +} + +type FetchTreeProcessors struct { + resolveInputTemplates *resolveInputTemplates + appendFetchID *fetchIDAppender + dedupe *deduplicateSingleFetches + addMissingNestedDependencies *addMissingNestedDependencies + createConcreteSingleFetchTypes *createConcreteSingleFetchTypes + orderSequenceByDependencies *orderSequenceByDependencies + createParallelNodes *createParallelNodes +} + +type ResponseTreeProcessors struct { + mergeFields *mergeFields } type processorOptions struct { @@ -39,6 +51,7 @@ type processorOptions struct { disableCreateParallelNodes bool disableAddMissingNestedDependencies bool collectDataSourceInfo bool + disableDefer bool } type ProcessorOption func(*processorOptions) @@ -92,6 +105,12 @@ func DisableAddMissingNestedDependencies() ProcessorOption { } } +func DisableDefer() ProcessorOption { + return func(o *processorOptions) { + o.disableDefer = true + } +} + func NewProcessor(options ...ProcessorOption) *Processor { opts := &processorOptions{} for _, o := range options { @@ -100,36 +119,39 @@ func NewProcessor(options ...ProcessorOption) *Processor { return &Processor{ collectDataSourceInfo: opts.collectDataSourceInfo, disableExtractFetches: opts.disableExtractFetches, - resolveInputTemplates: &resolveInputTemplates{ - disable: opts.disableResolveInputTemplates, - }, - appendFetchID: &fetchIDAppender{ - disable: opts.disableRewriteOpNames, - }, - dedupe: &deduplicateSingleFetches{ - disable: opts.disableDeduplicateSingleFetches, - }, - processFetchTree: []FetchTreeProcessor{ + processFetchTree: &FetchTreeProcessors{ + resolveInputTemplates: &resolveInputTemplates{ + disable: opts.disableResolveInputTemplates, + }, + appendFetchID: &fetchIDAppender{ + disable: opts.disableRewriteOpNames, + }, + dedupe: &deduplicateSingleFetches{ + disable: opts.disableDeduplicateSingleFetches, + }, // this must go first, as we need to deduplicate fetches so that subsequent processors can work correctly - &addMissingNestedDependencies{ + addMissingNestedDependencies: &addMissingNestedDependencies{ disable: opts.disableAddMissingNestedDependencies, }, // this must go after deduplication because it relies on the existence of a "sequence" fetch node in the root - &createConcreteSingleFetchTypes{ + createConcreteSingleFetchTypes: &createConcreteSingleFetchTypes{ disable: opts.disableCreateConcreteSingleFetchTypes, }, - &orderSequenceByDependencies{ + orderSequenceByDependencies: &orderSequenceByDependencies{ disable: opts.disableOrderSequenceByDependencies, }, - &createParallelNodes{ + createParallelNodes: &createParallelNodes{ disable: opts.disableCreateParallelNodes, }, }, - processResponseTree: []ResponseTreeProcessor{ - &mergeFields{ + processResponseTree: &ResponseTreeProcessors{ + mergeFields: &mergeFields{ disable: opts.disableMergeFields, }, }, + deferProcessor: &deferProcessor{ + disable: opts.disableDefer, + }, } } @@ -140,33 +162,56 @@ func NewProcessor(options ...ProcessorOption) *Processor { func (p *Processor) Process(pre plan.Plan) { switch t := pre.(type) { case *plan.SynchronousResponsePlan: - for i := range p.processResponseTree { - p.processResponseTree[i].Process(t.Response.Data) - } + p.processResponseTree.mergeFields.Process(t.Response.Data) // initialize the fetch tree p.createFetchTree(t.Response) // NOTE: deduplication relies on the fact that the fetch tree // have flat structure of child fetches - p.dedupe.ProcessFetchTree(t.Response.Fetches) + p.processFetchTree.dedupe.ProcessFetchTree(t.Response.Fetches) // Appending fetchIDs makes query content unique, thus it should happen after "dedupe". - p.appendFetchID.ProcessFetchTree(t.Response.Fetches) - p.resolveInputTemplates.ProcessFetchTree(t.Response.Fetches) - for i := range p.processFetchTree { - p.processFetchTree[i].ProcessFetchTree(t.Response.Fetches) + p.processFetchTree.appendFetchID.ProcessFetchTree(t.Response.Fetches) + p.processFetchTree.resolveInputTemplates.ProcessFetchTree(t.Response.Fetches) + p.processFetchTree.addMissingNestedDependencies.ProcessFetchTree(t.Response.Fetches) + p.processFetchTree.createConcreteSingleFetchTypes.ProcessFetchTree(t.Response.Fetches) + p.processFetchTree.orderSequenceByDependencies.ProcessFetchTree(t.Response.Fetches) + p.processFetchTree.createParallelNodes.ProcessFetchTree(t.Response.Fetches) + case *plan.DeferResponsePlan: + p.processResponseTree.mergeFields.Process(t.RawResponse.Data) + p.createFetchTree(t.RawResponse) + p.processFetchTree.dedupe.ProcessFetchTree(t.RawResponse.Fetches) + p.processFetchTree.appendFetchID.ProcessFetchTree(t.RawResponse.Fetches) + p.processFetchTree.resolveInputTemplates.ProcessFetchTree(t.RawResponse.Fetches) + p.processFetchTree.addMissingNestedDependencies.ProcessFetchTree(t.RawResponse.Fetches) + p.processFetchTree.createConcreteSingleFetchTypes.ProcessFetchTree(t.RawResponse.Fetches) + + // extract deferred fetches and fields into their own fetch trees + p.deferProcessor.Process(t) + + // process the initial response fetch tree + p.processFetchTree.orderSequenceByDependencies.ProcessFetchTree(t.InitialResponse.Fetches) + p.processFetchTree.createParallelNodes.ProcessFetchTree(t.InitialResponse.Fetches) + + // process each deferred response fetch tree + for _, deferResp := range t.DeferResponses { + p.processFetchTree.orderSequenceByDependencies.ProcessFetchTree(deferResp.Fetches) + p.processFetchTree.createParallelNodes.ProcessFetchTree(deferResp.Fetches) } + case *plan.SubscriptionResponsePlan: - for i := range p.processResponseTree { - p.processResponseTree[i].ProcessSubscription(t.Response.Response.Data) - } + p.processResponseTree.mergeFields.Process(t.Response.Response.Data) p.createFetchTree(t.Response.Response) p.appendTriggerToFetchTree(t.Response) - p.dedupe.ProcessFetchTree(t.Response.Response.Fetches) - p.appendFetchID.ProcessFetchTree(t.Response.Response.Fetches) - p.resolveInputTemplates.ProcessFetchTree(t.Response.Response.Fetches) - p.resolveInputTemplates.ProcessTrigger(&t.Response.Trigger) - for i := range p.processFetchTree { - p.processFetchTree[i].ProcessFetchTree(t.Response.Response.Fetches) - } + p.processFetchTree.dedupe.ProcessFetchTree(t.Response.Response.Fetches) + // Appending fetchIDs makes query content unique, thus it should happen after "dedupe". + p.processFetchTree.appendFetchID.ProcessFetchTree(t.Response.Response.Fetches) + // resolve input templates for nested fetches + p.processFetchTree.resolveInputTemplates.ProcessFetchTree(t.Response.Response.Fetches) + // resolve input template for the root query in the subscription trigger + p.processFetchTree.resolveInputTemplates.ProcessTrigger(&t.Response.Trigger) + p.processFetchTree.addMissingNestedDependencies.ProcessFetchTree(t.Response.Response.Fetches) + p.processFetchTree.createConcreteSingleFetchTypes.ProcessFetchTree(t.Response.Response.Fetches) + p.processFetchTree.orderSequenceByDependencies.ProcessFetchTree(t.Response.Response.Fetches) + p.processFetchTree.createParallelNodes.ProcessFetchTree(t.Response.Response.Fetches) } } diff --git a/v2/pkg/engine/resolve/response.go b/v2/pkg/engine/resolve/response.go index d8af8d017b..c8eb5e8e2f 100644 --- a/v2/pkg/engine/resolve/response.go +++ b/v2/pkg/engine/resolve/response.go @@ -56,6 +56,16 @@ func (g *GraphQLResponse) SingleFlightAllowed() bool { return false } +type DeferGraphQLResponse struct { + Patches []DeferData + Fetches *FetchTreeNode +} + +type DeferData struct { + Data *Object + Path []string +} + type GraphQLResponseInfo struct { OperationType ast.OperationType } From 06aa764d06cf7d9cb34c99513d6c17adae01d2cf Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 29 Jan 2026 21:59:20 +0200 Subject: [PATCH 17/79] implement defer postprocessor --- .../graphql_datasource_defer_test.go | 262 +++++++++++++++++- .../datasourcetesting/datasourcetesting.go | 23 +- v2/pkg/engine/plan/plan.go | 5 +- v2/pkg/engine/plan/visitor.go | 4 +- v2/pkg/engine/postprocess/defer.go | 46 ++- v2/pkg/engine/postprocess/postprocess.go | 86 +++--- v2/pkg/engine/resolve/response.go | 7 +- 7 files changed, 357 insertions(+), 76 deletions(-) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go index bd2cc4289b..4cad18feb2 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go @@ -5,6 +5,7 @@ import ( . "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasourcetesting" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/postprocess" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) @@ -81,7 +82,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, } - t.Run("defer User.title", func(t *testing.T) { + t.Run("defer User.title - defer postprocess disabled", func(t *testing.T) { RunWithPermutations( t, definition, @@ -95,7 +96,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { } }`, "User", - &plan.SynchronousResponsePlan{ + &plan.DeferResponsePlan{ Response: &resolve.GraphQLResponse{ Fetches: resolve.Sequence( resolve.Single(&resolve.SingleFetch{ @@ -104,12 +105,82 @@ func TestGraphQLDataSourceDefer(t *testing.T) { DeferID: "1", }, FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title}}"}}`, + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {name}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, DataSource: &Source{}, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), }), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("title"), + Defer: &resolve.DeferField{ + DeferID: "1", + }, + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableDefer()), + WithDefer(), + WithCalculateFieldDependencies(), + ) + }) + + t.Run("defer User.title", func(t *testing.T) { + RunWithPermutations( + t, + definition, + ` + query User { + user { + name + ... @defer { + title + } + } + }`, + "User", + &plan.DeferResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( resolve.Single(&resolve.SingleFetch{ FetchDependencies: resolve.FetchDependencies{ FetchID: 1, @@ -155,6 +226,25 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, }, }, + Defers: []*resolve.DeferGraphQLResponse{ + { + DeferID: "1", + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + DeferID: "1", + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + ), + }, + }, }, planConfiguration, WithDefaultPostProcessor(), @@ -283,7 +373,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, } - t.Run("defer User.lastName", func(t *testing.T) { + t.Run("defer User.lastName. defer postprocess disabled", func(t *testing.T) { RunWithPermutations( t, definition, @@ -298,12 +388,12 @@ func TestGraphQLDataSourceDefer(t *testing.T) { } }`, "User", - &plan.SynchronousResponsePlan{ + &plan.DeferResponsePlan{ Response: &resolve.GraphQLResponse{ Fetches: resolve.Sequence( resolve.Single(&resolve.SingleFetch{ FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title __typename id}}"}}`, + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title __typename id}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, DataSource: &Source{}, }, @@ -316,7 +406,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, FetchConfiguration: resolve.FetchConfiguration{ RequiresEntityBatchFetch: false, RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename firstName}}}","variables":{"representations":[$$0$$]}}}`, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename firstName}}}","variables":{"representations":[$$0$$]}}}`, DataSource: &Source{}, SetTemplateOutputToNullOnVariableNull: true, Variables: []resolve.Variable{ @@ -354,7 +444,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, FetchConfiguration: resolve.FetchConfiguration{ RequiresEntityBatchFetch: false, RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename lastName}}}","variables":{"representations":[$$0$$]}}}`, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename lastName}}}","variables":{"representations":[$$0$$]}}}`, DataSource: &Source{}, SetTemplateOutputToNullOnVariableNull: true, Variables: []resolve.Variable{ @@ -426,6 +516,162 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, }, planConfiguration, + WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableDefer()), + WithDefer(), + WithCalculateFieldDependencies(), + ) + }) + + t.Run("defer User.lastName", func(t *testing.T) { + RunWithPermutations( + t, + definition, + ` + query User { + user { + title + firstName + ... @defer { + lastName + } + } + }`, + "User", + &plan.DeferResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title __typename id}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename firstName}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + PostProcessing: SingleEntityPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user", resolve.ObjectPath("user")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("title"), + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + { + Name: []byte("firstName"), + Value: &resolve.String{ + Path: []string{"firstName"}, + }, + }, + { + Name: []byte("lastName"), + Defer: &resolve.DeferField{ + DeferID: "1", + }, + Value: &resolve.String{ + Path: []string{"lastName"}, + }, + }, + }, + }, + }, + }, + }, + }, + Defers: []*resolve.DeferGraphQLResponse{ + { + DeferID: "1", + Fetches: resolve.Sequence( + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + DependsOnFetchIDs: []int{0}, + DeferID: "1", + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename lastName}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + PostProcessing: SingleEntityPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user", resolve.ObjectPath("user")), + ), + }, + }, + }, + planConfiguration, WithDefaultPostProcessor(), WithDefer(), WithCalculateFieldDependencies(), diff --git a/v2/pkg/engine/datasourcetesting/datasourcetesting.go b/v2/pkg/engine/datasourcetesting/datasourcetesting.go index ad6c1311cb..0441acfb40 100644 --- a/v2/pkg/engine/datasourcetesting/datasourcetesting.go +++ b/v2/pkg/engine/datasourcetesting/datasourcetesting.go @@ -28,7 +28,7 @@ import ( ) type testOptions struct { - postProcessors []*postprocess.Processor + postProcessor *postprocess.Processor skipReason string withFieldInfo bool withPrintPlan bool @@ -45,12 +45,6 @@ func WithDefer() func(*testOptions) { } } -func WithPostProcessors(postProcessors ...*postprocess.Processor) func(*testOptions) { - return func(o *testOptions) { - o.postProcessors = postProcessors - } -} - func WithSkipReason(reason string) func(*testOptions) { return func(o *testOptions) { o.skipReason = reason @@ -58,11 +52,16 @@ func WithSkipReason(reason string) func(*testOptions) { } func WithDefaultPostProcessor() func(*testOptions) { - return WithPostProcessors(postprocess.NewProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields())) + return func(o *testOptions) { + o.postProcessor = postprocess.NewProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields()) + } } func WithDefaultCustomPostProcessor(options ...postprocess.ProcessorOption) func(*testOptions) { - return WithPostProcessors(postprocess.NewProcessor(options...)) + // TODO: rename to WithPostProcessor + return func(o *testOptions) { + o.postProcessor = postprocess.NewProcessor(options...) + } } func WithFieldInfo() func(*testOptions) { @@ -251,10 +250,8 @@ func RunTestWithVariables(definition, operation, operationName, variables string t.Fatal(report.Error()) } - if opts.postProcessors != nil { - for _, processor := range opts.postProcessors { - processor.Process(actualPlan) - } + if opts.postProcessor != nil { + opts.postProcessor.Process(actualPlan) } if opts.withPrintPlan { diff --git a/v2/pkg/engine/plan/plan.go b/v2/pkg/engine/plan/plan.go index 2d28a11573..89b2f70e4a 100644 --- a/v2/pkg/engine/plan/plan.go +++ b/v2/pkg/engine/plan/plan.go @@ -64,9 +64,8 @@ func (s *SubscriptionResponsePlan) SetCostCalculator(c *CostCalculator) { } type DeferResponsePlan struct { - RawResponse *resolve.GraphQLResponse - InitialResponse *resolve.GraphQLResponse - DeferResponses []*resolve.DeferGraphQLResponse + Response *resolve.GraphQLResponse + Defers []*resolve.DeferGraphQLResponse FlushInterval int64 } diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 25551e4b92..bcf74dfacf 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -205,7 +205,7 @@ func (v *Visitor) AllowVisitor(kind astvisitor.VisitorKind, ref int, visitor any } shouldWalkFieldsOnPath := - // check if the field path has type condition and matches the enclosing type + // check if the field path has type condition and matches the enclosing type config.ShouldWalkFieldsOnPath(path, enclosingTypeName) || // check if the planner has path without type condition // this could happen in case of union type @@ -1027,7 +1027,7 @@ func (v *Visitor) EnterOperationDefinition(opRef int) { } v.plan = &DeferResponsePlan{ - RawResponse: v.response, + Response: v.response, } default: if !v.Config.DisableIncludeInfo { diff --git a/v2/pkg/engine/postprocess/defer.go b/v2/pkg/engine/postprocess/defer.go index 255d81abeb..4823ed4e01 100644 --- a/v2/pkg/engine/postprocess/defer.go +++ b/v2/pkg/engine/postprocess/defer.go @@ -1,7 +1,11 @@ package postprocess import ( + "maps" + "slices" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) type deferProcessor struct { @@ -9,5 +13,45 @@ type deferProcessor struct { } func (d *deferProcessor) Process(deferPlan *plan.DeferResponsePlan) { + if d.disable { + return + } + + root, fetchGroups := d.fetchGroups(deferPlan) + + deferPlan.Response.Fetches = &resolve.FetchTreeNode{ + Kind: resolve.FetchTreeNodeKindSequence, + ChildNodes: root, + } -} \ No newline at end of file + deferIds := slices.Sorted(maps.Keys(fetchGroups)) + + for _, deferID := range deferIds { + fetches := fetchGroups[deferID] + deferResponse := &resolve.DeferGraphQLResponse{ + DeferID: deferID, + + Fetches: &resolve.FetchTreeNode{ + Kind: resolve.FetchTreeNodeKindSequence, + ChildNodes: fetches, + }, + } + deferPlan.Defers = append(deferPlan.Defers, deferResponse) + } +} + +func (d *deferProcessor) fetchGroups(deferPlan *plan.DeferResponsePlan) (root []*resolve.FetchTreeNode, deffered map[string][]*resolve.FetchTreeNode) { + fetchGroups := make(map[string][]*resolve.FetchTreeNode) + + for _, fetch := range deferPlan.Response.Fetches.ChildNodes { + deferID := fetch.Item.Fetch.Dependencies().DeferID + if deferID == "" { + root = append(root, fetch) + continue + } + + fetchGroups[deferID] = append(fetchGroups[deferID], fetch) + } + + return root, fetchGroups +} diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index 9288ec8a71..327617830c 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -19,11 +19,11 @@ type FetchTreeProcessor interface { // Processor transforms and optimizes the query plan after // it's been created by the planner but before execution. type Processor struct { - disableExtractFetches bool - collectDataSourceInfo bool - processFetchTree *FetchTreeProcessors - processResponseTree *ResponseTreeProcessors - deferProcessor *deferProcessor + disableExtractFetches bool + collectDataSourceInfo bool + fetchTreeProcessors *FetchTreeProcessors + responseTreeProcessors *ResponseTreeProcessors + deferProcessor *deferProcessor } type FetchTreeProcessors struct { @@ -36,6 +36,23 @@ type FetchTreeProcessors struct { createParallelNodes *createParallelNodes } +// processFlatFetchTree - process a flat fetch tree - single serial fetch with flat list of child fetches +func (p *FetchTreeProcessors) processFlatFetchTree(fetches *resolve.FetchTreeNode) { + p.dedupe.ProcessFetchTree(fetches) + // Appending fetchIDs makes query content unique, thus it should happen after "dedupe". + p.appendFetchID.ProcessFetchTree(fetches) + p.resolveInputTemplates.ProcessFetchTree(fetches) + p.addMissingNestedDependencies.ProcessFetchTree(fetches) + p.createConcreteSingleFetchTypes.ProcessFetchTree(fetches) +} + +// organizeFetchTree organizes the fetch tree by ordering sequence nodes by dependencies and creating parallel nodes. +// after this step fetches have tree structure of serial and parallel nodes. +func (p *FetchTreeProcessors) organizeFetchTree(fetches *resolve.FetchTreeNode) { + p.orderSequenceByDependencies.ProcessFetchTree(fetches) + p.createParallelNodes.ProcessFetchTree(fetches) +} + type ResponseTreeProcessors struct { mergeFields *mergeFields } @@ -119,7 +136,7 @@ func NewProcessor(options ...ProcessorOption) *Processor { return &Processor{ collectDataSourceInfo: opts.collectDataSourceInfo, disableExtractFetches: opts.disableExtractFetches, - processFetchTree: &FetchTreeProcessors{ + fetchTreeProcessors: &FetchTreeProcessors{ resolveInputTemplates: &resolveInputTemplates{ disable: opts.disableResolveInputTemplates, }, @@ -144,7 +161,7 @@ func NewProcessor(options ...ProcessorOption) *Processor { disable: opts.disableCreateParallelNodes, }, }, - processResponseTree: &ResponseTreeProcessors{ + responseTreeProcessors: &ResponseTreeProcessors{ mergeFields: &mergeFields{ disable: opts.disableMergeFields, }, @@ -162,56 +179,39 @@ func NewProcessor(options ...ProcessorOption) *Processor { func (p *Processor) Process(pre plan.Plan) { switch t := pre.(type) { case *plan.SynchronousResponsePlan: - p.processResponseTree.mergeFields.Process(t.Response.Data) + p.responseTreeProcessors.mergeFields.Process(t.Response.Data) // initialize the fetch tree p.createFetchTree(t.Response) - // NOTE: deduplication relies on the fact that the fetch tree - // have flat structure of child fetches - p.processFetchTree.dedupe.ProcessFetchTree(t.Response.Fetches) - // Appending fetchIDs makes query content unique, thus it should happen after "dedupe". - p.processFetchTree.appendFetchID.ProcessFetchTree(t.Response.Fetches) - p.processFetchTree.resolveInputTemplates.ProcessFetchTree(t.Response.Fetches) - p.processFetchTree.addMissingNestedDependencies.ProcessFetchTree(t.Response.Fetches) - p.processFetchTree.createConcreteSingleFetchTypes.ProcessFetchTree(t.Response.Fetches) - p.processFetchTree.orderSequenceByDependencies.ProcessFetchTree(t.Response.Fetches) - p.processFetchTree.createParallelNodes.ProcessFetchTree(t.Response.Fetches) + p.fetchTreeProcessors.processFlatFetchTree(t.Response.Fetches) + p.fetchTreeProcessors.organizeFetchTree(t.Response.Fetches) + case *plan.DeferResponsePlan: - p.processResponseTree.mergeFields.Process(t.RawResponse.Data) - p.createFetchTree(t.RawResponse) - p.processFetchTree.dedupe.ProcessFetchTree(t.RawResponse.Fetches) - p.processFetchTree.appendFetchID.ProcessFetchTree(t.RawResponse.Fetches) - p.processFetchTree.resolveInputTemplates.ProcessFetchTree(t.RawResponse.Fetches) - p.processFetchTree.addMissingNestedDependencies.ProcessFetchTree(t.RawResponse.Fetches) - p.processFetchTree.createConcreteSingleFetchTypes.ProcessFetchTree(t.RawResponse.Fetches) - - // extract deferred fetches and fields into their own fetch trees + p.responseTreeProcessors.mergeFields.Process(t.Response.Data) + p.createFetchTree(t.Response) + p.fetchTreeProcessors.processFlatFetchTree(t.Response.Fetches) + + // extract deferred fetches into their own fetch trees p.deferProcessor.Process(t) // process the initial response fetch tree - p.processFetchTree.orderSequenceByDependencies.ProcessFetchTree(t.InitialResponse.Fetches) - p.processFetchTree.createParallelNodes.ProcessFetchTree(t.InitialResponse.Fetches) + p.fetchTreeProcessors.organizeFetchTree(t.Response.Fetches) // process each deferred response fetch tree - for _, deferResp := range t.DeferResponses { - p.processFetchTree.orderSequenceByDependencies.ProcessFetchTree(deferResp.Fetches) - p.processFetchTree.createParallelNodes.ProcessFetchTree(deferResp.Fetches) + for _, deferResp := range t.Defers { + p.fetchTreeProcessors.organizeFetchTree(deferResp.Fetches) } case *plan.SubscriptionResponsePlan: - p.processResponseTree.mergeFields.Process(t.Response.Response.Data) + p.responseTreeProcessors.mergeFields.Process(t.Response.Response.Data) p.createFetchTree(t.Response.Response) p.appendTriggerToFetchTree(t.Response) - p.processFetchTree.dedupe.ProcessFetchTree(t.Response.Response.Fetches) - // Appending fetchIDs makes query content unique, thus it should happen after "dedupe". - p.processFetchTree.appendFetchID.ProcessFetchTree(t.Response.Response.Fetches) - // resolve input templates for nested fetches - p.processFetchTree.resolveInputTemplates.ProcessFetchTree(t.Response.Response.Fetches) + + p.fetchTreeProcessors.processFlatFetchTree(t.Response.Response.Fetches) + // resolve input template for the root query in the subscription trigger - p.processFetchTree.resolveInputTemplates.ProcessTrigger(&t.Response.Trigger) - p.processFetchTree.addMissingNestedDependencies.ProcessFetchTree(t.Response.Response.Fetches) - p.processFetchTree.createConcreteSingleFetchTypes.ProcessFetchTree(t.Response.Response.Fetches) - p.processFetchTree.orderSequenceByDependencies.ProcessFetchTree(t.Response.Response.Fetches) - p.processFetchTree.createParallelNodes.ProcessFetchTree(t.Response.Response.Fetches) + p.fetchTreeProcessors.resolveInputTemplates.ProcessTrigger(&t.Response.Trigger) + + p.fetchTreeProcessors.organizeFetchTree(t.Response.Response.Fetches) } } diff --git a/v2/pkg/engine/resolve/response.go b/v2/pkg/engine/resolve/response.go index c8eb5e8e2f..fe9896115b 100644 --- a/v2/pkg/engine/resolve/response.go +++ b/v2/pkg/engine/resolve/response.go @@ -57,15 +57,10 @@ func (g *GraphQLResponse) SingleFlightAllowed() bool { } type DeferGraphQLResponse struct { - Patches []DeferData + DeferID string Fetches *FetchTreeNode } -type DeferData struct { - Data *Object - Path []string -} - type GraphQLResponseInfo struct { OperationType ast.OperationType } From 4824743357528b744d7a8261483b8b14907d153d Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Feb 2026 13:41:10 +0200 Subject: [PATCH 18/79] cleanup --- .../graphql_datasource_defer_test.go | 4 ++-- v2/pkg/engine/plan/plan.go | 6 +++--- .../{defer.go => extract_defer_fetches.go} | 6 +++--- v2/pkg/engine/postprocess/postprocess.go | 14 +++++++------- 4 files changed, 15 insertions(+), 15 deletions(-) rename v2/pkg/engine/postprocess/{defer.go => extract_defer_fetches.go} (80%) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go index 4cad18feb2..7b79264de9 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go @@ -158,7 +158,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableDefer()), + WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableExtractDeferFetches()), WithDefer(), WithCalculateFieldDependencies(), ) @@ -516,7 +516,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableDefer()), + WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableExtractDeferFetches()), WithDefer(), WithCalculateFieldDependencies(), ) diff --git a/v2/pkg/engine/plan/plan.go b/v2/pkg/engine/plan/plan.go index 89b2f70e4a..a8c22dcd58 100644 --- a/v2/pkg/engine/plan/plan.go +++ b/v2/pkg/engine/plan/plan.go @@ -64,9 +64,9 @@ func (s *SubscriptionResponsePlan) SetCostCalculator(c *CostCalculator) { } type DeferResponsePlan struct { - Response *resolve.GraphQLResponse - Defers []*resolve.DeferGraphQLResponse - FlushInterval int64 + Response *resolve.GraphQLResponse + Defers []*resolve.DeferGraphQLResponse + FlushInterval int64 } func (d DeferResponsePlan) PlanKind() Kind { diff --git a/v2/pkg/engine/postprocess/defer.go b/v2/pkg/engine/postprocess/extract_defer_fetches.go similarity index 80% rename from v2/pkg/engine/postprocess/defer.go rename to v2/pkg/engine/postprocess/extract_defer_fetches.go index 4823ed4e01..e140241cf8 100644 --- a/v2/pkg/engine/postprocess/defer.go +++ b/v2/pkg/engine/postprocess/extract_defer_fetches.go @@ -8,11 +8,11 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) -type deferProcessor struct { +type extractDeferFetches struct { disable bool } -func (d *deferProcessor) Process(deferPlan *plan.DeferResponsePlan) { +func (d *extractDeferFetches) Process(deferPlan *plan.DeferResponsePlan) { if d.disable { return } @@ -40,7 +40,7 @@ func (d *deferProcessor) Process(deferPlan *plan.DeferResponsePlan) { } } -func (d *deferProcessor) fetchGroups(deferPlan *plan.DeferResponsePlan) (root []*resolve.FetchTreeNode, deffered map[string][]*resolve.FetchTreeNode) { +func (d *extractDeferFetches) fetchGroups(deferPlan *plan.DeferResponsePlan) (root []*resolve.FetchTreeNode, deffered map[string][]*resolve.FetchTreeNode) { fetchGroups := make(map[string][]*resolve.FetchTreeNode) for _, fetch := range deferPlan.Response.Fetches.ChildNodes { diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index 327617830c..4fe2f0a3b8 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -23,7 +23,7 @@ type Processor struct { collectDataSourceInfo bool fetchTreeProcessors *FetchTreeProcessors responseTreeProcessors *ResponseTreeProcessors - deferProcessor *deferProcessor + extractDeferFetches *extractDeferFetches } type FetchTreeProcessors struct { @@ -68,7 +68,7 @@ type processorOptions struct { disableCreateParallelNodes bool disableAddMissingNestedDependencies bool collectDataSourceInfo bool - disableDefer bool + disableExtractDeferFetches bool } type ProcessorOption func(*processorOptions) @@ -122,9 +122,9 @@ func DisableAddMissingNestedDependencies() ProcessorOption { } } -func DisableDefer() ProcessorOption { +func DisableExtractDeferFetches() ProcessorOption { return func(o *processorOptions) { - o.disableDefer = true + o.disableExtractDeferFetches = true } } @@ -166,8 +166,8 @@ func NewProcessor(options ...ProcessorOption) *Processor { disable: opts.disableMergeFields, }, }, - deferProcessor: &deferProcessor{ - disable: opts.disableDefer, + extractDeferFetches: &extractDeferFetches{ + disable: opts.disableExtractDeferFetches, }, } } @@ -191,7 +191,7 @@ func (p *Processor) Process(pre plan.Plan) { p.fetchTreeProcessors.processFlatFetchTree(t.Response.Fetches) // extract deferred fetches into their own fetch trees - p.deferProcessor.Process(t) + p.extractDeferFetches.Process(t) // process the initial response fetch tree p.fetchTreeProcessors.organizeFetchTree(t.Response.Fetches) From d2244ec60581534eb9eb58a04a99d840cd54700d Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Feb 2026 14:17:48 +0200 Subject: [PATCH 19/79] restructure --- .../graphql_datasource_defer_test.go | 606 +++++++++--------- v2/pkg/engine/plan/plan.go | 3 +- v2/pkg/engine/plan/visitor.go | 4 +- .../postprocess/extract_defer_fetches.go | 8 +- v2/pkg/engine/postprocess/postprocess.go | 10 +- v2/pkg/engine/resolve/response.go | 7 +- 6 files changed, 326 insertions(+), 312 deletions(-) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go index 7b79264de9..a8278f6578 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go @@ -97,57 +97,59 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }`, "User", &plan.DeferResponsePlan{ - Response: &resolve.GraphQLResponse{ - Fetches: resolve.Sequence( - resolve.Single(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 0, - DeferID: "1", - }, - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title}}"}}`, - PostProcessing: DefaultPostProcessingConfiguration, - DataSource: &Source{}, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - resolve.Single(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - }, - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {name}}"}}`, - PostProcessing: DefaultPostProcessingConfiguration, - DataSource: &Source{}, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - ), - Data: &resolve.Object{ - Fields: []*resolve.Field{ - { - Name: []byte("user"), - Value: &resolve.Object{ - Path: []string{"user"}, - Nullable: false, - PossibleTypes: map[string]struct{}{ - "User": {}, - }, - TypeName: "User", - Fields: []*resolve.Field{ - { - Name: []byte("name"), - Value: &resolve.String{ - Path: []string{"name"}, - }, + Response: &resolve.GraphQLDeferResponse{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + DeferID: "1", + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {name}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "User": {}, }, - { - Name: []byte("title"), - Defer: &resolve.DeferField{ - DeferID: "1", + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + }, }, - Value: &resolve.String{ - Path: []string{"title"}, + { + Name: []byte("title"), + Defer: &resolve.DeferField{ + DeferID: "1", + }, + Value: &resolve.String{ + Path: []string{"title"}, + }, }, }, }, @@ -179,70 +181,72 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }`, "User", &plan.DeferResponsePlan{ - Response: &resolve.GraphQLResponse{ - Fetches: resolve.Sequence( - resolve.Single(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - }, - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {name}}"}}`, - PostProcessing: DefaultPostProcessingConfiguration, - DataSource: &Source{}, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - ), - Data: &resolve.Object{ - Fields: []*resolve.Field{ - { - Name: []byte("user"), - Value: &resolve.Object{ - Path: []string{"user"}, - Nullable: false, - PossibleTypes: map[string]struct{}{ - "User": {}, - }, - TypeName: "User", - Fields: []*resolve.Field{ - { - Name: []byte("name"), - Value: &resolve.String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("title"), - Defer: &resolve.DeferField{ - DeferID: "1", - }, - Value: &resolve.String{ - Path: []string{"title"}, - }, - }, - }, - }, - }, - }, - }, - }, - Defers: []*resolve.DeferGraphQLResponse{ - { - DeferID: "1", + Response: &resolve.GraphQLDeferResponse{ + Response: &resolve.GraphQLResponse{ Fetches: resolve.Sequence( resolve.Single(&resolve.SingleFetch{ FetchDependencies: resolve.FetchDependencies{ - FetchID: 0, - DeferID: "1", + FetchID: 1, }, FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title}}"}}`, + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {name}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, DataSource: &Source{}, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), }), ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("title"), + Defer: &resolve.DeferField{ + DeferID: "1", + }, + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + }, + }, + }, + }, + }, + }, + Defers: []*resolve.DeferFetchGroup{ + { + DeferID: "1", + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + DeferID: "1", + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + ), + }, }, }, }, @@ -389,123 +393,125 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }`, "User", &plan.DeferResponsePlan{ - Response: &resolve.GraphQLResponse{ - Fetches: resolve.Sequence( - resolve.Single(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title __typename id}}"}}`, - PostProcessing: DefaultPostProcessingConfiguration, - DataSource: &Source{}, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - DependsOnFetchIDs: []int{0}, - }, FetchConfiguration: resolve.FetchConfiguration{ - RequiresEntityBatchFetch: false, - RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename firstName}}}","variables":{"representations":[$$0$$]}}}`, - DataSource: &Source{}, - SetTemplateOutputToNullOnVariableNull: true, - Variables: []resolve.Variable{ - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, + Response: &resolve.GraphQLDeferResponse{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title __typename id}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename firstName}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - { - Name: []byte("id"), - Value: &resolve.Scalar{ - Path: []string{"id"}, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, }, - OnTypeNames: [][]byte{[]byte("User")}, }, - }, - }), + }), + }, }, + PostProcessing: SingleEntityPostProcessingConfiguration, }, - PostProcessing: SingleEntityPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, "user", resolve.ObjectPath("user")), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 2, - DependsOnFetchIDs: []int{0}, - DeferID: "1", - }, FetchConfiguration: resolve.FetchConfiguration{ - RequiresEntityBatchFetch: false, - RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename lastName}}}","variables":{"representations":[$$0$$]}}}`, - DataSource: &Source{}, - SetTemplateOutputToNullOnVariableNull: true, - Variables: []resolve.Variable{ - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user", resolve.ObjectPath("user")), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + DependsOnFetchIDs: []int{0}, + DeferID: "1", + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename lastName}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - { - Name: []byte("id"), - Value: &resolve.Scalar{ - Path: []string{"id"}, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, }, - OnTypeNames: [][]byte{[]byte("User")}, }, - }, - }), + }), + }, }, + PostProcessing: SingleEntityPostProcessingConfiguration, }, - PostProcessing: SingleEntityPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, "user", resolve.ObjectPath("user")), - ), - Data: &resolve.Object{ - Fields: []*resolve.Field{ - { - Name: []byte("user"), - Value: &resolve.Object{ - Path: []string{"user"}, - Nullable: false, - PossibleTypes: map[string]struct{}{ - "User": {}, - }, - TypeName: "User", - Fields: []*resolve.Field{ - { - Name: []byte("title"), - Value: &resolve.String{ - Path: []string{"title"}, - }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user", resolve.ObjectPath("user")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "User": {}, }, - { - Name: []byte("firstName"), - Value: &resolve.String{ - Path: []string{"firstName"}, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("title"), + Value: &resolve.String{ + Path: []string{"title"}, + }, }, - }, - { - Name: []byte("lastName"), - Defer: &resolve.DeferField{ - DeferID: "1", + { + Name: []byte("firstName"), + Value: &resolve.String{ + Path: []string{"firstName"}, + }, }, - Value: &resolve.String{ - Path: []string{"lastName"}, + { + Name: []byte("lastName"), + Defer: &resolve.DeferField{ + DeferID: "1", + }, + Value: &resolve.String{ + Path: []string{"lastName"}, + }, }, }, }, @@ -538,106 +544,25 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }`, "User", &plan.DeferResponsePlan{ - Response: &resolve.GraphQLResponse{ - Fetches: resolve.Sequence( - resolve.Single(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title __typename id}}"}}`, - PostProcessing: DefaultPostProcessingConfiguration, - DataSource: &Source{}, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - DependsOnFetchIDs: []int{0}, - }, FetchConfiguration: resolve.FetchConfiguration{ - RequiresEntityBatchFetch: false, - RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename firstName}}}","variables":{"representations":[$$0$$]}}}`, - DataSource: &Source{}, - SetTemplateOutputToNullOnVariableNull: true, - Variables: []resolve.Variable{ - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - { - Name: []byte("id"), - Value: &resolve.Scalar{ - Path: []string{"id"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - }, - }), - }, - }, - PostProcessing: SingleEntityPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, "user", resolve.ObjectPath("user")), - ), - Data: &resolve.Object{ - Fields: []*resolve.Field{ - { - Name: []byte("user"), - Value: &resolve.Object{ - Path: []string{"user"}, - Nullable: false, - PossibleTypes: map[string]struct{}{ - "User": {}, - }, - TypeName: "User", - Fields: []*resolve.Field{ - { - Name: []byte("title"), - Value: &resolve.String{ - Path: []string{"title"}, - }, - }, - { - Name: []byte("firstName"), - Value: &resolve.String{ - Path: []string{"firstName"}, - }, - }, - { - Name: []byte("lastName"), - Defer: &resolve.DeferField{ - DeferID: "1", - }, - Value: &resolve.String{ - Path: []string{"lastName"}, - }, - }, - }, - }, - }, - }, - }, - }, - Defers: []*resolve.DeferGraphQLResponse{ - { - DeferID: "1", + Response: &resolve.GraphQLDeferResponse{ + Response: &resolve.GraphQLResponse{ Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{user {title __typename id}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), resolve.SingleWithPath(&resolve.SingleFetch{ FetchDependencies: resolve.FetchDependencies{ - FetchID: 2, + FetchID: 1, DependsOnFetchIDs: []int{0}, - DeferID: "1", }, FetchConfiguration: resolve.FetchConfiguration{ RequiresEntityBatchFetch: false, RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename lastName}}}","variables":{"representations":[$$0$$]}}}`, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename firstName}}}","variables":{"representations":[$$0$$]}}}`, DataSource: &Source{}, SetTemplateOutputToNullOnVariableNull: true, Variables: []resolve.Variable{ @@ -668,6 +593,89 @@ func TestGraphQLDataSourceDefer(t *testing.T) { DataSourceIdentifier: []byte("graphql_datasource.Source"), }, "user", resolve.ObjectPath("user")), ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: false, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("title"), + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + { + Name: []byte("firstName"), + Value: &resolve.String{ + Path: []string{"firstName"}, + }, + }, + { + Name: []byte("lastName"), + Defer: &resolve.DeferField{ + DeferID: "1", + }, + Value: &resolve.String{ + Path: []string{"lastName"}, + }, + }, + }, + }, + }, + }, + }, + }, + Defers: []*resolve.DeferFetchGroup{ + { + DeferID: "1", + Fetches: resolve.Sequence( + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + DependsOnFetchIDs: []int{0}, + DeferID: "1", + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: false, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename lastName}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + PostProcessing: SingleEntityPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "user", resolve.ObjectPath("user")), + ), + }, }, }, }, diff --git a/v2/pkg/engine/plan/plan.go b/v2/pkg/engine/plan/plan.go index a8c22dcd58..5e204f65d5 100644 --- a/v2/pkg/engine/plan/plan.go +++ b/v2/pkg/engine/plan/plan.go @@ -64,8 +64,7 @@ func (s *SubscriptionResponsePlan) SetCostCalculator(c *CostCalculator) { } type DeferResponsePlan struct { - Response *resolve.GraphQLResponse - Defers []*resolve.DeferGraphQLResponse + Response *resolve.GraphQLDeferResponse FlushInterval int64 } diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index bcf74dfacf..4344bd3107 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -1027,7 +1027,9 @@ func (v *Visitor) EnterOperationDefinition(opRef int) { } v.plan = &DeferResponsePlan{ - Response: v.response, + Response: &resolve.GraphQLDeferResponse{ + Response: v.response, + }, } default: if !v.Config.DisableIncludeInfo { diff --git a/v2/pkg/engine/postprocess/extract_defer_fetches.go b/v2/pkg/engine/postprocess/extract_defer_fetches.go index e140241cf8..3a3a2bcca9 100644 --- a/v2/pkg/engine/postprocess/extract_defer_fetches.go +++ b/v2/pkg/engine/postprocess/extract_defer_fetches.go @@ -19,7 +19,7 @@ func (d *extractDeferFetches) Process(deferPlan *plan.DeferResponsePlan) { root, fetchGroups := d.fetchGroups(deferPlan) - deferPlan.Response.Fetches = &resolve.FetchTreeNode{ + deferPlan.Response.Response.Fetches = &resolve.FetchTreeNode{ Kind: resolve.FetchTreeNodeKindSequence, ChildNodes: root, } @@ -28,7 +28,7 @@ func (d *extractDeferFetches) Process(deferPlan *plan.DeferResponsePlan) { for _, deferID := range deferIds { fetches := fetchGroups[deferID] - deferResponse := &resolve.DeferGraphQLResponse{ + deferResponse := &resolve.DeferFetchGroup{ DeferID: deferID, Fetches: &resolve.FetchTreeNode{ @@ -36,14 +36,14 @@ func (d *extractDeferFetches) Process(deferPlan *plan.DeferResponsePlan) { ChildNodes: fetches, }, } - deferPlan.Defers = append(deferPlan.Defers, deferResponse) + deferPlan.Response.Defers = append(deferPlan.Response.Defers, deferResponse) } } func (d *extractDeferFetches) fetchGroups(deferPlan *plan.DeferResponsePlan) (root []*resolve.FetchTreeNode, deffered map[string][]*resolve.FetchTreeNode) { fetchGroups := make(map[string][]*resolve.FetchTreeNode) - for _, fetch := range deferPlan.Response.Fetches.ChildNodes { + for _, fetch := range deferPlan.Response.Response.Fetches.ChildNodes { deferID := fetch.Item.Fetch.Dependencies().DeferID if deferID == "" { root = append(root, fetch) diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index 4fe2f0a3b8..52830cb9e3 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -186,18 +186,18 @@ func (p *Processor) Process(pre plan.Plan) { p.fetchTreeProcessors.organizeFetchTree(t.Response.Fetches) case *plan.DeferResponsePlan: - p.responseTreeProcessors.mergeFields.Process(t.Response.Data) - p.createFetchTree(t.Response) - p.fetchTreeProcessors.processFlatFetchTree(t.Response.Fetches) + p.responseTreeProcessors.mergeFields.Process(t.Response.Response.Data) + p.createFetchTree(t.Response.Response) + p.fetchTreeProcessors.processFlatFetchTree(t.Response.Response.Fetches) // extract deferred fetches into their own fetch trees p.extractDeferFetches.Process(t) // process the initial response fetch tree - p.fetchTreeProcessors.organizeFetchTree(t.Response.Fetches) + p.fetchTreeProcessors.organizeFetchTree(t.Response.Response.Fetches) // process each deferred response fetch tree - for _, deferResp := range t.Defers { + for _, deferResp := range t.Response.Defers { p.fetchTreeProcessors.organizeFetchTree(deferResp.Fetches) } diff --git a/v2/pkg/engine/resolve/response.go b/v2/pkg/engine/resolve/response.go index fe9896115b..a7b490f9ae 100644 --- a/v2/pkg/engine/resolve/response.go +++ b/v2/pkg/engine/resolve/response.go @@ -56,7 +56,12 @@ func (g *GraphQLResponse) SingleFlightAllowed() bool { return false } -type DeferGraphQLResponse struct { +type GraphQLDeferResponse struct { + Response *GraphQLResponse + Defers []*DeferFetchGroup +} + +type DeferFetchGroup struct { DeferID string Fetches *FetchTreeNode } From 054c0cbc91722403602a081569f6ed513dfd73ed Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Feb 2026 16:02:48 +0200 Subject: [PATCH 20/79] rework handling of internal typename placeholder --- execution/engine/execution_engine_test.go | 78 +++++++++++++++++++- v2/pkg/engine/plan/node_selection_visitor.go | 20 +++-- v2/pkg/engine/plan/visitor.go | 5 -- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 0f7c48ac00..042c95f124 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -1621,7 +1621,7 @@ func TestExecutionEngine_Execute(t *testing.T) { expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"__internal__typename_placeholder":"Query"}}`, + sendResponseBody: `doesn't matter, no fetch will be done, as query typename resolved by engine`, sendStatusCode: 200, }), ), @@ -1659,6 +1659,82 @@ func TestExecutionEngine_Execute(t *testing.T) { expectedResponse: `{"data":{}}`, })) + t.Run("execute operation with all nested fields skipped", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + schema := ` + type Query { + hero(name: String!): Hero! + } + + type Hero { + name: String! + } + ` + parseSchema, err := graphql.NewSchemaFromString(schema) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "MyHero", + Variables: []byte(`{"heroName": "Luke"}`), + Query: `query MyHero($heroName: String!){ + hero(name: $heroName) { + name @skip(if: true) + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Hero"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"hero"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "Hero", FieldNames: []string{"name"}}, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + nil, + `type Query { hero(name: String!): Hero! } type Hero { name: String! }`, + ), + }), + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "hero", + Path: []string{"hero"}, + Arguments: []plan.ArgumentConfiguration{ + { + Name: "name", + SourceType: plan.FieldArgumentSource, + }, + }, + }, + }, + expectedResponse: `{"data":{"hero":{}}}`, + })) + t.Run("execute operation and apply input coercion for lists without variables", runWithoutError(ExecutionEngineTestCase{ schema: graphql.InputCoercionForListSchema(t), operation: func(t *testing.T) graphql.Request { diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index db8403cd3c..d4e5905059 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -1,6 +1,8 @@ package plan import ( + "bytes" + "errors" "fmt" "slices" @@ -53,10 +55,13 @@ type nodeSelectionVisitor struct { newFieldRefs map[int]struct{} // newFieldRefs is a set of field refs which were added by the visitor or was modified by a rewrite } +func (c *nodeSelectionVisitor) addNewSkipFieldRefs(fieldRefs ...int) { + c.addSkipFieldRefs(fieldRefs...) + c.addNewFieldRefs(fieldRefs...) +} + func (c *nodeSelectionVisitor) addSkipFieldRefs(fieldRefs ...int) { c.skipFieldsRefs = append(c.skipFieldsRefs, fieldRefs...) - - c.addNewFieldRefs(fieldRefs...) } func (c *nodeSelectionVisitor) addNewFieldRefs(fieldRefs ...int) { @@ -254,6 +259,11 @@ func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires boo } func (c *nodeSelectionVisitor) LeaveField(ref int) { + if bytes.Equal(c.operation.FieldAliasOrNameBytes(ref), []byte("__internal__typename_placeholder")) { + // we should skip such typename as it was added as a placeholder to keep query valid + // when normalizaion removed all other selections from the selection set + c.addSkipFieldRefs(ref) + } } func (c *nodeSelectionVisitor) handleFieldRequiredByRequires(fieldRef int, parentPath, typeName, fieldName, currentPath string, dsConfig DataSource) { @@ -540,7 +550,7 @@ func (c *nodeSelectionVisitor) addFieldRequirementsToOperation(selectionSetRef i } c.resetVisitedAbstractChecksForModifiedFields(addFieldsResult.modifiedFieldRefs) - c.addSkipFieldRefs(addFieldsResult.skipFieldRefs...) + c.addNewSkipFieldRefs(addFieldsResult.skipFieldRefs...) // add mapping for the field dependencies for _, requestedByFieldRef := range requirements.requestedByFieldRefs { fieldKey := fieldIndexKey{requestedByFieldRef, requirements.dsHash} @@ -630,7 +640,7 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int // op, _ := astprinter.PrintStringIndentDebug(c.operation, " ") // fmt.Println("operation: ", op) - c.addSkipFieldRefs(addFieldsResult.skipFieldRefs...) + c.addNewSkipFieldRefs(addFieldsResult.skipFieldRefs...) // setup deps between key chain items if currentFieldRefs != nil && previousJump != nil { @@ -730,7 +740,7 @@ func (c *nodeSelectionVisitor) rewriteSelectionSetHavingAbstractFragments(fieldR return } - c.addSkipFieldRefs(rewriter.skipFieldRefs...) + c.addNewSkipFieldRefs(rewriter.skipFieldRefs...) c.hasNewFields = true c.rewrittenFieldRefs = append(c.rewrittenFieldRefs, fieldRef) c.persistedRewrittenFieldRefs[fieldRef] = struct{}{} diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 4344bd3107..a95bd58389 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -320,11 +320,6 @@ func (v *Visitor) EnterField(ref int) { fieldName := v.Operation.FieldNameBytes(ref) fieldAliasOrName := v.Operation.FieldAliasOrNameBytes(ref) - if bytes.Equal(fieldAliasOrName, []byte("__internal__typename_placeholder")) { - // we should skip such typename as it was added as a placeholder to keep query valid - return - } - fieldDefinition, ok := v.Walker.FieldDefinition(ref) if !ok { return From 5dc0009d5a7cccecbcfa0a3e52894123d3d70eb8 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Feb 2026 19:25:48 +0200 Subject: [PATCH 21/79] change defer internal definition --- .../astnormalization/astnormalization_test.go | 16 ++--- .../field_deduplication_test.go | 30 +++++----- .../inline_fragment_expand_defer.go | 17 ++++-- .../inline_fragment_expand_defer_test.go | 22 +++---- .../inline_fragment_selection_merging_test.go | 60 +++++++++---------- ...e_selections_from_inline_fragments_test.go | 40 ++++++------- v2/pkg/asttransform/baseschema.go | 14 +---- v2/pkg/asttransform/baseschema_test.go | 18 ++---- v2/pkg/asttransform/defer.graphql | 17 +++--- v2/pkg/asttransform/defer_internal.graphql | 19 ------ .../mutation_only_internal_defer.golden | 2 +- v2/pkg/asttransform/stream.graphql | 9 +++ v2/pkg/lexer/literal/literal.go | 2 +- 13 files changed, 122 insertions(+), 144 deletions(-) delete mode 100644 v2/pkg/asttransform/defer_internal.graphql create mode 100644 v2/pkg/asttransform/stream.graphql diff --git a/v2/pkg/astnormalization/astnormalization_test.go b/v2/pkg/astnormalization/astnormalization_test.go index 60257c9f4b..8d1285f4d7 100644 --- a/v2/pkg/astnormalization/astnormalization_test.go +++ b/v2/pkg/astnormalization/astnormalization_test.go @@ -558,12 +558,12 @@ func TestNormalizeOperation(t *testing.T) { query pet { pet { ... on Dog { - name @defer_internal(id: "1") - nickname @defer_internal(id: "1") - barkVolume @defer_internal(id: "2", parentDeferId: "1") - extra @defer_internal(id: "3") { - noString @defer_internal(id: "3") - string @defer_internal(id: "4") + name @__defer_internal(id: "1") + nickname @__defer_internal(id: "1") + barkVolume @__defer_internal(id: "2", parentDeferId: "1") + extra @__defer_internal(id: "3") { + noString @__defer_internal(id: "3") + string @__defer_internal(id: "4") } } ... on Cat { @@ -571,8 +571,8 @@ func TestNormalizeOperation(t *testing.T) { extra { bool } - meowVolume @defer_internal(id: "5") - nickname @defer_internal(id: "6") + meowVolume @__defer_internal(id: "5") + nickname @__defer_internal(id: "6") } } }`, ``, ``) diff --git a/v2/pkg/astnormalization/field_deduplication_test.go b/v2/pkg/astnormalization/field_deduplication_test.go index 40d5f467ef..4f7613f431 100644 --- a/v2/pkg/astnormalization/field_deduplication_test.go +++ b/v2/pkg/astnormalization/field_deduplication_test.go @@ -41,39 +41,39 @@ func TestDeDuplicateFields(t *testing.T) { query pet { pet { ... on Dog { - name @defer_internal(id: "1") - nickname @defer_internal(id: "2", parentDeferId: "1") - nickname @defer_internal(id: "1") - barkVolume @defer_internal(id: "2", parentDeferId: "1") + name @__defer_internal(id: "1") + nickname @__defer_internal(id: "2", parentDeferId: "1") + nickname @__defer_internal(id: "1") + barkVolume @__defer_internal(id: "2", parentDeferId: "1") } ... on Cat { - name @defer_internal(id: "4") - name @defer_internal(id: "3") + name @__defer_internal(id: "4") + name @__defer_internal(id: "3") name extra { bool - bool @defer_internal(id: "3") + bool @__defer_internal(id: "3") } - meowVolume @defer_internal(id: "4") - meowVolume @defer_internal(id: "3") - nickname @defer_internal(id: "4") + meowVolume @__defer_internal(id: "4") + meowVolume @__defer_internal(id: "3") + nickname @__defer_internal(id: "4") } } }`, ` query pet { pet { ... on Dog { - name @defer_internal(id: "1") - nickname @defer_internal(id: "1") - barkVolume @defer_internal(id: "2", parentDeferId: "1") + name @__defer_internal(id: "1") + nickname @__defer_internal(id: "1") + barkVolume @__defer_internal(id: "2", parentDeferId: "1") } ... on Cat { name extra { bool } - meowVolume @defer_internal(id: "3") - nickname @defer_internal(id: "4") + meowVolume @__defer_internal(id: "3") + nickname @__defer_internal(id: "4") } } }`, runOptions{indent: true}) diff --git a/v2/pkg/astnormalization/inline_fragment_expand_defer.go b/v2/pkg/astnormalization/inline_fragment_expand_defer.go index 705aef3797..3b77f14282 100644 --- a/v2/pkg/astnormalization/inline_fragment_expand_defer.go +++ b/v2/pkg/astnormalization/inline_fragment_expand_defer.go @@ -129,23 +129,30 @@ func (f *inlineFragmentExpandDeferVisitor) EnterSelectionSet(ref int) { } func (f *inlineFragmentExpandDeferVisitor) addInternalDeferDirective(fieldRef int) { - var args []int + var argRefs []int deferInfo := f.defers[len(f.defers)-1] if deferInfo.id != "" { - args = append(args, f.addStringArgument("id", deferInfo.id)) + argRefs = append(argRefs, f.addStringArgument("id", deferInfo.id)) } if deferInfo.parentDeferId != "" { - args = append(args, f.addStringArgument("parentDeferId", deferInfo.parentDeferId)) + argRefs = append(argRefs, f.addStringArgument("parentDeferId", deferInfo.parentDeferId)) } if deferInfo.label != "" { - args = append(args, f.addStringArgument("label", deferInfo.label)) + argRefs = append(argRefs, f.addStringArgument("label", deferInfo.label)) } - directiveRef := f.operation.ImportDirective("defer_internal", args) + directive := ast.Directive{ + Name: f.operation.Input.AppendInputBytes(literal.DEFER_INTERNAL), + HasArguments: len(argRefs) > 0, + Arguments: ast.ArgumentList{ + Refs: argRefs, + }, + } + directiveRef := f.operation.AddDirective(directive) f.operation.AddDirectiveToNode(directiveRef, ast.Node{ Kind: ast.NodeKindField, diff --git a/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go b/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go index e806e46e73..84bcb276c9 100644 --- a/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go +++ b/v2/pkg/astnormalization/inline_fragment_expand_defer_test.go @@ -16,7 +16,7 @@ func TestInlineFragmentExpandDefer(t *testing.T) { query dog { dog { ... { - name @defer_internal(id: "1") + name @__defer_internal(id: "1") } } }`) @@ -55,28 +55,28 @@ func TestInlineFragmentExpandDefer(t *testing.T) { query pet { pet { ... on Dog { - name @defer_internal(id: "1") - nickname @defer_internal(id: "1") + name @__defer_internal(id: "1") + nickname @__defer_internal(id: "1") ... { - barkVolume @defer_internal(id: "2", parentDeferId: "1") + barkVolume @__defer_internal(id: "2", parentDeferId: "1") } } ... on Dog { ... { - extra @defer_internal(id: "3") { - noString @defer_internal(id: "3") + extra @__defer_internal(id: "3") { + noString @__defer_internal(id: "3") } } ... { - extra @defer_internal(id: "4") { - string @defer_internal(id: "4") - noString @defer_internal(id: "4") + extra @__defer_internal(id: "4") { + string @__defer_internal(id: "4") + noString @__defer_internal(id: "4") } } } ... on Cat { - name @defer_internal(id: "5") - meowVolume @defer_internal(id: "5") + name @__defer_internal(id: "5") + meowVolume @__defer_internal(id: "5") } } }`, runOptions{indent: true}) diff --git a/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go b/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go index 016d3bbb1d..18131aeb2b 100644 --- a/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go +++ b/v2/pkg/astnormalization/inline_fragment_selection_merging_test.go @@ -354,18 +354,18 @@ func TestMergeInlineFragmentFieldSelections(t *testing.T) { query pet { pet { ... on Dog { - name @defer_internal(id: "1") - nickname @defer_internal(id: "1") - nickname @defer_internal(id: "2", parentDeferId: "1") - barkVolume @defer_internal(id: "2", parentDeferId: "1") + name @__defer_internal(id: "1") + nickname @__defer_internal(id: "1") + nickname @__defer_internal(id: "2", parentDeferId: "1") + barkVolume @__defer_internal(id: "2", parentDeferId: "1") } ... on Dog { - extra @defer_internal(id: "3") { - noString @defer_internal(id: "3") + extra @__defer_internal(id: "3") { + noString @__defer_internal(id: "3") } - extra @defer_internal(id: "4") { - string @defer_internal(id: "4") - noString @defer_internal(id: "4") + extra @__defer_internal(id: "4") { + string @__defer_internal(id: "4") + noString @__defer_internal(id: "4") } } ... on Cat { @@ -375,16 +375,16 @@ func TestMergeInlineFragmentFieldSelections(t *testing.T) { } } ... on Cat { - name @defer_internal(id: "5") - meowVolume @defer_internal(id: "5") - extra @defer_internal(id: "5") { - bool @defer_internal(id: "5") + name @__defer_internal(id: "5") + meowVolume @__defer_internal(id: "5") + extra @__defer_internal(id: "5") { + bool @__defer_internal(id: "5") } } ... on Cat { - name @defer_internal(id: "6") - nickname @defer_internal(id: "6") - meowVolume @defer_internal(id: "6") + name @__defer_internal(id: "6") + nickname @__defer_internal(id: "6") + meowVolume @__defer_internal(id: "6") } } }`, @@ -392,27 +392,27 @@ func TestMergeInlineFragmentFieldSelections(t *testing.T) { query pet { pet { ... on Dog { - name @defer_internal(id: "1") - nickname @defer_internal(id: "1") - nickname @defer_internal(id: "2", parentDeferId: "1") - barkVolume @defer_internal(id: "2", parentDeferId: "1") - extra @defer_internal(id: "3") { - noString @defer_internal(id: "3") - string @defer_internal(id: "4") - noString @defer_internal(id: "4") + name @__defer_internal(id: "1") + nickname @__defer_internal(id: "1") + nickname @__defer_internal(id: "2", parentDeferId: "1") + barkVolume @__defer_internal(id: "2", parentDeferId: "1") + extra @__defer_internal(id: "3") { + noString @__defer_internal(id: "3") + string @__defer_internal(id: "4") + noString @__defer_internal(id: "4") } } ... on Cat { name extra { bool - bool @defer_internal(id: "5") + bool @__defer_internal(id: "5") } - name @defer_internal(id: "5") - meowVolume @defer_internal(id: "5") - name @defer_internal(id: "6") - nickname @defer_internal(id: "6") - meowVolume @defer_internal(id: "6") + name @__defer_internal(id: "5") + meowVolume @__defer_internal(id: "5") + name @__defer_internal(id: "6") + nickname @__defer_internal(id: "6") + meowVolume @__defer_internal(id: "6") } } }`, runOptions{indent: true}) diff --git a/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go b/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go index 0e887ac518..6dc60eff32 100644 --- a/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go +++ b/v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go @@ -101,28 +101,28 @@ func TestResolveInlineFragments(t *testing.T) { query pet { pet { ... on Dog { - name @defer_internal(id: "1") - nickname @defer_internal(id: "1") + name @__defer_internal(id: "1") + nickname @__defer_internal(id: "1") ... { - barkVolume @defer_internal(id: "2", parentDeferId: "1") + barkVolume @__defer_internal(id: "2", parentDeferId: "1") } } ... on Dog { ... { - extra @defer_internal(id: "3") { - noString @defer_internal(id: "3") + extra @__defer_internal(id: "3") { + noString @__defer_internal(id: "3") } } ... { - extra @defer_internal(id: "4") { - string @defer_internal(id: "4") - noString @defer_internal(id: "4") + extra @__defer_internal(id: "4") { + string @__defer_internal(id: "4") + noString @__defer_internal(id: "4") } } } ... on Cat { - name @defer_internal(id: "5") - meowVolume @defer_internal(id: "5") + name @__defer_internal(id: "5") + meowVolume @__defer_internal(id: "5") } } }`, @@ -130,22 +130,22 @@ func TestResolveInlineFragments(t *testing.T) { query pet { pet { ... on Dog { - name @defer_internal(id: "1") - nickname @defer_internal(id: "1") - barkVolume @defer_internal(id: "2", parentDeferId: "1") + name @__defer_internal(id: "1") + nickname @__defer_internal(id: "1") + barkVolume @__defer_internal(id: "2", parentDeferId: "1") } ... on Dog { - extra @defer_internal(id: "3") { - noString @defer_internal(id: "3") + extra @__defer_internal(id: "3") { + noString @__defer_internal(id: "3") } - extra @defer_internal(id: "4") { - string @defer_internal(id: "4") - noString @defer_internal(id: "4") + extra @__defer_internal(id: "4") { + string @__defer_internal(id: "4") + noString @__defer_internal(id: "4") } } ... on Cat { - name @defer_internal(id: "5") - meowVolume @defer_internal(id: "5") + name @__defer_internal(id: "5") + meowVolume @__defer_internal(id: "5") } } }`) diff --git a/v2/pkg/asttransform/baseschema.go b/v2/pkg/asttransform/baseschema.go index a7aa52a45e..5d09e16e86 100644 --- a/v2/pkg/asttransform/baseschema.go +++ b/v2/pkg/asttransform/baseschema.go @@ -13,11 +13,8 @@ var ( //go:embed base.graphql baseSchema []byte - //go:embed defer_internal.graphql - deferInternal []byte - //go:embed defer.graphql - deferRegular []byte + deferDefinition []byte ) type Options struct { @@ -25,15 +22,8 @@ type Options struct { } func MergeDefinitionWithBaseSchema(definition *ast.Document) error { - return MergeDefinitionWithBaseSchemaWithOptions(definition, Options{}) -} - -func MergeDefinitionWithBaseSchemaWithOptions(definition *ast.Document, options Options) error { definition.Input.AppendInputBytes(baseSchema) - definition.Input.AppendInputBytes(deferRegular) - if options.InternalDefer { - definition.Input.AppendInputBytes(deferInternal) - } + definition.Input.AppendInputBytes(deferDefinition) parser := astparser.NewParser() report := operationreport.Report{} diff --git a/v2/pkg/asttransform/baseschema_test.go b/v2/pkg/asttransform/baseschema_test.go index 2f0baa38ef..552c97c039 100644 --- a/v2/pkg/asttransform/baseschema_test.go +++ b/v2/pkg/asttransform/baseschema_test.go @@ -4,6 +4,8 @@ import ( "bytes" "testing" + "github.com/stretchr/testify/require" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" @@ -17,21 +19,11 @@ func runTestMerge(definition, fixtureName string) func(t *testing.T) { func runTestMergeWithDefer(definition, fixtureName string, internalDefer bool) func(t *testing.T) { return func(t *testing.T) { doc := unsafeparser.ParseGraphqlDocumentString(definition) - var err error - if internalDefer { - err = asttransform.MergeDefinitionWithBaseSchemaWithOptions(&doc, asttransform.Options{InternalDefer: true}) - } else { - err = asttransform.MergeDefinitionWithBaseSchema(&doc) - } - - if err != nil { - panic(err) - } + err := asttransform.MergeDefinitionWithBaseSchema(&doc) + require.NoError(t, err) buf := bytes.Buffer{} err = astprinter.PrintIndent(&doc, []byte(" "), &buf) - if err != nil { - panic(err) - } + require.NoError(t, err) got := buf.Bytes() goldie.Assert(t, fixtureName, got) } diff --git a/v2/pkg/asttransform/defer.graphql b/v2/pkg/asttransform/defer.graphql index e697e35ba7..d3b6e09d5d 100644 --- a/v2/pkg/asttransform/defer.graphql +++ b/v2/pkg/asttransform/defer.graphql @@ -6,12 +6,11 @@ directive @defer( if: Boolean! = true ) on FRAGMENT_SPREAD | INLINE_FRAGMENT -#"Directs the executor to stream this array field when the if argument is true or undefined." -#directive @stream( -# "A unique identifier for the results." -# label: String -# "Controls streaming, usually via a variable." -# if: Boolean! = true -# "The number of results to include in the initial (non-streamed) response." -# initialCount: Int = 0 -#) on FIELD \ No newline at end of file +directive @__defer_internal( + id: String! + parentDeferId: String + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) repeatable on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/defer_internal.graphql b/v2/pkg/asttransform/defer_internal.graphql deleted file mode 100644 index 3e68af9f44..0000000000 --- a/v2/pkg/asttransform/defer_internal.graphql +++ /dev/null @@ -1,19 +0,0 @@ -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer_internal( - id: String! - parentDeferId: String - "A unique identifier for the results." - label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) repeatable on FIELD - -#"Directs the executor to stream this array field when the if argument is true or undefined." -#directive @stream( -# "A unique identifier for the results." -# label: String -# "Controls streaming, usually via a variable." -# if: Boolean! = true -# "The number of results to include in the initial (non-streamed) response." -# initialCount: Int = 0 -#) on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden b/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden index ca38127511..9ac63efe0f 100644 --- a/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden +++ b/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden @@ -217,7 +217,7 @@ enum __TypeKind { } "Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer_internal( +directive @__defer_internal( id: String! "A unique identifier for the results." label: String diff --git a/v2/pkg/asttransform/stream.graphql b/v2/pkg/asttransform/stream.graphql new file mode 100644 index 0000000000..a0aef5f0a0 --- /dev/null +++ b/v2/pkg/asttransform/stream.graphql @@ -0,0 +1,9 @@ +"Directs the executor to stream this array field when the if argument is true or undefined." +directive @stream( + "A unique identifier for the results." + label: String + "Controls streaming, usually via a variable." + if: Boolean! = true + "The number of results to include in the initial (non-streamed) response." + initialCount: Int = 0 +) on FIELD \ No newline at end of file diff --git a/v2/pkg/lexer/literal/literal.go b/v2/pkg/lexer/literal/literal.go index d8aa26fcc4..20a1da9420 100644 --- a/v2/pkg/lexer/literal/literal.go +++ b/v2/pkg/lexer/literal/literal.go @@ -66,7 +66,7 @@ var ( IF = []byte("if") SKIP = []byte("skip") DEFER = []byte("defer") - DEFER_INTERNAL = []byte("defer_internal") + DEFER_INTERNAL = []byte("__defer_internal") LABEL = []byte("label") STREAM = []byte("stream") SCHEMA = []byte("schema") From 548d0156d9afaf2e3bb1dd478c20ffb368475624 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Feb 2026 19:26:02 +0200 Subject: [PATCH 22/79] skip internal directives in introspection --- .../datasourcetesting/datasourcetesting.go | 7 +------ v2/pkg/introspection/generator.go | 3 +++ v2/pkg/introspection/generator_test.go | 16 +++++++--------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/v2/pkg/engine/datasourcetesting/datasourcetesting.go b/v2/pkg/engine/datasourcetesting/datasourcetesting.go index 0441acfb40..f6f170e67d 100644 --- a/v2/pkg/engine/datasourcetesting/datasourcetesting.go +++ b/v2/pkg/engine/datasourcetesting/datasourcetesting.go @@ -198,12 +198,7 @@ func RunTestWithVariables(definition, operation, operationName, variables string op.Input.Variables = []byte(variables) } - transformOptions := asttransform.Options{} - if opts.withDefer { - transformOptions.InternalDefer = true - } - - err := asttransform.MergeDefinitionWithBaseSchemaWithOptions(&def, transformOptions) + err := asttransform.MergeDefinitionWithBaseSchema(&def) if err != nil { t.Fatal(err) } diff --git a/v2/pkg/introspection/generator.go b/v2/pkg/introspection/generator.go index 820483f13f..9c9589e7aa 100644 --- a/v2/pkg/introspection/generator.go +++ b/v2/pkg/introspection/generator.go @@ -329,6 +329,9 @@ func (i *introspectionVisitor) EnterDirectiveDefinition(ref int) { } func (i *introspectionVisitor) LeaveDirectiveDefinition(ref int) { + if strings.HasPrefix(i.currentDirective.Name, "__") { + return + } i.data.Schema.Directives = append(i.data.Schema.Directives, i.currentDirective) } diff --git a/v2/pkg/introspection/generator_test.go b/v2/pkg/introspection/generator_test.go index 690e849a87..8818c63e90 100644 --- a/v2/pkg/introspection/generator_test.go +++ b/v2/pkg/introspection/generator_test.go @@ -6,22 +6,24 @@ import ( "testing" "github.com/jensneuse/diffview" + "github.com/stretchr/testify/require" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" "github.com/wundergraph/graphql-go-tools/v2/pkg/testing/goldie" ) func TestGenerator_Generate(t *testing.T) { starwarsSchemaBytes, err := os.ReadFile("./testdata/starwars.schema.graphql") - if err != nil { - panic(err) - } + require.NoError(t, err) definition, report := astparser.ParseGraphqlDocumentBytes(starwarsSchemaBytes) if report.HasErrors() { t.Fatal(report) } + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&definition)) + gen := NewGenerator() var data Data gen.Generate(&definition, &report, &data) @@ -30,16 +32,12 @@ func TestGenerator_Generate(t *testing.T) { } outputPretty, err := json.MarshalIndent(data, "", " ") - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) goldie.Assert(t, "starwars_introspected", outputPretty) if t.Failed() { fixture, err := os.ReadFile("./fixtures/starwars_introspected.golden") - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) diffview.NewGoland().DiffViewBytes("startwars_introspected", fixture, outputPretty) } From 87505788ddae20ea237e9961811930bf820ca9e5 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Feb 2026 20:54:08 +0200 Subject: [PATCH 23/79] draft: resolving defer response --- execution/engine/execution_engine.go | 4 + execution/engine/execution_engine_test.go | 101 ++++++++++++++++++ .../graphql_datasource/graphql_datasource.go | 5 + v2/pkg/engine/resolve/loader.go | 14 ++- v2/pkg/engine/resolve/resolvable.go | 37 +++++++ v2/pkg/engine/resolve/resolve.go | 65 +++++++++++ v2/pkg/engine/resolve/response.go | 5 + 7 files changed, 227 insertions(+), 4 deletions(-) diff --git a/execution/engine/execution_engine.go b/execution/engine/execution_engine.go index 178a8a5e3c..b24ab28e99 100644 --- a/execution/engine/execution_engine.go +++ b/execution/engine/execution_engine.go @@ -153,6 +153,7 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques astnormalization.WithRemoveFragmentDefinitions(), astnormalization.WithRemoveUnusedVariables(), astnormalization.WithInlineFragmentSpreads(), + astnormalization.WithInlineDefer(), ) if err != nil { return err @@ -243,6 +244,9 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques operation.ComputeActualCost(costCalculator, e.config.plannerConfig, execContext.resolveContext.ActualListSizes) } return nil + case *plan.DeferResponsePlan: + _, err := e.resolver.ResolveGraphQLDeferResponse(execContext.resolveContext, p.Response, writer) + return err case *plan.SubscriptionResponsePlan: return e.resolver.ResolveGraphQLSubscription(execContext.resolveContext, p.Response, writer) default: diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 042c95f124..dcbc8951d9 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -315,6 +315,7 @@ type _executionTestOptions struct { validateRequiredExternalFields bool computeCosts bool relaxFieldSelectionMergingNullability bool + streamingResponse bool } type executionTestOptions func(*_executionTestOptions) @@ -351,6 +352,12 @@ func relaxFieldSelectionMergingNullability() executionTestOptions { } } +func withStreamingResponse() executionTestOptions { + return func(options *_executionTestOptions) { + options.streamingResponse = true + } +} + func TestExecutionEngine_Execute(t *testing.T) { t.Run("apollo router compatibility subrequest HTTP error enabled", runWithoutError( ExecutionEngineTestCase{ @@ -5832,6 +5839,100 @@ func TestExecutionEngine_Execute(t *testing.T) { relaxFieldSelectionMergingNullability(), )) }) + + t.Run("defer", func(t *testing.T) { + t.Run("simple", func(t *testing.T) { + + definition := ` + type User { + id: ID! + name: String! + title: String! + } + + type Query { + user: User! + } + ` + + makeDataSource := func(t *testing.T, expectFetchReasons bool) []plan.DataSource { + return []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"{user {name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black"}}}`, + }, + `{"query":"{user {title}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"title":"Sabbat"}}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"user"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "title", "name"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://first/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: definition, + }, + definition, + ), + }), + ), + } + } + + t.Run("run", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}}}{"name":"Black"}{"data":{{"name":"Black"}}}`, + }, withStreamingResponse())) + }) + }) } func testNetHttpClient(t *testing.T, testCase roundTripperTestCase) *http.Client { diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go index f4268d1f6a..cdeb8474e5 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go @@ -151,6 +151,11 @@ func (p *Planner[T]) EnterDirective(ref int) { } func (p *Planner[T]) addDirectiveToNode(directiveRef int, node ast.Node) { + // do not propagate internal directives to upstream query document + if bytes.Equal(p.visitor.Operation.DirectiveNameBytes(directiveRef), literal.DEFER_INTERNAL) { + return + } + directiveName := p.visitor.Operation.DirectiveNameString(directiveRef) operationType := ast.OperationTypeQuery if !p.dataSourcePlannerConfig.IsNested { diff --git a/v2/pkg/engine/resolve/loader.go b/v2/pkg/engine/resolve/loader.go index 8c6fbed84f..2196ae32c9 100644 --- a/v2/pkg/engine/resolve/loader.go +++ b/v2/pkg/engine/resolve/loader.go @@ -200,14 +200,19 @@ func (l *Loader) Free() { } func (l *Loader) LoadGraphQLResponseData(ctx *Context, response *GraphQLResponse, resolvable *Resolvable) (err error) { + l.Init(ctx, response.Info, resolvable) + + return l.ResolveFetchNode(response.Fetches) +} + +func (l *Loader) Init(ctx *Context, responseInfo *GraphQLResponseInfo, resolvable *Resolvable) { l.resolvable = resolvable l.ctx = ctx - l.info = response.Info + l.info = responseInfo l.taintedObjs = make(taintedObjects) - return l.resolveFetchNode(response.Fetches) } -func (l *Loader) resolveFetchNode(node *FetchTreeNode) error { +func (l *Loader) ResolveFetchNode(node *FetchTreeNode) error { if node == nil { return nil } @@ -274,7 +279,7 @@ func (l *Loader) resolveParallel(nodes []*FetchTreeNode) error { func (l *Loader) resolveSerial(nodes []*FetchTreeNode) error { for i := range nodes { - err := l.resolveFetchNode(nodes[i]) + err := l.ResolveFetchNode(nodes[i]) if err != nil { return errors.WithStack(err) } @@ -567,6 +572,7 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson if responseData.Type() != astjson.TypeObject { return l.renderErrorsFailedToFetch(fetchItem, res, invalidGraphQLResponseShape) } + // TODO: unclear why we doing this l.resolvable.data = responseData return nil } diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 6eb3395327..f272b4b5fd 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -53,6 +53,8 @@ type Resolvable struct { wroteErrors bool wroteData bool skipValueCompletion bool + deferMode bool + deferID string typeNames [][]byte @@ -281,6 +283,10 @@ func (r *Resolvable) printData(root *Object) { r.printBytes(colon) r.printBytes(lBrace) r.print = true + + if r.deferMode && r.deferID != "" { + r.print = false + } _ = r.walkObject(root, r.data) r.print = false r.printBytes(rBrace) @@ -634,6 +640,21 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { } } + hasDeferredFields := false + for i := range obj.Fields { + if obj.Fields[i].Defer != nil && obj.Fields[i].Defer.DeferID == r.deferID { + hasDeferredFields = true + } + } + + if hasDeferredFields { + // enable printing + if !r.print { + r.print = true + defer func() { r.print = false }() + } + } + if r.print && !isRoot { r.printBytes(lBrace) } @@ -644,6 +665,22 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { r.typeNames = r.typeNames[:len(r.typeNames)-1] }() for i := range obj.Fields { + if r.deferMode { + + // for initial response render only fields without a defer id + if r.deferID == "" && obj.Fields[i].Defer != nil { + continue + } + + // skip fields with different defer id in deferred response + if r.deferID != "" && obj.Fields[i].Defer != nil && obj.Fields[i].Defer.DeferID == r.deferID { + continue + } + + // when defer id matches render field - render it + // walk objects to find other defers + } + if obj.Fields[i].ParentOnTypeNames != nil { if r.skipFieldOnParentTypeNames(obj.Fields[i]) { continue diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index f735752ef9..49da8b00c8 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -436,6 +436,71 @@ func (r *Resolver) ArenaResolveGraphQLResponse(ctx *Context, response *GraphQLRe return resp, err } +func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDeferResponse, writer DeferResponseWriter) (*GraphQLResolveInfo, error) { + resp := &GraphQLResolveInfo{} + + start := time.Now() + <-r.maxConcurrency + resp.ResolveAcquireWaitTime = time.Since(start) + defer func() { + r.maxConcurrency <- struct{}{} + }() + + t := newTools(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, nil) + + err := t.resolvable.Init(ctx, nil, response.Response.Info.OperationType) + if err != nil { + return nil, err + } + + if !ctx.ExecutionOptions.SkipLoader { + t.loader.Init(ctx, response.Response.Info, t.resolvable) + + // fetch initial response + if err := t.loader.ResolveFetchNode(response.Response.Fetches); err != nil { + return nil, err + } + + t.resolvable.deferMode = true + t.resolvable.deferID = "" + + // render initial response + err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, response.Response.Fetches, writer) + if err != nil { + return nil, err + } + + err = writer.Flush() + if err != nil { + return nil, err + } + + // fetch deferred responses + + for _, deferGroup := range response.Defers { + if err := t.loader.ResolveFetchNode(deferGroup.Fetches); err != nil { + return nil, err + } + + // render deferred response + t.resolvable.deferID = deferGroup.DeferID + err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, deferGroup.Fetches, writer) + if err != nil { + return nil, err + } + + // flush after each deferred response + + err = writer.Flush() + if err != nil { + return nil, err + } + } + } + + return resp, err +} + type trigger struct { id uint64 cancel context.CancelFunc diff --git a/v2/pkg/engine/resolve/response.go b/v2/pkg/engine/resolve/response.go index a7b490f9ae..08a314b75f 100644 --- a/v2/pkg/engine/resolve/response.go +++ b/v2/pkg/engine/resolve/response.go @@ -78,6 +78,11 @@ type ResponseWriter interface { io.Writer } +type DeferResponseWriter interface { + ResponseWriter + Flush() error +} + type SubscriptionCloseKind struct { WSCode ws.StatusCode Reason string From daffef7cc00686fe22ea858aff1f8111f708f783 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Sun, 15 Feb 2026 19:34:11 +0200 Subject: [PATCH 24/79] ensure typenames, fix test case expectations fix test expectation --- execution/engine/execution_engine_test.go | 222 +++++++++++++++++- v2/pkg/astnormalization/astnormalization.go | 3 +- .../astnormalization/defer_ensure_typename.go | 58 +++++ .../defer_ensure_typename_test.go | 95 ++++++++ .../directive_include_skip.go | 17 +- v2/pkg/engine/resolve/const.go | 3 + v2/pkg/engine/resolve/resolvable.go | 35 --- v2/pkg/engine/resolve/resolve.go | 84 ++++++- 8 files changed, 467 insertions(+), 50 deletions(-) create mode 100644 v2/pkg/astnormalization/defer_ensure_typename.go create mode 100644 v2/pkg/astnormalization/defer_ensure_typename_test.go diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index dcbc8951d9..d330d3b5d9 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -5848,6 +5848,12 @@ func TestExecutionEngine_Execute(t *testing.T) { id: ID! name: String! title: String! + info: Info! + } + + type Info { + email: String! + phone: String! } type Query { @@ -5868,10 +5874,34 @@ func TestExecutionEngine_Execute(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"name":"Black"}}}`, }, + `{"query":"{user {__internal__typename_placeholder: __typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal__typename_placeholder":"User"}}}`, + }, `{"query":"{user {title}}"}`: { statusCode: 200, body: `{"data":{"user":{"title":"Sabbat"}}}`, }, + `{"query":"{user {id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1"}}}`, + }, + `{"query":"{user {title id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"title":"Sabbat","id":"1"}}}`, + }, + `{"query":"{user {name title id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","title":"Sabbat","id":"1"}}}`, + }, + `{"query":"{user {info {email phone}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"email":"black@sabbat","phone":"123"}}}}`, + }, + `{"query":"{user {name info {__internal__typename_placeholder: __typename}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","info":{"__internal__typename_placeholder":"Info"}}}}`, + }, }, }), ), @@ -5885,7 +5915,11 @@ func TestExecutionEngine_Execute(t *testing.T) { ChildNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"id", "title", "name"}, + FieldNames: []string{"id", "title", "name", "info"}, + }, + { + TypeName: "Info", + FieldNames: []string{"email", "phone"}, }, }, }, @@ -5907,7 +5941,121 @@ func TestExecutionEngine_Execute(t *testing.T) { } } - t.Run("run", runWithoutError(ExecutionEngineTestCase{ + t.Run("single deffered field", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("multiple deffered fields", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + id + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("multiple deffered fields - all object fields deferred", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + ... @defer { + name + title + id + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Black","title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("nested defers", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + ... @defer { + id + } + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("parallel defers", runWithoutError(ExecutionEngineTestCase{ schema: func(t *testing.T) *graphql.Schema { t.Helper() parseSchema, err := graphql.NewSchemaFromString(definition) @@ -5924,12 +6072,78 @@ func TestExecutionEngine_Execute(t *testing.T) { ... @defer { title } + ... @defer { + id + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer nested object", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + info { + email + phone + } + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"info":{"email":"black@sabbat","phone":"123"}},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer nested object fields", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + info { + ... @defer { + email + phone + } + } } }`, } }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"name":"Black"}}}{"name":"Black"}{"data":{{"name":"Black"}}}`, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black","info":{}}},"hasNext":true} +{"incremental":[{"data":{"email":"black@sabbat","phone":"123"},"path":["user","info"]}],"hasNext":false} +`, }, withStreamingResponse())) }) }) diff --git a/v2/pkg/astnormalization/astnormalization.go b/v2/pkg/astnormalization/astnormalization.go index 9609c0c6b6..2a474df161 100644 --- a/v2/pkg/astnormalization/astnormalization.go +++ b/v2/pkg/astnormalization/astnormalization.go @@ -251,7 +251,8 @@ func (o *OperationNormalizer) setupOperationWalkers() { } if o.options.inlineDefer { - inlineDefer := astvisitor.NewWalker(8) + inlineDefer := astvisitor.NewWalkerWithID(8, "Inline defer") + deferEnsureTypename(&inlineDefer) inlineFragmentExpandDefer(&inlineDefer) o.operationWalkers = append(o.operationWalkers, walkerStage{ name: "inlineDefer", diff --git a/v2/pkg/astnormalization/defer_ensure_typename.go b/v2/pkg/astnormalization/defer_ensure_typename.go new file mode 100644 index 0000000000..12738b2fde --- /dev/null +++ b/v2/pkg/astnormalization/defer_ensure_typename.go @@ -0,0 +1,58 @@ +package astnormalization + +import ( + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" +) + +// deferEnsureTypename registers a visitor that +// adds internal typename to a selection set of non deferred field +// if all it's fields are deferred +func deferEnsureTypename(walker *astvisitor.Walker) { + visitor := deferEnsureTypenameVisitor{ + Walker: walker, + } + walker.RegisterEnterDocumentVisitor(&visitor) + walker.RegisterEnterSelectionSetVisitor(&visitor) +} + +type deferEnsureTypenameVisitor struct { + *astvisitor.Walker + operation *ast.Document +} + +func (f *deferEnsureTypenameVisitor) EnterDocument(operation, _ *ast.Document) { + f.operation = operation +} + +func (f *deferEnsureTypenameVisitor) EnterSelectionSet(ref int) { + fieldSelectionRefs := f.operation.SelectionSetFieldSelections(ref) + // if there are some fields in the current selection set, nothing to do + if len(fieldSelectionRefs) > 0 { + return + } + + inlineFragmentSelectionsRefs := f.operation.SelectionSetInlineFragmentSelections(ref) + + allFragmentsHasDefer := true + for _, inlineFragmentSelectionRef := range inlineFragmentSelectionsRefs { + fragmentRef := f.operation.Selections[inlineFragmentSelectionRef].Ref + // fragment has directives? + if !f.operation.InlineFragmentHasDirectives(fragmentRef) { + allFragmentsHasDefer = false + break + } + + // has defer directive? + _, exists := f.operation.InlineFragmentDirectiveByName(fragmentRef, literal.DEFER) + if !exists { + allFragmentsHasDefer = false + break + } + } + + if allFragmentsHasDefer { + addInternalTypeNamePlaceholder(f.operation, ref) + } +} diff --git a/v2/pkg/astnormalization/defer_ensure_typename_test.go b/v2/pkg/astnormalization/defer_ensure_typename_test.go new file mode 100644 index 0000000000..5ee748a4d7 --- /dev/null +++ b/v2/pkg/astnormalization/defer_ensure_typename_test.go @@ -0,0 +1,95 @@ +package astnormalization + +import ( + "testing" +) + +func TestDeferEnsureTypename(t *testing.T) { + t.Run("mixed fields and deferred fragments", func(t *testing.T) { + run(t, deferEnsureTypename, testDefinition, ` + { + user { + id + ... @defer { + name + } + } + }`, ` + { + user { + id + ... @defer { + name + } + } + }`) + }) + + t.Run("only deferred fragments", func(t *testing.T) { + run(t, deferEnsureTypename, testDefinition, ` + { + user { + ... @defer { + name + } + ... @defer { + age + } + } + }`, ` + { + user { + ... @defer { + name + } + ... @defer { + age + } + __internal__typename_placeholder: __typename + } + }`) + }) + + t.Run("mixed deferred and non-deferred fragments", func(t *testing.T) { + run(t, deferEnsureTypename, testDefinition, ` + { + user { + ... @defer { + name + } + ... { + age + } + } + }`, ` + { + user { + ... @defer { + name + } + ... { + age + } + } + }`) + }) + + t.Run("deferred fragment with other directives", func(t *testing.T) { + run(t, deferEnsureTypename, testDefinition, ` + { + user { + ... @defer @skip(if: false) { + name + } + } + }`, ` + { + user { + ... @defer @skip(if: false) { + name + } + __internal__typename_placeholder: __typename + } + }`) + }) +} diff --git a/v2/pkg/astnormalization/directive_include_skip.go b/v2/pkg/astnormalization/directive_include_skip.go index f3f716adf9..c2190ce23d 100644 --- a/v2/pkg/astnormalization/directive_include_skip.go +++ b/v2/pkg/astnormalization/directive_include_skip.go @@ -149,23 +149,24 @@ func (d *directiveIncludeSkipVisitor) removeParentNode() { selectionSetRef := grandParent.Ref if d.operation.SelectionSetIsEmpty(selectionSetRef) { - selectionRef, _ := d.typeNameSelection() - d.operation.AddSelectionRefToSelectionSet(selectionSetRef, selectionRef) + addInternalTypeNamePlaceholder(d.operation, selectionSetRef) } } -func (d *directiveIncludeSkipVisitor) typeNameSelection() (selectionRef int, fieldRef int) { - field := d.operation.AddField(ast.Field{ - Name: d.operation.Input.AppendInputString("__typename"), +func addInternalTypeNamePlaceholder(operation *ast.Document, selectionSetRef int) { + field := operation.AddField(ast.Field{ + Name: operation.Input.AppendInputString("__typename"), // We are adding an alias to the __typename field to mark it as internally added // So planner could ignore this field during creation of the response shape Alias: ast.Alias{ IsDefined: true, - Name: d.operation.Input.AppendInputString("__internal__typename_placeholder"), + Name: operation.Input.AppendInputString("__internal__typename_placeholder"), }, }) - return d.operation.AddSelectionToDocument(ast.Selection{ + selectionRef := operation.AddSelectionToDocument(ast.Selection{ Ref: field.Ref, Kind: ast.SelectionKindField, - }), field.Ref + }) + + operation.AddSelectionRefToSelectionSet(selectionSetRef, selectionRef) } diff --git a/v2/pkg/engine/resolve/const.go b/v2/pkg/engine/resolve/const.go index 8702e93a06..808bf771a1 100644 --- a/v2/pkg/engine/resolve/const.go +++ b/v2/pkg/engine/resolve/const.go @@ -31,6 +31,9 @@ var ( literalValueCompletion = []byte("valueCompletion") literalRateLimit = []byte("rateLimit") literalAuthorization = []byte("authorization") + literalIncremental = []byte("incremental") + literalHasNext = []byte("hasNext") + literalNewLine = []byte("\n") emptyArray = []byte("[]") emptyObject = []byte("{}") diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index f272b4b5fd..9c1bcbc574 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -283,10 +283,6 @@ func (r *Resolvable) printData(root *Object) { r.printBytes(colon) r.printBytes(lBrace) r.print = true - - if r.deferMode && r.deferID != "" { - r.print = false - } _ = r.walkObject(root, r.data) r.print = false r.printBytes(rBrace) @@ -640,21 +636,6 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { } } - hasDeferredFields := false - for i := range obj.Fields { - if obj.Fields[i].Defer != nil && obj.Fields[i].Defer.DeferID == r.deferID { - hasDeferredFields = true - } - } - - if hasDeferredFields { - // enable printing - if !r.print { - r.print = true - defer func() { r.print = false }() - } - } - if r.print && !isRoot { r.printBytes(lBrace) } @@ -665,22 +646,6 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { r.typeNames = r.typeNames[:len(r.typeNames)-1] }() for i := range obj.Fields { - if r.deferMode { - - // for initial response render only fields without a defer id - if r.deferID == "" && obj.Fields[i].Defer != nil { - continue - } - - // skip fields with different defer id in deferred response - if r.deferID != "" && obj.Fields[i].Defer != nil && obj.Fields[i].Defer.DeferID == r.deferID { - continue - } - - // when defer id matches render field - render it - // walk objects to find other defers - } - if obj.Fields[i].ParentOnTypeNames != nil { if r.skipFieldOnParentTypeNames(obj.Fields[i]) { continue diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index 49da8b00c8..a40be62367 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -436,6 +436,22 @@ func (r *Resolver) ArenaResolveGraphQLResponse(ctx *Context, response *GraphQLRe return resp, err } +func (r *Resolvable) printHasNext(hasNext bool) { + if r.printErr != nil { + return + } + r.printBytes(comma) + r.printBytes(quote) + r.printBytes(literalHasNext) + r.printBytes(quote) + r.printBytes(colon) + if hasNext { + r.printBytes(literalTrue) + } else { + r.printBytes(literalFalse) + } +} + func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDeferResponse, writer DeferResponseWriter) (*GraphQLResolveInfo, error) { resp := &GraphQLResolveInfo{} @@ -470,6 +486,23 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe return nil, err } + // check if we have any deferred responses + // if yes, we need to print hasNext: true + if len(response.Defers) > 0 { + t.resolvable.printHasNext(true) + } + + // manually close the root object because Resolve doesn't do it in deferMode + _, err = writer.Write(rBrace) + if err != nil { + return nil, err + } + + _, err = writer.Write(literalNewLine) + if err != nil { + return nil, err + } + err = writer.Flush() if err != nil { return nil, err @@ -477,18 +510,65 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe // fetch deferred responses - for _, deferGroup := range response.Defers { + for i, deferGroup := range response.Defers { if err := t.loader.ResolveFetchNode(deferGroup.Fetches); err != nil { return nil, err } - // render deferred response + // render deferred response envelope + // {"incremental": [ ... ]} + _, err = writer.Write(lBrace) + if err != nil { + return nil, err + } + _, err = writer.Write(quote) + if err != nil { + return nil, err + } + _, err = writer.Write(literalIncremental) + if err != nil { + return nil, err + } + _, err = writer.Write(quote) + if err != nil { + return nil, err + } + _, err = writer.Write(colon) + if err != nil { + return nil, err + } + _, err = writer.Write(lBrack) + if err != nil { + return nil, err + } + + // render deferred response items t.resolvable.deferID = deferGroup.DeferID err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, deferGroup.Fetches, writer) if err != nil { return nil, err } + // close incremental array + _, err = writer.Write(rBrack) + if err != nil { + return nil, err + } + + // print hasNext + t.resolvable.printHasNext(i < len(response.Defers)-1) + + // close envelope + _, err = writer.Write(rBrace) + if err != nil { + return nil, err + } + + _, err = writer.Write(literalNewLine) + if err != nil { + return nil, err + } + // flush after each deferred response err = writer.Flush() From ddcb42448f83ca44a923f2835e5ce9fb84bcd18e Mon Sep 17 00:00:00 2001 From: spetrunin Date: Sun, 15 Feb 2026 22:56:58 +0200 Subject: [PATCH 25/79] rename print to enableRender, change usages of r.print to r.render() --- v2/pkg/engine/resolve/resolvable.go | 71 ++++++++++++++++------------- v2/pkg/engine/resolve/resolve.go | 4 ++ 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 9c1bcbc574..6086e4c156 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -37,7 +37,8 @@ type Resolvable struct { astjsonArena arena.Arena parsers []*astjson.Parser - print bool + enableRender bool + enableDeferRender bool out io.Writer printErr error path []fastjsonext.PathElement @@ -98,7 +99,7 @@ func (r *Resolvable) Reset() { r.errors = nil r.valueCompletion = nil r.depth = 0 - r.print = false + r.enableRender = false r.out = nil r.printErr = nil r.path = r.path[:0] @@ -178,7 +179,7 @@ func (r *Resolvable) InitSubscription(ctx *Context, initialData []byte, postProc func (r *Resolvable) ResolveNode(node Node, data *astjson.Value, out io.Writer) error { r.out = out - r.print = false + r.enableRender = false r.printErr = nil r.authorizationError = nil // don't init errors! It will heavily increase memory usage @@ -189,7 +190,7 @@ func (r *Resolvable) ResolveNode(node Node, data *astjson.Value, out io.Writer) return fmt.Errorf("error resolving node") } - r.print = true + r.enableRender = true hasErrors = r.walkNode(node, data) if hasErrors { return fmt.Errorf("error resolving node: %w", r.printErr) @@ -199,7 +200,7 @@ func (r *Resolvable) ResolveNode(node Node, data *astjson.Value, out io.Writer) func (r *Resolvable) Resolve(ctx context.Context, rootData *Object, fetchTree *FetchTreeNode, out io.Writer) error { r.out = out - r.print = false + r.enableRender = false r.printErr = nil r.authorizationError = nil @@ -266,6 +267,14 @@ func (r *Resolvable) err() bool { return true } +func (r *Resolvable) render() bool { + if !r.deferMode { + return r.enableRender + } + + return r.enableRender && r.enableDeferRender +} + func (r *Resolvable) printErrors() { r.printBytes(quote) r.printBytes(literalErrors) @@ -282,9 +291,9 @@ func (r *Resolvable) printData(root *Object) { r.printBytes(quote) r.printBytes(colon) r.printBytes(lBrace) - r.print = true + r.enableRender = true _ = r.walkObject(root, r.data) - r.print = false + r.enableRender = false r.printBytes(rBrace) r.wroteData = true } @@ -611,7 +620,7 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { // when we have a typename field present in a json object, we need to check if the type is valid if _, ok := obj.PossibleTypes[string(typeName)]; !ok { - if !r.print { + if !r.render() { // during pre-walk we need to add an error when the typename do not match a possible type if r.options.ApolloCompatibilityValueCompletionInExtensions { r.addValueCompletion(fmt.Sprintf("Invalid __typename found for object at %s.", r.pathLastElementDescription(obj.TypeName)), errorcodes.InvalidGraphql) @@ -636,7 +645,7 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { } } - if r.print && !isRoot { + if r.render() && !isRoot { r.printBytes(lBrace) } addComma := false @@ -656,7 +665,7 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { continue } } - if !r.print { + if !r.render() { skip := r.authorizeField(value, obj.Fields[i]) if skip { if obj.Fields[i].Value.NodeNullable() { @@ -680,7 +689,7 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { continue } } - if r.print { + if r.render() { if addComma { r.printBytes(comma) } @@ -702,7 +711,7 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { } addComma = true } - if r.print && !isRoot { + if r.render() && !isRoot { r.printBytes(rBrace) } return false @@ -848,7 +857,7 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { r.addError("Array cannot represent non-array value.", arr.Path) return r.err() } - if r.print { + if r.render() { r.printBytes(lBrack) } values := value.GetArray() @@ -861,7 +870,7 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { hasPrintedValue := false for i, arrayValue := range values { skip := false - if r.print && arr.SkipItem != nil { + if r.render() && arr.SkipItem != nil { skip = arr.SkipItem(r.ctx, arrayValue) } @@ -869,7 +878,7 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { continue } - if r.print && i != 0 && hasPrintedValue { + if r.render() && i != 0 && hasPrintedValue { r.printBytes(comma) } @@ -890,7 +899,7 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { return err } } - if r.print { + if r.render() { r.printBytes(rBrack) } return false @@ -908,14 +917,14 @@ func (r *Resolvable) currentFieldPath() string { } func (r *Resolvable) walkNull() bool { - if r.print { + if r.render() { r.printBytes(null) } return false } func (r *Resolvable) walkStaticString(str *StaticString) bool { - if r.print { + if r.render() { r.printBytes(quote) r.printBytes([]byte(str.Value)) r.printBytes(quote) @@ -938,7 +947,7 @@ func (r *Resolvable) walkString(s *String, value *astjson.Value) bool { r.addError(fmt.Sprintf("String cannot represent non-string value: \"%s\"", string(r.marshalBuf)), s.Path) return r.err() } - if r.print { + if r.render() { if s.IsTypeName { content := value.GetStringBytes() for i := range r.renameTypeNames { @@ -984,7 +993,7 @@ func (r *Resolvable) walkBoolean(b *Boolean, value *astjson.Value) bool { r.addError(fmt.Sprintf("Bool cannot represent non-boolean value: \"%s\"", string(r.marshalBuf)), b.Path) return r.err() } - if r.print { + if r.render() { r.renderScalarFieldValue(value, b.Nullable) } return false @@ -1005,7 +1014,7 @@ func (r *Resolvable) walkInteger(i *Integer, value *astjson.Value) bool { r.addError(fmt.Sprintf("Int cannot represent non-integer value: \"%s\"", string(r.marshalBuf)), i.Path) return r.err() } - if r.print { + if r.render() { r.renderScalarFieldValue(value, i.Nullable) } return false @@ -1021,14 +1030,14 @@ func (r *Resolvable) walkFloat(f *Float, value *astjson.Value) bool { r.addNonNullableFieldError(f.Path, parent) return r.err() } - if !r.print { + if !r.render() { if value.Type() != astjson.TypeNumber { r.marshalBuf = value.MarshalTo(r.marshalBuf[:0]) r.addError(fmt.Sprintf("Float cannot represent non-float value: \"%s\"", string(r.marshalBuf)), f.Path) return r.err() } } - if r.print { + if r.render() { if r.options.ApolloCompatibilityTruncateFloatValues { floatValue := value.GetFloat64() if floatValue == float64(int64(floatValue)) { @@ -1051,7 +1060,7 @@ func (r *Resolvable) walkBigInt(b *BigInt, value *astjson.Value) bool { r.addNonNullableFieldError(b.Path, parent) return r.err() } - if r.print { + if r.render() { r.renderScalarFieldValue(value, b.Nullable) } return false @@ -1067,14 +1076,14 @@ func (r *Resolvable) walkScalar(s *Scalar, value *astjson.Value) bool { r.addNonNullableFieldError(s.Path, parent) return r.err() } - if r.print { + if r.render() { r.renderScalarFieldValue(value, s.Nullable) } return false } func (r *Resolvable) walkEmptyObject(_ *EmptyObject) bool { - if r.print { + if r.render() { r.printBytes(lBrace) r.printBytes(rBrace) } @@ -1082,7 +1091,7 @@ func (r *Resolvable) walkEmptyObject(_ *EmptyObject) bool { } func (r *Resolvable) walkEmptyArray(_ *EmptyArray) bool { - if r.print { + if r.render() { r.printBytes(lBrack) r.printBytes(rBrack) } @@ -1105,7 +1114,7 @@ func (r *Resolvable) walkCustom(c *CustomNode, value *astjson.Value) bool { r.addError(err.Error(), c.Path) return r.err() } - if r.print { + if r.render() { r.renderScalarFieldBytes(resolved, c.Nullable) } return false @@ -1190,7 +1199,7 @@ func (r *Resolvable) walkEnum(e *Enum, value *astjson.Value) bool { * To avoid appending an error twice, the appending only happens on the first walk * and not the second walk (which prints the data). */ - if !r.print { + if !r.render() { if r.options.ApolloCompatibilityValueCompletionInExtensions { r.renderInaccessibleEnumValueError(e) } else { @@ -1208,7 +1217,7 @@ func (r *Resolvable) walkEnum(e *Enum, value *astjson.Value) bool { * To avoid appending an error/value completion twice, the appending only happens on the first walk * and not the second walk (which prints the data). */ - if !r.print { + if !r.render() { r.renderInaccessibleEnumValueError(e) } // Inaccessible enum values are always converted to null @@ -1217,7 +1226,7 @@ func (r *Resolvable) walkEnum(e *Enum, value *astjson.Value) bool { } return r.err() } - if r.print { + if r.render() { r.renderEnumValue(value, e.Nullable) } return false diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index a40be62367..cfa77e51f1 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -479,6 +479,8 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe t.resolvable.deferMode = true t.resolvable.deferID = "" + // for initial response we allow render initial structure + t.resolvable.enableDeferRender = true // render initial response err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, response.Response.Fetches, writer) @@ -544,6 +546,8 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe // render deferred response items t.resolvable.deferID = deferGroup.DeferID + // we disable rendering until we hit defferred fields + t.resolvable.enableDeferRender = false err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, deferGroup.Fetches, writer) if err != nil { return nil, err From e347762e41b705fe4df86b596aed452627db7345 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 16 Feb 2026 03:56:18 +0200 Subject: [PATCH 26/79] implement rendering defer response --- execution/engine/execution_engine_test.go | 69 +++++++ .../datasource_filter_node_suggestions.go | 17 +- v2/pkg/engine/resolve/resolvable.go | 195 ++++++++++++++++++ v2/pkg/engine/resolve/resolve.go | 69 +------ 4 files changed, 278 insertions(+), 72 deletions(-) diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index d330d3b5d9..bd550159de 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -5898,6 +5898,14 @@ func TestExecutionEngine_Execute(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"info":{"email":"black@sabbat","phone":"123"}}}}`, }, + `{"query":"{user {info {phone} title}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"phone":"123"},"title":"Sabbat"}}}`, + }, + `{"query":"{user {name info {email}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}}}`, + }, `{"query":"{user {name info {__internal__typename_placeholder: __typename}}}"}`: { statusCode: 200, body: `{"data":{"user":{"name":"Black","info":{"__internal__typename_placeholder":"Info"}}}}`, @@ -5968,6 +5976,34 @@ func TestExecutionEngine_Execute(t *testing.T) { `, }, withStreamingResponse())) + t.Run("single deffered field between regular fields", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + title + ... @defer { + name + } + id + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"title":"Sabbat","id":"1"}},"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + t.Run("multiple deffered fields", runWithoutError(ExecutionEngineTestCase{ schema: func(t *testing.T) *graphql.Schema { t.Helper() @@ -6116,6 +6152,39 @@ func TestExecutionEngine_Execute(t *testing.T) { `, }, withStreamingResponse())) + t.Run("defer nested object with duplicated non defered object", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + info { + email + } + ... @defer { + info { + phone + } + title + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]},{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + t.Run("defer nested object fields", runWithoutError(ExecutionEngineTestCase{ schema: func(t *testing.T) *graphql.Schema { t.Helper() diff --git a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go index c09e34ab8c..7e78497bc3 100644 --- a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go +++ b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go @@ -182,22 +182,31 @@ func (f *NodeSuggestions) propagateDeferParentsUpToRootNode(i int) { continue } + if f.items[parentIdx].deferInfo != nil && f.items[parentIdx].deferInfo.ID == f.items[i].deferInfo.ID { + // if parent item is in the same defer - + // we should not mark it as a defer parent, + // because defer parents are planned twice - in a deffered planner and regular + break + } + if slices.Contains(f.items[parentIdx].deferIDs, f.items[i].deferInfo.ID) { - // no need to update - return + // no need to update already contains this defer id + break } else { parentIdToUpdate = parentIdx } } if parentIdToUpdate == -1 { - // should not happen - return + // could happen if we haven't set it + // because it already contains this defer id + break } parentIndexesToAddDeferID = append(parentIndexesToAddDeferID, parentIdToUpdate) if f.items[parentIdToUpdate].IsRootNode { + // we have found a root node from which we could branch out break } diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 6086e4c156..ba9927f82b 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -68,6 +68,8 @@ type Resolvable struct { // actualListSizes maps the JSON path to the list size in the final response. // Used to compute the actual cost of the operation. actualListSizes map[string]int + + incrementalItemWritten bool } type ResolvableOptions struct { @@ -117,6 +119,10 @@ func (r *Resolvable) Reset() { for k := range r.actualListSizes { delete(r.actualListSizes, k) } + r.deferMode = false + r.deferID = "" + r.enableDeferRender = false + r.incrementalItemWritten = false } func (r *Resolvable) Init(ctx *Context, initialData []byte, operationType ast.OperationType) (err error) { @@ -198,6 +204,27 @@ func (r *Resolvable) ResolveNode(node Node, data *astjson.Value, out io.Writer) return nil } +func (r *Resolvable) renderPath() { + r.printBytes(lBrack) + for i, p := range r.path { + if i > 0 { + r.printBytes(comma) + } + if p.Name != "" { + r.printBytes(quote) + r.printBytes(unsafebytes.StringToBytes(p.Name)) + r.printBytes(quote) + } else { + r.printBytes(unsafebytes.StringToBytes(strconv.Itoa(p.Idx))) + } + } + r.printBytes(rBrack) +} + +func (r *Resolvable) printDeferDelimeter() { + r.printBytes(literalNewLine) +} + func (r *Resolvable) Resolve(ctx context.Context, rootData *Object, fetchTree *FetchTreeNode, out io.Writer) error { r.out = out r.enableRender = false @@ -245,7 +272,70 @@ func (r *Resolvable) Resolve(ctx context.Context, rootData *Object, fetchTree *F r.printBytes(comma) r.printErr = r.printExtensions(ctx, fetchTree) } + + if r.deferMode { + r.printHasNext(true) + } + + r.printBytes(rBrace) + + if r.deferMode { + r.printDeferDelimeter() + } + + return r.printErr +} + +func (r *Resolvable) ResolveDefer(rootData *Object, out io.Writer, hasNext bool) error { + r.out = out + r.printErr = nil + r.authorizationError = nil + + // This method acts as a generator for the incremental response + // It will print the incremental response envelope and then use walkObject to find and render the deferred fields + + // First pass: validate and check for authorization errors + r.enableRender = false + r.deferMode = true + r.enableDeferRender = false + + _ = r.walkObject(rootData, r.data) + if r.authorizationError != nil { + return r.authorizationError + } + + // Second pass: render the incremental response + r.enableRender = true + r.incrementalItemWritten = false + // deferMode stays true + // enableDeferRender starts false, will be toggled in walkObject when match found + + r.printBytes(lBrace) + r.printBytes(quote) + r.printBytes(literalIncremental) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(lBrack) + + _ = r.walkObject(rootData, r.data) + + r.printBytes(rBrack) + + r.printHasNext(hasNext) + + if r.hasErrors() { + r.printBytes(comma) + r.printBytes(quote) + r.printBytes(literalErrors) + r.printBytes(quote) + r.printBytes(colon) + r.printNode(r.errors) + } + r.printBytes(rBrace) + + r.printDeferDelimeter() + return r.printErr } @@ -654,6 +744,79 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { defer func() { r.typeNames = r.typeNames[:len(r.typeNames)-1] }() + + // In Defer Seeking Mode, we first identify and render all matching fields for the current DeferID as a single incremental item. + if r.deferMode && !r.enableDeferRender { + var ( + deferFieldIndices []int + ) + + for k := range obj.Fields { + if obj.Fields[k].Defer == nil || obj.Fields[k].Defer.DeferID != r.deferID { + continue + } + + // Duplicate skip checks to ensure we only include valid fields + if obj.Fields[k].ParentOnTypeNames != nil { + if r.skipFieldOnParentTypeNames(obj.Fields[k]) { + continue + } + } + if obj.Fields[k].OnTypeNames != nil { + if r.skipFieldOnTypeNames(obj.Fields[k]) { + continue + } + } + + deferFieldIndices = append(deferFieldIndices, k) + } + + if len(deferFieldIndices) > 0 && r.enableRender { + if r.incrementalItemWritten { + r.printBytes(comma) + } + + // Render Incremental Item Envelope: {"data":{...},"path":[...]} + r.printBytes(lBrace) + + r.printBytes(quote) + r.printBytes(literalData) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(lBrace) + + for k, fieldIdx := range deferFieldIndices { + if k > 0 { + r.printBytes(comma) + } + + r.enableDeferRender = true + r.printBytes(quote) + r.printBytes(obj.Fields[fieldIdx].Name) + r.printBytes(quote) + r.printBytes(colon) + + r.currentFieldInfo = obj.Fields[fieldIdx].Info + _ = r.walkNode(obj.Fields[fieldIdx].Value, value) + r.enableDeferRender = false + } + + r.printBytes(rBrace) + + r.printBytes(comma) + r.printBytes(quote) + r.printBytes(literalPath) + r.printBytes(quote) + r.printBytes(colon) + r.renderPath() + + r.printBytes(rBrace) + + r.wroteData = true + r.incrementalItemWritten = true + } + } + for i := range obj.Fields { if obj.Fields[i].ParentOnTypeNames != nil { if r.skipFieldOnParentTypeNames(obj.Fields[i]) { @@ -665,6 +828,38 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { continue } } + + // When NOT in defer mode (initial response), skip fields that are deferred. + // They will be handled by the deferred response. + // Also if in deferMode but deferID is empty, it means we are in the initial response of a deferred request. + if obj.Fields[i].Defer != nil { + if !r.deferMode || (r.deferMode && r.deferID == "") { + continue + } + } + + if r.deferMode && !r.enableDeferRender { + // DEFER SEEKING MODE + + // Check if this field matches the current defer ID + isMatch := obj.Fields[i].Defer != nil && obj.Fields[i].Defer.DeferID == r.deferID + + if isMatch { + // Match found - already rendered in pre-scan + continue + } + + // No match - recurse to find nested defers + // We only need to recurse if the node is an Object or Array, as Scalars cannot have nested defers. + // Recursing into Scalars would trigger "non-nullable field returned null" error in handleNodeNotRendered because we are not rendering them. + kind := obj.Fields[i].Value.NodeKind() + if kind == NodeKindObject || kind == NodeKindArray { + r.currentFieldInfo = obj.Fields[i].Info + _ = r.walkNode(obj.Fields[i].Value, value) + } + continue + } + if !r.render() { skip := r.authorizeField(value, obj.Fields[i]) if skip { diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index cfa77e51f1..d0ebd8d3e7 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -479,7 +479,6 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe t.resolvable.deferMode = true t.resolvable.deferID = "" - // for initial response we allow render initial structure t.resolvable.enableDeferRender = true // render initial response @@ -488,23 +487,6 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe return nil, err } - // check if we have any deferred responses - // if yes, we need to print hasNext: true - if len(response.Defers) > 0 { - t.resolvable.printHasNext(true) - } - - // manually close the root object because Resolve doesn't do it in deferMode - _, err = writer.Write(rBrace) - if err != nil { - return nil, err - } - - _, err = writer.Write(literalNewLine) - if err != nil { - return nil, err - } - err = writer.Flush() if err != nil { return nil, err @@ -517,58 +499,9 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe return nil, err } - // render deferred response envelope - // {"incremental": [ ... ]} - _, err = writer.Write(lBrace) - if err != nil { - return nil, err - } - _, err = writer.Write(quote) - if err != nil { - return nil, err - } - _, err = writer.Write(literalIncremental) - if err != nil { - return nil, err - } - _, err = writer.Write(quote) - if err != nil { - return nil, err - } - _, err = writer.Write(colon) - if err != nil { - return nil, err - } - _, err = writer.Write(lBrack) - if err != nil { - return nil, err - } - - // render deferred response items t.resolvable.deferID = deferGroup.DeferID - // we disable rendering until we hit defferred fields - t.resolvable.enableDeferRender = false - err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, deferGroup.Fetches, writer) - if err != nil { - return nil, err - } - - // close incremental array - _, err = writer.Write(rBrack) - if err != nil { - return nil, err - } - - // print hasNext - t.resolvable.printHasNext(i < len(response.Defers)-1) - - // close envelope - _, err = writer.Write(rBrace) - if err != nil { - return nil, err - } - _, err = writer.Write(literalNewLine) + err = t.resolvable.ResolveDefer(response.Response.Data, writer, i < len(response.Defers)-1) if err != nil { return nil, err } From 5ebde37f0856b71916c19be6dac7cef36eaa80d4 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 17 Feb 2026 20:47:31 +0200 Subject: [PATCH 27/79] after rebase fixes move defer tests into its own file --- .../engine/execution_engine_defer_test.go | 346 ++++++++++++++++ execution/engine/execution_engine_test.go | 392 +----------------- v2/pkg/engine/plan/node_selection_visitor.go | 1 - v2/pkg/engine/plan/plan.go | 17 +- v2/pkg/engine/plan/visitor.go | 1 - v2/pkg/engine/resolve/resolvable.go | 2 +- 6 files changed, 374 insertions(+), 385 deletions(-) create mode 100644 execution/engine/execution_engine_defer_test.go diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go new file mode 100644 index 0000000000..5b58064dd7 --- /dev/null +++ b/execution/engine/execution_engine_defer_test.go @@ -0,0 +1,346 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/execution/graphql" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" +) + +func TestExecutionEngine_Execute_Defer(t *testing.T) { + t.Run("simple - defer on non entity field", func(t *testing.T) { + + definition := ` + type User { + id: ID! + name: String! + title: String! + info: Info! + } + + type Info { + email: String! + phone: String! + } + + type Query { + user: User! + } + ` + + schema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + + makeDataSource := func(t *testing.T, expectFetchReasons bool) []plan.DataSource { + return []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"{user {name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black"}}}`, + }, + `{"query":"{user {__internal__typename_placeholder: __typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal__typename_placeholder":"User"}}}`, + }, + `{"query":"{user {title}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"title":"Sabbat"}}}`, + }, + `{"query":"{user {id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1"}}}`, + }, + `{"query":"{user {title id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"title":"Sabbat","id":"1"}}}`, + }, + `{"query":"{user {name title id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","title":"Sabbat","id":"1"}}}`, + }, + `{"query":"{user {info {email phone}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"email":"black@sabbat","phone":"123"}}}}`, + }, + `{"query":"{user {info {phone} title}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"phone":"123"},"title":"Sabbat"}}}`, + }, + `{"query":"{user {name info {email}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {name info {__internal__typename_placeholder: __typename}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","info":{"__internal__typename_placeholder":"Info"}}}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"user"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "title", "name", "info"}, + }, + { + TypeName: "Info", + FieldNames: []string{"email", "phone"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://first/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: definition, + }, + definition, + ), + }), + ), + } + } + + t.Run("single deffered field", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("single deffered field between regular fields", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + title + ... @defer { + name + } + id + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"title":"Sabbat","id":"1"}},"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("multiple deffered fields", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + id + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("multiple deffered fields - all object fields deferred", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + ... @defer { + name + title + id + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Black","title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("nested defers", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + ... @defer { + id + } + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("parallel defers", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + } + ... @defer { + id + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer nested object", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + info { + email + phone + } + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"info":{"email":"black@sabbat","phone":"123"}},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer nested object with duplicated non defered object", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + info { + email + } + ... @defer { + info { + phone + } + title + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]},{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer nested object fields", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + info { + ... @defer { + email + phone + } + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{"name":"Black","info":{}}},"hasNext":true} +{"incremental":[{"data":{"email":"black@sabbat","phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) +} diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index bd550159de..5e715e7027 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -106,6 +106,14 @@ func runExecutionTest(testCase ExecutionEngineTestCase, withError bool, expected operation := testCase.operation(t) resultWriter := graphql.NewEngineResultWriter() + + streamingBuf := bytes.NewBuffer(nil) + if opts.streamingResponse { + resultWriter.SetFlushCallback(func(data []byte) { + streamingBuf.Write(data) + }) + } + execCtx, execCtxCancel := context.WithCancel(context.Background()) defer execCtxCancel() err = engine.Execute(execCtx, &operation, &resultWriter, testCase.engineOptions...) @@ -137,7 +145,12 @@ func runExecutionTest(testCase ExecutionEngineTestCase, withError bool, expected } if testCase.expectedResponse != "" { - assert.Equal(t, testCase.expectedResponse, actualResponse) + if opts.streamingResponse { + streamingResponse := streamingBuf.String() + assert.Equal(t, testCase.expectedResponse, streamingResponse) + } else { + assert.Equal(t, testCase.expectedResponse, actualResponse) + } } if testCase.expectedEstimatedCost != 0 { @@ -5839,383 +5852,6 @@ func TestExecutionEngine_Execute(t *testing.T) { relaxFieldSelectionMergingNullability(), )) }) - - t.Run("defer", func(t *testing.T) { - t.Run("simple", func(t *testing.T) { - - definition := ` - type User { - id: ID! - name: String! - title: String! - info: Info! - } - - type Info { - email: String! - phone: String! - } - - type Query { - user: User! - } - ` - - makeDataSource := func(t *testing.T, expectFetchReasons bool) []plan.DataSource { - return []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, - "id-1", - mustFactory(t, - testConditionalNetHttpClient(t, conditionalTestCase{ - expectedHost: "first", - expectedPath: "/", - responses: map[string]sendResponse{ - `{"query":"{user {name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Black"}}}`, - }, - `{"query":"{user {__internal__typename_placeholder: __typename}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__internal__typename_placeholder":"User"}}}`, - }, - `{"query":"{user {title}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"title":"Sabbat"}}}`, - }, - `{"query":"{user {id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"id":"1"}}}`, - }, - `{"query":"{user {title id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"title":"Sabbat","id":"1"}}}`, - }, - `{"query":"{user {name title id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Black","title":"Sabbat","id":"1"}}}`, - }, - `{"query":"{user {info {email phone}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"email":"black@sabbat","phone":"123"}}}}`, - }, - `{"query":"{user {info {phone} title}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"phone":"123"},"title":"Sabbat"}}}`, - }, - `{"query":"{user {name info {email}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}}}`, - }, - `{"query":"{user {name info {__internal__typename_placeholder: __typename}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Black","info":{"__internal__typename_placeholder":"Info"}}}}`, - }, - }, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"user"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "title", "name", "info"}, - }, - { - TypeName: "Info", - FieldNames: []string{"email", "phone"}, - }, - }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://first/", - Method: "POST", - }, - SchemaConfiguration: mustSchemaConfig( - t, - &graphql_datasource.FederationConfiguration{ - Enabled: true, - ServiceSDL: definition, - }, - definition, - ), - }), - ), - } - } - - t.Run("single deffered field", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - title - } - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("single deffered field between regular fields", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - title - ... @defer { - name - } - id - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"title":"Sabbat","id":"1"}},"hasNext":true} -{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("multiple deffered fields", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - title - id - } - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("multiple deffered fields - all object fields deferred", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - ... @defer { - name - title - id - } - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{}},"hasNext":true} -{"incremental":[{"data":{"name":"Black","title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("nested defers", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - title - ... @defer { - id - } - } - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("parallel defers", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - title - } - ... @defer { - id - } - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("defer nested object", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - info { - email - phone - } - } - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"info":{"email":"black@sabbat","phone":"123"}},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("defer nested object with duplicated non defered object", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - info { - email - } - ... @defer { - info { - phone - } - title - } - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]},{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("defer nested object fields", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - parseSchema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - info { - ... @defer { - email - phone - } - } - } - }`, - } - }, - dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{"name":"Black","info":{}}},"hasNext":true} -{"incremental":[{"data":{"email":"black@sabbat","phone":"123"},"path":["user","info"]}],"hasNext":false} -`, - }, withStreamingResponse())) - }) - }) } func testNetHttpClient(t *testing.T, testCase roundTripperTestCase) *http.Client { diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index d4e5905059..3e3e95a25a 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -2,7 +2,6 @@ package plan import ( "bytes" - "errors" "fmt" "slices" diff --git a/v2/pkg/engine/plan/plan.go b/v2/pkg/engine/plan/plan.go index 5e204f65d5..18763d04b1 100644 --- a/v2/pkg/engine/plan/plan.go +++ b/v2/pkg/engine/plan/plan.go @@ -64,14 +64,23 @@ func (s *SubscriptionResponsePlan) SetCostCalculator(c *CostCalculator) { } type DeferResponsePlan struct { - Response *resolve.GraphQLDeferResponse - FlushInterval int64 + Response *resolve.GraphQLDeferResponse + FlushInterval int64 + CostCalculator *CostCalculator } -func (d DeferResponsePlan) PlanKind() Kind { +func (d *DeferResponsePlan) PlanKind() Kind { return DeferResponsePlanKind } -func (d DeferResponsePlan) SetFlushInterval(interval int64) { +func (d *DeferResponsePlan) SetFlushInterval(interval int64) { d.FlushInterval = interval } + +func (d *DeferResponsePlan) GetCostCalculator() *CostCalculator { + return d.CostCalculator +} + +func (d *DeferResponsePlan) SetCostCalculator(c *CostCalculator) { + d.CostCalculator = c +} diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index a95bd58389..0bf7bd4c35 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -73,7 +73,6 @@ func NewVisitor(w *astvisitor.Walker) *Visitor { Walker: w, fieldConfigs: map[int]*FieldConfiguration{}, exportedVariables: map[string]struct{}{}, - skipIncludeOnFragments: map[int]skipIncludeInfo{}, indirectInterfaceFields: map[int]indirectInterfaceField{}, pathCache: map[astvisitor.VisitorKind]map[int]string{}, plannerFields: map[int][]int{}, diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index ba9927f82b..6f7d383173 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -1057,7 +1057,7 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { } values := value.GetArray() - if !r.print { + if !r.render() { pathKey := r.currentFieldPath() r.actualListSizes[pathKey] += len(values) } From b4df227f0a4cf62c09f3e89af296e04b79a1611a Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Mar 2026 19:52:45 +0200 Subject: [PATCH 28/79] chore: improvements --- v2/pkg/engine/resolve/resolvable.go | 2 +- v2/pkg/engine/resolve/resolve.go | 16 +++++++++++++--- v2/pkg/engine/resolve/response.go | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 6f7d383173..44bbefc59c 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -273,7 +273,7 @@ func (r *Resolvable) Resolve(ctx context.Context, rootData *Object, fetchTree *F r.printErr = r.printExtensions(ctx, fetchTree) } - if r.deferMode { + if r.deferMode && !r.hasErrors() { r.printHasNext(true) } diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index d0ebd8d3e7..894546b185 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -453,11 +453,11 @@ func (r *Resolvable) printHasNext(hasNext bool) { } func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDeferResponse, writer DeferResponseWriter) (*GraphQLResolveInfo, error) { - resp := &GraphQLResolveInfo{} + resolveInfo := &GraphQLResolveInfo{} start := time.Now() <-r.maxConcurrency - resp.ResolveAcquireWaitTime = time.Since(start) + resolveInfo.ResolveAcquireWaitTime = time.Since(start) defer func() { r.maxConcurrency <- struct{}{} }() @@ -492,6 +492,10 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe return nil, err } + if t.resolvable.hasErrors() { + return resolveInfo, nil + } + // fetch deferred responses for i, deferGroup := range response.Defers { @@ -512,10 +516,16 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe if err != nil { return nil, err } + + if t.resolvable.hasErrors() { + return resolveInfo, nil + } } + + writer.Complete() } - return resp, err + return resolveInfo, err } type trigger struct { diff --git a/v2/pkg/engine/resolve/response.go b/v2/pkg/engine/resolve/response.go index 08a314b75f..dd90a2af53 100644 --- a/v2/pkg/engine/resolve/response.go +++ b/v2/pkg/engine/resolve/response.go @@ -81,6 +81,7 @@ type ResponseWriter interface { type DeferResponseWriter interface { ResponseWriter Flush() error + Complete() } type SubscriptionCloseKind struct { From 186780e6caaebc7ac04845365d519e1f8c1d401f Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Mar 2026 19:53:56 +0200 Subject: [PATCH 29/79] fix defer planning logic --- v2/pkg/engine/plan/path_builder_visitor.go | 30 ++++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 5bf05393ca..7a752918d9 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -548,29 +548,31 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { field.ds = ds field.suggestion = suggestion - switch { - case len(suggestion.deferIDs) > 0: - for _, deferID := range suggestion.deferIDs { + // the field was deffered, but it also could be a parent path for some other defer + hasDeferInfo := suggestion.DeferInfo != nil + // the field may be not deferred, but it is a parent for the child node which was deferred + isDeferParent := len(suggestion.DeferIDs) > 0 + + // plan defer parent paths + if isDeferParent { + for _, deferID := range suggestion.DeferIDs { field.deferID = deferID field.deferField = false // defer parent path planning - should be planned as a deferred path c.handlePlanningField(field) } - // and as a normal path - - // NOTE: when all child fields was deferred - we should not plan normal path? - // where to detect it? + } - field.deferID = "" - field.deferField = false - c.handlePlanningField(field) - case suggestion.deferInfo != nil: - field.deferID = suggestion.deferInfo.ID + // plan deferred field + if hasDeferInfo { + field.deferID = suggestion.DeferInfo.ID field.deferField = true // should be planned only as a deferred path c.handlePlanningField(field) - default: - // normal field planning + } + + // normal field planning is handled if the field itself is not deferred + if !hasDeferInfo { field.deferID = "" field.deferField = false c.handlePlanningField(field) From 8ff579e2c81ae87e2bf3f56d73881ade5f53da97 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Mar 2026 19:55:03 +0200 Subject: [PATCH 30/79] add more test cases --- .../engine/execution_engine_defer_test.go | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 5b58064dd7..39d49d9078 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -83,6 +83,18 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"name":"Black","info":{"__internal__typename_placeholder":"Info"}}}}`, }, + `{"query":"{user {info {__internal__typename_placeholder: __typename}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"__internal__typename_placeholder":"Info"}}}}`, + }, + `{"query":"{user {info {email}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {info {phone}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"phone":"123"}}}}`, + }, }, }), ), @@ -340,6 +352,84 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { dataSources: makeDataSource(t, false), expectedResponse: `{"data":{"user":{"name":"Black","info":{}}},"hasNext":true} {"incremental":[{"data":{"email":"black@sabbat","phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("extensive parallel defers across all possible fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferEverythingParallel", + Query: ` + query DeferEverythingParallel { + ... @defer { + user { + ... @defer { id } + ... @defer { name } + ... @defer { title } + ... @defer { + info { + ... @defer { email } + ... @defer { phone } + } + } + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"info":{}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"email":"black@sabbat"},"path":["user","info"]}],"hasNext":true} +{"incremental":[{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("extensive fully nested defers across all possible fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferEverythingNested", + Query: ` + query DeferEverythingNested { + ... @defer { + user { + ... @defer { + id + ... @defer { + name + ... @defer { + title + ... @defer { + info { + ... @defer { + email + ... @defer { + phone + } + } + } + } + } + } + } + } + } + }`, + } + }, + dataSources: makeDataSource(t, false), + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"info":{}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"email":"black@sabbat"},"path":["user","info"]}],"hasNext":true} +{"incremental":[{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} `, }, withStreamingResponse())) }) From efa0814193962140604c23e7b80971ccc38be568 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 2 Mar 2026 20:04:56 +0200 Subject: [PATCH 31/79] add todo; fix linter --- v2/pkg/astnormalization/defer_ensure_typename.go | 8 ++++++++ v2/pkg/astnormalization/inline_fragment_expand_defer.go | 1 + v2/pkg/engine/plan/visitor.go | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/v2/pkg/astnormalization/defer_ensure_typename.go b/v2/pkg/astnormalization/defer_ensure_typename.go index 12738b2fde..5284390e1a 100644 --- a/v2/pkg/astnormalization/defer_ensure_typename.go +++ b/v2/pkg/astnormalization/defer_ensure_typename.go @@ -19,6 +19,7 @@ func deferEnsureTypename(walker *astvisitor.Walker) { type deferEnsureTypenameVisitor struct { *astvisitor.Walker + operation *ast.Document } @@ -52,6 +53,13 @@ func (f *deferEnsureTypenameVisitor) EnterSelectionSet(ref int) { } } + // TODO: need more checks + // we don't have to do it if: + // if we have an intersection between parent defer ids and field defer ids + + // if we under deferred path + // field should also have defer id from parent + if allFragmentsHasDefer { addInternalTypeNamePlaceholder(f.operation, ref) } diff --git a/v2/pkg/astnormalization/inline_fragment_expand_defer.go b/v2/pkg/astnormalization/inline_fragment_expand_defer.go index 3b77f14282..fe52b301ec 100644 --- a/v2/pkg/astnormalization/inline_fragment_expand_defer.go +++ b/v2/pkg/astnormalization/inline_fragment_expand_defer.go @@ -21,6 +21,7 @@ func inlineFragmentExpandDefer(walker *astvisitor.Walker) { type inlineFragmentExpandDeferVisitor struct { *astvisitor.Walker + operation *ast.Document defers []deferInfo currentDeferId int diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 0bf7bd4c35..8f5ac44c99 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -204,7 +204,7 @@ func (v *Visitor) AllowVisitor(kind astvisitor.VisitorKind, ref int, visitor any } shouldWalkFieldsOnPath := - // check if the field path has type condition and matches the enclosing type + // check if the field path has type condition and matches the enclosing type config.ShouldWalkFieldsOnPath(path, enclosingTypeName) || // check if the planner has path without type condition // this could happen in case of union type @@ -597,7 +597,7 @@ func (v *Visitor) assignDefer(fieldRef int) { currentField := v.currentFields[len(v.currentFields)-1] // ignore existence check - we should always have planners for the field - plannerIds, _ := v.fieldPlanners[fieldRef] + plannerIds := v.fieldPlanners[fieldRef] for _, plannerId := range plannerIds { planner := v.planners[plannerId] From 4cc28b6f4506df03255fd24298f787d05215ab1c Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 3 Mar 2026 04:05:21 +0200 Subject: [PATCH 32/79] rework defer rendering --- .../engine/execution_engine_defer_test.go | 6 +- v2/pkg/engine/resolve/const.go | 1 - v2/pkg/engine/resolve/resolvable.go | 326 ++++++++++++------ v2/pkg/engine/resolve/resolve.go | 17 - 4 files changed, 215 insertions(+), 135 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 39d49d9078..1ae46d8a90 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -379,7 +379,8 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { } }, dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{}},"hasNext":true} + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"user":{}},"path":[]}],"hasNext":true} {"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} @@ -423,7 +424,8 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { } }, dataSources: makeDataSource(t, false), - expectedResponse: `{"data":{"user":{}},"hasNext":true} + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"user":{}},"path":[]}],"hasNext":true} {"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} diff --git a/v2/pkg/engine/resolve/const.go b/v2/pkg/engine/resolve/const.go index 808bf771a1..8fe77c1aa1 100644 --- a/v2/pkg/engine/resolve/const.go +++ b/v2/pkg/engine/resolve/const.go @@ -33,7 +33,6 @@ var ( literalAuthorization = []byte("authorization") literalIncremental = []byte("incremental") literalHasNext = []byte("hasNext") - literalNewLine = []byte("\n") emptyArray = []byte("[]") emptyObject = []byte("{}") diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 44bbefc59c..fe7d55148f 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -204,27 +204,6 @@ func (r *Resolvable) ResolveNode(node Node, data *astjson.Value, out io.Writer) return nil } -func (r *Resolvable) renderPath() { - r.printBytes(lBrack) - for i, p := range r.path { - if i > 0 { - r.printBytes(comma) - } - if p.Name != "" { - r.printBytes(quote) - r.printBytes(unsafebytes.StringToBytes(p.Name)) - r.printBytes(quote) - } else { - r.printBytes(unsafebytes.StringToBytes(strconv.Itoa(p.Idx))) - } - } - r.printBytes(rBrack) -} - -func (r *Resolvable) printDeferDelimeter() { - r.printBytes(literalNewLine) -} - func (r *Resolvable) Resolve(ctx context.Context, rootData *Object, fetchTree *FetchTreeNode, out io.Writer) error { r.out = out r.enableRender = false @@ -279,10 +258,6 @@ func (r *Resolvable) Resolve(ctx context.Context, rootData *Object, fetchTree *F r.printBytes(rBrace) - if r.deferMode { - r.printDeferDelimeter() - } - return r.printErr } @@ -298,6 +273,7 @@ func (r *Resolvable) ResolveDefer(rootData *Object, out io.Writer, hasNext bool) r.enableRender = false r.deferMode = true r.enableDeferRender = false + r.incrementalItemWritten = false _ = r.walkObject(rootData, r.data) if r.authorizationError != nil { @@ -334,11 +310,71 @@ func (r *Resolvable) ResolveDefer(rootData *Object, out io.Writer, hasNext bool) r.printBytes(rBrace) - r.printDeferDelimeter() - return r.printErr } +func (r *Resolvable) renderPath() { + r.printBytes(lBrack) + for i, p := range r.path { + if i > 0 { + r.printBytes(comma) + } + if p.Name != "" { + r.printBytes(quote) + r.printBytes(unsafebytes.StringToBytes(p.Name)) + r.printBytes(quote) + } else { + r.printBytes(unsafebytes.StringToBytes(strconv.Itoa(p.Idx))) + } + } + r.printBytes(rBrack) +} + +func (r *Resolvable) printHasNext(hasNext bool) { + if r.printErr != nil { + return + } + r.printBytes(comma) + r.printBytes(quote) + r.printBytes(literalHasNext) + r.printBytes(quote) + r.printBytes(colon) + if hasNext { + r.printBytes(literalTrue) + } else { + r.printBytes(literalFalse) + } +} + +func (r *Resolvable) printDeferEnvelopeOpen() { + if !r.render() { + return + } + + // Render Incremental Item Envelope: {"data":{...},"path":[...]} + r.printBytes(lBrace) + r.printBytes(quote) + r.printBytes(literalData) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(lBrace) +} + +func (r *Resolvable) printDeferEnvelopeClose() { + if !r.render() { + return + } + + r.printBytes(rBrace) + r.printBytes(comma) + r.printBytes(quote) + r.printBytes(literalPath) + r.printBytes(quote) + r.printBytes(colon) + r.renderPath() + r.printBytes(rBrace) +} + // ensureErrorsInitialized is used to lazily init r.errors if needed func (r *Resolvable) ensureErrorsInitialized() { if r.errors == nil { @@ -738,128 +774,177 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { if r.render() && !isRoot { r.printBytes(lBrace) } - addComma := false r.typeNames = append(r.typeNames, typeName) defer func() { r.typeNames = r.typeNames[:len(r.typeNames)-1] }() - // In Defer Seeking Mode, we first identify and render all matching fields for the current DeferID as a single incremental item. - if r.deferMode && !r.enableDeferRender { - var ( - deferFieldIndices []int - ) + if r.deferMode { + deferFields, seekFiels := r.collectDeferFields(obj) + + if len(deferFields) > 0 { + startedRender := false - for k := range obj.Fields { - if obj.Fields[k].Defer == nil || obj.Fields[k].Defer.DeferID != r.deferID { - continue - } + if !r.enableDeferRender { + r.enableDeferRender = true + startedRender = true - // Duplicate skip checks to ensure we only include valid fields - if obj.Fields[k].ParentOnTypeNames != nil { - if r.skipFieldOnParentTypeNames(obj.Fields[k]) { - continue + if r.enableRender && r.incrementalItemWritten { + r.printBytes(comma) } - } - if obj.Fields[k].OnTypeNames != nil { - if r.skipFieldOnTypeNames(obj.Fields[k]) { - continue + + if r.deferID != "" { + r.printDeferEnvelopeOpen() } } - deferFieldIndices = append(deferFieldIndices, k) - } - - if len(deferFieldIndices) > 0 && r.enableRender { - if r.incrementalItemWritten { - r.printBytes(comma) + // render initial batch of fields + if r.walkFields(obj, value, parent, walkFieldsFilter{deferFields: deferFields, seek: false, enabled: true}) { + return true } - // Render Incremental Item Envelope: {"data":{...},"path":[...]} - r.printBytes(lBrace) - - r.printBytes(quote) - r.printBytes(literalData) - r.printBytes(quote) - r.printBytes(colon) - r.printBytes(lBrace) - - for k, fieldIdx := range deferFieldIndices { - if k > 0 { - r.printBytes(comma) + if startedRender { + if r.deferID != "" { + r.printDeferEnvelopeClose() + r.incrementalItemWritten = true } - - r.enableDeferRender = true - r.printBytes(quote) - r.printBytes(obj.Fields[fieldIdx].Name) - r.printBytes(quote) - r.printBytes(colon) - - r.currentFieldInfo = obj.Fields[fieldIdx].Info - _ = r.walkNode(obj.Fields[fieldIdx].Value, value) r.enableDeferRender = false } + } - r.printBytes(rBrace) - - r.printBytes(comma) - r.printBytes(quote) - r.printBytes(literalPath) - r.printBytes(quote) - r.printBytes(colon) - r.renderPath() - - r.printBytes(rBrace) + if r.deferID != "" && len(seekFiels) > 0 { + // seek for additional nested defer fields + if r.walkFields(obj, value, parent, walkFieldsFilter{seekFields: seekFiels, seek: true, enabled: true}) { + return true + } + } - r.wroteData = true - r.incrementalItemWritten = true + } else { + if r.walkFields(obj, value, parent, walkFieldsFilter{}) { + return true } } + if r.render() && !isRoot { + r.printBytes(rBrace) + } + return false +} + +func (r *Resolvable) collectDeferFields(obj *Object) (deferFields map[int]struct{}, seekFields map[int]struct{}) { + deferFields = make(map[int]struct{}) + seekFields = make(map[int]struct{}) + for i := range obj.Fields { - if obj.Fields[i].ParentOnTypeNames != nil { - if r.skipFieldOnParentTypeNames(obj.Fields[i]) { - continue - } + if r.shoulSkipObjectFieldByTypenames(obj.Fields[i]) { + continue } - if obj.Fields[i].OnTypeNames != nil { - if r.skipFieldOnTypeNames(obj.Fields[i]) { + + if r.deferID == "" { + // we are rendering the initial response + + // skip all fields with defer + if obj.Fields[i].Defer != nil { continue } + + // collect object fields without defer + deferFields[i] = struct{}{} } - // When NOT in defer mode (initial response), skip fields that are deferred. - // They will be handled by the deferred response. - // Also if in deferMode but deferID is empty, it means we are in the initial response of a deferred request. - if obj.Fields[i].Defer != nil { - if !r.deferMode || (r.deferMode && r.deferID == "") { + // we are rendering defer response + + // collect fields without defer into seek fields + if obj.Fields[i].Defer == nil { + if !r.fieldNodeKindAllowsSeek(obj.Fields[i]) { continue } + + seekFields[i] = struct{}{} + continue } - if r.deferMode && !r.enableDeferRender { - // DEFER SEEKING MODE + // allow to seek fields with other defer ids + if obj.Fields[i].Defer.DeferID != r.deferID { + // but only if their id is smaller than current, + // which means this nodes already was fetched, + // as defers ordered by id - // Check if this field matches the current defer ID - isMatch := obj.Fields[i].Defer != nil && obj.Fields[i].Defer.DeferID == r.deferID + fieldDeferId, _ := strconv.Atoi(obj.Fields[i].Defer.DeferID) + currentDeferIDInt, _ := strconv.Atoi(r.deferID) - if isMatch { - // Match found - already rendered in pre-scan + // TODO: it is a temporary solution, + // because defer could be parallel + if currentDeferIDInt < fieldDeferId { continue } - // No match - recurse to find nested defers - // We only need to recurse if the node is an Object or Array, as Scalars cannot have nested defers. - // Recursing into Scalars would trigger "non-nullable field returned null" error in handleNodeNotRendered because we are not rendering them. - kind := obj.Fields[i].Value.NodeKind() - if kind == NodeKindObject || kind == NodeKindArray { - r.currentFieldInfo = obj.Fields[i].Info - _ = r.walkNode(obj.Fields[i].Value, value) + if !r.fieldNodeKindAllowsSeek(obj.Fields[i]) { + continue } + + seekFields[i] = struct{}{} continue } + // store fields with matching defer id + deferFields[i] = struct{}{} + } + + return +} + +func (r *Resolvable) fieldNodeKindAllowsSeek(field *Field) bool { + kind := field.Value.NodeKind() + if kind != NodeKindObject { + if kind != NodeKindArray { + // skip scalar fields + return false + } + + // skip array if it's item do not have an object kind + if field.Value.(*Array).Item.NodeKind() != NodeKindObject { + // we could have a nested array, + // but we do not care for now + return false + } + } + + return true +} + +type walkFieldsFilter struct { + deferFields map[int]struct{} + seekFields map[int]struct{} + seek bool + enabled bool +} + +func (r *Resolvable) walkFields(obj *Object, value *astjson.Value, parent *astjson.Value, filter walkFieldsFilter) (hasErrors bool) { + addComma := false + + for i := range obj.Fields { + if filter.enabled { + // if mode is seek + if filter.seek { + // skip all fields to which we should not go into + if _, ok := filter.seekFields[i]; !ok { + continue + } + } else { + // if mode is render + // skip all fields that we should not render + if _, ok := filter.deferFields[i]; !ok { + continue + } + } + } else { + if r.shoulSkipObjectFieldByTypenames(obj.Fields[i]) { + continue + } + } + if !r.render() { skip := r.authorizeField(value, obj.Fields[i]) if skip { @@ -871,17 +956,18 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { if field != nil { astjson.SetNull(r.astjsonArena, value, path...) } + + continue } else if obj.Nullable && len(obj.Path) > 0 { // if the field value is not nullable, but the object is nullable // we can just set the whole object to null astjson.SetNull(r.astjsonArena, parent, obj.Path...) return false - } else { - // if the field value is not nullable and the object is not nullable - // we return true to indicate an error - return true } - continue + + // if the field value is not nullable and the object is not nullable + // we return true to indicate an error + return true } } if r.render() { @@ -906,9 +992,19 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { } addComma = true } - if r.render() && !isRoot { - r.printBytes(rBrace) + + return false +} + +func (r *Resolvable) shoulSkipObjectFieldByTypenames(field *Field) bool { + if field.ParentOnTypeNames != nil && r.skipFieldOnParentTypeNames(field) { + return true } + + if field.OnTypeNames != nil && r.skipFieldOnTypeNames(field) { + return true + } + return false } diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index 894546b185..3c08b9a934 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -436,22 +436,6 @@ func (r *Resolver) ArenaResolveGraphQLResponse(ctx *Context, response *GraphQLRe return resp, err } -func (r *Resolvable) printHasNext(hasNext bool) { - if r.printErr != nil { - return - } - r.printBytes(comma) - r.printBytes(quote) - r.printBytes(literalHasNext) - r.printBytes(quote) - r.printBytes(colon) - if hasNext { - r.printBytes(literalTrue) - } else { - r.printBytes(literalFalse) - } -} - func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDeferResponse, writer DeferResponseWriter) (*GraphQLResolveInfo, error) { resolveInfo := &GraphQLResolveInfo{} @@ -479,7 +463,6 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe t.resolvable.deferMode = true t.resolvable.deferID = "" - t.resolvable.enableDeferRender = true // render initial response err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, response.Response.Fetches, writer) From 9b4369e7d80e18110c5e545a171982267adea1ae Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 4 Mar 2026 19:00:25 +0200 Subject: [PATCH 33/79] chore: fix --- v2/pkg/engine/plan/path_builder_visitor.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 7a752918d9..1146a79ccd 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -549,13 +549,13 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { field.suggestion = suggestion // the field was deffered, but it also could be a parent path for some other defer - hasDeferInfo := suggestion.DeferInfo != nil + hasDeferInfo := suggestion.deferInfo != nil // the field may be not deferred, but it is a parent for the child node which was deferred - isDeferParent := len(suggestion.DeferIDs) > 0 + isDeferParent := len(suggestion.deferIDs) > 0 // plan defer parent paths if isDeferParent { - for _, deferID := range suggestion.DeferIDs { + for _, deferID := range suggestion.deferIDs { field.deferID = deferID field.deferField = false // defer parent path planning - should be planned as a deferred path @@ -565,7 +565,7 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { // plan deferred field if hasDeferInfo { - field.deferID = suggestion.DeferInfo.ID + field.deferID = suggestion.deferInfo.ID field.deferField = true // should be planned only as a deferred path c.handlePlanningField(field) From d8ec33a10ba40c0003848d8aacd5464faebbc103 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 4 Mar 2026 19:12:31 +0200 Subject: [PATCH 34/79] chore: add entity tests --- .../engine/execution_engine_defer_test.go | 506 +++++++++++++----- 1 file changed, 375 insertions(+), 131 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 1ae46d8a90..b38d5a279a 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -11,129 +11,13 @@ import ( ) func TestExecutionEngine_Execute_Defer(t *testing.T) { - t.Run("simple - defer on non entity field", func(t *testing.T) { - definition := ` - type User { - id: ID! - name: String! - title: String! - info: Info! - } - - type Info { - email: String! - phone: String! - } - - type Query { - user: User! - } - ` + runDeferTests := func(t *testing.T, definition string, dataSources []plan.DataSource) { + t.Helper() schema, err := graphql.NewSchemaFromString(definition) require.NoError(t, err) - makeDataSource := func(t *testing.T, expectFetchReasons bool) []plan.DataSource { - return []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, - "id-1", - mustFactory(t, - testConditionalNetHttpClient(t, conditionalTestCase{ - expectedHost: "first", - expectedPath: "/", - responses: map[string]sendResponse{ - `{"query":"{user {name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Black"}}}`, - }, - `{"query":"{user {__internal__typename_placeholder: __typename}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__internal__typename_placeholder":"User"}}}`, - }, - `{"query":"{user {title}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"title":"Sabbat"}}}`, - }, - `{"query":"{user {id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"id":"1"}}}`, - }, - `{"query":"{user {title id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"title":"Sabbat","id":"1"}}}`, - }, - `{"query":"{user {name title id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Black","title":"Sabbat","id":"1"}}}`, - }, - `{"query":"{user {info {email phone}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"email":"black@sabbat","phone":"123"}}}}`, - }, - `{"query":"{user {info {phone} title}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"phone":"123"},"title":"Sabbat"}}}`, - }, - `{"query":"{user {name info {email}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}}}`, - }, - `{"query":"{user {name info {__internal__typename_placeholder: __typename}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Black","info":{"__internal__typename_placeholder":"Info"}}}}`, - }, - `{"query":"{user {info {__internal__typename_placeholder: __typename}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"__internal__typename_placeholder":"Info"}}}}`, - }, - `{"query":"{user {info {email}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"email":"black@sabbat"}}}}`, - }, - `{"query":"{user {info {phone}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"phone":"123"}}}}`, - }, - }, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"user"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "title", "name", "info"}, - }, - { - TypeName: "Info", - FieldNames: []string{"email", "phone"}, - }, - }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://first/", - Method: "POST", - }, - SchemaConfiguration: mustSchemaConfig( - t, - &graphql_datasource.FederationConfiguration{ - Enabled: true, - ServiceSDL: definition, - }, - definition, - ), - }), - ), - } - } - t.Run("single deffered field", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -150,7 +34,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} {"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} `, @@ -173,7 +57,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{"title":"Sabbat","id":"1"}},"hasNext":true} {"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":false} `, @@ -196,7 +80,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} {"incremental":[{"data":{"title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} `, @@ -219,7 +103,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{}},"hasNext":true} {"incremental":[{"data":{"name":"Black","title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} `, @@ -244,13 +128,36 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} {"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} `, }, withStreamingResponse())) + t.Run("nested defers variation", runWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserNameTitle", + Query: ` + query DeferUserNameTitle { + user { + ... @defer { + name + ... @defer { title } + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + t.Run("parallel defers", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -270,7 +177,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} {"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} @@ -296,7 +203,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} {"incremental":[{"data":{"info":{"email":"black@sabbat","phone":"123"}},"path":["user"]}],"hasNext":false} `, @@ -324,7 +231,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}},"hasNext":true} {"incremental":[{"data":{"title":"Sabbat"},"path":["user"]},{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} `, @@ -349,13 +256,13 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{"user":{"name":"Black","info":{}}},"hasNext":true} {"incremental":[{"data":{"email":"black@sabbat","phone":"123"},"path":["user","info"]}],"hasNext":false} `, }, withStreamingResponse())) - t.Run("extensive parallel defers across all possible fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("extensive parallel defers across all possible fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -378,7 +285,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{},"hasNext":true} {"incremental":[{"data":{"user":{}},"path":[]}],"hasNext":true} {"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} @@ -390,7 +297,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("extensive fully nested defers across all possible fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("extensive fully nested defers across all possible fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -423,7 +330,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }`, } }, - dataSources: makeDataSource(t, false), + dataSources: dataSources, expectedResponse: `{"data":{},"hasNext":true} {"incremental":[{"data":{"user":{}},"path":[]}],"hasNext":true} {"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} @@ -434,5 +341,342 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { {"incremental":[{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} `, }, withStreamingResponse())) + } + + t.Run("simple - defer on non entity field", func(t *testing.T) { + + definition := ` + type User { + id: ID! + name: String! + title: String! + info: Info! + } + + type Info { + email: String! + phone: String! + } + + type Query { + user: User! + } + ` + + dataSources := []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"{user {name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black"}}}`, + }, + `{"query":"{user {__internal__typename_placeholder: __typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal__typename_placeholder":"User"}}}`, + }, + `{"query":"{user {title}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"title":"Sabbat"}}}`, + }, + `{"query":"{user {id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1"}}}`, + }, + `{"query":"{user {title id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"title":"Sabbat","id":"1"}}}`, + }, + `{"query":"{user {name title id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","title":"Sabbat","id":"1"}}}`, + }, + `{"query":"{user {info {email phone}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"email":"black@sabbat","phone":"123"}}}}`, + }, + `{"query":"{user {info {phone} title}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"phone":"123"},"title":"Sabbat"}}}`, + }, + `{"query":"{user {name info {email}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {name info {__internal__typename_placeholder: __typename}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Black","info":{"__internal__typename_placeholder":"Info"}}}}`, + }, + `{"query":"{user {info {__internal__typename_placeholder: __typename}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"__internal__typename_placeholder":"Info"}}}}`, + }, + `{"query":"{user {info {email}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {info {phone}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"phone":"123"}}}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"user"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "title", "name", "info"}, + }, + { + TypeName: "Info", + FieldNames: []string{"email", "phone"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://first/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: definition, + }, + definition, + ), + }), + ), + } + + runDeferTests(t, definition, dataSources) + }) + + t.Run("entity - distributed fields", func(t *testing.T) { + + definition := ` + type User { + id: ID! + name: String! + title: String! + info: Info! + } + + type Info { + email: String! + phone: String! + } + + type Query { + user: User! + } + ` + + firstSubgraphSDL := ` + type User @key(fields: "id") { + id: ID! + info: Info! + } + + type Info { + email: String! + } + + type Query { + user: User! + } + ` + + secondSubgraphSDL := ` + type User @key(fields: "id") { + id: ID! + name: String! + title: String! + info: Info! + } + + type Info { + phone: String! + } + ` + + dataSources := []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"{user {__typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {id __typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {__typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1","info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {__internal__typename_placeholder: __typename __typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal__typename_placeholder":"User","__typename":"User","info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {__internal__typename_placeholder: __typename __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal__typename_placeholder":"User","__typename":"User","id":"1","info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {info {email}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"email":"black@sabbat"}}}}`, + }, + `{"query":"{user {info {__internal__typename_placeholder: __typename}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"__internal__typename_placeholder":"Info"}}}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"user"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "info"}, + }, + { + TypeName: "Info", + FieldNames: []string{"email"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://first/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: firstSubgraphSDL, + }, + firstSubgraphSDL, + ), + }), + ), + mustGraphqlDataSourceConfiguration(t, + "id-2", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "second", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name title}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename info {phone} title}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename info {phone}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name info {__internal__typename_placeholder: __typename}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename info {email phone}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name info {email}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "title", "name", "info"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Info", + FieldNames: []string{"phone"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://second/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: secondSubgraphSDL, + }, + secondSubgraphSDL, + ), + }), + ), + } + + runDeferTests(t, definition, dataSources) }) } From 82ccd0c56208b030de459a9dabf86967e50b1208 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 10 Mar 2026 17:09:15 +0200 Subject: [PATCH 35/79] chore: cleanup --- execution/engine/execution_engine_defer_test.go | 4 ++-- v2/pkg/engine/plan/required_fields_visitor.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index b38d5a279a..ca3fd076a6 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -561,12 +561,12 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { TypeName: "Query", FieldNames: []string{"user"}, }, - }, - ChildNodes: []plan.TypeField{ { TypeName: "User", FieldNames: []string{"id", "info"}, }, + }, + ChildNodes: []plan.TypeField{ { TypeName: "Info", FieldNames: []string{"email"}, diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index 2123605015..466d13c37c 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -246,7 +246,7 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { // do not add required field if the field is already present in the operation with the same name // but add an operation node from operation if the field has selections - if !v.config.operation.FieldHasSelections(operationFieldRef) { + if isLeafField { return } From 18845eb3e8c19559f696a74f3cf8dafc9df81aea Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 10 Mar 2026 19:59:01 +0200 Subject: [PATCH 36/79] change set of args to a struct --- v2/pkg/engine/plan/node_selection_visitor.go | 88 +++++++++++++------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index 3e3e95a25a..6e3b5f249b 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -213,6 +213,16 @@ func (c *nodeSelectionVisitor) EnterField(fieldRef int) { c.handleEnterField(fieldRef, false) } +type fieldRequirementsContext struct { + fieldRef int + parentPath string + typeName string + fieldName string + currentPath string + dsConfig DataSource + deferID string +} + func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires bool) { root := c.walker.Ancestors[0] if root.Kind != ast.NodeKindOperationDefinition { @@ -238,22 +248,36 @@ func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires boo c.walker.StopWithInternalErr(fmt.Errorf("do not have a datasource for a field suggestion for field %s at path %s", fieldName, currentPath)) return } - ds := c.dataSources[dsIdx] + + deferID := "" + if suggestion.deferInfo != nil { + deferID = suggestion.deferInfo.ID + } + + fieldCtx := fieldRequirementsContext{ + fieldRef: fieldRef, + parentPath: parentPath, + typeName: typeName, + fieldName: fieldName, + currentPath: currentPath, + dsConfig: c.dataSources[dsIdx], + deferID: deferID, + } if handleRequires { // check if the field has @requires directive - c.handleFieldRequiredByRequires(fieldRef, parentPath, typeName, fieldName, currentPath, ds) + c.handleFieldRequiredByRequires(fieldCtx) // skip to the next suggestion as we only handle requires here continue } if suggestion.requiresKey != nil { // add @key requirements for the field - c.handleFieldsRequiredByKey(fieldRef, parentPath, typeName, fieldName, currentPath, ds, *suggestion.requiresKey) + c.handleFieldsRequiredByKey(fieldCtx, *suggestion.requiresKey) } // check if field selections are abstract and needs rewrites - c.rewriteSelectionSetHavingAbstractFragments(fieldRef, ds) + c.rewriteSelectionSetHavingAbstractFragments(fieldRef, fieldCtx.dsConfig) } } @@ -265,28 +289,28 @@ func (c *nodeSelectionVisitor) LeaveField(ref int) { } } -func (c *nodeSelectionVisitor) handleFieldRequiredByRequires(fieldRef int, parentPath, typeName, fieldName, currentPath string, dsConfig DataSource) { - fieldKey := fieldIndexKey{fieldRef, dsConfig.Hash()} +func (c *nodeSelectionVisitor) handleFieldRequiredByRequires(fieldCtx fieldRequirementsContext) { + fieldKey := fieldIndexKey{fieldCtx.fieldRef, fieldCtx.dsConfig.Hash()} _, visited := c.visitedFieldsRequiresChecks[fieldKey] if visited { return } c.visitedFieldsRequiresChecks[fieldKey] = struct{}{} - if fieldName == typeNameField { + if fieldCtx.fieldName == typeNameField { // the __typename field could not have @requires directive return } - requiresConfiguration, exists := dsConfig.RequiredFieldsByRequires(typeName, fieldName) + requiresConfiguration, exists := fieldCtx.dsConfig.RequiredFieldsByRequires(fieldCtx.typeName, fieldCtx.fieldName) if !exists { - for _, io := range dsConfig.FederationConfiguration().InterfaceObjects { - if slices.Contains(io.ConcreteTypeNames, typeName) { + for _, io := range fieldCtx.dsConfig.FederationConfiguration().InterfaceObjects { + if slices.Contains(io.ConcreteTypeNames, fieldCtx.typeName) { // we should check if we have a @requires configuration for the interface object - requiresConfiguration, exists = dsConfig.RequiredFieldsByRequires(io.InterfaceTypeName, fieldName) + requiresConfiguration, exists = fieldCtx.dsConfig.RequiredFieldsByRequires(io.InterfaceTypeName, fieldCtx.fieldName) if exists { - requiresConfiguration.TypeName = typeName + requiresConfiguration.TypeName = fieldCtx.typeName break } } @@ -300,17 +324,17 @@ func (c *nodeSelectionVisitor) handleFieldRequiredByRequires(fieldRef int, paren // check if the required fields are already provided input := areRequiredFieldsProvidedInput{ - typeName: typeName, + typeName: fieldCtx.typeName, requiredFields: requiresConfiguration.SelectionSet, definition: c.definition, - dataSource: dsConfig, - providedFields: c.nodeSuggestions.providedFields[dsConfig.Hash()], - parentPath: parentPath, + dataSource: fieldCtx.dsConfig, + providedFields: c.nodeSuggestions.providedFields[fieldCtx.dsConfig.Hash()], + parentPath: fieldCtx.parentPath, } provided, report := areRequiredFieldsProvided(input) if report.HasErrors() { - c.walker.StopWithInternalErr(fmt.Errorf("failed to check if required fields are provided for field %s at path %s: %w", fieldName, currentPath, report)) + c.walker.StopWithInternalErr(fmt.Errorf("failed to check if required fields are provided for field %s at path %s: %w", fieldCtx.fieldName, fieldCtx.currentPath, report)) return } @@ -322,19 +346,19 @@ func (c *nodeSelectionVisitor) handleFieldRequiredByRequires(fieldRef int, paren // we should plan to add required fields for the field // they will be added in the on LeaveSelectionSet callback for the current selection set, // and the current field ref will be added to the fieldDependsOn map - c.addPendingFieldRequirements(fieldRef, dsConfig.Hash(), requiresConfiguration, currentPath, false) - c.handleKeyRequirementsForBackJumpOnSameDataSource(fieldRef, dsConfig, typeName, parentPath) + c.addPendingFieldRequirements(fieldCtx.fieldRef, fieldCtx.dsConfig.Hash(), requiresConfiguration, fieldCtx.currentPath, false) + c.handleKeyRequirementsForBackJumpOnSameDataSource(fieldCtx.fieldRef, fieldCtx.dsConfig, fieldCtx.typeName, fieldCtx.parentPath) } -func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldRef int, parentPath, typeName, fieldName, currentPath string, dsConfig DataSource, sc SourceConnection) { - fieldKey := fieldIndexKey{fieldRef, dsConfig.Hash()} +func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldCtx fieldRequirementsContext, sc SourceConnection) { + fieldKey := fieldIndexKey{fieldCtx.fieldRef, fieldCtx.dsConfig.Hash()} _, visited := c.visitedFieldsKeyChecks[fieldKey] if visited { return } c.visitedFieldsKeyChecks[fieldKey] = struct{}{} - selectedParentsDSHashes := c.getSelectedParentsDSHashes(fieldRef) + selectedParentsDSHashes := c.getSelectedParentsDSHashes(fieldCtx.fieldRef) isParentHasInterfaceObject := slices.ContainsFunc(selectedParentsDSHashes, func(dsHash DSHash) bool { dsIdx := slices.IndexFunc(c.dataSources, func(d DataSource) bool { @@ -344,13 +368,13 @@ func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldRef int, parentPat return false } - return c.dataSources[dsIdx].HasInterfaceObject(typeName) + return c.dataSources[dsIdx].HasInterfaceObject(fieldCtx.typeName) }) - entityInterface := dsConfig.HasEntityInterface(typeName) - interfaceObject := dsConfig.HasInterfaceObject(typeName) + entityInterface := fieldCtx.dsConfig.HasEntityInterface(fieldCtx.typeName) + interfaceObject := fieldCtx.dsConfig.HasInterfaceObject(fieldCtx.typeName) - if fieldName == typeNameField && !entityInterface { + if fieldCtx.fieldName == typeNameField && !entityInterface { // the __typename field could not have @key directive // but for the interface object we have to plan it differently // e.g. we should get a __typename from a concrete type, not the interface object @@ -358,18 +382,18 @@ func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldRef int, parentPat return } - c.addPendingKeyRequirements(fieldRef, dsConfig.Hash(), sc, interfaceObject, parentPath, typeName) + c.addPendingKeyRequirements(fieldCtx.fieldRef, fieldCtx.dsConfig.Hash(), sc, interfaceObject, fieldCtx.parentPath, fieldCtx.typeName) if isParentHasInterfaceObject && !interfaceObject && !entityInterface { c.addPendingFieldRequirements( - fieldRef, - dsConfig.Hash(), + fieldCtx.fieldRef, + fieldCtx.dsConfig.Hash(), FederationFieldConfiguration{ - TypeName: typeName, - FieldName: fieldName, + TypeName: fieldCtx.typeName, + FieldName: fieldCtx.fieldName, SelectionSet: "__typename", }, - currentPath, + fieldCtx.currentPath, true, ) } From 915ea5f333c0901aa34ee37f4977f87bb94494b0 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 10 Mar 2026 20:53:55 +0200 Subject: [PATCH 37/79] pass defer id into required fields visitor --- v2/pkg/engine/plan/node_selection_visitor.go | 49 ++++++++++++------- v2/pkg/engine/plan/required_fields_visitor.go | 1 + 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index 6e3b5f249b..90b541849d 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -79,9 +79,14 @@ type fieldIndexKey struct { } // selectionSetPendingRequirements - is a wrapper to been able to have predictable order of keyRequirements but at the same time deduplicate keyRequirements +type pendingKeyRequirementExistsKey struct { + dsHash DSHash + deferID string +} + type pendingKeyRequirements struct { - existsTracker map[DSHash]struct{} // existsTracker allows us to not add duplicated keyRequirements - requirementConfigs []keyRequirements // requirementConfigs is a list of keyRequirements which should be added to the selection set + existsTracker map[pendingKeyRequirementExistsKey]struct{} // existsTracker allows us to not add duplicated keyRequirements + requirementConfigs []keyRequirements // requirementConfigs is a list of keyRequirements which should be added to the selection set } // keyRequirements is a mapping between requestedByPlannerID or requestedByFieldRef, which requested required fields, @@ -93,6 +98,7 @@ type keyRequirements struct { sc SourceConnection requestedByFieldRefs []int typeName string + deferID string } type fieldRequirements struct { @@ -101,6 +107,7 @@ type fieldRequirements struct { selectionSet string requestedByFieldRefs []int isTypenameForEntityInterface bool + deferID string } type pendingFieldRequirements struct { @@ -112,6 +119,7 @@ type pendingFieldRequirementExistsKey struct { dsHash DSHash selectionSet string isTypenameForEntityInterface bool + deferID string } func (c *nodeSelectionVisitor) currentSelectionSet() int { @@ -346,8 +354,8 @@ func (c *nodeSelectionVisitor) handleFieldRequiredByRequires(fieldCtx fieldRequi // we should plan to add required fields for the field // they will be added in the on LeaveSelectionSet callback for the current selection set, // and the current field ref will be added to the fieldDependsOn map - c.addPendingFieldRequirements(fieldCtx.fieldRef, fieldCtx.dsConfig.Hash(), requiresConfiguration, fieldCtx.currentPath, false) - c.handleKeyRequirementsForBackJumpOnSameDataSource(fieldCtx.fieldRef, fieldCtx.dsConfig, fieldCtx.typeName, fieldCtx.parentPath) + c.addPendingFieldRequirements(fieldCtx.fieldRef, fieldCtx.dsConfig.Hash(), requiresConfiguration, fieldCtx.currentPath, false, fieldCtx.deferID) + c.handleKeyRequirementsForBackJumpOnSameDataSource(fieldCtx.fieldRef, fieldCtx.dsConfig, fieldCtx.typeName, fieldCtx.parentPath, fieldCtx.deferID) } func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldCtx fieldRequirementsContext, sc SourceConnection) { @@ -382,7 +390,7 @@ func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldCtx fieldRequireme return } - c.addPendingKeyRequirements(fieldCtx.fieldRef, fieldCtx.dsConfig.Hash(), sc, interfaceObject, fieldCtx.parentPath, fieldCtx.typeName) + c.addPendingKeyRequirements(fieldCtx.fieldRef, fieldCtx.dsConfig.Hash(), sc, interfaceObject, fieldCtx.parentPath, fieldCtx.typeName, fieldCtx.deferID) if isParentHasInterfaceObject && !interfaceObject && !entityInterface { c.addPendingFieldRequirements( @@ -395,6 +403,7 @@ func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldCtx fieldRequireme }, fieldCtx.currentPath, true, + fieldCtx.deferID, ) } } @@ -417,7 +426,7 @@ func (c *nodeSelectionVisitor) getSelectedParentsDSHashes(fieldRef int) (out []D return out } -func (c *nodeSelectionVisitor) handleKeyRequirementsForBackJumpOnSameDataSource(fieldRef int, dsConfig DataSource, typeName string, parentPath string) { +func (c *nodeSelectionVisitor) handleKeyRequirementsForBackJumpOnSameDataSource(fieldRef int, dsConfig DataSource, typeName string, parentPath string, deferID string) { selectedParentsDSHashes := c.getSelectedParentsDSHashes(fieldRef) // regularly keys are required only when the datasource hash differs from the parent datasource hash @@ -460,10 +469,10 @@ func (c *nodeSelectionVisitor) handleKeyRequirementsForBackJumpOnSameDataSource( }, } - c.addPendingKeyRequirements(fieldRef, dsConfig.Hash(), sc, false, parentPath, typeName) + c.addPendingKeyRequirements(fieldRef, dsConfig.Hash(), sc, false, parentPath, typeName, deferID) } -func (c *nodeSelectionVisitor) addPendingFieldRequirements(requestedByFieldRef int, dsHash DSHash, fieldConfiguration FederationFieldConfiguration, currentPath string, isTypenameForEntityInterface bool) { +func (c *nodeSelectionVisitor) addPendingFieldRequirements(requestedByFieldRef int, dsHash DSHash, fieldConfiguration FederationFieldConfiguration, currentPath string, isTypenameForEntityInterface bool, deferID string) { currentSelectionSet := c.currentSelectionSet() requirements, hasRequirements := c.pendingFieldRequirements[currentSelectionSet] @@ -473,7 +482,7 @@ func (c *nodeSelectionVisitor) addPendingFieldRequirements(requestedByFieldRef i } } - existsKey := pendingFieldRequirementExistsKey{dsHash, fieldConfiguration.SelectionSet, isTypenameForEntityInterface} + existsKey := pendingFieldRequirementExistsKey{dsHash, fieldConfiguration.SelectionSet, isTypenameForEntityInterface, deferID} if _, exists := requirements.existsTracker[existsKey]; !exists { config := fieldRequirements{ dsHash: dsHash, @@ -481,6 +490,7 @@ func (c *nodeSelectionVisitor) addPendingFieldRequirements(requestedByFieldRef i selectionSet: fieldConfiguration.SelectionSet, requestedByFieldRefs: []int{requestedByFieldRef}, isTypenameForEntityInterface: isTypenameForEntityInterface, + deferID: deferID, } requirements.existsTracker[existsKey] = struct{}{} @@ -501,18 +511,18 @@ func (c *nodeSelectionVisitor) addPendingFieldRequirements(requestedByFieldRef i c.pendingFieldRequirements[currentSelectionSet] = requirements } -func (c *nodeSelectionVisitor) addPendingKeyRequirements(requestedByFieldRef int, dsHash DSHash, sc SourceConnection, isInterfaceObject bool, parentPath string, typeName string) { +func (c *nodeSelectionVisitor) addPendingKeyRequirements(requestedByFieldRef int, dsHash DSHash, sc SourceConnection, isInterfaceObject bool, parentPath string, typeName string, deferID string) { currentSelectionSet := c.currentSelectionSet() requirements, hasRequirements := c.pendingKeyRequirements[currentSelectionSet] if !hasRequirements { requirements = pendingKeyRequirements{ - existsTracker: make(map[DSHash]struct{}), + existsTracker: make(map[pendingKeyRequirementExistsKey]struct{}), } } - existsKey := dsHash + existsKey := pendingKeyRequirementExistsKey{dsHash: dsHash, deferID: deferID} if _, exists := requirements.existsTracker[existsKey]; !exists { config := keyRequirements{ targetDSHash: dsHash, @@ -521,13 +531,14 @@ func (c *nodeSelectionVisitor) addPendingKeyRequirements(requestedByFieldRef int sc: sc, requestedByFieldRefs: []int{requestedByFieldRef}, typeName: typeName, + deferID: deferID, } requirements.existsTracker[existsKey] = struct{}{} requirements.requirementConfigs = append(requirements.requirementConfigs, config) } else { for i := range requirements.requirementConfigs { - if requirements.requirementConfigs[i].targetDSHash == dsHash { + if requirements.requirementConfigs[i].targetDSHash == dsHash && requirements.requirementConfigs[i].deferID == deferID { if !slices.Contains(requirements.requirementConfigs[i].requestedByFieldRefs, requestedByFieldRef) { requirements.requirementConfigs[i].requestedByFieldRefs = append(requirements.requirementConfigs[i].requestedByFieldRefs, requestedByFieldRef) } @@ -563,6 +574,7 @@ func (c *nodeSelectionVisitor) addFieldRequirementsToOperation(selectionSetRef i allowTypename: false, typeName: typeName, fieldSet: requirements.selectionSet, + deferID: requirements.deferID, addTypenameInNestedSelections: c.addTypenameInNestedSelections, } @@ -651,6 +663,7 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int allowTypename: allowTypeName, typeName: jump.TypeName, fieldSet: jump.SelectionSet, + deferID: pendingKey.deferID, } addFieldsResult, report := addRequiredFields(input) @@ -681,8 +694,9 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int } c.fieldRequirementsConfigs[fieldKey] = append(c.fieldRequirementsConfigs[fieldKey], FederationFieldConfiguration{ - TypeName: previousJump.TypeName, - SelectionSet: previousJump.SelectionSet, + TypeName: previousJump.TypeName, + SelectionSet: previousJump.SelectionSet, + RemappedPaths: addFieldsResult.remappedPaths, }) for _, requiredFieldRef := range currentFieldRefs { c.fieldDependencyKind[fieldDependencyKey{field: requestedByFieldRef, dependsOn: requiredFieldRef}] = fieldDependencyKindKey @@ -708,8 +722,9 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int } c.fieldRequirementsConfigs[fieldKey] = append(c.fieldRequirementsConfigs[fieldKey], FederationFieldConfiguration{ - TypeName: jump.TypeName, - SelectionSet: jump.SelectionSet, + TypeName: jump.TypeName, + SelectionSet: jump.SelectionSet, + RemappedPaths: addFieldsResult.remappedPaths, }) for _, requiredFieldRef := range currentFieldRefs { c.fieldDependencyKind[fieldDependencyKey{field: requestedByFieldRef, dependsOn: requiredFieldRef}] = fieldDependencyKindKey diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index 466d13c37c..118596299c 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -58,6 +58,7 @@ type addRequiredFieldsConfiguration struct { allowTypename bool typeName string fieldSet string + deferID string // addTypenameInNestedSelections controls forced addition of __typename to nested selection sets // used by "requires" keys, not only when fragments are present. From 50b0beb9eaf3b57291f1fee6c2c78d5e23ffb581 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 10 Mar 2026 21:02:10 +0200 Subject: [PATCH 38/79] refactor more methods signatures to use fieldRequirementsContext --- v2/pkg/engine/plan/node_selection_visitor.go | 69 ++++++++++---------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index 90b541849d..9e518a2e6f 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -354,8 +354,8 @@ func (c *nodeSelectionVisitor) handleFieldRequiredByRequires(fieldCtx fieldRequi // we should plan to add required fields for the field // they will be added in the on LeaveSelectionSet callback for the current selection set, // and the current field ref will be added to the fieldDependsOn map - c.addPendingFieldRequirements(fieldCtx.fieldRef, fieldCtx.dsConfig.Hash(), requiresConfiguration, fieldCtx.currentPath, false, fieldCtx.deferID) - c.handleKeyRequirementsForBackJumpOnSameDataSource(fieldCtx.fieldRef, fieldCtx.dsConfig, fieldCtx.typeName, fieldCtx.parentPath, fieldCtx.deferID) + c.addPendingFieldRequirements(fieldCtx, requiresConfiguration, false) + c.handleKeyRequirementsForBackJumpOnSameDataSource(fieldCtx) } func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldCtx fieldRequirementsContext, sc SourceConnection) { @@ -390,20 +390,17 @@ func (c *nodeSelectionVisitor) handleFieldsRequiredByKey(fieldCtx fieldRequireme return } - c.addPendingKeyRequirements(fieldCtx.fieldRef, fieldCtx.dsConfig.Hash(), sc, interfaceObject, fieldCtx.parentPath, fieldCtx.typeName, fieldCtx.deferID) + c.addPendingKeyRequirements(fieldCtx, sc, interfaceObject) if isParentHasInterfaceObject && !interfaceObject && !entityInterface { c.addPendingFieldRequirements( - fieldCtx.fieldRef, - fieldCtx.dsConfig.Hash(), + fieldCtx, FederationFieldConfiguration{ TypeName: fieldCtx.typeName, FieldName: fieldCtx.fieldName, SelectionSet: "__typename", }, - fieldCtx.currentPath, true, - fieldCtx.deferID, ) } } @@ -426,19 +423,19 @@ func (c *nodeSelectionVisitor) getSelectedParentsDSHashes(fieldRef int) (out []D return out } -func (c *nodeSelectionVisitor) handleKeyRequirementsForBackJumpOnSameDataSource(fieldRef int, dsConfig DataSource, typeName string, parentPath string, deferID string) { - selectedParentsDSHashes := c.getSelectedParentsDSHashes(fieldRef) +func (c *nodeSelectionVisitor) handleKeyRequirementsForBackJumpOnSameDataSource(fieldCtx fieldRequirementsContext) { + selectedParentsDSHashes := c.getSelectedParentsDSHashes(fieldCtx.fieldRef) // regularly keys are required only when the datasource hash differs from the parent datasource hash // one exception when the field has requires directive and planned on the same datasource as a parent // in this case we have to add a back jump on the same datasource to get required fields for the field resolver // but jump is possible only with keys, so we have to add any key for this datasource - sameAsParentDS := len(selectedParentsDSHashes) == 1 && selectedParentsDSHashes[0] == dsConfig.Hash() + sameAsParentDS := len(selectedParentsDSHashes) == 1 && selectedParentsDSHashes[0] == fieldCtx.dsConfig.Hash() if !sameAsParentDS { return } - keyConfigurations := dsConfig.RequiredFieldsByKey(typeName) + keyConfigurations := fieldCtx.dsConfig.RequiredFieldsByKey(fieldCtx.typeName) if len(keyConfigurations) == 0 { // required fields could be of zero length in case type is not entity @@ -447,8 +444,8 @@ func (c *nodeSelectionVisitor) handleKeyRequirementsForBackJumpOnSameDataSource( // When entity has disabled entity resolver, but we have field with requires directive on this entity // we should add key fields for the field with requires - to pass them into field resolver - keys := dsConfig.FederationConfiguration().Keys - keyConfigurations = keys.FilterByTypeAndResolvability(typeName, false) + keys := fieldCtx.dsConfig.FederationConfiguration().Keys + keyConfigurations = keys.FilterByTypeAndResolvability(fieldCtx.typeName, false) } if len(keyConfigurations) == 0 { @@ -461,18 +458,18 @@ func (c *nodeSelectionVisitor) handleKeyRequirementsForBackJumpOnSameDataSource( Type: SourceConnectionTypeDirect, Jumps: []KeyJump{ { - From: dsConfig.Hash(), - To: dsConfig.Hash(), + From: fieldCtx.dsConfig.Hash(), + To: fieldCtx.dsConfig.Hash(), SelectionSet: keyToUse.SelectionSet, - TypeName: typeName, + TypeName: fieldCtx.typeName, }, }, } - c.addPendingKeyRequirements(fieldRef, dsConfig.Hash(), sc, false, parentPath, typeName, deferID) + c.addPendingKeyRequirements(fieldCtx, sc, false) } -func (c *nodeSelectionVisitor) addPendingFieldRequirements(requestedByFieldRef int, dsHash DSHash, fieldConfiguration FederationFieldConfiguration, currentPath string, isTypenameForEntityInterface bool, deferID string) { +func (c *nodeSelectionVisitor) addPendingFieldRequirements(fieldCtx fieldRequirementsContext, fieldConfiguration FederationFieldConfiguration, isTypenameForEntityInterface bool) { currentSelectionSet := c.currentSelectionSet() requirements, hasRequirements := c.pendingFieldRequirements[currentSelectionSet] @@ -482,26 +479,26 @@ func (c *nodeSelectionVisitor) addPendingFieldRequirements(requestedByFieldRef i } } - existsKey := pendingFieldRequirementExistsKey{dsHash, fieldConfiguration.SelectionSet, isTypenameForEntityInterface, deferID} + existsKey := pendingFieldRequirementExistsKey{fieldCtx.dsConfig.Hash(), fieldConfiguration.SelectionSet, isTypenameForEntityInterface, fieldCtx.deferID} if _, exists := requirements.existsTracker[existsKey]; !exists { config := fieldRequirements{ - dsHash: dsHash, - path: currentPath, + dsHash: fieldCtx.dsConfig.Hash(), + path: fieldCtx.currentPath, selectionSet: fieldConfiguration.SelectionSet, - requestedByFieldRefs: []int{requestedByFieldRef}, + requestedByFieldRefs: []int{fieldCtx.fieldRef}, isTypenameForEntityInterface: isTypenameForEntityInterface, - deferID: deferID, + deferID: fieldCtx.deferID, } requirements.existsTracker[existsKey] = struct{}{} requirements.requirementConfigs = append(requirements.requirementConfigs, config) } else { for i := range requirements.requirementConfigs { - if requirements.requirementConfigs[i].selectionSet == fieldConfiguration.SelectionSet && requirements.requirementConfigs[i].dsHash == dsHash && requirements.requirementConfigs[i].isTypenameForEntityInterface == isTypenameForEntityInterface { + if requirements.requirementConfigs[i].selectionSet == fieldConfiguration.SelectionSet && requirements.requirementConfigs[i].dsHash == fieldCtx.dsConfig.Hash() && requirements.requirementConfigs[i].isTypenameForEntityInterface == isTypenameForEntityInterface { if slices.IndexFunc(requirements.requirementConfigs[i].requestedByFieldRefs, func(fieldRef int) bool { - return fieldRef == requestedByFieldRef + return fieldRef == fieldCtx.fieldRef }) == -1 { - requirements.requirementConfigs[i].requestedByFieldRefs = append(requirements.requirementConfigs[i].requestedByFieldRefs, requestedByFieldRef) + requirements.requirementConfigs[i].requestedByFieldRefs = append(requirements.requirementConfigs[i].requestedByFieldRefs, fieldCtx.fieldRef) } break } @@ -511,7 +508,7 @@ func (c *nodeSelectionVisitor) addPendingFieldRequirements(requestedByFieldRef i c.pendingFieldRequirements[currentSelectionSet] = requirements } -func (c *nodeSelectionVisitor) addPendingKeyRequirements(requestedByFieldRef int, dsHash DSHash, sc SourceConnection, isInterfaceObject bool, parentPath string, typeName string, deferID string) { +func (c *nodeSelectionVisitor) addPendingKeyRequirements(fieldCtx fieldRequirementsContext, sc SourceConnection, isInterfaceObject bool) { currentSelectionSet := c.currentSelectionSet() requirements, hasRequirements := c.pendingKeyRequirements[currentSelectionSet] @@ -522,25 +519,25 @@ func (c *nodeSelectionVisitor) addPendingKeyRequirements(requestedByFieldRef int } } - existsKey := pendingKeyRequirementExistsKey{dsHash: dsHash, deferID: deferID} + existsKey := pendingKeyRequirementExistsKey{dsHash: fieldCtx.dsConfig.Hash(), deferID: fieldCtx.deferID} if _, exists := requirements.existsTracker[existsKey]; !exists { config := keyRequirements{ - targetDSHash: dsHash, - path: parentPath, + targetDSHash: fieldCtx.dsConfig.Hash(), + path: fieldCtx.parentPath, isInterfaceObject: isInterfaceObject, sc: sc, - requestedByFieldRefs: []int{requestedByFieldRef}, - typeName: typeName, - deferID: deferID, + requestedByFieldRefs: []int{fieldCtx.fieldRef}, + typeName: fieldCtx.typeName, + deferID: fieldCtx.deferID, } requirements.existsTracker[existsKey] = struct{}{} requirements.requirementConfigs = append(requirements.requirementConfigs, config) } else { for i := range requirements.requirementConfigs { - if requirements.requirementConfigs[i].targetDSHash == dsHash && requirements.requirementConfigs[i].deferID == deferID { - if !slices.Contains(requirements.requirementConfigs[i].requestedByFieldRefs, requestedByFieldRef) { - requirements.requirementConfigs[i].requestedByFieldRefs = append(requirements.requirementConfigs[i].requestedByFieldRefs, requestedByFieldRef) + if requirements.requirementConfigs[i].targetDSHash == fieldCtx.dsConfig.Hash() && requirements.requirementConfigs[i].deferID == fieldCtx.deferID { + if !slices.Contains(requirements.requirementConfigs[i].requestedByFieldRefs, fieldCtx.fieldRef) { + requirements.requirementConfigs[i].requestedByFieldRefs = append(requirements.requirementConfigs[i].requestedByFieldRefs, fieldCtx.fieldRef) } break } From 294cd8732d6759fd550e32bd93b9ee74d01400c3 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 10 Mar 2026 21:22:05 +0200 Subject: [PATCH 39/79] alias deferred field requirements --- v2/pkg/engine/plan/required_fields_visitor.go | 23 +++- .../plan/required_fields_visitor_test.go | 122 ++++++++++++++++++ 2 files changed, 138 insertions(+), 7 deletions(-) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index 118596299c..fc137aa2ec 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -224,6 +224,10 @@ func (v *requiredFieldsVisitor) EnterField(ref int) { v.handleRequiredField(ref) } +func (v *requiredFieldsVisitor) isRootLevel() bool { + return len(v.OperationNodes) == 1 +} + func (v *requiredFieldsVisitor) handleRequiredField(ref int) { fieldName := v.key.FieldNameBytes(ref) isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) @@ -233,11 +237,12 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { // - the field has arguments isLeafField := !v.key.FieldHasSelections(ref) needAlias := v.key.FieldHasArguments(ref) + deferAlias := v.config.deferID != "" && v.isRootLevel() selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) - if operationHasField && !needAlias { + if operationHasField && !needAlias && !deferAlias { // we are skipping adding __typename field to the required fields, // because we want to depend only on the regular key fields, not the __typename field // for entity interface we need real typename, so we use this dependency @@ -256,7 +261,7 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { return } - fieldNode := v.addRequiredField(ref, fieldName, selectionSetRef, operationHasField && needAlias) + fieldNode := v.addRequiredField(ref, fieldName, selectionSetRef, deferAlias || (operationHasField && needAlias)) if !isLeafField { v.OperationNodes = append(v.OperationNodes, fieldNode) } @@ -266,10 +271,11 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { fieldName := v.key.FieldNameBytes(ref) isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) isLeafField := !v.key.FieldHasSelections(ref) + deferAlias := v.config.deferID != "" && v.isRootLevel() selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) - if operationHasField { + if operationHasField && !deferAlias { // we are skipping adding __typename field to the required fields, // because we want to depend only on the regular key fields, not the __typename field // for entity interface we need real typename, so we use this dependency @@ -288,7 +294,7 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { return } - fieldNode := v.addRequiredField(ref, fieldName, selectionSetRef, false) + fieldNode := v.addRequiredField(ref, fieldName, selectionSetRef, deferAlias) if !isLeafField { v.OperationNodes = append(v.OperationNodes, fieldNode) } @@ -311,9 +317,12 @@ func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteS } if addAlias { - aliasName := bytes.NewBuffer([]byte("__internal_")) - aliasName.Write(fieldName) - fullAliasName := aliasName.Bytes() + var fullAliasName []byte + if v.config.deferID != "" { + fullAliasName = []byte(fmt.Sprintf("__internal_%s_%s", v.config.deferID, string(fieldName))) + } else { + fullAliasName = append([]byte("__internal_"), fieldName...) + } field.Alias = ast.Alias{ IsDefined: true, diff --git a/v2/pkg/engine/plan/required_fields_visitor_test.go b/v2/pkg/engine/plan/required_fields_visitor_test.go index 0d305f4c6e..fce6a7b880 100644 --- a/v2/pkg/engine/plan/required_fields_visitor_test.go +++ b/v2/pkg/engine/plan/required_fields_visitor_test.go @@ -23,6 +23,7 @@ func TestAddRequiredFields(t *testing.T) { isTypeNameForEntityInterface bool selectionSetRef int enforceTypenameForRequired bool + deferID string // output expectedOperation string @@ -484,6 +485,126 @@ func TestAddRequiredFields(t *testing.T) { expectedSkipFieldsCount: 8, // id, account, __typename, id, type, settings, __typename, theme expectedRequiredFieldsCount: 6, }, + { + name: "key with defer id - new field gets aliased", + definition: ` + type Query { user: User } + type User { id: ID! name: String! }`, + operation: `query { user { name } }`, + typeName: "User", + fieldSet: "id", + isKey: true, + deferID: "1", + expectedOperation: ` + query { + user { + name + __internal_1_id: id + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.id": "__internal_1_id"}, + }, + { + name: "key with defer id - existing field still gets aliased", + definition: ` + type Query { user: User } + type User { id: ID! name: String! }`, + operation: `query { user { id name } }`, + typeName: "User", + fieldSet: "id", + isKey: true, + deferID: "1", + expectedOperation: ` + query { + user { + id + name + __internal_1_id: id + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.id": "__internal_1_id"}, + }, + { + name: "requires with defer id - new field gets aliased", + definition: ` + type Query { user: User } + type User { id: ID! firstName: String! lastName: String! fullName: String! }`, + operation: `query { user { fullName } }`, + typeName: "User", + fieldSet: "firstName lastName", + isKey: false, + deferID: "1", + expectedOperation: ` + query { + user { + fullName + __internal_1_firstName: firstName + __internal_1_lastName: lastName + } + }`, + expectedSkipFieldsCount: 2, + expectedRequiredFieldsCount: 2, + expectedRemappedPaths: map[string]string{ + "User.firstName": "__internal_1_firstName", + "User.lastName": "__internal_1_lastName", + }, + }, + { + name: "requires with defer id - existing field still gets aliased", + definition: ` + type Query { user: User } + type User { id: ID! firstName: String! fullName: String! }`, + operation: `query { user { firstName fullName } }`, + typeName: "User", + fieldSet: "firstName", + isKey: false, + deferID: "1", + expectedOperation: ` + query { + user { + firstName + fullName + __internal_1_firstName: firstName + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.firstName": "__internal_1_firstName"}, + }, + { + name: "key with defer id - only root fields aliased, nested fields are not", + definition: ` + type Query { user: User } + type User { id: ID! address: Address! } + type Address { street: String! city: String! }`, + operation: `query { user { address { city } } }`, + typeName: "User", + fieldSet: "address { street }", + isKey: true, + deferID: "1", + selectionSetRef: 1, + // with deferAlias, the existing address field is left untouched; + // a new aliased field with its own selection set is created + expectedOperation: ` + query { + user { + address { + city + } + __internal_1_address: address { + street + } + } + }`, + expectedSkipFieldsCount: 2, // __internal_1_address, street inside it + expectedRequiredFieldsCount: 2, + expectedModifiedFieldsCount: 0, + expectedRemappedPaths: map[string]string{"User.address": "__internal_1_address"}, + }, } for _, tt := range tests { @@ -500,6 +621,7 @@ func TestAddRequiredFields(t *testing.T) { allowTypename: tt.allowTypename, typeName: tt.typeName, fieldSet: tt.fieldSet, + deferID: tt.deferID, addTypenameInNestedSelections: tt.enforceTypenameForRequired, } From a1dfc51d2bfce2ef77a70cc4f46ddee9d6cde057 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 10 Mar 2026 21:48:24 +0200 Subject: [PATCH 40/79] pass whole defer info into required fields visitor --- v2/pkg/engine/plan/node_selection_visitor.go | 35 ++++++++++--------- v2/pkg/engine/plan/required_fields_visitor.go | 10 +++--- .../plan/required_fields_visitor_test.go | 16 ++++----- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index 9e518a2e6f..61a295f27d 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -98,7 +98,7 @@ type keyRequirements struct { sc SourceConnection requestedByFieldRefs []int typeName string - deferID string + deferInfo *DeferInfo } type fieldRequirements struct { @@ -107,7 +107,7 @@ type fieldRequirements struct { selectionSet string requestedByFieldRefs []int isTypenameForEntityInterface bool - deferID string + deferInfo *DeferInfo } type pendingFieldRequirements struct { @@ -228,7 +228,7 @@ type fieldRequirementsContext struct { fieldName string currentPath string dsConfig DataSource - deferID string + deferInfo *DeferInfo } func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires bool) { @@ -257,11 +257,6 @@ func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires boo return } - deferID := "" - if suggestion.deferInfo != nil { - deferID = suggestion.deferInfo.ID - } - fieldCtx := fieldRequirementsContext{ fieldRef: fieldRef, parentPath: parentPath, @@ -269,7 +264,7 @@ func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires boo fieldName: fieldName, currentPath: currentPath, dsConfig: c.dataSources[dsIdx], - deferID: deferID, + deferInfo: suggestion.deferInfo, } if handleRequires { @@ -479,7 +474,11 @@ func (c *nodeSelectionVisitor) addPendingFieldRequirements(fieldCtx fieldRequire } } - existsKey := pendingFieldRequirementExistsKey{fieldCtx.dsConfig.Hash(), fieldConfiguration.SelectionSet, isTypenameForEntityInterface, fieldCtx.deferID} + deferID := "" + if fieldCtx.deferInfo != nil { + deferID = fieldCtx.deferInfo.ID + } + existsKey := pendingFieldRequirementExistsKey{fieldCtx.dsConfig.Hash(), fieldConfiguration.SelectionSet, isTypenameForEntityInterface, deferID} if _, exists := requirements.existsTracker[existsKey]; !exists { config := fieldRequirements{ dsHash: fieldCtx.dsConfig.Hash(), @@ -487,7 +486,7 @@ func (c *nodeSelectionVisitor) addPendingFieldRequirements(fieldCtx fieldRequire selectionSet: fieldConfiguration.SelectionSet, requestedByFieldRefs: []int{fieldCtx.fieldRef}, isTypenameForEntityInterface: isTypenameForEntityInterface, - deferID: fieldCtx.deferID, + deferInfo: fieldCtx.deferInfo, } requirements.existsTracker[existsKey] = struct{}{} @@ -519,7 +518,11 @@ func (c *nodeSelectionVisitor) addPendingKeyRequirements(fieldCtx fieldRequireme } } - existsKey := pendingKeyRequirementExistsKey{dsHash: fieldCtx.dsConfig.Hash(), deferID: fieldCtx.deferID} + deferID := "" + if fieldCtx.deferInfo != nil { + deferID = fieldCtx.deferInfo.ID + } + existsKey := pendingKeyRequirementExistsKey{dsHash: fieldCtx.dsConfig.Hash(), deferID: deferID} if _, exists := requirements.existsTracker[existsKey]; !exists { config := keyRequirements{ targetDSHash: fieldCtx.dsConfig.Hash(), @@ -528,14 +531,14 @@ func (c *nodeSelectionVisitor) addPendingKeyRequirements(fieldCtx fieldRequireme sc: sc, requestedByFieldRefs: []int{fieldCtx.fieldRef}, typeName: fieldCtx.typeName, - deferID: fieldCtx.deferID, + deferInfo: fieldCtx.deferInfo, } requirements.existsTracker[existsKey] = struct{}{} requirements.requirementConfigs = append(requirements.requirementConfigs, config) } else { for i := range requirements.requirementConfigs { - if requirements.requirementConfigs[i].targetDSHash == fieldCtx.dsConfig.Hash() && requirements.requirementConfigs[i].deferID == fieldCtx.deferID { + if requirements.requirementConfigs[i].targetDSHash == fieldCtx.dsConfig.Hash() && requirements.requirementConfigs[i].deferInfo == fieldCtx.deferInfo { if !slices.Contains(requirements.requirementConfigs[i].requestedByFieldRefs, fieldCtx.fieldRef) { requirements.requirementConfigs[i].requestedByFieldRefs = append(requirements.requirementConfigs[i].requestedByFieldRefs, fieldCtx.fieldRef) } @@ -571,7 +574,7 @@ func (c *nodeSelectionVisitor) addFieldRequirementsToOperation(selectionSetRef i allowTypename: false, typeName: typeName, fieldSet: requirements.selectionSet, - deferID: requirements.deferID, + deferInfo: requirements.deferInfo, addTypenameInNestedSelections: c.addTypenameInNestedSelections, } @@ -660,7 +663,7 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int allowTypename: allowTypeName, typeName: jump.TypeName, fieldSet: jump.SelectionSet, - deferID: pendingKey.deferID, + deferInfo: pendingKey.deferInfo, } addFieldsResult, report := addRequiredFields(input) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index fc137aa2ec..f4f96888c0 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -58,7 +58,7 @@ type addRequiredFieldsConfiguration struct { allowTypename bool typeName string fieldSet string - deferID string + deferInfo *DeferInfo // addTypenameInNestedSelections controls forced addition of __typename to nested selection sets // used by "requires" keys, not only when fragments are present. @@ -237,7 +237,7 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { // - the field has arguments isLeafField := !v.key.FieldHasSelections(ref) needAlias := v.key.FieldHasArguments(ref) - deferAlias := v.config.deferID != "" && v.isRootLevel() + deferAlias := v.config.deferInfo != nil && v.isRootLevel() selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) @@ -271,7 +271,7 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { fieldName := v.key.FieldNameBytes(ref) isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) isLeafField := !v.key.FieldHasSelections(ref) - deferAlias := v.config.deferID != "" && v.isRootLevel() + deferAlias := v.config.deferInfo != nil && v.isRootLevel() selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) @@ -318,8 +318,8 @@ func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteS if addAlias { var fullAliasName []byte - if v.config.deferID != "" { - fullAliasName = []byte(fmt.Sprintf("__internal_%s_%s", v.config.deferID, string(fieldName))) + if v.config.deferInfo != nil { + fullAliasName = []byte(fmt.Sprintf("__internal_%s_%s", v.config.deferInfo.ID, string(fieldName))) } else { fullAliasName = append([]byte("__internal_"), fieldName...) } diff --git a/v2/pkg/engine/plan/required_fields_visitor_test.go b/v2/pkg/engine/plan/required_fields_visitor_test.go index fce6a7b880..4dc156dbf0 100644 --- a/v2/pkg/engine/plan/required_fields_visitor_test.go +++ b/v2/pkg/engine/plan/required_fields_visitor_test.go @@ -22,8 +22,8 @@ func TestAddRequiredFields(t *testing.T) { allowTypename bool isTypeNameForEntityInterface bool selectionSetRef int - enforceTypenameForRequired bool - deferID string + enforceTypenameForRequired bool + deferInfo *DeferInfo // output expectedOperation string @@ -494,7 +494,7 @@ func TestAddRequiredFields(t *testing.T) { typeName: "User", fieldSet: "id", isKey: true, - deferID: "1", + deferInfo: &DeferInfo{ID: "1"}, expectedOperation: ` query { user { @@ -515,7 +515,7 @@ func TestAddRequiredFields(t *testing.T) { typeName: "User", fieldSet: "id", isKey: true, - deferID: "1", + deferInfo: &DeferInfo{ID: "1"}, expectedOperation: ` query { user { @@ -537,7 +537,7 @@ func TestAddRequiredFields(t *testing.T) { typeName: "User", fieldSet: "firstName lastName", isKey: false, - deferID: "1", + deferInfo: &DeferInfo{ID: "1"}, expectedOperation: ` query { user { @@ -562,7 +562,7 @@ func TestAddRequiredFields(t *testing.T) { typeName: "User", fieldSet: "firstName", isKey: false, - deferID: "1", + deferInfo: &DeferInfo{ID: "1"}, expectedOperation: ` query { user { @@ -585,7 +585,7 @@ func TestAddRequiredFields(t *testing.T) { typeName: "User", fieldSet: "address { street }", isKey: true, - deferID: "1", + deferInfo: &DeferInfo{ID: "1"}, selectionSetRef: 1, // with deferAlias, the existing address field is left untouched; // a new aliased field with its own selection set is created @@ -621,7 +621,7 @@ func TestAddRequiredFields(t *testing.T) { allowTypename: tt.allowTypename, typeName: tt.typeName, fieldSet: tt.fieldSet, - deferID: tt.deferID, + deferInfo: tt.deferInfo, addTypenameInNestedSelections: tt.enforceTypenameForRequired, } From 7746a47e6e13084c1c1071c9d6d8edfdad126bed Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 10 Mar 2026 22:17:03 +0200 Subject: [PATCH 41/79] add required fields with defer directives applied --- v2/pkg/engine/plan/required_fields_visitor.go | 54 +++++++++++ .../plan/required_fields_visitor_test.go | 95 +++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index f4f96888c0..11438e4f91 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -8,6 +8,7 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/astimport" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) @@ -357,5 +358,58 @@ func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteS v.storeRequiredFieldRef(addedField.Ref) } + if v.config.deferInfo != nil && v.config.deferInfo.ParentID != "" { + v.addDeferInternalDirective(addedField.Ref) + } + return addedField } + +func (v *requiredFieldsVisitor) addDeferInternalDirective(fieldRef int) { + deferInfo := v.config.deferInfo + var argRefs []int + + if v.config.isKey { + // for key fields: use parentId as the id + // key should be in scope of parent defer id, not be the deferred inside same fragment, + // otherwise it can't be planned properly + argRefs = append(argRefs, v.addStringArgument("id", deferInfo.ParentID)) + } else { + // for requires fields: include all deferInfo fields + // required fields goes into the same scope as the current defer + argRefs = append(argRefs, v.addStringArgument("id", deferInfo.ID)) + + if deferInfo.Label != "" { + argRefs = append(argRefs, v.addStringArgument("label", deferInfo.Label)) + } + if deferInfo.ParentID != "" { + argRefs = append(argRefs, v.addStringArgument("parentDeferId", deferInfo.ParentID)) + } + } + + directive := ast.Directive{ + Name: v.config.operation.Input.AppendInputBytes(literal.DEFER_INTERNAL), + HasArguments: len(argRefs) > 0, + Arguments: ast.ArgumentList{ + Refs: argRefs, + }, + } + directiveRef := v.config.operation.AddDirective(directive) + v.config.operation.AddDirectiveToNode(directiveRef, ast.Node{ + Kind: ast.NodeKindField, + Ref: fieldRef, + }) +} + +func (v *requiredFieldsVisitor) addStringArgument(name, value string) int { + strValueRef := v.config.operation.AddStringValue(ast.StringValue{ + Content: v.config.operation.Input.AppendInputString(value), + }) + + arg := ast.Argument{ + Name: v.config.operation.Input.AppendInputString(name), + Value: ast.Value{Kind: ast.ValueKindString, Ref: strValueRef}, + } + + return v.config.operation.AddArgument(arg) +} diff --git a/v2/pkg/engine/plan/required_fields_visitor_test.go b/v2/pkg/engine/plan/required_fields_visitor_test.go index 4dc156dbf0..0cfca070af 100644 --- a/v2/pkg/engine/plan/required_fields_visitor_test.go +++ b/v2/pkg/engine/plan/required_fields_visitor_test.go @@ -605,6 +605,101 @@ func TestAddRequiredFields(t *testing.T) { expectedModifiedFieldsCount: 0, expectedRemappedPaths: map[string]string{"User.address": "__internal_1_address"}, }, + { + name: "key with defer id and parentId - directive added to aliased field", + definition: ` + type Query { user: User } + type User { id: ID! name: String! }`, + operation: `query { user { name } }`, + typeName: "User", + fieldSet: "id", + isKey: true, + deferInfo: &DeferInfo{ID: "2", ParentID: "1"}, + expectedOperation: ` + query { + user { + name + __internal_2_id: id @__defer_internal(id: "1") + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.id": "__internal_2_id"}, + }, + { + name: "requires with defer id and parentId - directive added with all fields", + definition: ` + type Query { user: User } + type User { id: ID! firstName: String! fullName: String! }`, + operation: `query { user { fullName } }`, + typeName: "User", + fieldSet: "firstName", + isKey: false, + deferInfo: &DeferInfo{ID: "2", Label: "myLabel", ParentID: "1"}, + expectedOperation: ` + query { + user { + fullName + __internal_2_firstName: firstName @__defer_internal(id: "2", label: "myLabel", parentDeferId: "1") + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.firstName": "__internal_2_firstName"}, + }, + { + name: "key with defer id and parentId - directive added to nested fields too", + definition: ` + type Query { user: User } + type User { id: ID! address: Address! } + type Address { street: String! city: String! }`, + operation: `query { user { address { city } } }`, + typeName: "User", + fieldSet: "address { street }", + isKey: true, + deferInfo: &DeferInfo{ID: "2", ParentID: "1"}, + selectionSetRef: 1, + expectedOperation: ` + query { + user { + address { + city + } + __internal_2_address: address @__defer_internal(id: "1") { + street @__defer_internal(id: "1") + } + } + }`, + expectedSkipFieldsCount: 2, + expectedRequiredFieldsCount: 2, + expectedModifiedFieldsCount: 0, + expectedRemappedPaths: map[string]string{"User.address": "__internal_2_address"}, + }, + { + name: "requires with defer id and parentId - directive added to nested fields too", + definition: ` + type Query { user: User } + type User { id: ID! address: Address! fullAddress: String! } + type Address { street: String! city: String! }`, + operation: `query { user { fullAddress } }`, + typeName: "User", + fieldSet: "address { street }", + isKey: false, + deferInfo: &DeferInfo{ID: "2", ParentID: "1"}, + expectedOperation: ` + query { + user { + fullAddress + __internal_2_address: address @__defer_internal(id: "2", parentDeferId: "1") { + street @__defer_internal(id: "2", parentDeferId: "1") + } + } + }`, + expectedSkipFieldsCount: 2, + expectedRequiredFieldsCount: 2, + expectedModifiedFieldsCount: 0, + expectedRemappedPaths: map[string]string{"User.address": "__internal_2_address"}, + }, } for _, tt := range tests { From 5fa2794004a3f9f50fe04aea193a0c6d615611a7 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 10 Mar 2026 22:43:48 +0200 Subject: [PATCH 42/79] handle edge case when we are adding requirementes for non deferred path but existing field is deffered --- v2/pkg/engine/plan/required_fields_visitor.go | 20 +++- .../plan/required_fields_visitor_test.go | 99 +++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index 11438e4f91..523738a203 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -190,6 +190,11 @@ func (v *requiredFieldsVisitor) EnterSelectionSet(ref int) { v.OperationNodes = append(v.OperationNodes, selectionSetNode) } +func (v *requiredFieldsVisitor) fieldHasDeferInternal(fieldRef int) bool { + _, exists := v.config.operation.Fields[fieldRef].Directives.HasDirectiveByNameBytes(v.config.operation, literal.DEFER_INTERNAL) + return exists +} + func (v *requiredFieldsVisitor) selectionSetHasTypeNameSelection(operationSelectionSetRef int) bool { exists, _ := v.config.operation.SelectionSetHasFieldSelectionWithExactName(operationSelectionSetRef, typeNameFieldBytes) return exists @@ -243,6 +248,12 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) + // if the existing field is deferred but we are adding requirements for a non-deferred scope, + // we must not reuse it — add an alias instead + if operationHasField && v.config.deferInfo == nil && v.fieldHasDeferInternal(operationFieldRef) { + needAlias = true + } + if operationHasField && !needAlias && !deferAlias { // we are skipping adding __typename field to the required fields, // because we want to depend only on the regular key fields, not the __typename field @@ -276,7 +287,12 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) - if operationHasField && !deferAlias { + + // if the existing field is deferred but we are adding requirements for a non-deferred scope, + // we must not reuse it — add an alias instead + existingFieldIsDeferred := operationHasField && v.config.deferInfo == nil && v.fieldHasDeferInternal(operationFieldRef) + + if operationHasField && !deferAlias && !existingFieldIsDeferred { // we are skipping adding __typename field to the required fields, // because we want to depend only on the regular key fields, not the __typename field // for entity interface we need real typename, so we use this dependency @@ -295,7 +311,7 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { return } - fieldNode := v.addRequiredField(ref, fieldName, selectionSetRef, deferAlias) + fieldNode := v.addRequiredField(ref, fieldName, selectionSetRef, deferAlias || existingFieldIsDeferred) if !isLeafField { v.OperationNodes = append(v.OperationNodes, fieldNode) } diff --git a/v2/pkg/engine/plan/required_fields_visitor_test.go b/v2/pkg/engine/plan/required_fields_visitor_test.go index 0cfca070af..62969879ab 100644 --- a/v2/pkg/engine/plan/required_fields_visitor_test.go +++ b/v2/pkg/engine/plan/required_fields_visitor_test.go @@ -700,6 +700,105 @@ func TestAddRequiredFields(t *testing.T) { expectedModifiedFieldsCount: 0, expectedRemappedPaths: map[string]string{"User.address": "__internal_2_address"}, }, + { + name: "key - existing field has defer_internal, non-deferred requirement gets aliased", + definition: ` + type Query { user: User } + type User { id: ID! name: String! }`, + operation: `query { user { id @__defer_internal(id: "1") name } }`, + typeName: "User", + fieldSet: "id", + isKey: true, + deferInfo: nil, + expectedOperation: ` + query { + user { + id @__defer_internal(id: "1") + name + __internal_id: id + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.id": "__internal_id"}, + }, + { + name: "requires - existing field has defer_internal, non-deferred requirement gets aliased", + definition: ` + type Query { user: User } + type User { id: ID! firstName: String! fullName: String! }`, + operation: `query { user { firstName @__defer_internal(id: "1") fullName } }`, + typeName: "User", + fieldSet: "firstName", + isKey: false, + deferInfo: nil, + expectedOperation: ` + query { + user { + firstName @__defer_internal(id: "1") + fullName + __internal_firstName: firstName + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.firstName": "__internal_firstName"}, + }, + { + name: "key - nested field has defer_internal, non-deferred requirement gets aliased", + definition: ` + type Query { user: User } + type User { id: ID! address: Address! } + type Address { street: String! city: String! }`, + operation: `query { user { address { street @__defer_internal(id: "1") city } } }`, + typeName: "User", + fieldSet: "address { street }", + isKey: true, + deferInfo: nil, + selectionSetRef: 1, + expectedOperation: ` + query { + user { + address { + street @__defer_internal(id: "1") + city + __internal_street: street + } + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 2, // address (reused) + __internal_street + expectedModifiedFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.address.street": "__internal_street"}, + }, + { + name: "requires - nested field has defer_internal, non-deferred requirement gets aliased", + definition: ` + type Query { user: User } + type User { id: ID! address: Address! fullAddress: String! } + type Address { street: String! city: String! }`, + operation: `query { user { address { street @__defer_internal(id: "1") city } fullAddress } }`, + typeName: "User", + fieldSet: "address { street }", + isKey: false, + deferInfo: nil, + selectionSetRef: 1, + expectedOperation: ` + query { + user { + address { + street @__defer_internal(id: "1") + city + __internal_street: street + } + fullAddress + } + }`, + expectedSkipFieldsCount: 1, + expectedRequiredFieldsCount: 2, // address (reused) + __internal_street + expectedModifiedFieldsCount: 1, + expectedRemappedPaths: map[string]string{"User.address.street": "__internal_street"}, + }, } for _, tt := range tests { From 78921e64b386ef4676d01c91c554bdb91623eb78 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 11 Mar 2026 21:31:54 +0200 Subject: [PATCH 43/79] pass information about parent field defer into required fields visitor, use it to apply parent scope defers --- v2/pkg/engine/plan/node_selection_visitor.go | 58 ++++++++++++++----- v2/pkg/engine/plan/required_fields_visitor.go | 57 +++++++++++------- .../plan/required_fields_visitor_test.go | 30 +++++----- 3 files changed, 97 insertions(+), 48 deletions(-) diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index 61a295f27d..98740725e5 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -7,6 +7,7 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" ) // nodeSelectionVisitor walks through the operation multiple times to rewrite it @@ -99,6 +100,7 @@ type keyRequirements struct { requestedByFieldRefs []int typeName string deferInfo *DeferInfo + parentFieldDeferID string } type fieldRequirements struct { @@ -108,6 +110,7 @@ type fieldRequirements struct { requestedByFieldRefs []int isTypenameForEntityInterface bool deferInfo *DeferInfo + parentFieldDeferID string } type pendingFieldRequirements struct { @@ -222,13 +225,14 @@ func (c *nodeSelectionVisitor) EnterField(fieldRef int) { } type fieldRequirementsContext struct { - fieldRef int - parentPath string - typeName string - fieldName string - currentPath string - dsConfig DataSource - deferInfo *DeferInfo + fieldRef int + parentPath string + typeName string + fieldName string + currentPath string + dsConfig DataSource + deferInfo *DeferInfo + parentFieldDeferID string } func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires bool) { @@ -258,13 +262,14 @@ func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires boo } fieldCtx := fieldRequirementsContext{ - fieldRef: fieldRef, - parentPath: parentPath, - typeName: typeName, - fieldName: fieldName, - currentPath: currentPath, - dsConfig: c.dataSources[dsIdx], - deferInfo: suggestion.deferInfo, + fieldRef: fieldRef, + parentPath: parentPath, + typeName: typeName, + fieldName: fieldName, + currentPath: currentPath, + dsConfig: c.dataSources[dsIdx], + deferInfo: suggestion.deferInfo, + parentFieldDeferID: c.wrappingFieldDeferID(), } if handleRequires { @@ -284,6 +289,27 @@ func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires boo } } +// wrappingFieldDeferID walks the walker ancestors in reverse to find the nearest wrapping field +// that has a @__defer_internal directive and returns its "id" argument value. +func (c *nodeSelectionVisitor) wrappingFieldDeferID() string { + for i := len(c.walker.Ancestors) - 1; i >= 0; i-- { + ancestor := c.walker.Ancestors[i] + if ancestor.Kind != ast.NodeKindField { + continue + } + directiveRef, exists := c.operation.Fields[ancestor.Ref].Directives.HasDirectiveByNameBytes(c.operation, literal.DEFER_INTERNAL) + if !exists { + return "" + } + idValue, ok := c.operation.DirectiveArgumentValueByName(directiveRef, []byte("id")) + if !ok { + return "" + } + return c.operation.StringValueContentString(idValue.Ref) + } + return "" +} + func (c *nodeSelectionVisitor) LeaveField(ref int) { if bytes.Equal(c.operation.FieldAliasOrNameBytes(ref), []byte("__internal__typename_placeholder")) { // we should skip such typename as it was added as a placeholder to keep query valid @@ -487,6 +513,7 @@ func (c *nodeSelectionVisitor) addPendingFieldRequirements(fieldCtx fieldRequire requestedByFieldRefs: []int{fieldCtx.fieldRef}, isTypenameForEntityInterface: isTypenameForEntityInterface, deferInfo: fieldCtx.deferInfo, + parentFieldDeferID: fieldCtx.parentFieldDeferID, } requirements.existsTracker[existsKey] = struct{}{} @@ -532,6 +559,7 @@ func (c *nodeSelectionVisitor) addPendingKeyRequirements(fieldCtx fieldRequireme requestedByFieldRefs: []int{fieldCtx.fieldRef}, typeName: fieldCtx.typeName, deferInfo: fieldCtx.deferInfo, + parentFieldDeferID: fieldCtx.parentFieldDeferID, } requirements.existsTracker[existsKey] = struct{}{} @@ -575,6 +603,7 @@ func (c *nodeSelectionVisitor) addFieldRequirementsToOperation(selectionSetRef i typeName: typeName, fieldSet: requirements.selectionSet, deferInfo: requirements.deferInfo, + parentFieldDeferID: requirements.parentFieldDeferID, addTypenameInNestedSelections: c.addTypenameInNestedSelections, } @@ -664,6 +693,7 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int typeName: jump.TypeName, fieldSet: jump.SelectionSet, deferInfo: pendingKey.deferInfo, + parentFieldDeferID: pendingKey.parentFieldDeferID, } addFieldsResult, report := addRequiredFields(input) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index 523738a203..25f574021a 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -60,6 +60,7 @@ type addRequiredFieldsConfiguration struct { typeName string fieldSet string deferInfo *DeferInfo + parentFieldDeferID string // addTypenameInNestedSelections controls forced addition of __typename to nested selection sets // used by "requires" keys, not only when fragments are present. @@ -238,7 +239,7 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { fieldName := v.key.FieldNameBytes(ref) isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) - // we need to add alias if operation has such field and: + // we need to add an alias if the operation has such a field and: // - the field is not a leaf // - the field has arguments isLeafField := !v.key.FieldHasSelections(ref) @@ -374,33 +375,47 @@ func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteS v.storeRequiredFieldRef(addedField.Ref) } - if v.config.deferInfo != nil && v.config.deferInfo.ParentID != "" { - v.addDeferInternalDirective(addedField.Ref) - } + v.applyDeferInternalDirective(addedField.Ref) return addedField } -func (v *requiredFieldsVisitor) addDeferInternalDirective(fieldRef int) { - deferInfo := v.config.deferInfo - var argRefs []int +func (v *requiredFieldsVisitor) applyDeferInternalDirective(fieldRef int) { + if v.config.deferInfo == nil { + return + } - if v.config.isKey { - // for key fields: use parentId as the id - // key should be in scope of parent defer id, not be the deferred inside same fragment, + // when we are adding required fields from the requires directive + if !v.config.isKey { + // required fields should land in the same scope as the current field + // to be fetched in the same defer group, but not in the parent scope + v.addDeferInternalDirective(fieldRef, v.config.deferInfo) + return + } + + // when we are adding key fields + // and the parent field has the defer id + if v.config.parentFieldDeferID != "" { + // for key fields: use parentFieldDeferID as the id + // key should be in scope of the parent defer id, not be the deferred inside the same fragment, // otherwise it can't be planned properly - argRefs = append(argRefs, v.addStringArgument("id", deferInfo.ParentID)) - } else { - // for requires fields: include all deferInfo fields - // required fields goes into the same scope as the current defer - argRefs = append(argRefs, v.addStringArgument("id", deferInfo.ID)) + v.addDeferInternalDirective(fieldRef, &DeferInfo{ID: v.config.parentFieldDeferID}) + } - if deferInfo.Label != "" { - argRefs = append(argRefs, v.addStringArgument("label", deferInfo.Label)) - } - if deferInfo.ParentID != "" { - argRefs = append(argRefs, v.addStringArgument("parentDeferId", deferInfo.ParentID)) - } + // if the parent field does not have a defer id, + // fields should be unscoped, as is the parent field itself +} + +func (v *requiredFieldsVisitor) addDeferInternalDirective(fieldRef int, deferInfo *DeferInfo) { + var argRefs []int + + argRefs = append(argRefs, v.addStringArgument("id", deferInfo.ID)) + + if deferInfo.Label != "" { + argRefs = append(argRefs, v.addStringArgument("label", deferInfo.Label)) + } + if deferInfo.ParentID != "" { + argRefs = append(argRefs, v.addStringArgument("parentDeferId", deferInfo.ParentID)) } directive := ast.Directive{ diff --git a/v2/pkg/engine/plan/required_fields_visitor_test.go b/v2/pkg/engine/plan/required_fields_visitor_test.go index 62969879ab..805278c4c6 100644 --- a/v2/pkg/engine/plan/required_fields_visitor_test.go +++ b/v2/pkg/engine/plan/required_fields_visitor_test.go @@ -24,6 +24,7 @@ func TestAddRequiredFields(t *testing.T) { selectionSetRef int enforceTypenameForRequired bool deferInfo *DeferInfo + parentFieldDeferID string // output expectedOperation string @@ -542,8 +543,8 @@ func TestAddRequiredFields(t *testing.T) { query { user { fullName - __internal_1_firstName: firstName - __internal_1_lastName: lastName + __internal_1_firstName: firstName @__defer_internal(id: "1") + __internal_1_lastName: lastName @__defer_internal(id: "1") } }`, expectedSkipFieldsCount: 2, @@ -568,7 +569,7 @@ func TestAddRequiredFields(t *testing.T) { user { firstName fullName - __internal_1_firstName: firstName + __internal_1_firstName: firstName @__defer_internal(id: "1") } }`, expectedSkipFieldsCount: 1, @@ -611,10 +612,11 @@ func TestAddRequiredFields(t *testing.T) { type Query { user: User } type User { id: ID! name: String! }`, operation: `query { user { name } }`, - typeName: "User", - fieldSet: "id", - isKey: true, - deferInfo: &DeferInfo{ID: "2", ParentID: "1"}, + typeName: "User", + fieldSet: "id", + isKey: true, + deferInfo: &DeferInfo{ID: "2", ParentID: "2"}, + parentFieldDeferID: "1", expectedOperation: ` query { user { @@ -653,12 +655,13 @@ func TestAddRequiredFields(t *testing.T) { type Query { user: User } type User { id: ID! address: Address! } type Address { street: String! city: String! }`, - operation: `query { user { address { city } } }`, - typeName: "User", - fieldSet: "address { street }", - isKey: true, - deferInfo: &DeferInfo{ID: "2", ParentID: "1"}, - selectionSetRef: 1, + operation: `query { user { address { city } } }`, + typeName: "User", + fieldSet: "address { street }", + isKey: true, + deferInfo: &DeferInfo{ID: "2", ParentID: "1"}, + parentFieldDeferID: "1", + selectionSetRef: 1, expectedOperation: ` query { user { @@ -816,6 +819,7 @@ func TestAddRequiredFields(t *testing.T) { typeName: tt.typeName, fieldSet: tt.fieldSet, deferInfo: tt.deferInfo, + parentFieldDeferID: tt.parentFieldDeferID, addTypenameInNestedSelections: tt.enforceTypenameForRequired, } From 8db4c192629af5b11881af1ed8e1c4b591aa3aa9 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 12 Mar 2026 20:58:40 +0200 Subject: [PATCH 44/79] fix placing and scope of the defer typename placeholder --- v2/pkg/astnormalization/astnormalization.go | 3 +- .../astnormalization/astnormalization_test.go | 1 + .../astnormalization/defer_ensure_typename.go | 134 ++++++++++++++---- .../defer_ensure_typename_test.go | 86 ++++++----- .../directive_include_skip.go | 5 +- .../directive_include_skip_test.go | 30 ++-- v2/pkg/engine/plan/node_selection_visitor.go | 4 +- 7 files changed, 174 insertions(+), 89 deletions(-) diff --git a/v2/pkg/astnormalization/astnormalization.go b/v2/pkg/astnormalization/astnormalization.go index 2a474df161..52a7ab1295 100644 --- a/v2/pkg/astnormalization/astnormalization.go +++ b/v2/pkg/astnormalization/astnormalization.go @@ -227,6 +227,8 @@ func (o *OperationNormalizer) setupOperationWalkers() { cleanup := astvisitor.NewWalkerWithID(8, "Cleanup") deduplicateFields(&cleanup) + // should happen after inlining defer fragments, to not produce unnecessary typename placeholders + deferEnsureTypename(&cleanup) if o.options.removeUnusedVariables { del := deleteUnusedVariables(&cleanup) // register variable usage detection on the first stage @@ -252,7 +254,6 @@ func (o *OperationNormalizer) setupOperationWalkers() { if o.options.inlineDefer { inlineDefer := astvisitor.NewWalkerWithID(8, "Inline defer") - deferEnsureTypename(&inlineDefer) inlineFragmentExpandDefer(&inlineDefer) o.operationWalkers = append(o.operationWalkers, walkerStage{ name: "inlineDefer", diff --git a/v2/pkg/astnormalization/astnormalization_test.go b/v2/pkg/astnormalization/astnormalization_test.go index 8d1285f4d7..7f0ed84ce6 100644 --- a/v2/pkg/astnormalization/astnormalization_test.go +++ b/v2/pkg/astnormalization/astnormalization_test.go @@ -565,6 +565,7 @@ func TestNormalizeOperation(t *testing.T) { noString @__defer_internal(id: "3") string @__defer_internal(id: "4") } + ___typename: __typename } ... on Cat { name diff --git a/v2/pkg/astnormalization/defer_ensure_typename.go b/v2/pkg/astnormalization/defer_ensure_typename.go index 5284390e1a..3c2cabbfd5 100644 --- a/v2/pkg/astnormalization/defer_ensure_typename.go +++ b/v2/pkg/astnormalization/defer_ensure_typename.go @@ -6,9 +6,21 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" ) -// deferEnsureTypename registers a visitor that -// adds internal typename to a selection set of non deferred field -// if all it's fields are deferred +// deferEnsureTypename registers a visitor that ensures a non-deferred field always +// has at least one non-deferred field selection (a __typename placeholder) when all +// of its child fields carry @__defer_internal. This runs after defer expansion, so +// only the expanded field form with @__defer_internal is considered. +// +// This placeholder is necessary for the planner to not produce an empty selection set, +// when all nested fields are deffered +// +// When the enclosing parent field is not deferred, a plain placeholder is added. +// +// When the enclosing parent field is itself deferred, a placeholder is added only if +// none of the child fields share the same defer id as the parent (no intersection). +// In that case the placeholder is annotated with the parent's defer id so it lands +// in the correct defer scope. If there is an intersection (at least one child field +// has the same defer id as the parent), no placeholder is needed. func deferEnsureTypename(walker *astvisitor.Walker) { visitor := deferEnsureTypenameVisitor{ Walker: walker, @@ -28,39 +40,111 @@ func (f *deferEnsureTypenameVisitor) EnterDocument(operation, _ *ast.Document) { } func (f *deferEnsureTypenameVisitor) EnterSelectionSet(ref int) { - fieldSelectionRefs := f.operation.SelectionSetFieldSelections(ref) - // if there are some fields in the current selection set, nothing to do - if len(fieldSelectionRefs) > 0 { + // skip root-level selection sets: we need at least depth > 2 + // and a field ancestor to be inside a field's selection set + if len(f.Ancestors) <= 2 { return } - - inlineFragmentSelectionsRefs := f.operation.SelectionSetInlineFragmentSelections(ref) - - allFragmentsHasDefer := true - for _, inlineFragmentSelectionRef := range inlineFragmentSelectionsRefs { - fragmentRef := f.operation.Selections[inlineFragmentSelectionRef].Ref - // fragment has directives? - if !f.operation.InlineFragmentHasDirectives(fragmentRef) { - allFragmentsHasDefer = false + hasFieldAncestor := false + for i := len(f.Ancestors) - 1; i >= 0; i-- { + if f.Ancestors[i].Kind == ast.NodeKindField { + hasFieldAncestor = true break } + } + if !hasFieldAncestor { + return + } - // has defer directive? - _, exists := f.operation.InlineFragmentDirectiveByName(fragmentRef, literal.DEFER) + fieldSelectionRefs := f.operation.SelectionSetFieldSelections(ref) + if len(fieldSelectionRefs) == 0 { + return + } + + // single pass over field selections to gather: + // - whether all fields carry @__defer_internal + // - whether any field's defer id matches the parent field's defer id (intersection) + parentDeferID := f.parentFieldDeferID() + allDeferred := true + hasDeferIntersection := false + + for _, selectionRef := range fieldSelectionRefs { + fieldRef := f.operation.Selections[selectionRef].Ref + directiveRef, exists := f.operation.Fields[fieldRef].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) if !exists { - allFragmentsHasDefer = false + allDeferred = false break } + if parentDeferID != "" && !hasDeferIntersection { + idValue, ok := f.operation.DirectiveArgumentValueByName(directiveRef, []byte("id")) + if ok && f.operation.StringValueContentString(idValue.Ref) == parentDeferID { + hasDeferIntersection = true + } + } } - // TODO: need more checks - // we don't have to do it if: - // if we have an intersection between parent defer ids and field defer ids - - // if we under deferred path - // field should also have defer id from parent + // if at least one field is not deffered we do not need to add the typename placeholder + if !allDeferred { + return + } - if allFragmentsHasDefer { + if parentDeferID == "" { + // the enclosing field is not deferred; add a plain placeholder so the + // selection set has at least one non-deferred field selection addInternalTypeNamePlaceholder(f.operation, ref) + return + } + + // the enclosing field is deferred; if at least one child shares the same + // defer id there is an intersection and no placeholder is needed + if hasDeferIntersection { + return + } + + // no intersection: add a placeholder annotated with the parent's defer id + // so it is planned in the parent field defer scope + fieldRef := addInternalTypeNamePlaceholder(f.operation, ref) + f.addDeferInternalDirective(fieldRef, parentDeferID) +} + +// parentFieldDeferID returns the defer id of the nearest enclosing field that +// carries a @__defer_internal directive, or an empty string if there is none. +func (f *deferEnsureTypenameVisitor) parentFieldDeferID() string { + for i := len(f.Ancestors) - 1; i >= 0; i-- { + ancestor := f.Ancestors[i] + if ancestor.Kind != ast.NodeKindField { + continue + } + directiveRef, exists := f.operation.Fields[ancestor.Ref].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) + if !exists { + return "" + } + idValue, ok := f.operation.DirectiveArgumentValueByName(directiveRef, []byte("id")) + if !ok { + return "" + } + return f.operation.StringValueContentString(idValue.Ref) + } + return "" +} + +// addDeferInternalDirective attaches @__defer_internal(id: deferID) to the given field. +func (f *deferEnsureTypenameVisitor) addDeferInternalDirective(fieldRef int, deferID string) { + strValueRef := f.operation.AddStringValue(ast.StringValue{ + Content: f.operation.Input.AppendInputString(deferID), + }) + argRef := f.operation.AddArgument(ast.Argument{ + Name: f.operation.Input.AppendInputString("id"), + Value: ast.Value{Kind: ast.ValueKindString, Ref: strValueRef}, + }) + directive := ast.Directive{ + Name: f.operation.Input.AppendInputBytes(literal.DEFER_INTERNAL), + HasArguments: true, + Arguments: ast.ArgumentList{Refs: []int{argRef}}, } + directiveRef := f.operation.AddDirective(directive) + f.operation.AddDirectiveToNode(directiveRef, ast.Node{ + Kind: ast.NodeKindField, + Ref: fieldRef, + }) } diff --git a/v2/pkg/astnormalization/defer_ensure_typename_test.go b/v2/pkg/astnormalization/defer_ensure_typename_test.go index 5ee748a4d7..5b05f4d264 100644 --- a/v2/pkg/astnormalization/defer_ensure_typename_test.go +++ b/v2/pkg/astnormalization/defer_ensure_typename_test.go @@ -5,91 +5,87 @@ import ( ) func TestDeferEnsureTypename(t *testing.T) { - t.Run("mixed fields and deferred fragments", func(t *testing.T) { + t.Run("mixed deferred and non-deferred fields - no placeholder needed", func(t *testing.T) { run(t, deferEnsureTypename, testDefinition, ` { user { id - ... @defer { - name - } + name @__defer_internal(id: "1") } }`, ` { user { id - ... @defer { - name - } + name @__defer_internal(id: "1") } }`) }) - t.Run("only deferred fragments", func(t *testing.T) { + t.Run("all fields deferred, parent not deferred - plain placeholder added", func(t *testing.T) { run(t, deferEnsureTypename, testDefinition, ` { user { - ... @defer { - name - } - ... @defer { - age - } + name @__defer_internal(id: "1") + age @__defer_internal(id: "1") } }`, ` { user { - ... @defer { - name - } - ... @defer { - age - } - __internal__typename_placeholder: __typename + name @__defer_internal(id: "1") + age @__defer_internal(id: "1") + ___typename: __typename } }`) }) - t.Run("mixed deferred and non-deferred fragments", func(t *testing.T) { + t.Run("all fields deferred with different ids, parent not deferred - plain placeholder added", func(t *testing.T) { run(t, deferEnsureTypename, testDefinition, ` { user { - ... @defer { - name - } - ... { - age - } + name @__defer_internal(id: "1") + age @__defer_internal(id: "2") } }`, ` { user { - ... @defer { - name - } - ... { - age - } + name @__defer_internal(id: "1") + age @__defer_internal(id: "2") + ___typename: __typename } }`) }) - t.Run("deferred fragment with other directives", func(t *testing.T) { + t.Run("all fields deferred, parent deferred with same id - intersection, no placeholder", func(t *testing.T) { run(t, deferEnsureTypename, testDefinition, ` { - user { - ... @defer @skip(if: false) { - name - } + user @__defer_internal(id: "1") { + name @__defer_internal(id: "1") + age @__defer_internal(id: "2") } }`, ` { - user { - ... @defer @skip(if: false) { - name - } - __internal__typename_placeholder: __typename + user @__defer_internal(id: "1") { + name @__defer_internal(id: "1") + age @__defer_internal(id: "2") + } + }`) + }) + + t.Run("all fields deferred, parent deferred with different id - no intersection, placeholder with parent id added", func(t *testing.T) { + run(t, deferEnsureTypename, testDefinition, ` + { + user @__defer_internal(id: "1") { + name @__defer_internal(id: "2") + age @__defer_internal(id: "3") + } + }`, ` + { + user @__defer_internal(id: "1") { + name @__defer_internal(id: "2") + age @__defer_internal(id: "3") + ___typename: __typename @__defer_internal(id: "1") } }`) }) -} + +} \ No newline at end of file diff --git a/v2/pkg/astnormalization/directive_include_skip.go b/v2/pkg/astnormalization/directive_include_skip.go index c2190ce23d..0db16fc1c5 100644 --- a/v2/pkg/astnormalization/directive_include_skip.go +++ b/v2/pkg/astnormalization/directive_include_skip.go @@ -153,14 +153,14 @@ func (d *directiveIncludeSkipVisitor) removeParentNode() { } } -func addInternalTypeNamePlaceholder(operation *ast.Document, selectionSetRef int) { +func addInternalTypeNamePlaceholder(operation *ast.Document, selectionSetRef int) int { field := operation.AddField(ast.Field{ Name: operation.Input.AppendInputString("__typename"), // We are adding an alias to the __typename field to mark it as internally added // So planner could ignore this field during creation of the response shape Alias: ast.Alias{ IsDefined: true, - Name: operation.Input.AppendInputString("__internal__typename_placeholder"), + Name: operation.Input.AppendInputString("___typename"), }, }) selectionRef := operation.AddSelectionToDocument(ast.Selection{ @@ -169,4 +169,5 @@ func addInternalTypeNamePlaceholder(operation *ast.Document, selectionSetRef int }) operation.AddSelectionRefToSelectionSet(selectionSetRef, selectionRef) + return field.Ref } diff --git a/v2/pkg/astnormalization/directive_include_skip_test.go b/v2/pkg/astnormalization/directive_include_skip_test.go index 03681c7e0a..0bfbed4805 100644 --- a/v2/pkg/astnormalization/directive_include_skip_test.go +++ b/v2/pkg/astnormalization/directive_include_skip_test.go @@ -53,9 +53,9 @@ func TestDirectiveIncludeVisitor(t *testing.T) { } }`, ` { - dog {__internal__typename_placeholder: __typename} - notInclude: dog {__internal__typename_placeholder: __typename} - skip: dog {__internal__typename_placeholder: __typename} + dog {___typename: __typename} + notInclude: dog {___typename: __typename} + skip: dog {___typename: __typename} }`) }) t.Run("include variables true", func(t *testing.T) { @@ -95,10 +95,10 @@ func TestDirectiveIncludeVisitor(t *testing.T) { }`, ` query($no: Boolean!){ dog { - __internal__typename_placeholder: __typename + ___typename: __typename } withAlias: dog { - __internal__typename_placeholder: __typename + ___typename: __typename } }`, `{"no":false}`) }) @@ -116,7 +116,7 @@ func TestDirectiveIncludeVisitor(t *testing.T) { }`, ` query($yes: Boolean! $no: Boolean!){ dog { - __internal__typename_placeholder: __typename + ___typename: __typename } withAlias: dog { name @@ -137,10 +137,10 @@ func TestDirectiveIncludeVisitor(t *testing.T) { }`, ` query($yes: Boolean!) { dog { - __internal__typename_placeholder: __typename + ___typename: __typename } withAlias: dog { - __internal__typename_placeholder: __typename + ___typename: __typename } }`, `{"yes":true}`) }) @@ -181,7 +181,7 @@ func TestDirectiveIncludeVisitor(t *testing.T) { }`, ` query($yes: Boolean!, $no: Boolean!) { dog { - __internal__typename_placeholder: __typename + ___typename: __typename } withAlias: dog { name @@ -202,10 +202,10 @@ func TestDirectiveIncludeVisitor(t *testing.T) { }`, ` query($yes: Boolean!, $no: Boolean!) { dog { - __internal__typename_placeholder: __typename + ___typename: __typename } withAlias: dog { - __internal__typename_placeholder: __typename + ___typename: __typename } }`, `{"yes":true,"no":false}`) }) @@ -246,7 +246,7 @@ func TestDirectiveIncludeVisitor(t *testing.T) { }`, ` query($yes: Boolean = true, $no: Boolean = false) { dog { - __internal__typename_placeholder: __typename + ___typename: __typename } withAlias: dog { name @@ -272,7 +272,7 @@ func TestDirectiveIncludeVisitor(t *testing.T) { } } withAlias: dog { - __internal__typename_placeholder: __typename + ___typename: __typename } }`, `{}`) }) @@ -290,7 +290,7 @@ func TestDirectiveIncludeVisitor(t *testing.T) { }`, ` query($yes: Boolean = false, $no: Boolean = true) { dog { - __internal__typename_placeholder: __typename + ___typename: __typename } withAlias: dog { name @@ -316,7 +316,7 @@ func TestDirectiveIncludeVisitor(t *testing.T) { } } withAlias: dog { - __internal__typename_placeholder: __typename + ___typename: __typename } }`, `{"yes":true,"no":false}`) }) diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index 98740725e5..cb8978623a 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -311,7 +311,9 @@ func (c *nodeSelectionVisitor) wrappingFieldDeferID() string { } func (c *nodeSelectionVisitor) LeaveField(ref int) { - if bytes.Equal(c.operation.FieldAliasOrNameBytes(ref), []byte("__internal__typename_placeholder")) { + // "___typename" is an internal typename placeholder + // added by astnormalization.directiveIncludeSkip or astnormalization.deferEnsureTypename normalization rule + if bytes.Equal(c.operation.FieldAliasOrNameBytes(ref), []byte("___typename")) { // we should skip such typename as it was added as a placeholder to keep query valid // when normalizaion removed all other selections from the selection set c.addSkipFieldRefs(ref) From 2cafe941cc8b2af92fe3b57146e549f4585cde53 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 12 Mar 2026 21:38:51 +0200 Subject: [PATCH 45/79] update test expectations --- .../engine/execution_engine_defer_test.go | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index ca3fd076a6..e561d617f3 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -375,9 +375,9 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"name":"Black"}}}`, }, - `{"query":"{user {__internal__typename_placeholder: __typename}}"}`: { + `{"query":"{user {___typename: __typename}}"}`: { statusCode: 200, - body: `{"data":{"user":{"__internal__typename_placeholder":"User"}}}`, + body: `{"data":{"user":{"___typename":"User"}}}`, }, `{"query":"{user {title}}"}`: { statusCode: 200, @@ -407,13 +407,13 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}}}`, }, - `{"query":"{user {name info {__internal__typename_placeholder: __typename}}}"}`: { + `{"query":"{user {name info {___typename: __typename}}}"}`: { statusCode: 200, - body: `{"data":{"user":{"name":"Black","info":{"__internal__typename_placeholder":"Info"}}}}`, + body: `{"data":{"user":{"name":"Black","info":{"___typename":"Info"}}}}`, }, - `{"query":"{user {info {__internal__typename_placeholder: __typename}}}"}`: { + `{"query":"{user {info {___typename: __typename}}}"}`: { statusCode: 200, - body: `{"data":{"user":{"info":{"__internal__typename_placeholder":"Info"}}}}`, + body: `{"data":{"user":{"info":{"___typename":"Info"}}}}`, }, `{"query":"{user {info {email}}}"}`: { statusCode: 200, @@ -536,21 +536,33 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"id":"1","info":{"email":"black@sabbat"}}}}`, }, - `{"query":"{user {__internal__typename_placeholder: __typename __typename}}"}`: { + `{"query":"{user {___typename: __typename __typename}}"}`: { statusCode: 200, - body: `{"data":{"user":{"__internal__typename_placeholder":"User","__typename":"User","info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"___typename":"User","__typename":"User","info":{"email":"black@sabbat"}}}}`, }, - `{"query":"{user {__internal__typename_placeholder: __typename __typename id}}"}`: { + `{"query":"{user {___typename: __typename __typename id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"__internal__typename_placeholder":"User","__typename":"User","id":"1","info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"___typename":"User","__typename":"User","id":"1","info":{"email":"black@sabbat"}}}}`, }, `{"query":"{user {info {email}}}"}`: { statusCode: 200, body: `{"data":{"user":{"info":{"email":"black@sabbat"}}}}`, }, - `{"query":"{user {info {__internal__typename_placeholder: __typename}}}"}`: { + `{"query":"{user {info {___typename: __typename}}}"}`: { statusCode: 200, - body: `{"data":{"user":{"info":{"__internal__typename_placeholder":"Info"}}}}`, + body: `{"data":{"user":{"info":{"___typename":"Info"}}}}`, + }, + `{"query":"{user {__typename id __internal_1___typename: __typename __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1___typename":"User","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {id __typename __internal_1___typename: __typename __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1","__typename":"User","__internal_1___typename":"User","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __internal_1___typename: __typename __internal_1_id: id __internal_2___typename: __typename __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__internal_1___typename":"User","__internal_1_id":"1","__internal_2___typename":"User","__internal_2_id":"1"}}}`, }, }, }), @@ -623,7 +635,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name info {__internal__typename_placeholder: __typename}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name info {___typename: __typename}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, }, @@ -679,4 +691,4 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { runDeferTests(t, definition, dataSources) }) -} +} \ No newline at end of file From b4b6f9504446a4759c13249eb9fd095e468da065 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 12 Mar 2026 21:48:06 +0200 Subject: [PATCH 46/79] restructure defer tests --- .../engine/execution_engine_defer_test.go | 695 +++++++++--------- 1 file changed, 356 insertions(+), 339 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index e561d617f3..f7b85d5387 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -11,340 +11,13 @@ import ( ) func TestExecutionEngine_Execute_Defer(t *testing.T) { - - runDeferTests := func(t *testing.T, definition string, dataSources []plan.DataSource) { - t.Helper() - - schema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - - t.Run("single deffered field", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - title - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("single deffered field between regular fields", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - title - ... @defer { - name - } - id - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"title":"Sabbat","id":"1"}},"hasNext":true} -{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("multiple deffered fields", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - title - id - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("multiple deffered fields - all object fields deferred", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - ... @defer { - name - title - id - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{}},"hasNext":true} -{"incremental":[{"data":{"name":"Black","title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("nested defers", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - title - ... @defer { - id - } - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("nested defers variation", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserNameTitle", - Query: ` - query DeferUserNameTitle { - user { - ... @defer { - name - ... @defer { title } - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{}},"hasNext":true} -{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("parallel defers", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - title - } - ... @defer { - id - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("defer nested object", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - ... @defer { - info { - email - phone - } - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} -{"incremental":[{"data":{"info":{"email":"black@sabbat","phone":"123"}},"path":["user"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("defer nested object with duplicated non defered object", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - info { - email - } - ... @defer { - info { - phone - } - title - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}},"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]},{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("defer nested object fields", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferUserTitle", - Query: ` - query DeferUserTitle { - user { - name - info { - ... @defer { - email - phone - } - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Black","info":{}}},"hasNext":true} -{"incremental":[{"data":{"email":"black@sabbat","phone":"123"},"path":["user","info"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("extensive parallel defers across all possible fields", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferEverythingParallel", - Query: ` - query DeferEverythingParallel { - ... @defer { - user { - ... @defer { id } - ... @defer { name } - ... @defer { title } - ... @defer { - info { - ... @defer { email } - ... @defer { phone } - } - } - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{},"hasNext":true} -{"incremental":[{"data":{"user":{}},"path":[]}],"hasNext":true} -{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"info":{}},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"email":"black@sabbat"},"path":["user","info"]}],"hasNext":true} -{"incremental":[{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} -`, - }, withStreamingResponse())) - - t.Run("extensive fully nested defers across all possible fields", runWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferEverythingNested", - Query: ` - query DeferEverythingNested { - ... @defer { - user { - ... @defer { - id - ... @defer { - name - ... @defer { - title - ... @defer { - info { - ... @defer { - email - ... @defer { - phone - } - } - } - } - } - } - } - } - } - }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{},"hasNext":true} -{"incremental":[{"data":{"user":{}},"path":[]}],"hasNext":true} -{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"info":{}},"path":["user"]}],"hasNext":true} -{"incremental":[{"data":{"email":"black@sabbat"},"path":["user","info"]}],"hasNext":true} -{"incremental":[{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} -`, - }, withStreamingResponse())) + type TestCase struct { + name string + definition string + dataSources []plan.DataSource } - t.Run("simple - defer on non entity field", func(t *testing.T) { - + makeRootNodesTestCase := func() TestCase { definition := ` type User { id: ID! @@ -461,11 +134,14 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { ), } - runDeferTests(t, definition, dataSources) - }) - - t.Run("entity - distributed fields", func(t *testing.T) { + return TestCase{ + name: "defer on non entity field", + definition: definition, + dataSources: dataSources, + } + } + makeEntityTestCase := func() TestCase { definition := ` type User { id: ID! @@ -689,6 +365,347 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { ), } - runDeferTests(t, definition, dataSources) - }) -} \ No newline at end of file + return TestCase{ + name: "entity - distributed fields", + definition: definition, + dataSources: dataSources, + } + } + + testCases := []TestCase{ + makeRootNodesTestCase(), + makeEntityTestCase(), + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + schema, err := graphql.NewSchemaFromString(tc.definition) + require.NoError(t, err) + + t.Run("single deffered field", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("single deffered field between regular fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + title + ... @defer { + name + } + id + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{"title":"Sabbat","id":"1"}},"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("multiple deffered fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + id + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("multiple deffered fields - all object fields deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + ... @defer { + name + title + id + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Black","title":"Sabbat","id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("nested defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + ... @defer { + id + } + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("nested defers variation", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserNameTitle", + Query: ` + query DeferUserNameTitle { + user { + ... @defer { + name + ... @defer { title } + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + title + } + ... @defer { + id + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer nested object", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + ... @defer { + info { + email + phone + } + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{"name":"Black"}},"hasNext":true} +{"incremental":[{"data":{"info":{"email":"black@sabbat","phone":"123"}},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer nested object with duplicated non defered object", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + info { + email + } + ... @defer { + info { + phone + } + title + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{"name":"Black","info":{"email":"black@sabbat"}}},"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]},{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer nested object fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferUserTitle", + Query: ` + query DeferUserTitle { + user { + name + info { + ... @defer { + email + phone + } + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{"user":{"name":"Black","info":{}}},"hasNext":true} +{"incremental":[{"data":{"email":"black@sabbat","phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("extensive parallel defers across all possible fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferEverythingParallel", + Query: ` + query DeferEverythingParallel { + ... @defer { + user { + ... @defer { id } + ... @defer { name } + ... @defer { title } + ... @defer { + info { + ... @defer { email } + ... @defer { phone } + } + } + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"user":{}},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"info":{}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"email":"black@sabbat"},"path":["user","info"]}],"hasNext":true} +{"incremental":[{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("extensive fully nested defers across all possible fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferEverythingNested", + Query: ` + query DeferEverythingNested { + ... @defer { + user { + ... @defer { + id + ... @defer { + name + ... @defer { + title + ... @defer { + info { + ... @defer { + email + ... @defer { + phone + } + } + } + } + } + } + } + } + } + }`, + } + }, + dataSources: tc.dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"user":{}},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"name":"Black"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"title":"Sabbat"},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"info":{}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"email":"black@sabbat"},"path":["user","info"]}],"hasNext":true} +{"incremental":[{"data":{"phone":"123"},"path":["user","info"]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) + } +} From 5acdf88adcb9191c8161e199b82dd3c22b52d76e Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 12 Mar 2026 21:50:06 +0200 Subject: [PATCH 47/79] change require to assert in conditional round tripper --- execution/engine/execution_engine_helpers_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/execution/engine/execution_engine_helpers_test.go b/execution/engine/execution_engine_helpers_test.go index 89b181d563..9d7aa3a992 100644 --- a/execution/engine/execution_engine_helpers_test.go +++ b/execution/engine/execution_engine_helpers_test.go @@ -83,7 +83,12 @@ func createConditionalTestRoundTripper(t *testing.T, testCase conditionalTestCas require.NoError(t, err) defer req.Body.Close() - require.Containsf(t, testCase.responses, string(gotBody), "received unexpected body: %v", string(gotBody)) + if !assert.Containsf(t, testCase.responses, string(gotBody), "received unexpected body: %v", string(gotBody)) { + return &http.Response{ + StatusCode: 400, + Body: io.NopCloser(bytes.NewBuffer([]byte("received unexpected body"))), + } + } response := testCase.responses[string(gotBody)] return &http.Response{ StatusCode: response.statusCode, From 702c2c29158fbb5d47ec1ce11512fb1dddf0a47d Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 13 Mar 2026 14:55:13 +0200 Subject: [PATCH 48/79] chore: improve debug logs --- v2/pkg/engine/plan/node_selection_builder.go | 4 ++-- v2/pkg/engine/plan/path_builder.go | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/v2/pkg/engine/plan/node_selection_builder.go b/v2/pkg/engine/plan/node_selection_builder.go index 29a2428e69..7e3bf4859b 100644 --- a/v2/pkg/engine/plan/node_selection_builder.go +++ b/v2/pkg/engine/plan/node_selection_builder.go @@ -121,7 +121,7 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, } if p.config.Debug.PrintOperationTransformations { - debugMessage("Selected nodes on run #1 for operation:") + debugMessage("SelectNodes. on run #1 operation:") p.printOperation(operation) } @@ -147,7 +147,7 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, } if p.config.Debug.PrintOperationTransformations || p.config.Debug.PrintNodeSuggestions { - debugMessage(fmt.Sprintf("Selected nodes on additional run #%d.", i+1)) + debugMessage(fmt.Sprintf("SelectNodes. on run #%d.", i+1)) } if p.config.Debug.PrintNodeSuggestions { diff --git a/v2/pkg/engine/plan/path_builder.go b/v2/pkg/engine/plan/path_builder.go index 5e456028a7..c5efde4ee0 100644 --- a/v2/pkg/engine/plan/path_builder.go +++ b/v2/pkg/engine/plan/path_builder.go @@ -144,7 +144,14 @@ func (p *PathBuilder) printRevisitInfo() { fmt.Println("\n Fields waiting for dependency:") for fieldKey, deps := range p.visitor.fieldDependsOn { - fmt.Printf(" Field ref: %d ds: %d depends on fields: %v\n", fieldKey.fieldRef, fieldKey.dsHash, deps) + fmt.Printf(" Field: %s ref: %d ds: %d depends on fields: ", p.visitor.operation.FieldAliasOrNameString(fieldKey.fieldRef), fieldKey.fieldRef, fieldKey.dsHash) + for i, depFieldRef := range deps { + fmt.Printf("field: %s ref: %d ", p.visitor.operation.FieldAliasOrNameString(depFieldRef), depFieldRef) + if len(deps) > 1 && i < len(deps)-1 { + fmt.Printf(", ") + } + } + fmt.Println() } } } From 383d703b70bc2993aac080cc4bec4be456431427 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 13 Mar 2026 14:55:41 +0200 Subject: [PATCH 49/79] fix propagating defer parent ids to root nodes --- .../plan/datasource_filter_node_suggestions.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go index 7e78497bc3..31a3fd717c 100644 --- a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go +++ b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go @@ -159,14 +159,14 @@ func (f *NodeSuggestions) ProcessDefer() { continue } - // TODO: node should not be deffered in case it is a dependency for not deffered node or another defer on the same level? - f.propagateDeferParentsUpToRootNode(i) } } func (f *NodeSuggestions) propagateDeferParentsUpToRootNode(i int) { - if f.items[i].IsRootNode { + // if the item is a root node and requires a key we are already able to jump from here, + // so we skip propagating defer id + if f.items[i].IsRootNode && f.items[i].requiresKey != nil { return } @@ -205,8 +205,10 @@ func (f *NodeSuggestions) propagateDeferParentsUpToRootNode(i int) { parentIndexesToAddDeferID = append(parentIndexesToAddDeferID, parentIdToUpdate) - if f.items[parentIdToUpdate].IsRootNode { - // we have found a root node from which we could branch out + // if we have found a root node, and it requires a key - we have found the root node from which we could branch out. + // if the node is a root node, but it doesn't require a key, we need to go up to the root query node, + // because it is an entity node within the query started from the root query node + if f.items[parentIdToUpdate].IsRootNode && f.items[parentIdToUpdate].requiresKey != nil { break } From 5d175e4a2118ba2a423b4e9df7677f202b8f1a22 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 13 Mar 2026 20:30:23 +0200 Subject: [PATCH 50/79] add have child fields to plan check --- v2/pkg/engine/plan/path_builder_visitor.go | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 1146a79ccd..9f7e8a86e3 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -604,6 +604,51 @@ func (c *pathBuilderVisitor) LeaveField(ref int) { }) } +func (c *pathBuilderVisitor) haveChildFieldsToPlan(field *currentFieldInfo) bool { + nodeId := field.suggestion.treeNodeID() + + node, ok := c.nodeSuggestions.responseTree.Find(nodeId) + if !ok { + return false + } + + children := treeNodeChildren(node) + + result := slices.ContainsFunc(children, func(child int) bool { + childNode := c.nodeSuggestions.items[child] + + if childNode.DataSourceHash != field.ds.Hash() { + return false + } + + if !childNode.Selected { + return false + } + + if field.deferID == "" { + if childNode.deferInfo != nil { + return false + } + } else { + isDeferParentPath := childNode.deferParentPath && slices.Contains(childNode.deferIDs, field.deferID) + + if childNode.deferInfo == nil { + if !isDeferParentPath { + return false + } + } else { + if childNode.deferInfo.ID != field.deferID && !isDeferParentPath { + return false + } + } + } + + return true + }) + + return result +} + func (c *pathBuilderVisitor) handlePlanningField(field *currentFieldInfo) { plannedOnPlannerIds := c.fieldsPlannedOn[field.fieldRef] @@ -616,6 +661,10 @@ func (c *pathBuilderVisitor) handlePlanningField(field *currentFieldInfo) { return } + if !field.suggestion.IsLeaf && !c.haveChildFieldsToPlan(field) { + return + } + isMutationRoot := c.isMutationRoot(field.currentPath) var ( From c7a10f77511e4295b8cffe190aeb63c42bf6b0cf Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 13 Mar 2026 22:15:52 +0200 Subject: [PATCH 51/79] do not add an alias to typename --- v2/pkg/engine/plan/required_fields_visitor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index 25f574021a..d59245e280 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -284,7 +284,7 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { fieldName := v.key.FieldNameBytes(ref) isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) isLeafField := !v.key.FieldHasSelections(ref) - deferAlias := v.config.deferInfo != nil && v.isRootLevel() + deferAlias := v.config.deferInfo != nil && v.isRootLevel() && !isTypeName selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) From f59c28697f37e34a6127a0a08dc98e03b2a71e7b Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 13 Mar 2026 22:23:22 +0200 Subject: [PATCH 52/79] fix test expectations --- .../engine/execution_engine_defer_test.go | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index f7b85d5387..8274a8e71b 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -196,49 +196,49 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedHost: "first", expectedPath: "/", responses: map[string]sendResponse{ - `{"query":"{user {__typename id}}"}`: { + `{"query":"{user {id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"id":"1","info":{"email":"black@sabbat"}}}}`, }, - `{"query":"{user {id __typename}}"}`: { + `{"query":"{user {info {email}}}"}`: { statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"info":{"email":"black@sabbat"}}}}`, }, - `{"query":"{user {__typename}}"}`: { + `{"query":"{user {info {___typename: __typename}}}"}`: { statusCode: 200, - body: `{"data":{"user":{"__typename":"User","info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"info":{"___typename":"Info"}}}}`, }, - `{"query":"{user {id}}"}`: { + `{"query":"{user {__typename id __internal_1_id: id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"id":"1","info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1"}}}`, }, - `{"query":"{user {___typename: __typename __typename}}"}`: { + `{"query":"{user {id __typename __internal_1_id: id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"id":"1","__typename":"User","__internal_1_id":"1"}}}`, }, - `{"query":"{user {___typename: __typename __typename id}}"}`: { + `{"query":"{user {__typename __internal_id: id __internal_1_id: id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","id":"1","info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"__typename":"User","__internal_id":"1","__internal_1_id":"1"}}}`, }, - `{"query":"{user {info {email}}}"}`: { + `{"query":"{user {___typename: __typename __typename __internal_1_id: id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"info":{"email":"black@sabbat"}}}}`, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_1_id":"1"}}}`, }, - `{"query":"{user {info {___typename: __typename}}}"}`: { + `{"query":"{user {___typename: __typename __typename __internal_1_id: id __internal_2_id: id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"info":{"___typename":"Info"}}}}`, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_1_id":"1","__internal_2_id":"1"}}}`, }, - `{"query":"{user {__typename id __internal_1___typename: __typename __internal_1_id: id}}"}`: { + `{"query":"{user {info {email} __typename id __internal_1_id: id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1___typename":"User","__internal_1_id":"1"}}}`, + body: `{"data":{"user":{"info":{"email":"black@sabbat"},"__typename":"User","id":"1","__internal_1_id":"1"}}}`, }, - `{"query":"{user {id __typename __internal_1___typename: __typename __internal_1_id: id}}"}`: { + `{"query":"{user {info {___typename: __typename} __typename id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"id":"1","__typename":"User","__internal_1___typename":"User","__internal_1_id":"1"}}}`, + body: `{"data":{"user":{"info":{"___typename":"Info"},"__typename":"User","id":"1"}}}`, }, - `{"query":"{user {___typename: __typename __internal_1___typename: __typename __internal_1_id: id __internal_2___typename: __typename __internal_2_id: id}}"}`: { + `{"query":"{user {___typename: __typename __typename __internal_3_id: id __internal_4_id: id __internal_5_id: id}}"}`: { statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__internal_1___typename":"User","__internal_1_id":"1","__internal_2___typename":"User","__internal_2_id":"1"}}}`, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_3_id":"1","__internal_4_id":"1","__internal_5_id":"1"}}}`, }, }, }), From a06879a87347cab9f8c464c4b018449d8241f56f Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 18 Mar 2026 22:23:43 +0200 Subject: [PATCH 53/79] simplify conditions --- v2/pkg/engine/plan/path_builder_visitor.go | 31 ++++------------------ 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 9f7e8a86e3..a06b6df875 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -612,41 +612,20 @@ func (c *pathBuilderVisitor) haveChildFieldsToPlan(field *currentFieldInfo) bool return false } - children := treeNodeChildren(node) - - result := slices.ContainsFunc(children, func(child int) bool { + return slices.ContainsFunc(treeNodeChildren(node), func(child int) bool { childNode := c.nodeSuggestions.items[child] - if childNode.DataSourceHash != field.ds.Hash() { - return false - } - - if !childNode.Selected { + if childNode.DataSourceHash != field.ds.Hash() || !childNode.Selected { return false } if field.deferID == "" { - if childNode.deferInfo != nil { - return false - } - } else { - isDeferParentPath := childNode.deferParentPath && slices.Contains(childNode.deferIDs, field.deferID) - - if childNode.deferInfo == nil { - if !isDeferParentPath { - return false - } - } else { - if childNode.deferInfo.ID != field.deferID && !isDeferParentPath { - return false - } - } + return childNode.deferInfo == nil } - return true + isDeferParentPath := childNode.deferParentPath && slices.Contains(childNode.deferIDs, field.deferID) + return isDeferParentPath || (childNode.deferInfo != nil && childNode.deferInfo.ID == field.deferID) }) - - return result } func (c *pathBuilderVisitor) handlePlanningField(field *currentFieldInfo) { From 8e6286bd70f1972e277e9a2820e227c35a9d00b9 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 24 Mar 2026 00:13:32 +0200 Subject: [PATCH 54/79] properly set requirements for duplicated planner for the same field and datasource --- v2/pkg/engine/plan/path_builder_visitor.go | 47 ++++++++++------------ 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index a06b6df875..87c06961cd 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -51,6 +51,7 @@ type pathBuilderVisitor struct { fieldDependsOn map[fieldIndexKey][]int // fieldDependsOn is a map[fieldRef][]fieldRef - holds list of field refs which are required by a field ref, e.g. field should be planned only after required fields were planned fieldRequirementsConfigs map[fieldIndexKey][]FederationFieldConfiguration + processedFieldDeps map[fieldIndexKey][]int // processedFieldDeps tracks which plannerIds have already had dependencies wired for a given fieldIndexKey - pair of fieldRef and dsHash currentFetchPath []resolve.FetchItemPathElement currentResponsePath []string @@ -344,6 +345,7 @@ func (c *pathBuilderVisitor) EnterDocument(operation, definition *ast.Document) c.fieldDependenciesForPlanners = make(map[int][]int) c.fieldsPlannedOn = make(map[int][]int) + c.processedFieldDeps = make(map[fieldIndexKey][]int) } func (c *pathBuilderVisitor) LeaveDocument(operation, definition *ast.Document) { @@ -579,6 +581,16 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { } } + // Clean up fieldDependsOn entries that were fully processed during this EnterField call. + // We keep entries alive throughout the suggestions loop so couldPlanField can still read them, + // and delete them only after all planners for this fieldRef have been wired up. + for _, suggestion := range suggestions { + fieldKey := fieldIndexKey{fieldRef, suggestion.DataSourceHash} + if _, processed := c.processedFieldDeps[fieldKey]; processed { + delete(c.fieldDependsOn, fieldKey) + } + } + c.addArrayField(fieldRef, currentPath) // pushResponsePath uses array fields so it should be called after addArrayField c.pushResponsePath(fieldRef, fieldAliasOrName) @@ -722,31 +734,6 @@ func (c *pathBuilderVisitor) fieldIsChildNode(plannerIdx int) bool { return strings.ContainsAny(fieldPath, ".") } -// addPlannerDependencies adds dependencies between planners based on @key directive -// e.g. when we have a record in a map, that this fieldRef is a dependency for the planner id -// we will notify that planner about the dependency on thecurrentPlannerIdx where this field is landed -func (c *pathBuilderVisitor) addPlannerDependencies(fieldRef int, plannedOnPlannerId int) { - plannerIds, mappingExists := c.fieldDependenciesForPlanners[fieldRef] - if !mappingExists { - return - } - - for _, notifyPlannerIdx := range plannerIds { - fetchConfiguration := c.planners[notifyPlannerIdx].ObjectFetchConfiguration() - - notified := slices.Contains(fetchConfiguration.dependsOnFetchIDs, plannedOnPlannerId) - if !notified { - if notifyPlannerIdx == plannedOnPlannerId { - return - // c.walker.StopWithInternalErr(fmt.Errorf("wrong fetch dependencies planner %d depends on itself", notifyPlannerIdx)) - } - - fetchConfiguration.dependsOnFetchIDs = append(fetchConfiguration.dependsOnFetchIDs, plannedOnPlannerId) - slices.Sort(fetchConfiguration.dependsOnFetchIDs) - } - } -} - // recordFieldPlannedOn - records the planner id on which the field was planned func (c *pathBuilderVisitor) recordFieldPlannedOn(fieldRef int, plannerIdx int) { if !slices.Contains(c.fieldsPlannedOn[fieldRef], plannerIdx) { @@ -770,7 +757,11 @@ func (c *pathBuilderVisitor) addFieldDependencies(field *currentFieldInfo, curre if !mappingExists { return } - delete(c.fieldDependsOn, fieldKey) + + if slices.Contains(c.processedFieldDeps[fieldKey], currentPlannerIdx) { + return + } + c.processedFieldDeps[fieldKey] = append(c.processedFieldDeps[fieldKey], currentPlannerIdx) requiresConfigurations, ok := c.fieldRequirementsConfigs[fieldKey] if !ok { @@ -798,8 +789,12 @@ func (c *pathBuilderVisitor) addFieldDependencies(field *currentFieldInfo, curre notified := slices.Contains(fetchConfiguration.dependsOnFetchIDs, plannerIdx) if !notified { + fetchConfiguration.dependsOnFetchIDs = append(fetchConfiguration.dependsOnFetchIDs, plannerIdx) + // sort slices.Sort(fetchConfiguration.dependsOnFetchIDs) + // remove consecutive duplicates + fetchConfiguration.dependsOnFetchIDs = slices.Compact(fetchConfiguration.dependsOnFetchIDs) } } } From bfe2681e82a6756dce9b7480b222d9a256c0aaf9 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 24 Mar 2026 00:16:03 +0200 Subject: [PATCH 55/79] add draft of requires test --- .../engine/execution_engine_defer_test.go | 962 ++++++++++++++++++ 1 file changed, 962 insertions(+) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 8274a8e71b..3d18502e81 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -709,3 +709,965 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }) } } + +func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { + // Merged schema visible to clients. + definition := ` + type Query { + user: User! + } + type User { + id: ID! + name: String! + billing: Billing! + settings: Settings! + account: Account! + notifications: [String!]! + } + type Billing { + plan: String! + currency: String! + } + type Settings { + region: String! + language: String! + } + type Account { + type: String! + limit: Int! + } + ` + + // Subgraph 1: owns Query.user, User.name, User.account. + // account @requires(fields: "billing { plan } settings { region }") — depends on sub2 and sub3. + firstSubgraphSDL := ` + type Query { + user: User! + } + + type User @key(fields: "id") { + id: ID! + name: String! + account: Account! @requires(fields: "billing { plan } settings { region }") + billing: Billing! @external + settings: Settings! @external + } + + type Account { + type: String! + limit: Int! + } + + type Billing { + plan: String! @external + } + + type Settings { + region: String! @external + } + ` + + // Subgraph 2: owns User.billing, User.notifications. + // notifications @requires(fields: "name settings { language }") — depends on sub1 (name) and sub3 (settings). + secondSubgraphSDL := ` + type User @key(fields: "id") { + id: ID! + name: String! @external + notifications: [String!]! @requires(fields: "name settings { language }") + billing: Billing! + settings: Settings! @external + } + + type Billing { + plan: String! + currency: String! + } + + type Settings { + language: String! @external + } + ` + + // Subgraph 3: owns User.settings. + thirdSubgraphSDL := ` + type User @key(fields: "id") { + id: ID! + settings: Settings! + } + + type Settings { + region: String! + language: String! + } + ` + + schema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + + dataSources := []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + // Direct root queries (non-defer, no entity deps) + `{"query":"{user {name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice"}}}`, + }, + // Initial root query when only entity key is needed (account @requires billing+settings from sub2/sub3) + `{"query":"{user {id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1"}}}`, + }, + // Initial root query with name also included (name needed for notifications @requires) + `{"query":"{user {id name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1","name":"Alice"}}}`, + }, + `{"query":"{user {name id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","id":"1"}}}`, + }, + // Defer variants — include __typename and internal id aliases + `{"query":"{user {__typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_2_id: id __internal_1_id: id __internal_3_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_2_id":"1","__internal_1_id":"1","__internal_3_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_1_id: id id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1","id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_2_id: id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_2_id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {__typename id name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice"}}}`, + }, + `{"query":"{user {name __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1"}}}`, + }, + `{"query":"{user {__typename id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, + }, + `{"query":"{user {__typename id name __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {name __typename id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {name __typename id __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, + }, + `{"query":"{user {name __typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, + }, + `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id __internal_4_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1","__internal_4_id":"1"}}}`, + }, + `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id __internal_3_id: id __internal_4_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1","__internal_4_id":"1"}}}`, + }, + // Entity fetch for name (when name is in a deferred block) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Alice"}]}}`, + }, + // Entity fetch for account — representations carry @requires data (billing.plan + settings.region) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type limit}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium","limit":100}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium"}}]}}`, + }, + // Direct root queries for deferred account/name fields + `{"query":"{user {account {type}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"account":{"type":"premium"}}}}`, + }, + `{"query":"{user {__internal_1_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal_1_name":"Alice"}}}`, + }, + `{"query":"{user {__internal_2_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal_2_name":"Alice"}}}`, + }, + `{"query":"{user {account {type} __internal_2_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"account":{"type":"premium"},"__internal_2_name":"Alice"}}}`, + }, + `{"query":"{user {__internal_3_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal_3_name":"Alice"}}}`, + }, + `{"query":"{user {name account {type} __internal_1_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"__internal_1_name":"Alice"}}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"user"}, + }, + { + TypeName: "User", + FieldNames: []string{"id", "name", "account"}, + ExternalFieldNames: []string{"billing", "settings"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Account", + FieldNames: []string{"type", "limit"}, + }, + { + TypeName: "Billing", + ExternalFieldNames: []string{"plan"}, + }, + { + TypeName: "Settings", + ExternalFieldNames: []string{"region"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + }, + Requires: plan.FederationFieldConfigurations{ + { + TypeName: "User", + FieldName: "account", + SelectionSet: "billing { plan } settings { region }", + }, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://first/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: firstSubgraphSDL, + }, + firstSubgraphSDL, + ), + }), + ), + mustGraphqlDataSourceConfiguration(t, + "id-2", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "second", + expectedPath: "/", + responses: map[string]sendResponse{ + // Entity fetches for billing.plan (needed as @requires input for sub1 account) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan currency}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro","currency":"USD"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {currency}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"currency":"USD"}}]}}`, + }, + // Entity fetch for notifications — representations carry @requires data (name + settings.language) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications}}}","variables":{"representations":[{"__typename":"User","name":"Alice","settings":{"language":"en"},"id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"]}]}}`, + }, + // Aliased billing fetches for deferred chunks + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_1_billing":{"plan":"pro"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_2_billing":{"plan":"pro"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} __internal_1_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"__internal_1_billing":{"plan":"pro"}}]}}`, + }, + // Combined billing+notifications fetch (planner merges them into one entity request). + // Two field-order variants since the planner may order selections differently. + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} notifications}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"notifications":["msg1","msg2"]}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"],"billing":{"plan":"pro"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"]}]}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "billing", "notifications"}, + ExternalFieldNames: []string{"name", "settings"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Billing", + FieldNames: []string{"plan", "currency"}, + }, + { + TypeName: "Settings", + ExternalFieldNames: []string{"language"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + }, + Requires: plan.FederationFieldConfigurations{ + { + TypeName: "User", + FieldName: "notifications", + SelectionSet: "name settings { language }", + }, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://second/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: secondSubgraphSDL, + }, + secondSubgraphSDL, + ), + }), + ), + mustGraphqlDataSourceConfiguration(t, + "id-3", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "third", + expectedPath: "/", + responses: map[string]sendResponse{ + // Entity fetches for settings (needed for both account and notifications @requires) + // Aliased settings fetches for deferred chunks + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_1_settings":{"region":"us-east"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_2_settings":{"region":"us-east"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_1_settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_3_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_3_settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_2_settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region} __internal_1_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"},"__internal_1_settings":{"region":"us-east"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {language} __internal_1_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"},"__internal_1_settings":{"language":"en"}}]}}`, + }, + // Original entity fetches for settings + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east","language":"en"}}]}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "settings"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Settings", + FieldNames: []string{"region", "language"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://third/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: thirdSubgraphSDL, + }, + thirdSubgraphSDL, + ), + }), + ), + } + + t.Run("non-defer - name only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { name } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}}}`, + })) + + t.Run("non-defer - account requires billing and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { account { type } } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"account":{"type":"premium"}}}}`, + })) + + t.Run("non-defer - notifications requires name and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { notifications } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"notifications":["msg1","msg2"]}}}`, + })) + + t.Run("non-defer - both requires fields together", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { name account { type } notifications } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, + })) + + t.Run("non-defer - all fields including raw billing and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { name billing { plan } settings { region } account { type } notifications } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east"},"account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, + })) + + t.Run("defer - account field deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAccount", + Query: ` + query DeferAccount { + user { + name + ... @defer { + account { type } + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - notifications field deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferNotifications", + Query: ` + query DeferNotifications { + user { + name + ... @defer { + notifications + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - all user fields deferred in single block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAll", + Query: ` + query DeferAll { + user { + ... @defer { + name + account { type } + notifications + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Alice","account":{"type":"premium"},"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - parallel defers on both cross-subgraph requires fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferBothRequires", + Query: ` + query DeferBothRequires { + user { + name + ... @defer { + account { type } + } + ... @defer { + notifications + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - nested defers: outer has account, inner has notifications", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferNested", + Query: ` + query DeferNested { + user { + name + ... @defer { + account { type } + ... @defer { + notifications + } + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - parallel defers on raw entity fields alongside requires", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferMixed", + Query: ` + query DeferMixed { + user { + name + billing { plan } + ... @defer { + account { type } + } + ... @defer { + notifications + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"}}},"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - deeply nested requires: account outer, notifications inner, with raw fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferDeepNested", + Query: ` + query DeferDeepNested { + user { + ... @defer { + name + billing { plan } + ... @defer { + account { type } + ... @defer { + notifications + } + } + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Alice","billing":{"plan":"pro"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + // Defer versions of each non-defer test — verify @defer doesn't break @requires resolution. + + t.Run("defer - name only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferNameOnly", + Query: ` + query DeferNameOnly { + user { + ... @defer { name } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Alice"},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - only account deferred (no other immediate fields)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAccountOnly", + Query: ` + query DeferAccountOnly { + user { + ... @defer { account { type } } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - only notifications deferred (no other immediate fields)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferNotificationsOnly", + Query: ` + query DeferNotificationsOnly { + user { + ... @defer { notifications } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - all fields in single defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAllFields", + Query: ` + query DeferAllFields { + user { + ... @defer { + name + billing { plan } + settings { region } + account { type } + notifications + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} +{"incremental":[{"data":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east"},"account":{"type":"premium"},"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + // Tests mixing requires-source fields (billing, settings) with derived @requires fields + // (account, notifications) in same or parallel defer blocks. + + t.Run("defer - requires source (billing) and derived field (account) in same defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferBillingAndAccount", + Query: ` + query DeferBillingAndAccount { + user { + name + ... @defer { + billing { plan } + account { type } + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"billing":{"plan":"pro"},"account":{"type":"premium"}},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - requires source (billing) and derived field (account) in parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferBillingParallelAccount", + Query: ` + query DeferBillingParallelAccount { + user { + name + ... @defer { billing { plan } } + ... @defer { account { type } } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"billing":{"plan":"pro"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - requires source (settings) and derived field (notifications) in same defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferSettingsAndNotifications", + Query: ` + query DeferSettingsAndNotifications { + user { + name + ... @defer { + settings { language } + notifications + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"settings":{"language":"en"},"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - requires source (settings) and derived field (notifications) in parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferSettingsParallelNotifications", + Query: ` + query DeferSettingsParallelNotifications { + user { + name + ... @defer { settings { language } } + ... @defer { notifications } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"settings":{"language":"en"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - all requires sources deferred together, then derived fields deferred in parallel", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferSourcesThenDerived", + Query: ` + query DeferSourcesThenDerived { + user { + name + ... @defer { + billing { plan } + settings { region language } + } + ... @defer { + account { type } + notifications + } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} +{"incremental":[{"data":{"billing":{"plan":"pro"},"settings":{"region":"us-east","language":"en"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"},"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer - requires sources immediate, both derived fields deferred in parallel", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferDerivedFieldsOnly", + Query: ` + query DeferDerivedFieldsOnly { + user { + name + billing { plan } + settings { region language } + ... @defer { account { type } } + ... @defer { notifications } + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east","language":"en"}}},"hasNext":true} +{"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} +{"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} +`, + }, withStreamingResponse())) +} From af2ffed1a74051ba016102f2e886e007225e4af3 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 24 Mar 2026 21:03:26 +0200 Subject: [PATCH 56/79] add comments to required fields visitor --- v2/pkg/engine/plan/required_fields_visitor.go | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index d59245e280..d47a499de3 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -239,11 +239,20 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { fieldName := v.key.FieldNameBytes(ref) isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) - // we need to add an alias if the operation has such a field and: - // - the field is not a leaf - // - the field has arguments isLeafField := !v.key.FieldHasSelections(ref) + + // @requires fields can carry arguments (e.g. price(currency: USD)). + // If the same field already appears in the query with different arguments, + // the two selections cannot share the same field node, so we must alias the + // required copy to avoid clobbering the user's selection. + // Key fields never have arguments, so this check is absent in handleKeyField. needAlias := v.key.FieldHasArguments(ref) + + // Unlike handleKeyField, __typename IS included in deferAlias here. + // For interface objects (entity interfaces) the planner adds __typename as a + // @requires field (not a key field) so the owning subgraph can return the real + // concrete type. That __typename must travel through the same deferred path as + // the rest of the requires fields, so it must not be excluded from aliasing. deferAlias := v.config.deferInfo != nil && v.isRootLevel() selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref @@ -256,9 +265,12 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { } if operationHasField && !needAlias && !deferAlias { - // we are skipping adding __typename field to the required fields, - // because we want to depend only on the regular key fields, not the __typename field - // for entity interface we need real typename, so we use this dependency + // Skip storing __typename as a required field — we only want to depend on + // the actual key fields, not __typename. + // Exception: for interface objects the planner adds __typename via @requires + // so we do need it as a real dependency in that case. + // (handleKeyField always skips __typename here because it handles __typename + // through the representation variables builder instead.) if !isTypeName || v.config.isTypeNameForEntityInterface { v.storeRequiredFieldRef(operationFieldRef) } @@ -284,8 +296,19 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { fieldName := v.key.FieldNameBytes(ref) isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) isLeafField := !v.key.FieldHasSelections(ref) + + // Key fields must never alias __typename, even in a deferred context. + // __typename is not part of the user-visible key field set; instead it is + // always injected by the representation variables builder with the static + // name "__typename". Aliasing it would break that builder. + // (handleRequiredField does NOT exclude __typename here because for + // interface objects __typename is fetched via @requires, not keys.) deferAlias := v.config.deferInfo != nil && v.isRootLevel() && !isTypeName + // Key fields cannot have arguments — they are always simple scalar + // identifiers — so there is no needAlias check for arguments here + // (unlike handleRequiredField). + selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) @@ -294,9 +317,10 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { existingFieldIsDeferred := operationHasField && v.config.deferInfo == nil && v.fieldHasDeferInternal(operationFieldRef) if operationHasField && !deferAlias && !existingFieldIsDeferred { - // we are skipping adding __typename field to the required fields, - // because we want to depend only on the regular key fields, not the __typename field - // for entity interface we need real typename, so we use this dependency + // Skip storing __typename as a required field. + // Unlike handleRequiredField there is no isTypeNameForEntityInterface + // exception here: for interface objects the real __typename is fetched + // via @requires (handled by handleRequiredField), never as a key field. if !isTypeName { v.storeRequiredFieldRef(operationFieldRef) } From ac6d08ec0d8d4ff6288f080c51d96a92e2e8e6f0 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 25 Mar 2026 00:35:09 +0200 Subject: [PATCH 57/79] fix defer pointer comparison --- .../engine/plan/datasource_filter_node_suggestions.go | 11 +++++++++++ v2/pkg/engine/plan/node_selection_visitor.go | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go index 31a3fd717c..8415bfee25 100644 --- a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go +++ b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go @@ -51,6 +51,17 @@ type DeferInfo struct { ParentID string } +func (d *DeferInfo) Equals(o *DeferInfo) bool { + if d == nil && o == nil { + return true + } + if d == nil || o == nil { + return false + } + + return d.ID == o.ID && d.Label == o.Label && d.ParentID == o.ParentID +} + func (n *NodeSuggestion) treeNodeID() uint { return TreeNodeID(n.FieldRef) } diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index cb8978623a..1e4bea5a2d 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -568,7 +568,7 @@ func (c *nodeSelectionVisitor) addPendingKeyRequirements(fieldCtx fieldRequireme requirements.requirementConfigs = append(requirements.requirementConfigs, config) } else { for i := range requirements.requirementConfigs { - if requirements.requirementConfigs[i].targetDSHash == fieldCtx.dsConfig.Hash() && requirements.requirementConfigs[i].deferInfo == fieldCtx.deferInfo { + if requirements.requirementConfigs[i].targetDSHash == fieldCtx.dsConfig.Hash() && requirements.requirementConfigs[i].deferInfo.Equals(fieldCtx.deferInfo) { if !slices.Contains(requirements.requirementConfigs[i].requestedByFieldRefs, fieldCtx.fieldRef) { requirements.requirementConfigs[i].requestedByFieldRefs = append(requirements.requirementConfigs[i].requestedByFieldRefs, fieldCtx.fieldRef) } From eb9ee84498c763b60fe3cb604f80c2df7c6199e0 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 25 Mar 2026 00:35:36 +0200 Subject: [PATCH 58/79] fix mocks --- .../engine/execution_engine_defer_test.go | 1263 +++++++++-------- 1 file changed, 688 insertions(+), 575 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 3d18502e81..7952a27015 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -200,6 +200,10 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"id":"1","info":{"email":"black@sabbat"}}}}`, }, + `{"query":"{user {___typename: __typename __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","id":1}}}`, + }, `{"query":"{user {info {email}}}"}`: { statusCode: 200, body: `{"data":{"user":{"info":{"email":"black@sabbat"}}}}`, @@ -239,6 +243,50 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `{"query":"{user {___typename: __typename __typename __internal_3_id: id __internal_4_id: id __internal_5_id: id}}"}`: { statusCode: 200, body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_3_id":"1","__internal_4_id":"1","__internal_5_id":"1"}}}`, + }, // New alias format: simple __internal_id for first defer scope + `{"query":"{user {__typename id __internal_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_id":"1"}}}`, + }, + `{"query":"{user {id __typename __internal_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1","__typename":"User","__internal_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {info {email} __typename id __internal_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"email":"black@sabbat"},"__typename":"User","id":"1","__internal_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_4_id: id __internal_5_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_4_id":"1","__internal_5_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_1_id: id __internal_3_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_1_id":"1","__internal_3_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_2_id":"1"}}}`, + }, // New format: plain id (no alias for first deferred occurrence) + `{"query":"{user {__typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1"}}}`, + }, + `{"query":"{user {id __typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1","__typename":"User"}}}`, + }, + `{"query":"{user {info {email} __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"info":{"email":"black@sabbat"},"__typename":"User","id":"1"}}}`, }, }, }), @@ -295,6 +343,14 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name}}}","variables":{"representations":[{"__typename":"User","id":1}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Black"}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[{"__typename":"User","id":1}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","title":"Sabbat"}]}}`, + }, `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, @@ -708,11 +764,10 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, withStreamingResponse())) }) } -} -func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { - // Merged schema visible to clients. - definition := ` + t.Run("cross subgraph requires", func(t *testing.T) { + // Merged schema visible to clients. + definition := ` type Query { user: User! } @@ -738,9 +793,9 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } ` - // Subgraph 1: owns Query.user, User.name, User.account. - // account @requires(fields: "billing { plan } settings { region }") — depends on sub2 and sub3. - firstSubgraphSDL := ` + // Subgraph 1: owns Query.user, User.name, User.account. + // account @requires(fields: "billing { plan } settings { region }") — depends on sub2 and sub3. + firstSubgraphSDL := ` type Query { user: User! } @@ -767,9 +822,9 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } ` - // Subgraph 2: owns User.billing, User.notifications. - // notifications @requires(fields: "name settings { language }") — depends on sub1 (name) and sub3 (settings). - secondSubgraphSDL := ` + // Subgraph 2: owns User.billing, User.notifications. + // notifications @requires(fields: "name settings { language }") — depends on sub1 (name) and sub3 (settings). + secondSubgraphSDL := ` type User @key(fields: "id") { id: ID! name: String! @external @@ -788,8 +843,8 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } ` - // Subgraph 3: owns User.settings. - thirdSubgraphSDL := ` + // Subgraph 3: owns User.settings. + thirdSubgraphSDL := ` type User @key(fields: "id") { id: ID! settings: Settings! @@ -801,172 +856,213 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } ` - schema, err := graphql.NewSchemaFromString(definition) - require.NoError(t, err) - - dataSources := []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, - "id-1", - mustFactory(t, - testConditionalNetHttpClient(t, conditionalTestCase{ - expectedHost: "first", - expectedPath: "/", - responses: map[string]sendResponse{ - // Direct root queries (non-defer, no entity deps) - `{"query":"{user {name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice"}}}`, - }, - // Initial root query when only entity key is needed (account @requires billing+settings from sub2/sub3) - `{"query":"{user {id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"id":"1"}}}`, - }, - // Initial root query with name also included (name needed for notifications @requires) - `{"query":"{user {id name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"id":"1","name":"Alice"}}}`, - }, - `{"query":"{user {name id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","id":"1"}}}`, - }, - // Defer variants — include __typename and internal id aliases - `{"query":"{user {__typename id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1"}}}`, - }, - `{"query":"{user {___typename: __typename}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User"}}}`, - }, - `{"query":"{user {___typename: __typename __typename __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {___typename: __typename __typename __internal_2_id: id __internal_1_id: id __internal_3_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_2_id":"1","__internal_1_id":"1","__internal_3_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_1_id: id id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1","id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_2_id: id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_2_id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {__typename id name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice"}}}`, - }, - `{"query":"{user {name __typename id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1"}}}`, - }, - `{"query":"{user {__typename id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, - }, - `{"query":"{user {__typename id name __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {name __typename id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {name __typename id __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, - }, - `{"query":"{user {name __typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, - }, - `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id __internal_4_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1","__internal_4_id":"1"}}}`, - }, - `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id __internal_3_id: id __internal_4_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1","__internal_4_id":"1"}}}`, - }, - // Entity fetch for name (when name is in a deferred block) - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","name":"Alice"}]}}`, - }, - // Entity fetch for account — representations carry @requires data (billing.plan + settings.region) - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type limit}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium","limit":100}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium"}}]}}`, - }, - // Direct root queries for deferred account/name fields - `{"query":"{user {account {type}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"account":{"type":"premium"}}}}`, - }, - `{"query":"{user {__internal_1_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__internal_1_name":"Alice"}}}`, - }, - `{"query":"{user {__internal_2_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__internal_2_name":"Alice"}}}`, - }, - `{"query":"{user {account {type} __internal_2_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"account":{"type":"premium"},"__internal_2_name":"Alice"}}}`, - }, - `{"query":"{user {__internal_3_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__internal_3_name":"Alice"}}}`, - }, - `{"query":"{user {name account {type} __internal_1_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"__internal_1_name":"Alice"}}}`, - }, + schema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + + dataSources := []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id-1", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + // Direct root queries (non-defer, no entity deps) + `{"query":"{user {name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice"}}}`, + }, // Initial root query when only entity key is needed (account @requires billing+settings from sub2/sub3) + `{"query":"{user {id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1"}}}`, + }, // Initial root query with name also included (name needed for notifications @requires) + `{"query":"{user {id name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"id":"1","name":"Alice"}}}`, }, - }), - ), - &plan.DataSourceMetadata{ + `{"query":"{user {name id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","id":"1"}}}`, + }, // Defer variants — include __typename and internal id aliases + `{"query":"{user {__typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_2_id: id __internal_1_id: id __internal_3_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_2_id":"1","__internal_1_id":"1","__internal_3_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_1_id: id id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1","id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_2_id: id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_2_id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {__typename id name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice"}}}`, + }, + `{"query":"{user {name __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1"}}}`, + }, + `{"query":"{user {__typename id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, + }, + `{"query":"{user {__typename id name __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {name __typename id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {name __typename id __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, + }, + `{"query":"{user {name __typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, + }, + `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id __internal_4_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1","__internal_4_id":"1"}}}`, + }, + `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id __internal_3_id: id __internal_4_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1","__internal_4_id":"1"}}}`, + }, // Entity fetch for name (when name is in a deferred block) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","name":"Alice"}]}}`, + }, // Entity fetch for account — representations carry @requires data (billing.plan + settings.region) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type limit}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium","limit":100}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium"}}]}}`, + }, // Direct root queries for deferred account/name fields + `{"query":"{user {account {type}}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"account":{"type":"premium"}}}}`, + }, + `{"query":"{user {__internal_1_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal_1_name":"Alice"}}}`, + }, + `{"query":"{user {__internal_2_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal_2_name":"Alice"}}}`, + }, + `{"query":"{user {account {type} __internal_2_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"account":{"type":"premium"},"__internal_2_name":"Alice"}}}`, + }, + `{"query":"{user {__internal_3_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal_3_name":"Alice"}}}`, + }, + `{"query":"{user {name account {type} __internal_1_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"__internal_1_name":"Alice"}}}`, + }, // New alias format: simple __internal_id for first defer scope + `{"query":"{user {___typename: __typename __typename __internal_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_id: id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_id: id __internal_2_id: id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","__internal_2_id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_id: id id __internal_2_id: id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","id":"1","__internal_2_id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {name __typename __internal_id: id __internal_1_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, + }, + `{"query":"{user {__internal_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"__internal_name":"Alice"}}}`, + }, + `{"query":"{user {name account {type} __internal_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"__internal_name":"Alice"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_1_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_1_id":"1"}}}`, + }, + `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_1_id: id __internal_3_id: id __internal_2_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_1_id":"1","__internal_3_id":"1","__internal_2_id":"1"}}}`, + }, // New format: plain id key field (no alias for first occurrence) + `{"query":"{user {___typename: __typename __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"___typename":"User","__typename":"User","id":"1"}}}`, + }, // Deferred sub1 root fetch without redundant plain name (covered by __internal_name alias) + `{"query":"{user {account {type} __internal_name: name}}"}`: { + statusCode: 200, + body: `{"data":{"user":{"account":{"type":"premium"},"__internal_name":"Alice"}}}`, + }, + }, + })), &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ { TypeName: "Query", FieldNames: []string{"user"}, }, { - TypeName: "User", - FieldNames: []string{"id", "name", "account"}, + TypeName: "User", + FieldNames: []string{"id", "name", "account"}, ExternalFieldNames: []string{"billing", "settings"}, }, }, @@ -999,82 +1095,76 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { }, }, }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ + }, mustConfiguration(t, graphql_datasource.ConfigurationInput{ Fetch: &graphql_datasource.FetchConfiguration{ URL: "https://first/", Method: "POST", }, - SchemaConfiguration: mustSchemaConfig( - t, - &graphql_datasource.FederationConfiguration{ - Enabled: true, - ServiceSDL: firstSubgraphSDL, + SchemaConfiguration: mustSchemaConfig(t, &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: firstSubgraphSDL, + }, firstSubgraphSDL), + })), + mustGraphqlDataSourceConfiguration(t, "id-2", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "second", + expectedPath: "/", + responses: map[string]sendResponse{ + // Entity fetches for billing.plan (needed as @requires input for sub1 account) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"}}]}}`, }, - firstSubgraphSDL, - ), - }), - ), - mustGraphqlDataSourceConfiguration(t, - "id-2", - mustFactory(t, - testConditionalNetHttpClient(t, conditionalTestCase{ - expectedHost: "second", - expectedPath: "/", - responses: map[string]sendResponse{ - // Entity fetches for billing.plan (needed as @requires input for sub1 account) - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan currency}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro","currency":"USD"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {currency}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","billing":{"currency":"USD"}}]}}`, - }, - // Entity fetch for notifications — representations carry @requires data (name + settings.language) - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications}}}","variables":{"representations":[{"__typename":"User","name":"Alice","settings":{"language":"en"},"id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"]}]}}`, - }, - // Aliased billing fetches for deferred chunks - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_1_billing":{"plan":"pro"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_2_billing":{"plan":"pro"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} __internal_1_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"__internal_1_billing":{"plan":"pro"}}]}}`, - }, - // Combined billing+notifications fetch (planner merges them into one entity request). - // Two field-order variants since the planner may order selections differently. - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} notifications}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"notifications":["msg1","msg2"]}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"],"billing":{"plan":"pro"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"]}]}}`, - }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan currency}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro","currency":"USD"}}]}}`, }, - }), - ), - &plan.DataSourceMetadata{ + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {currency}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"currency":"USD"}}]}}`, + }, // Entity fetch for notifications — representations carry @requires data (name + settings.language) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications}}}","variables":{"representations":[{"__typename":"User","name":"Alice","settings":{"language":"en"},"id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"]}]}}`, + }, // Aliased billing fetches for deferred chunks + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_1_billing":{"plan":"pro"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_2_billing":{"plan":"pro"}}]}}`, + }, // New alias format: simple __internal_billing for first defer scope + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_billing":{"plan":"pro"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} __internal_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"__internal_billing":{"plan":"pro"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} __internal_1_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"__internal_1_billing":{"plan":"pro"}}]}}`, + }, // Combined billing+notifications fetch (planner merges them into one entity request). + // Two field-order variants since the planner may order selections differently. + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} notifications}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"notifications":["msg1","msg2"]}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"],"billing":{"plan":"pro"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"]}]}}`, + }, + }, + })), &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ { - TypeName: "User", - FieldNames: []string{"id", "billing", "notifications"}, + TypeName: "User", + FieldNames: []string{"id", "billing", "notifications"}, ExternalFieldNames: []string{"name", "settings"}, }, }, @@ -1103,76 +1193,84 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { }, }, }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ + }, mustConfiguration(t, graphql_datasource.ConfigurationInput{ Fetch: &graphql_datasource.FetchConfiguration{ URL: "https://second/", Method: "POST", }, - SchemaConfiguration: mustSchemaConfig( - t, - &graphql_datasource.FederationConfiguration{ - Enabled: true, - ServiceSDL: secondSubgraphSDL, + SchemaConfiguration: mustSchemaConfig(t, &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: secondSubgraphSDL, + }, secondSubgraphSDL), + })), + mustGraphqlDataSourceConfiguration(t, "id-3", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + expectedHost: "third", + expectedPath: "/", + responses: map[string]sendResponse{ + // Entity fetches for settings (needed for both account and notifications @requires) + // Aliased settings fetches for deferred chunks + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_1_settings":{"region":"us-east"}}]}}`, }, - secondSubgraphSDL, - ), - }), - ), - mustGraphqlDataSourceConfiguration(t, - "id-3", - mustFactory(t, - testConditionalNetHttpClient(t, conditionalTestCase{ - expectedHost: "third", - expectedPath: "/", - responses: map[string]sendResponse{ - // Entity fetches for settings (needed for both account and notifications @requires) - // Aliased settings fetches for deferred chunks - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_1_settings":{"region":"us-east"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_2_settings":{"region":"us-east"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_1_settings":{"language":"en"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_3_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_3_settings":{"language":"en"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_2_settings":{"language":"en"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region} __internal_1_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"},"__internal_1_settings":{"region":"us-east"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {language} __internal_1_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"},"__internal_1_settings":{"language":"en"}}]}}`, - }, - // Original entity fetches for settings - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east","language":"en"}}]}}`, - }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_2_settings":{"region":"us-east"}}]}}`, }, - }), - ), - &plan.DataSourceMetadata{ + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_1_settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_3_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_3_settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_2_settings":{"language":"en"}}]}}`, + }, // New alias format: simple __internal_settings for first defer scope + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_settings":{"region":"us-east"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {language} __internal_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"},"__internal_settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region} __internal_1_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"},"__internal_1_settings":{"region":"us-east"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {language} __internal_1_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"},"__internal_1_settings":{"language":"en"}}]}}`, + }, // Original entity fetches for settings + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east","language":"en"}}]}}`, + }, // Combined region+language with simple alias (new format) + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_settings: settings {region language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","__internal_settings":{"region":"us-east","language":"en"}}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region} __internal_settings: settings {region language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"},"__internal_settings":{"region":"us-east","language":"en"}}]}}`, + }, + }, + })), &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ { TypeName: "User", @@ -1193,85 +1291,79 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { }, }, }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ + }, mustConfiguration(t, graphql_datasource.ConfigurationInput{ Fetch: &graphql_datasource.FetchConfiguration{ URL: "https://third/", Method: "POST", }, - SchemaConfiguration: mustSchemaConfig( - t, - &graphql_datasource.FederationConfiguration{ - Enabled: true, - ServiceSDL: thirdSubgraphSDL, - }, - thirdSubgraphSDL, - ), - }), - ), - } + SchemaConfiguration: mustSchemaConfig(t, &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: thirdSubgraphSDL, + }, thirdSubgraphSDL), + })), + } - t.Run("non-defer - name only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ user { name } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}}}`, - })) - - t.Run("non-defer - account requires billing and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ user { account { type } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"account":{"type":"premium"}}}}`, - })) - - t.Run("non-defer - notifications requires name and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ user { notifications } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"notifications":["msg1","msg2"]}}}`, - })) - - t.Run("non-defer - both requires fields together", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ user { name account { type } notifications } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, - })) - - t.Run("non-defer - all fields including raw billing and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ user { name billing { plan } settings { region } account { type } notifications } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east"},"account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, - })) - - t.Run("defer - account field deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferAccount", - Query: ` + t.Run("non-defer - name only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { name } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}}}`, + })) + + t.Run("non-defer - account requires billing and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { account { type } } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"account":{"type":"premium"}}}}`, + })) + + t.Run("non-defer - notifications requires name and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { notifications } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"notifications":["msg1","msg2"]}}}`, + })) + + t.Run("non-defer - both requires fields together", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { name account { type } notifications } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, + })) + + t.Run("non-defer - all fields including raw billing and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ user { name billing { plan } settings { region } account { type } notifications } }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east"},"account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, + })) + + t.Run("defer - account field deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAccount", + Query: ` query DeferAccount { user { name @@ -1280,20 +1372,20 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - notifications field deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferNotifications", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - notifications field deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferNotifications", + Query: ` query DeferNotifications { user { name @@ -1302,20 +1394,20 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - all user fields deferred in single block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferAll", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - all user fields deferred in single block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAll", + Query: ` query DeferAll { user { ... @defer { @@ -1325,20 +1417,39 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} {"incremental":[{"data":{"name":"Alice","account":{"type":"premium"},"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - parallel defers on both cross-subgraph requires fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferBothRequires", - Query: ` + }, withStreamingResponse())) + + t.Run("all user fields without defer", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAll", + Query: ` + query DeferAll { + user { + name + account { type } + notifications + } + }`, + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, + })) + + t.Run("defer - parallel defers on both cross-subgraph requires fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferBothRequires", + Query: ` query DeferBothRequires { user { name @@ -1350,21 +1461,21 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - nested defers: outer has account, inner has notifications", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferNested", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - nested defers: outer has account, inner has notifications", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferNested", + Query: ` query DeferNested { user { name @@ -1376,21 +1487,21 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - parallel defers on raw entity fields alongside requires", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferMixed", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - parallel defers on raw entity fields alongside requires", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferMixed", + Query: ` query DeferMixed { user { name @@ -1403,21 +1514,21 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"}}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"}}},"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - deeply nested requires: account outer, notifications inner, with raw fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferDeepNested", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - deeply nested requires: account outer, notifications inner, with raw fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferDeepNested", + Query: ` query DeferDeepNested { user { ... @defer { @@ -1432,81 +1543,81 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} {"incremental":[{"data":{"name":"Alice","billing":{"plan":"pro"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) + }, withStreamingResponse())) - // Defer versions of each non-defer test — verify @defer doesn't break @requires resolution. + // Defer versions of each non-defer test — verify @defer doesn't break @requires resolution. - t.Run("defer - name only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferNameOnly", - Query: ` + t.Run("defer - name only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferNameOnly", + Query: ` query DeferNameOnly { user { ... @defer { name } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} {"incremental":[{"data":{"name":"Alice"},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - only account deferred (no other immediate fields)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferAccountOnly", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - only account deferred (no other immediate fields)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAccountOnly", + Query: ` query DeferAccountOnly { user { ... @defer { account { type } } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - only notifications deferred (no other immediate fields)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferNotificationsOnly", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - only notifications deferred (no other immediate fields)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferNotificationsOnly", + Query: ` query DeferNotificationsOnly { user { ... @defer { notifications } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} {"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - all fields in single defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferAllFields", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - all fields in single defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferAllFields", + Query: ` query DeferAllFields { user { ... @defer { @@ -1518,23 +1629,23 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{}},"hasNext":true} {"incremental":[{"data":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east"},"account":{"type":"premium"},"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) + }, withStreamingResponse())) - // Tests mixing requires-source fields (billing, settings) with derived @requires fields - // (account, notifications) in same or parallel defer blocks. + // Tests mixing requires-source fields (billing, settings) with derived @requires fields + // (account, notifications) in same or parallel defer blocks. - t.Run("defer - requires source (billing) and derived field (account) in same defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferBillingAndAccount", - Query: ` + t.Run("defer - requires source (billing) and derived field (account) in same defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferBillingAndAccount", + Query: ` query DeferBillingAndAccount { user { name @@ -1544,20 +1655,20 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"billing":{"plan":"pro"},"account":{"type":"premium"}},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - requires source (billing) and derived field (account) in parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferBillingParallelAccount", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - requires source (billing) and derived field (account) in parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferBillingParallelAccount", + Query: ` query DeferBillingParallelAccount { user { name @@ -1565,21 +1676,21 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { ... @defer { account { type } } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"billing":{"plan":"pro"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - requires source (settings) and derived field (notifications) in same defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferSettingsAndNotifications", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - requires source (settings) and derived field (notifications) in same defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferSettingsAndNotifications", + Query: ` query DeferSettingsAndNotifications { user { name @@ -1589,20 +1700,20 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"settings":{"language":"en"},"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - requires source (settings) and derived field (notifications) in parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferSettingsParallelNotifications", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - requires source (settings) and derived field (notifications) in parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferSettingsParallelNotifications", + Query: ` query DeferSettingsParallelNotifications { user { name @@ -1610,21 +1721,21 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { ... @defer { notifications } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"settings":{"language":"en"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - all requires sources deferred together, then derived fields deferred in parallel", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferSourcesThenDerived", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - all requires sources deferred together, then derived fields deferred in parallel", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferSourcesThenDerived", + Query: ` query DeferSourcesThenDerived { user { name @@ -1638,21 +1749,21 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice"}},"hasNext":true} {"incremental":[{"data":{"billing":{"plan":"pro"},"settings":{"region":"us-east","language":"en"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"},"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) - - t.Run("defer - requires sources immediate, both derived fields deferred in parallel", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "DeferDerivedFieldsOnly", - Query: ` + }, withStreamingResponse())) + + t.Run("defer - requires sources immediate, both derived fields deferred in parallel", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "DeferDerivedFieldsOnly", + Query: ` query DeferDerivedFieldsOnly { user { name @@ -1662,12 +1773,14 @@ func TestExecutionEngine_Execute_Defer_CrossSubgraphRequires(t *testing.T) { ... @defer { notifications } } }`, - } - }, - dataSources: dataSources, - expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east","language":"en"}}},"hasNext":true} + } + }, + dataSources: dataSources, + expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east","language":"en"}}},"hasNext":true} {"incremental":[{"data":{"account":{"type":"premium"}},"path":["user"]}],"hasNext":true} {"incremental":[{"data":{"notifications":["msg1","msg2"]},"path":["user"]}],"hasNext":false} `, - }, withStreamingResponse())) + }, withStreamingResponse())) + }) + } From 12ca35fe1406328f768d131ca0240acf198c6000 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 25 Mar 2026 01:18:04 +0200 Subject: [PATCH 59/79] cleanup unused mocks --- .../engine/execution_engine_defer_test.go | 261 +----------------- .../engine/execution_engine_helpers_test.go | 28 ++ 2 files changed, 34 insertions(+), 255 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 7952a27015..1dc40df618 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -41,6 +41,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { "id-1", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, expectedHost: "first", expectedPath: "/", responses: map[string]sendResponse{ @@ -193,6 +194,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { "id-1", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, expectedHost: "first", expectedPath: "/", responses: map[string]sendResponse{ @@ -212,70 +214,22 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"info":{"___typename":"Info"}}}}`, }, - `{"query":"{user {__typename id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {id __typename __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"id":"1","__typename":"User","__internal_1_id":"1"}}}`, - }, `{"query":"{user {__typename __internal_id: id __internal_1_id: id}}"}`: { statusCode: 200, body: `{"data":{"user":{"__typename":"User","__internal_id":"1","__internal_1_id":"1"}}}`, }, - `{"query":"{user {___typename: __typename __typename __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {___typename: __typename __typename __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {info {email} __typename id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"email":"black@sabbat"},"__typename":"User","id":"1","__internal_1_id":"1"}}}`, - }, `{"query":"{user {info {___typename: __typename} __typename id}}"}`: { statusCode: 200, body: `{"data":{"user":{"info":{"___typename":"Info"},"__typename":"User","id":"1"}}}`, }, - `{"query":"{user {___typename: __typename __typename __internal_3_id: id __internal_4_id: id __internal_5_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_3_id":"1","__internal_4_id":"1","__internal_5_id":"1"}}}`, - }, // New alias format: simple __internal_id for first defer scope - `{"query":"{user {__typename id __internal_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_id":"1"}}}`, - }, - `{"query":"{user {id __typename __internal_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"id":"1","__typename":"User","__internal_id":"1"}}}`, - }, `{"query":"{user {___typename: __typename __typename __internal_id: id}}"}`: { statusCode: 200, body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1"}}}`, }, - `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {info {email} __typename id __internal_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"info":{"email":"black@sabbat"},"__typename":"User","id":"1","__internal_id":"1"}}}`, - }, `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_4_id: id __internal_5_id: id}}"}`: { statusCode: 200, body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_4_id":"1","__internal_5_id":"1"}}}`, }, - `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_1_id: id __internal_3_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_1_id":"1","__internal_3_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_2_id":"1"}}}`, - }, // New format: plain id (no alias for first deferred occurrence) `{"query":"{user {__typename id}}"}`: { statusCode: 200, body: `{"data":{"user":{"__typename":"User","id":"1"}}}`, @@ -336,6 +290,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { "id-2", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, expectedHost: "second", expectedPath: "/", responses: map[string]sendResponse{ @@ -367,18 +322,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name info {___typename: __typename}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename info {email phone}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name info {email}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","name":"Black","title":"Sabbat","info":{"phone":"123"}}]}}`, - }, }, }), ), @@ -861,6 +804,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { dataSources := []plan.DataSource{ mustGraphqlDataSourceConfiguration(t, "id-1", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, expectedHost: "first", expectedPath: "/", responses: map[string]sendResponse{ @@ -869,18 +813,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"name":"Alice"}}}`, }, // Initial root query when only entity key is needed (account @requires billing+settings from sub2/sub3) - `{"query":"{user {id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"id":"1"}}}`, - }, // Initial root query with name also included (name needed for notifications @requires) - `{"query":"{user {id name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"id":"1","name":"Alice"}}}`, - }, - `{"query":"{user {name id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","id":"1"}}}`, - }, // Defer variants — include __typename and internal id aliases `{"query":"{user {__typename id}}"}`: { statusCode: 200, body: `{"data":{"user":{"__typename":"User","id":"1"}}}`, @@ -889,90 +821,10 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"___typename":"User"}}}`, }, - `{"query":"{user {___typename: __typename __typename __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {___typename: __typename __typename __internal_2_id: id __internal_1_id: id __internal_3_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_2_id":"1","__internal_1_id":"1","__internal_3_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_1_id: id id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_1_id":"1","id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_2_id: id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_2_id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {__typename id name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice"}}}`, - }, `{"query":"{user {name __typename id}}"}`: { statusCode: 200, body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1"}}}`, }, - `{"query":"{user {__typename id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, - }, - `{"query":"{user {__typename id name __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {name __typename id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {name __typename id __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, - }, - `{"query":"{user {name __typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1"}}}`, - }, - `{"query":"{user {__typename id __internal_1_id: id __internal_2_id: id __internal_3_id: id __internal_4_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1","__internal_4_id":"1"}}}`, - }, - `{"query":"{user {__typename id name __internal_1_id: id __internal_2_id: id __internal_3_id: id __internal_4_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__typename":"User","id":"1","name":"Alice","__internal_1_id":"1","__internal_2_id":"1","__internal_3_id":"1","__internal_4_id":"1"}}}`, - }, // Entity fetch for name (when name is in a deferred block) - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename name}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","name":"Alice"}]}}`, - }, // Entity fetch for account — representations carry @requires data (billing.plan + settings.region) - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type limit}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium","limit":100}}]}}`, - }, `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium"}}]}}`, @@ -981,54 +833,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"account":{"type":"premium"}}}}`, }, - `{"query":"{user {__internal_1_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__internal_1_name":"Alice"}}}`, - }, - `{"query":"{user {__internal_2_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__internal_2_name":"Alice"}}}`, - }, - `{"query":"{user {account {type} __internal_2_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"account":{"type":"premium"},"__internal_2_name":"Alice"}}}`, - }, - `{"query":"{user {__internal_3_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"__internal_3_name":"Alice"}}}`, - }, - `{"query":"{user {name account {type} __internal_1_name: name}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"__internal_1_name":"Alice"}}}`, - }, // New alias format: simple __internal_id for first defer scope - `{"query":"{user {___typename: __typename __typename __internal_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_id: id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","__internal_2_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_id: id __internal_2_id: id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","__internal_2_id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_id: id id __internal_2_id: id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","id":"1","__internal_2_id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {name __typename __internal_id: id __internal_1_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"name":"Alice","__typename":"User","__internal_id":"1","__internal_1_id":"1","__internal_2_id":"1"}}}`, - }, `{"query":"{user {__internal_name: name}}"}`: { statusCode: 200, body: `{"data":{"user":{"__internal_name":"Alice"}}}`, @@ -1037,14 +841,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"__internal_name":"Alice"}}}`, }, - `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_1_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_1_id":"1"}}}`, - }, - `{"query":"{user {___typename: __typename __typename __internal_id: id __internal_1_id: id __internal_3_id: id __internal_2_id: id}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"___typename":"User","__typename":"User","__internal_id":"1","__internal_1_id":"1","__internal_3_id":"1","__internal_2_id":"1"}}}`, - }, // New format: plain id key field (no alias for first occurrence) `{"query":"{user {___typename: __typename __typename id}}"}`: { statusCode: 200, body: `{"data":{"user":{"___typename":"User","__typename":"User","id":"1"}}}`, @@ -1106,6 +902,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, firstSubgraphSDL), })), mustGraphqlDataSourceConfiguration(t, "id-2", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, expectedHost: "second", expectedPath: "/", responses: map[string]sendResponse{ @@ -1114,26 +911,10 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"}}]}}`, }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan currency}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro","currency":"USD"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {currency}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","billing":{"currency":"USD"}}]}}`, - }, // Entity fetch for notifications — representations carry @requires data (name + settings.language) `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications}}}","variables":{"representations":[{"__typename":"User","name":"Alice","settings":{"language":"en"},"id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"]}]}}`, - }, // Aliased billing fetches for deferred chunks - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_1_billing":{"plan":"pro"}}]}}`, }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_2_billing":{"plan":"pro"}}]}}`, - }, // New alias format: simple __internal_billing for first defer scope `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","__internal_billing":{"plan":"pro"}}]}}`, @@ -1142,11 +923,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"__internal_billing":{"plan":"pro"}}]}}`, }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} __internal_1_billing: billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"__internal_1_billing":{"plan":"pro"}}]}}`, - }, // Combined billing+notifications fetch (planner merges them into one entity request). - // Two field-order variants since the planner may order selections differently. `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan} notifications}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"},"notifications":["msg1","msg2"]}]}}`, @@ -1155,10 +931,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"],"billing":{"plan":"pro"}}]}}`, }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename notifications}}}","variables":{"representations":[{"__typename":"User","id":"1","name":"Alice","settings":{"language":"en"}}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","notifications":["msg1","msg2"]}]}}`, - }, }, })), &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ @@ -1204,23 +976,10 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, secondSubgraphSDL), })), mustGraphqlDataSourceConfiguration(t, "id-3", mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, expectedHost: "third", expectedPath: "/", responses: map[string]sendResponse{ - // Entity fetches for settings (needed for both account and notifications @requires) - // Aliased settings fetches for deferred chunks - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_1_settings":{"region":"us-east"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_2_settings":{"region":"us-east"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_1_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","__internal_1_settings":{"language":"en"}}]}}`, - }, `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_3_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","__internal_3_settings":{"language":"en"}}]}}`, @@ -1241,14 +1000,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"},"__internal_settings":{"language":"en"}}]}}`, }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region} __internal_1_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"},"__internal_1_settings":{"region":"us-east"}}]}}`, - }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {language} __internal_1_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"User","settings":{"language":"en"},"__internal_1_settings":{"language":"en"}}]}}`, - }, // Original entity fetches for settings `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east"}}]}}`, diff --git a/execution/engine/execution_engine_helpers_test.go b/execution/engine/execution_engine_helpers_test.go index 9d7aa3a992..e05afa6ad0 100644 --- a/execution/engine/execution_engine_helpers_test.go +++ b/execution/engine/execution_engine_helpers_test.go @@ -59,6 +59,9 @@ type conditionalTestCase struct { // responses map an expected body to the output that should be sent responses map[string]sendResponse + + reportUnused bool + reportUsed bool } type sendResponse struct { @@ -71,6 +74,17 @@ func createConditionalTestRoundTripper(t *testing.T, testCase conditionalTestCas require.True(t, len(testCase.responses) > 0, "no responses defined") + used := make(map[string]bool) + if testCase.reportUnused { + t.Cleanup(func() { + for key := range testCase.responses { + if !used[key] { + t.Logf("UNUSED MOCK [%s]: %s", testCase.expectedHost, key) + } + } + }) + } + return func(req *http.Request) *http.Response { t.Helper() @@ -83,13 +97,27 @@ func createConditionalTestRoundTripper(t *testing.T, testCase conditionalTestCas require.NoError(t, err) defer req.Body.Close() + if testCase.reportUsed { + t.Logf("Requested MOCK [%s]: %s", testCase.expectedHost, string(gotBody)) + } + if !assert.Containsf(t, testCase.responses, string(gotBody), "received unexpected body: %v", string(gotBody)) { return &http.Response{ StatusCode: 400, Body: io.NopCloser(bytes.NewBuffer([]byte("received unexpected body"))), } } + response := testCase.responses[string(gotBody)] + + if testCase.reportUnused { + used[string(gotBody)] = true + } + + if testCase.reportUsed { + t.Logf("Send MOCK Response:\n %s", response.body) + } + return &http.Response{ StatusCode: response.statusCode, Body: io.NopCloser(bytes.NewBuffer([]byte(response.body))), From cdc2b4a9009daf77da66f29fc26160d93fb0ac81 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 25 Mar 2026 01:18:46 +0200 Subject: [PATCH 60/79] tmp: refactor aliasing --- v2/pkg/engine/plan/required_fields_visitor.go | 254 +++++++++++++++--- .../plan/required_fields_visitor_test.go | 184 +++++++++---- 2 files changed, 345 insertions(+), 93 deletions(-) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index d47a499de3..2a8007a9d0 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -67,6 +67,16 @@ type addRequiredFieldsConfiguration struct { addTypenameInNestedSelections bool } +// requiredFieldInfo holds pre-computed field properties shared across +// the deferred and non-deferred handling paths. +type requiredFieldInfo struct { + ref int + fieldName ast.ByteSlice + isTypeName bool + isLeafField bool + selectionSetRef int +} + type AddRequiredFieldsResult struct { skipFieldRefs []int requiredFieldRefs []int @@ -196,6 +206,63 @@ func (v *requiredFieldsVisitor) fieldHasDeferInternal(fieldRef int) bool { return exists } +// fieldDeferID returns the "id" argument value of the @__defer_internal directive +// on fieldRef, or "" if the directive is not present. +func (v *requiredFieldsVisitor) fieldDeferID(fieldRef int) string { + for _, dirRef := range v.config.operation.Fields[fieldRef].Directives.Refs { + if !bytes.Equal(v.config.operation.DirectiveNameBytes(dirRef), literal.DEFER_INTERNAL) { + continue // not the right directive + } + // found @__defer_internal — extract the "id" argument + val, ok := v.config.operation.DirectiveArgumentValueByName(dirRef, []byte("id")) + if !ok || val.Kind != ast.ValueKindString { + continue + } + return v.config.operation.StringValueContentString(val.Ref) + } + return "" +} + +type deferAliasResult struct { + addAlias bool + includeDeferID bool + reuseFieldRef int // ast.InvalidRef when not reusing +} + +// resolveDeferredAlias decides how to alias a deferred required field. +// Precondition: v.config.deferInfo != nil && v.isRootLevel(). +// +// Decision table: +// - __internal_{fieldName} absent → addAlias=true, includeDeferID=false +// - __internal_{fieldName} present, same scope → reuseFieldRef set +// - __internal_{fieldName} present, diff scope, __internal_{deferID}_{fieldName} absent → addAlias=true, includeDeferID=true +// - __internal_{fieldName} present, diff scope, __internal_{deferID}_{fieldName} present → reuseFieldRef set +func (v *requiredFieldsVisitor) resolveDeferredAlias(fieldName ast.ByteSlice, selectionSetRef int) deferAliasResult { + // --- Level 1: look for __internal_{fieldName} --- + simpleAlias := append([]byte("__internal_"), fieldName...) + exists, existingRef := v.config.operation.SelectionSetHasFieldSelectionWithNameOrAliasBytes(selectionSetRef, simpleAlias) + if !exists { + // no alias yet — create the simple one + return deferAliasResult{addAlias: true, reuseFieldRef: ast.InvalidRef} + } + if v.fieldDeferID(existingRef) == v.config.deferInfo.ID { + // simple alias already belongs to this defer scope — reuse it + return deferAliasResult{reuseFieldRef: existingRef} + } + + // --- Level 2: simple alias belongs to a different scope --- + // look for an existing conflict alias __internal_{deferID}_{fieldName} + conflictAlias := fmt.Appendf(nil, "__internal_%s_%s", v.config.deferInfo.ID, fieldName) + conflictExists, conflictRef := v.config.operation.SelectionSetHasFieldSelectionWithNameOrAliasBytes(selectionSetRef, conflictAlias) + if conflictExists { + // conflict alias already exists for this scope — reuse it + return deferAliasResult{reuseFieldRef: conflictRef} + } + + // no existing conflict alias — create one with the defer ID included + return deferAliasResult{addAlias: true, includeDeferID: true, reuseFieldRef: ast.InvalidRef} +} + func (v *requiredFieldsVisitor) selectionSetHasTypeNameSelection(operationSelectionSetRef int) bool { exists, _ := v.config.operation.SelectionSetHasFieldSelectionWithExactName(operationSelectionSetRef, typeNameFieldBytes) return exists @@ -235,49 +302,90 @@ func (v *requiredFieldsVisitor) isRootLevel() bool { return len(v.OperationNodes) == 1 } +// handleRequiredField is the EnterField entry point for @requires fields. +// It builds requiredFieldInfo and dispatches to the deferred or non-deferred path. func (v *requiredFieldsVisitor) handleRequiredField(ref int) { fieldName := v.key.FieldNameBytes(ref) - isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) - isLeafField := !v.key.FieldHasSelections(ref) - - // @requires fields can carry arguments (e.g. price(currency: USD)). - // If the same field already appears in the query with different arguments, - // the two selections cannot share the same field node, so we must alias the - // required copy to avoid clobbering the user's selection. - // Key fields never have arguments, so this check is absent in handleKeyField. - needAlias := v.key.FieldHasArguments(ref) + fi := requiredFieldInfo{ + ref: ref, + fieldName: fieldName, + isTypeName: bytes.Equal(fieldName, typeNameFieldBytes), + isLeafField: !v.key.FieldHasSelections(ref), + selectionSetRef: v.OperationNodes[len(v.OperationNodes)-1].Ref, + } - // Unlike handleKeyField, __typename IS included in deferAlias here. + // Unlike handleKeyField, __typename IS included in the deferred path here. // For interface objects (entity interfaces) the planner adds __typename as a // @requires field (not a key field) so the owning subgraph can return the real - // concrete type. That __typename must travel through the same deferred path as + // concrete type. That __typename must travel through the same deferred path as // the rest of the requires fields, so it must not be excluded from aliasing. - deferAlias := v.config.deferInfo != nil && v.isRootLevel() + if v.config.deferInfo != nil && v.isRootLevel() { + v.handleRequiredFieldDeferred(fi) + return + } + v.handleRequiredFieldNonDeferred(fi) +} + +// handleRequiredFieldDeferred handles @requires fields in a deferred context. +// Uses resolveDeferredAlias to reuse or create __internal_{fieldName} aliases. +func (v *requiredFieldsVisitor) handleRequiredFieldDeferred(fi requiredFieldInfo) { + ar := v.resolveDeferredAlias(fi.fieldName, fi.selectionSetRef) + + if ar.reuseFieldRef != ast.InvalidRef { + // reuse the existing aliased field from the same defer scope + v.recordRemappedPathIfAliased(ar.reuseFieldRef, fi.fieldName) + if !fi.isTypeName || v.config.isTypeNameForEntityInterface { + v.storeRequiredFieldRef(ar.reuseFieldRef) + } + if !fi.isLeafField { + // push to OperationNodes so nested key fields are traversed, + // but do NOT add to modifiedFieldRefs — the selection set was already + // set up by the prior addRequiredFields call that created this alias + v.OperationNodes = append(v.OperationNodes, ast.Node{Kind: ast.NodeKindField, Ref: ar.reuseFieldRef}) + } + return + } - selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref - operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) + fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, ar.addAlias, ar.includeDeferID) + if !fi.isLeafField { + v.OperationNodes = append(v.OperationNodes, fieldNode) + } +} + +// handleRequiredFieldNonDeferred handles @requires fields outside a deferred context. +func (v *requiredFieldsVisitor) handleRequiredFieldNonDeferred(fi requiredFieldInfo) { + operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(fi.selectionSetRef, fi.fieldName) + + // @requires fields can carry arguments (e.g. price(currency: USD)). + // If the same field already appears in the query with different arguments, + // the two selections cannot share the same field node, so we must alias the + // required copy to avoid clobbering the user's selection. + // Key fields never have arguments, so this check is absent in handleKeyFieldNonDeferred. + needAlias := v.key.FieldHasArguments(fi.ref) // if the existing field is deferred but we are adding requirements for a non-deferred scope, - // we must not reuse it — add an alias instead + // we must not reuse it — add an alias instead. + // When deferInfo is set (deferred context) and we're nested inside a reused deferred field, + // the nested field is already in the correct defer scope — reuse it directly. if operationHasField && v.config.deferInfo == nil && v.fieldHasDeferInternal(operationFieldRef) { needAlias = true } - if operationHasField && !needAlias && !deferAlias { + if operationHasField && !needAlias { // Skip storing __typename as a required field — we only want to depend on // the actual key fields, not __typename. // Exception: for interface objects the planner adds __typename via @requires // so we do need it as a real dependency in that case. - // (handleKeyField always skips __typename here because it handles __typename + // (handleKeyFieldNonDeferred always skips __typename because it handles __typename // through the representation variables builder instead.) - if !isTypeName || v.config.isTypeNameForEntityInterface { + if !fi.isTypeName || v.config.isTypeNameForEntityInterface { v.storeRequiredFieldRef(operationFieldRef) } // do not add required field if the field is already present in the operation with the same name // but add an operation node from operation if the field has selections - if isLeafField { + if fi.isLeafField { return } @@ -286,48 +394,101 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { return } - fieldNode := v.addRequiredField(ref, fieldName, selectionSetRef, deferAlias || (operationHasField && needAlias)) - if !isLeafField { + fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, operationHasField && needAlias, false) + if !fi.isLeafField { v.OperationNodes = append(v.OperationNodes, fieldNode) } } +// handleKeyField is the EnterField entry point for key fields. +// It builds requiredFieldInfo and dispatches to the deferred or non-deferred path. func (v *requiredFieldsVisitor) handleKeyField(ref int) { fieldName := v.key.FieldNameBytes(ref) - isTypeName := bytes.Equal(fieldName, typeNameFieldBytes) - isLeafField := !v.key.FieldHasSelections(ref) + + fi := requiredFieldInfo{ + ref: ref, + fieldName: fieldName, + isTypeName: bytes.Equal(fieldName, typeNameFieldBytes), + isLeafField: !v.key.FieldHasSelections(ref), + selectionSetRef: v.OperationNodes[len(v.OperationNodes)-1].Ref, + } // Key fields must never alias __typename, even in a deferred context. // __typename is not part of the user-visible key field set; instead it is // always injected by the representation variables builder with the static - // name "__typename". Aliasing it would break that builder. + // name "__typename". Aliasing it would break that builder. // (handleRequiredField does NOT exclude __typename here because for // interface objects __typename is fetched via @requires, not keys.) - deferAlias := v.config.deferInfo != nil && v.isRootLevel() && !isTypeName + if v.config.deferInfo != nil && v.isRootLevel() && !fi.isTypeName { + v.handleKeyFieldDeferred(fi) + return + } + v.handleKeyFieldNonDeferred(fi) +} - // Key fields cannot have arguments — they are always simple scalar - // identifiers — so there is no needAlias check for arguments here - // (unlike handleRequiredField). +// handleKeyFieldDeferred handles key fields in a deferred context. +// Key fields are added to the initial (non-deferred) selection set so they can be +// used as entity representation inputs. The first occurrence of a key field is +// always added as a plain field (no alias); subsequent callers from different defer +// scopes reuse it. An alias is only needed when a plain field already exists but +// belongs to a specific defer scope (has @deferInternal) and therefore cannot be +// shared. +func (v *requiredFieldsVisitor) handleKeyFieldDeferred(fi requiredFieldInfo) { + // First preference: a plain (non-deferred) field that all scopes can share. + plainExists, plainRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(fi.selectionSetRef, fi.fieldName) + if plainExists && !v.fieldHasDeferInternal(plainRef) { + v.storeRequiredFieldRef(plainRef) + if !fi.isLeafField { + v.modifiedFieldRefs = append(v.modifiedFieldRefs, plainRef) + v.OperationNodes = append(v.OperationNodes, ast.Node{Kind: ast.NodeKindField, Ref: plainRef}) + } + return + } - selectionSetRef := v.OperationNodes[len(v.OperationNodes)-1].Ref - operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(selectionSetRef, fieldName) + ar := v.resolveDeferredAlias(fi.fieldName, fi.selectionSetRef) - // if the existing field is deferred but we are adding requirements for a non-deferred scope, - // we must not reuse it — add an alias instead + if ar.reuseFieldRef != ast.InvalidRef { + // reuse the existing aliased field from the same defer scope + v.recordRemappedPathIfAliased(ar.reuseFieldRef, fi.fieldName) + v.storeRequiredFieldRef(ar.reuseFieldRef) + if !fi.isLeafField { + v.OperationNodes = append(v.OperationNodes, ast.Node{Kind: ast.NodeKindField, Ref: ar.reuseFieldRef}) + } + return + } + + // No existing field to reuse. An alias is only needed when a plain field + // already exists but is deferred (has @deferInternal). When no field exists + // at all, add a plain field so subsequent callers from any scope can reuse it. + addAlias := plainExists // true only when plain field exists but is deferred + fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, addAlias, ar.includeDeferID) + if !fi.isLeafField { + v.OperationNodes = append(v.OperationNodes, fieldNode) + } +} + +// handleKeyFieldNonDeferred handles key fields outside a deferred context. +func (v *requiredFieldsVisitor) handleKeyFieldNonDeferred(fi requiredFieldInfo) { + operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(fi.selectionSetRef, fi.fieldName) + + // If the existing field has @deferInternal it belongs to a specific defer scope; + // the non-deferred planner must not reuse it — add an alias instead. existingFieldIsDeferred := operationHasField && v.config.deferInfo == nil && v.fieldHasDeferInternal(operationFieldRef) - if operationHasField && !deferAlias && !existingFieldIsDeferred { + if operationHasField && !existingFieldIsDeferred { // Skip storing __typename as a required field. - // Unlike handleRequiredField there is no isTypeNameForEntityInterface + // Unlike handleRequiredFieldNonDeferred there is no isTypeNameForEntityInterface // exception here: for interface objects the real __typename is fetched // via @requires (handled by handleRequiredField), never as a key field. - if !isTypeName { + // Key fields cannot have arguments, so there is no needAlias check here + // (unlike handleRequiredFieldNonDeferred). + if !fi.isTypeName { v.storeRequiredFieldRef(operationFieldRef) } // do not add required field if the field is already present in the operation with the same name // but add an operation node from operation if the field has selections - if isLeafField { + if fi.isLeafField { return } @@ -336,8 +497,8 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { return } - fieldNode := v.addRequiredField(ref, fieldName, selectionSetRef, deferAlias || existingFieldIsDeferred) - if !isLeafField { + fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, existingFieldIsDeferred, false) + if !fi.isLeafField { v.OperationNodes = append(v.OperationNodes, fieldNode) } } @@ -352,7 +513,18 @@ func (v *requiredFieldsVisitor) storeRequiredFieldRef(fieldRef int) { v.requiredFieldRefs = append(v.requiredFieldRefs, fieldRef) } -func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteSlice, selectionSet int, addAlias bool) ast.Node { +// recordRemappedPathIfAliased records the path → alias mapping when reusing an +// existing aliased field. Each AddRequiredFields call gets a fresh v.mapping, +// so every planner that reuses an alias must record the mapping itself. +func (v *requiredFieldsVisitor) recordRemappedPathIfAliased(fieldRef int, fieldName ast.ByteSlice) { + if !v.config.operation.FieldAliasIsDefined(fieldRef) { + return + } + currentPath := v.Walker.Path.DotDelimitedString() + "." + string(fieldName) + v.mapping[currentPath] = string(v.config.operation.FieldAliasBytes(fieldRef)) +} + +func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteSlice, selectionSet int, addAlias bool, includeDeferIDInAlias bool) ast.Node { field := ast.Field{ Name: v.config.operation.Input.AppendInputBytes(fieldName), SelectionSet: ast.InvalidRef, @@ -360,8 +532,8 @@ func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteS if addAlias { var fullAliasName []byte - if v.config.deferInfo != nil { - fullAliasName = []byte(fmt.Sprintf("__internal_%s_%s", v.config.deferInfo.ID, string(fieldName))) + if includeDeferIDInAlias && v.config.deferInfo != nil { + fullAliasName = fmt.Appendf(nil, "__internal_%s_%s", v.config.deferInfo.ID, fieldName) } else { fullAliasName = append([]byte("__internal_"), fieldName...) } diff --git a/v2/pkg/engine/plan/required_fields_visitor_test.go b/v2/pkg/engine/plan/required_fields_visitor_test.go index 805278c4c6..a849c035c7 100644 --- a/v2/pkg/engine/plan/required_fields_visitor_test.go +++ b/v2/pkg/engine/plan/required_fields_visitor_test.go @@ -22,9 +22,9 @@ func TestAddRequiredFields(t *testing.T) { allowTypename bool isTypeNameForEntityInterface bool selectionSetRef int - enforceTypenameForRequired bool - deferInfo *DeferInfo - parentFieldDeferID string + enforceTypenameForRequired bool + deferInfo *DeferInfo + parentFieldDeferID string // output expectedOperation string @@ -487,47 +487,46 @@ func TestAddRequiredFields(t *testing.T) { expectedRequiredFieldsCount: 6, }, { - name: "key with defer id - new field gets aliased", + name: "key with defer id - new field added as plain (no alias needed)", definition: ` type Query { user: User } type User { id: ID! name: String! }`, operation: `query { user { name } }`, - typeName: "User", - fieldSet: "id", - isKey: true, + typeName: "User", + fieldSet: "id", + isKey: true, deferInfo: &DeferInfo{ID: "1"}, expectedOperation: ` query { user { name - __internal_1_id: id + id } }`, expectedSkipFieldsCount: 1, expectedRequiredFieldsCount: 1, - expectedRemappedPaths: map[string]string{"User.id": "__internal_1_id"}, + expectedRemappedPaths: map[string]string{}, }, { - name: "key with defer id - existing field still gets aliased", + name: "key with defer id - existing plain field is reused (no alias)", definition: ` type Query { user: User } type User { id: ID! name: String! }`, operation: `query { user { id name } }`, - typeName: "User", - fieldSet: "id", - isKey: true, + typeName: "User", + fieldSet: "id", + isKey: true, deferInfo: &DeferInfo{ID: "1"}, expectedOperation: ` query { user { id name - __internal_1_id: id } }`, - expectedSkipFieldsCount: 1, + expectedSkipFieldsCount: 0, expectedRequiredFieldsCount: 1, - expectedRemappedPaths: map[string]string{"User.id": "__internal_1_id"}, + expectedRemappedPaths: map[string]string{}, }, { name: "requires with defer id - new field gets aliased", @@ -535,23 +534,23 @@ func TestAddRequiredFields(t *testing.T) { type Query { user: User } type User { id: ID! firstName: String! lastName: String! fullName: String! }`, operation: `query { user { fullName } }`, - typeName: "User", - fieldSet: "firstName lastName", - isKey: false, + typeName: "User", + fieldSet: "firstName lastName", + isKey: false, deferInfo: &DeferInfo{ID: "1"}, expectedOperation: ` query { user { fullName - __internal_1_firstName: firstName @__defer_internal(id: "1") - __internal_1_lastName: lastName @__defer_internal(id: "1") + __internal_firstName: firstName @__defer_internal(id: "1") + __internal_lastName: lastName @__defer_internal(id: "1") } }`, expectedSkipFieldsCount: 2, expectedRequiredFieldsCount: 2, expectedRemappedPaths: map[string]string{ - "User.firstName": "__internal_1_firstName", - "User.lastName": "__internal_1_lastName", + "User.firstName": "__internal_firstName", + "User.lastName": "__internal_lastName", }, }, { @@ -560,24 +559,24 @@ func TestAddRequiredFields(t *testing.T) { type Query { user: User } type User { id: ID! firstName: String! fullName: String! }`, operation: `query { user { firstName fullName } }`, - typeName: "User", - fieldSet: "firstName", - isKey: false, + typeName: "User", + fieldSet: "firstName", + isKey: false, deferInfo: &DeferInfo{ID: "1"}, expectedOperation: ` query { user { firstName fullName - __internal_1_firstName: firstName @__defer_internal(id: "1") + __internal_firstName: firstName @__defer_internal(id: "1") } }`, expectedSkipFieldsCount: 1, expectedRequiredFieldsCount: 1, - expectedRemappedPaths: map[string]string{"User.firstName": "__internal_1_firstName"}, + expectedRemappedPaths: map[string]string{"User.firstName": "__internal_firstName"}, }, { - name: "key with defer id - only root fields aliased, nested fields are not", + name: "key with defer id - existing plain nested field is reused, leaf added inside", definition: ` type Query { user: User } type User { id: ID! address: Address! } @@ -588,30 +587,27 @@ func TestAddRequiredFields(t *testing.T) { isKey: true, deferInfo: &DeferInfo{ID: "1"}, selectionSetRef: 1, - // with deferAlias, the existing address field is left untouched; - // a new aliased field with its own selection set is created + // existing plain address is reused; street is added into it expectedOperation: ` query { user { address { city - } - __internal_1_address: address { street } } }`, - expectedSkipFieldsCount: 2, // __internal_1_address, street inside it - expectedRequiredFieldsCount: 2, - expectedModifiedFieldsCount: 0, - expectedRemappedPaths: map[string]string{"User.address": "__internal_1_address"}, + expectedSkipFieldsCount: 1, // street + expectedRequiredFieldsCount: 2, // address (reused) + street + expectedModifiedFieldsCount: 1, // address selection set was modified + expectedRemappedPaths: map[string]string{}, }, { - name: "key with defer id and parentId - directive added to aliased field", + name: "key with defer id and parentId - plain field added with directive", definition: ` type Query { user: User } type User { id: ID! name: String! }`, - operation: `query { user { name } }`, + operation: `query { user { name } }`, typeName: "User", fieldSet: "id", isKey: true, @@ -621,12 +617,12 @@ func TestAddRequiredFields(t *testing.T) { query { user { name - __internal_2_id: id @__defer_internal(id: "1") + id @__defer_internal(id: "1") } }`, expectedSkipFieldsCount: 1, expectedRequiredFieldsCount: 1, - expectedRemappedPaths: map[string]string{"User.id": "__internal_2_id"}, + expectedRemappedPaths: map[string]string{}, }, { name: "requires with defer id and parentId - directive added with all fields", @@ -642,15 +638,15 @@ func TestAddRequiredFields(t *testing.T) { query { user { fullName - __internal_2_firstName: firstName @__defer_internal(id: "2", label: "myLabel", parentDeferId: "1") + __internal_firstName: firstName @__defer_internal(id: "2", label: "myLabel", parentDeferId: "1") } }`, expectedSkipFieldsCount: 1, expectedRequiredFieldsCount: 1, - expectedRemappedPaths: map[string]string{"User.firstName": "__internal_2_firstName"}, + expectedRemappedPaths: map[string]string{"User.firstName": "__internal_firstName"}, }, { - name: "key with defer id and parentId - directive added to nested fields too", + name: "key with defer id and parentId - existing plain nested reused, leaf gets directive", definition: ` type Query { user: User } type User { id: ID! address: Address! } @@ -662,21 +658,20 @@ func TestAddRequiredFields(t *testing.T) { deferInfo: &DeferInfo{ID: "2", ParentID: "1"}, parentFieldDeferID: "1", selectionSetRef: 1, + // existing plain address reused; street added with @deferInternal expectedOperation: ` query { user { address { city - } - __internal_2_address: address @__defer_internal(id: "1") { street @__defer_internal(id: "1") } } }`, - expectedSkipFieldsCount: 2, - expectedRequiredFieldsCount: 2, - expectedModifiedFieldsCount: 0, - expectedRemappedPaths: map[string]string{"User.address": "__internal_2_address"}, + expectedSkipFieldsCount: 1, // street + expectedRequiredFieldsCount: 2, // address (reused) + street + expectedModifiedFieldsCount: 1, // address modified + expectedRemappedPaths: map[string]string{}, }, { name: "requires with defer id and parentId - directive added to nested fields too", @@ -693,7 +688,7 @@ func TestAddRequiredFields(t *testing.T) { query { user { fullAddress - __internal_2_address: address @__defer_internal(id: "2", parentDeferId: "1") { + __internal_address: address @__defer_internal(id: "2", parentDeferId: "1") { street @__defer_internal(id: "2", parentDeferId: "1") } } @@ -701,7 +696,7 @@ func TestAddRequiredFields(t *testing.T) { expectedSkipFieldsCount: 2, expectedRequiredFieldsCount: 2, expectedModifiedFieldsCount: 0, - expectedRemappedPaths: map[string]string{"User.address": "__internal_2_address"}, + expectedRemappedPaths: map[string]string{"User.address": "__internal_address"}, }, { name: "key - existing field has defer_internal, non-deferred requirement gets aliased", @@ -802,6 +797,91 @@ func TestAddRequiredFields(t *testing.T) { expectedModifiedFieldsCount: 1, expectedRemappedPaths: map[string]string{"User.address.street": "__internal_street"}, }, + { + name: "requires with defer id - second call with same defer id reuses existing alias", + definition: ` + type Query { user: User } + type User { id: ID! settings: Settings! fullName: String! account: Account! } + type Settings { region: String! } + type Account { type: String! }`, + // operation already has __internal_settings from a prior addRequiredFields call; + // nested region also carries the defer directive + operation: `query { user { fullName __internal_settings: settings @__defer_internal(id: "1") { region @__defer_internal(id: "1") } account } }`, + typeName: "User", + fieldSet: "settings { region }", + isKey: false, + selectionSetRef: 1, + deferInfo: &DeferInfo{ID: "1"}, + // __internal_settings already exists with same defer scope — reuse it; no new field added + expectedOperation: ` + query { + user { + fullName + __internal_settings: settings @__defer_internal(id: "1") { region @__defer_internal(id: "1") } + account + } + }`, + expectedSkipFieldsCount: 0, + expectedRequiredFieldsCount: 2, // reused settings ref + reused region ref (nested non-deferred path) + expectedRemappedPaths: map[string]string{"User.settings": "__internal_settings"}, + }, + { + name: "requires with defer id - existing alias from different defer scope gets defer-id alias", + definition: ` + type Query { user: User } + type User { id: ID! settings: Settings! fullName: String! account: Account! } + type Settings { region: String! } + type Account { type: String! }`, + // operation has __internal_settings belonging to defer scope "1" with directive on nested field too + operation: `query { user { fullName __internal_settings: settings @__defer_internal(id: "1") { region @__defer_internal(id: "1") } account } }`, + typeName: "User", + fieldSet: "settings { region }", + isKey: false, + selectionSetRef: 1, // user's inner selection set; ref 0 is the pre-seeded settings' inner selection set + deferInfo: &DeferInfo{ID: "2"}, + // __internal_settings exists but belongs to defer "1"; no __internal_2_settings yet — create it + expectedOperation: ` + query { + user { + fullName + __internal_settings: settings @__defer_internal(id: "1") { region @__defer_internal(id: "1") } + account + __internal_2_settings: settings @__defer_internal(id: "2") { region @__defer_internal(id: "2") } + } + }`, + expectedSkipFieldsCount: 2, // __internal_2_settings + nested region + expectedRequiredFieldsCount: 2, + expectedRemappedPaths: map[string]string{"User.settings": "__internal_2_settings"}, + }, + { + name: "requires with defer id - third call with same conflict defer id reuses conflict alias", + definition: ` + type Query { user: User } + type User { id: ID! settings: Settings! fullName: String! account: Account! } + type Settings { region: String! } + type Account { type: String! }`, + operation: `query { user { + fullName + __internal_settings: settings @__defer_internal(id: "1") { region @__defer_internal(id: "1") } + __internal_2_settings: settings @__defer_internal(id: "2") { region @__defer_internal(id: "2") } + account + } }`, + typeName: "User", + fieldSet: "settings { region }", + isKey: false, + selectionSetRef: 2, // user's inner selection set; refs 0 and 1 are the two pre-seeded settings' inner selection sets + deferInfo: &DeferInfo{ID: "2"}, + // __internal_settings exists but defer "1" != "2"; __internal_2_settings exists with defer "2" — reuse it + expectedOperation: `query { user { + fullName + __internal_settings: settings @__defer_internal(id: "1") { region @__defer_internal(id: "1") } + __internal_2_settings: settings @__defer_internal(id: "2") { region @__defer_internal(id: "2") } + account + } }`, + expectedSkipFieldsCount: 0, + expectedRequiredFieldsCount: 2, // reused __internal_2_settings ref + reused nested region ref + expectedRemappedPaths: map[string]string{"User.settings": "__internal_2_settings"}, + }, } for _, tt := range tests { From 2661dde362d4d372969e7c458c0124c99df9b098 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 25 Mar 2026 22:41:47 +0200 Subject: [PATCH 61/79] fix naming --- v2/pkg/engine/plan/required_fields_visitor.go | 112 +++++++++--------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index 2a8007a9d0..9c3e7b3b24 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -73,7 +73,7 @@ type requiredFieldInfo struct { ref int fieldName ast.ByteSlice isTypeName bool - isLeafField bool + isLeaf bool selectionSetRef int } @@ -85,7 +85,7 @@ type AddRequiredFieldsResult struct { } func addRequiredFields(config *addRequiredFieldsConfiguration) (out AddRequiredFieldsResult, report *operationreport.Report) { - key, report := RequiredFieldsFragment(config.typeName, config.fieldSet, config.allowTypename) + parsedSelectionSet, report := RequiredFieldsFragment(config.typeName, config.fieldSet, config.allowTypename) if report.HasErrors() { return out, report } @@ -95,7 +95,7 @@ func addRequiredFields(config *addRequiredFieldsConfiguration) (out AddRequiredF visitor := &requiredFieldsVisitor{ Walker: &walker, config: config, - key: key, + key: parsedSelectionSet, importer: &astimport.Importer{}, skipFieldRefs: make([]int, 0, 2), requiredFieldRefs: make([]int, 0, 2), @@ -106,7 +106,7 @@ func addRequiredFields(config *addRequiredFieldsConfiguration) (out AddRequiredF walker.RegisterSelectionSetVisitor(visitor) walker.RegisterInlineFragmentVisitor(visitor) - walker.Walk(key, config.definition, report) + walker.Walk(parsedSelectionSet, config.definition, report) return AddRequiredFieldsResult{ skipFieldRefs: visitor.skipFieldRefs, @@ -311,7 +311,7 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { ref: ref, fieldName: fieldName, isTypeName: bytes.Equal(fieldName, typeNameFieldBytes), - isLeafField: !v.key.FieldHasSelections(ref), + isLeaf: !v.key.FieldHasSelections(ref), selectionSetRef: v.OperationNodes[len(v.OperationNodes)-1].Ref, } @@ -330,39 +330,39 @@ func (v *requiredFieldsVisitor) handleRequiredField(ref int) { // handleRequiredFieldDeferred handles @requires fields in a deferred context. // Uses resolveDeferredAlias to reuse or create __internal_{fieldName} aliases. func (v *requiredFieldsVisitor) handleRequiredFieldDeferred(fi requiredFieldInfo) { - ar := v.resolveDeferredAlias(fi.fieldName, fi.selectionSetRef) + aliasResult := v.resolveDeferredAlias(fi.fieldName, fi.selectionSetRef) - if ar.reuseFieldRef != ast.InvalidRef { + if aliasResult.reuseFieldRef != ast.InvalidRef { // reuse the existing aliased field from the same defer scope - v.recordRemappedPathIfAliased(ar.reuseFieldRef, fi.fieldName) + v.recordRemappedPathIfAliased(aliasResult.reuseFieldRef, fi.fieldName) if !fi.isTypeName || v.config.isTypeNameForEntityInterface { - v.storeRequiredFieldRef(ar.reuseFieldRef) + v.storeRequiredFieldRef(aliasResult.reuseFieldRef) } - if !fi.isLeafField { + if !fi.isLeaf { // push to OperationNodes so nested key fields are traversed, // but do NOT add to modifiedFieldRefs — the selection set was already // set up by the prior addRequiredFields call that created this alias - v.OperationNodes = append(v.OperationNodes, ast.Node{Kind: ast.NodeKindField, Ref: ar.reuseFieldRef}) + v.OperationNodes = append(v.OperationNodes, ast.Node{Kind: ast.NodeKindField, Ref: aliasResult.reuseFieldRef}) } return } - fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, ar.addAlias, ar.includeDeferID) - if !fi.isLeafField { + fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, aliasResult.addAlias, aliasResult.includeDeferID) + if !fi.isLeaf { v.OperationNodes = append(v.OperationNodes, fieldNode) } } // handleRequiredFieldNonDeferred handles @requires fields outside a deferred context. -func (v *requiredFieldsVisitor) handleRequiredFieldNonDeferred(fi requiredFieldInfo) { - operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(fi.selectionSetRef, fi.fieldName) +func (v *requiredFieldsVisitor) handleRequiredFieldNonDeferred(field requiredFieldInfo) { + operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(field.selectionSetRef, field.fieldName) // @requires fields can carry arguments (e.g. price(currency: USD)). // If the same field already appears in the query with different arguments, // the two selections cannot share the same field node, so we must alias the // required copy to avoid clobbering the user's selection. // Key fields never have arguments, so this check is absent in handleKeyFieldNonDeferred. - needAlias := v.key.FieldHasArguments(fi.ref) + needAlias := v.key.FieldHasArguments(field.ref) // if the existing field is deferred but we are adding requirements for a non-deferred scope, // we must not reuse it — add an alias instead. @@ -379,13 +379,13 @@ func (v *requiredFieldsVisitor) handleRequiredFieldNonDeferred(fi requiredFieldI // so we do need it as a real dependency in that case. // (handleKeyFieldNonDeferred always skips __typename because it handles __typename // through the representation variables builder instead.) - if !fi.isTypeName || v.config.isTypeNameForEntityInterface { + if !field.isTypeName || v.config.isTypeNameForEntityInterface { v.storeRequiredFieldRef(operationFieldRef) } // do not add required field if the field is already present in the operation with the same name // but add an operation node from operation if the field has selections - if fi.isLeafField { + if field.isLeaf { return } @@ -394,22 +394,22 @@ func (v *requiredFieldsVisitor) handleRequiredFieldNonDeferred(fi requiredFieldI return } - fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, operationHasField && needAlias, false) - if !fi.isLeafField { + fieldNode := v.addRequiredField(field.ref, field.fieldName, field.selectionSetRef, operationHasField && needAlias, false) + if !field.isLeaf { v.OperationNodes = append(v.OperationNodes, fieldNode) } } // handleKeyField is the EnterField entry point for key fields. // It builds requiredFieldInfo and dispatches to the deferred or non-deferred path. -func (v *requiredFieldsVisitor) handleKeyField(ref int) { - fieldName := v.key.FieldNameBytes(ref) +func (v *requiredFieldsVisitor) handleKeyField(keyFieldRef int) { + fieldName := v.key.FieldNameBytes(keyFieldRef) - fi := requiredFieldInfo{ - ref: ref, + field := requiredFieldInfo{ + ref: keyFieldRef, fieldName: fieldName, isTypeName: bytes.Equal(fieldName, typeNameFieldBytes), - isLeafField: !v.key.FieldHasSelections(ref), + isLeaf: !v.key.FieldHasSelections(keyFieldRef), selectionSetRef: v.OperationNodes[len(v.OperationNodes)-1].Ref, } @@ -419,11 +419,11 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { // name "__typename". Aliasing it would break that builder. // (handleRequiredField does NOT exclude __typename here because for // interface objects __typename is fetched via @requires, not keys.) - if v.config.deferInfo != nil && v.isRootLevel() && !fi.isTypeName { - v.handleKeyFieldDeferred(fi) + if v.config.deferInfo != nil && v.isRootLevel() && !field.isTypeName { + v.handleKeyFieldDeferred(field) return } - v.handleKeyFieldNonDeferred(fi) + v.handleKeyFieldNonDeferred(field) } // handleKeyFieldDeferred handles key fields in a deferred context. @@ -433,26 +433,26 @@ func (v *requiredFieldsVisitor) handleKeyField(ref int) { // scopes reuse it. An alias is only needed when a plain field already exists but // belongs to a specific defer scope (has @deferInternal) and therefore cannot be // shared. -func (v *requiredFieldsVisitor) handleKeyFieldDeferred(fi requiredFieldInfo) { +func (v *requiredFieldsVisitor) handleKeyFieldDeferred(field requiredFieldInfo) { // First preference: a plain (non-deferred) field that all scopes can share. - plainExists, plainRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(fi.selectionSetRef, fi.fieldName) + plainExists, plainRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(field.selectionSetRef, field.fieldName) if plainExists && !v.fieldHasDeferInternal(plainRef) { v.storeRequiredFieldRef(plainRef) - if !fi.isLeafField { + if !field.isLeaf { v.modifiedFieldRefs = append(v.modifiedFieldRefs, plainRef) v.OperationNodes = append(v.OperationNodes, ast.Node{Kind: ast.NodeKindField, Ref: plainRef}) } return } - ar := v.resolveDeferredAlias(fi.fieldName, fi.selectionSetRef) + aliasResult := v.resolveDeferredAlias(field.fieldName, field.selectionSetRef) - if ar.reuseFieldRef != ast.InvalidRef { + if aliasResult.reuseFieldRef != ast.InvalidRef { // reuse the existing aliased field from the same defer scope - v.recordRemappedPathIfAliased(ar.reuseFieldRef, fi.fieldName) - v.storeRequiredFieldRef(ar.reuseFieldRef) - if !fi.isLeafField { - v.OperationNodes = append(v.OperationNodes, ast.Node{Kind: ast.NodeKindField, Ref: ar.reuseFieldRef}) + v.recordRemappedPathIfAliased(aliasResult.reuseFieldRef, field.fieldName) + v.storeRequiredFieldRef(aliasResult.reuseFieldRef) + if !field.isLeaf { + v.OperationNodes = append(v.OperationNodes, ast.Node{Kind: ast.NodeKindField, Ref: aliasResult.reuseFieldRef}) } return } @@ -461,15 +461,15 @@ func (v *requiredFieldsVisitor) handleKeyFieldDeferred(fi requiredFieldInfo) { // already exists but is deferred (has @deferInternal). When no field exists // at all, add a plain field so subsequent callers from any scope can reuse it. addAlias := plainExists // true only when plain field exists but is deferred - fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, addAlias, ar.includeDeferID) - if !fi.isLeafField { + fieldNode := v.addRequiredField(field.ref, field.fieldName, field.selectionSetRef, addAlias, aliasResult.includeDeferID) + if !field.isLeaf { v.OperationNodes = append(v.OperationNodes, fieldNode) } } // handleKeyFieldNonDeferred handles key fields outside a deferred context. -func (v *requiredFieldsVisitor) handleKeyFieldNonDeferred(fi requiredFieldInfo) { - operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(fi.selectionSetRef, fi.fieldName) +func (v *requiredFieldsVisitor) handleKeyFieldNonDeferred(field requiredFieldInfo) { + operationHasField, operationFieldRef := v.config.operation.SelectionSetHasFieldSelectionWithExactName(field.selectionSetRef, field.fieldName) // If the existing field has @deferInternal it belongs to a specific defer scope; // the non-deferred planner must not reuse it — add an alias instead. @@ -482,13 +482,13 @@ func (v *requiredFieldsVisitor) handleKeyFieldNonDeferred(fi requiredFieldInfo) // via @requires (handled by handleRequiredField), never as a key field. // Key fields cannot have arguments, so there is no needAlias check here // (unlike handleRequiredFieldNonDeferred). - if !fi.isTypeName { + if !field.isTypeName { v.storeRequiredFieldRef(operationFieldRef) } - // do not add required field if the field is already present in the operation with the same name + // do not add the required field if the field is already present in the operation with the same name // but add an operation node from operation if the field has selections - if fi.isLeafField { + if field.isLeaf { return } @@ -497,8 +497,8 @@ func (v *requiredFieldsVisitor) handleKeyFieldNonDeferred(fi requiredFieldInfo) return } - fieldNode := v.addRequiredField(fi.ref, fi.fieldName, fi.selectionSetRef, existingFieldIsDeferred, false) - if !fi.isLeafField { + fieldNode := v.addRequiredField(field.ref, field.fieldName, field.selectionSetRef, existingFieldIsDeferred, false) + if !field.isLeaf { v.OperationNodes = append(v.OperationNodes, fieldNode) } } @@ -524,7 +524,7 @@ func (v *requiredFieldsVisitor) recordRemappedPathIfAliased(fieldRef int, fieldN v.mapping[currentPath] = string(v.config.operation.FieldAliasBytes(fieldRef)) } -func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteSlice, selectionSet int, addAlias bool, includeDeferIDInAlias bool) ast.Node { +func (v *requiredFieldsVisitor) addRequiredField(keyFieldRef int, fieldName ast.ByteSlice, selectionSet int, addAlias bool, includeDeferIDInAlias bool) ast.Node { field := ast.Field{ Name: v.config.operation.Input.AppendInputBytes(fieldName), SelectionSet: ast.InvalidRef, @@ -547,33 +547,33 @@ func (v *requiredFieldsVisitor) addRequiredField(keyRef int, fieldName ast.ByteS v.mapping[currentPath] = string(fullAliasName) } - addedField := v.config.operation.AddField(field) + addedFieldNode := v.config.operation.AddField(field) - if v.key.FieldHasArguments(keyRef) { - importedArgs := v.importer.ImportArguments(v.key.Fields[keyRef].Arguments.Refs, v.key, v.config.operation) + if v.key.FieldHasArguments(keyFieldRef) { + importedArgs := v.importer.ImportArguments(v.key.Fields[keyFieldRef].Arguments.Refs, v.key, v.config.operation) for _, arg := range importedArgs { - v.config.operation.AddArgumentToField(addedField.Ref, arg) + v.config.operation.AddArgumentToField(addedFieldNode.Ref, arg) } } selection := ast.Selection{ Kind: ast.SelectionKindField, - Ref: addedField.Ref, + Ref: addedFieldNode.Ref, } v.config.operation.AddSelection(selectionSet, selection) - v.skipFieldRefs = append(v.skipFieldRefs, addedField.Ref) + v.skipFieldRefs = append(v.skipFieldRefs, addedFieldNode.Ref) // we are skipping adding __typename field to the required fields, // because we want to depend only on the regular key fields, not the __typename field if !bytes.Equal(fieldName, typeNameFieldBytes) || (bytes.Equal(fieldName, typeNameFieldBytes) && v.config.isTypeNameForEntityInterface) { - v.storeRequiredFieldRef(addedField.Ref) + v.storeRequiredFieldRef(addedFieldNode.Ref) } - v.applyDeferInternalDirective(addedField.Ref) + v.applyDeferInternalDirective(addedFieldNode.Ref) - return addedField + return addedFieldNode } func (v *requiredFieldsVisitor) applyDeferInternalDirective(fieldRef int) { From aa8f1980252f4e74f0e6d579c54da4db94b285bc Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 25 Mar 2026 23:53:53 +0200 Subject: [PATCH 62/79] add failing tests with errors --- .../engine/execution_engine_defer_test.go | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 1dc40df618..7279a19d17 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -1534,4 +1534,177 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, withStreamingResponse())) }) + t.Run("non-nullable field errors", func(t *testing.T) { + definition := ` + type Query { product: Product! } + type Product { + id: ID! + name: String! + nameWithError: String! + price: Float! + } + ` + + firstSubgraphSDL := ` + type Query { product: Product! } + type Product @key(fields: "id") { + id: ID! + name: String! + nameWithError: String + } + ` + + secondSubgraphSDL := ` + type Product @key(fields: "id") { + id: ID! + price: Float! + } + ` + + dataSources := []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"{product {___typename: __typename}}"}`: { + statusCode: 200, + body: `{"data":{"product":{"___typename":"Product"}}}`, + }, + `{"query":"{product {___typename: __typename __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"product":{"___typename":"Product","__typename":"Product","id":"1"}}}`, + }, + `{"query":"{product {name}}"}`: { + statusCode: 200, + body: `{"data":{"product":{"name":null}}}`, + }, + `{"query":"{product {nameWithError}}"}`: { + statusCode: 200, + body: `{"data":{"product":{"nameWithError":null}},"errors":[{"message":"upstream name error","path":["product","nameWithError"]}]}`, + }, + `{"query":"{product {name nameWithError}}"}`: { + statusCode: 200, + body: `{"data":{"product":{"name":null,"nameWithError":null}},"errors":[{"message":"upstream name error","path":["product","nameWithError"]}]}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"product"}}, + {TypeName: "Product", FieldNames: []string{"id", "name", "nameWithError"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Product", SelectionSet: "id"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: "https://first/", Method: "POST"}, + SchemaConfiguration: mustSchemaConfig(t, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: firstSubgraphSDL}, + firstSubgraphSDL, + ), + }), + ), + mustGraphqlDataSourceConfiguration(t, + "id-2", + mustFactory(t, + testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, + expectedHost: "second", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename price}}}","variables":{"representations":[{"__typename":"Product","id":"1"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"Product","price":null}]}}`, + }, + }, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Product", FieldNames: []string{"price"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Product", SelectionSet: "id"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: "https://second/", Method: "POST"}, + SchemaConfiguration: mustSchemaConfig(t, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: secondSubgraphSDL}, + secondSubgraphSDL, + ), + }), + ), + } + + schema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + + t.Run("defer from first subgraph - null non-nullable field", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ product { ... @defer { name } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"product":{}},"hasNext":true} +{"incremental":[{"data":null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]},{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]}]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer from first subgraph - null field with upstream error", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ product { ... @defer { nameWithError } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"product":{}},"hasNext":true} +{"incremental":[{"data":{"nameWithError":null},"path":["product"],"errors":[{"message":"Failed to fetch from Subgraph 'id-1'."},{"message":"Cannot return null for non-nullable field 'Query.product.nameWithError'.","path":["product","nameWithError"]},{"message":"Cannot return null for non-nullable field 'Query.product.nameWithError'.","path":["product","nameWithError"]}]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer from second subgraph - null non-nullable field", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ product { ... @defer { price } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"product":{}},"hasNext":true} +{"incremental":[{"data": null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]},{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]}]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer from both subgraphs - null non-nullable fields - name first", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ product { ... @defer { name } ... @defer { price } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"product":{}},"hasNext":true} +{"incremental":[{"data":null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]},{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]}]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer from both subgraphs - null non-nullable fields - price first", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ product { ... @defer { price } ... @defer { name } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"product":{}},"hasNext":true} +{"incremental":[{"data": null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]},{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]}]}],"hasNext":false} +`, + }, withStreamingResponse())) + + }) + } From 7d4756f617b7347bc6e2df86d4905229542b485b Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 26 Mar 2026 01:29:26 +0200 Subject: [PATCH 63/79] implement proper defer error handling --- .../engine/execution_engine_defer_test.go | 27 +++--- v2/pkg/engine/resolve/resolvable.go | 83 +++++++++++++++---- 2 files changed, 85 insertions(+), 25 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 7279a19d17..c8e0100f7a 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -1540,7 +1540,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { type Product { id: ID! name: String! - nameWithError: String! + nameWithError: String price: Float! } ` @@ -1586,10 +1586,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"product":{"nameWithError":null}},"errors":[{"message":"upstream name error","path":["product","nameWithError"]}]}`, }, - `{"query":"{product {name nameWithError}}"}`: { - statusCode: 200, - body: `{"data":{"product":{"name":null,"nameWithError":null}},"errors":[{"message":"upstream name error","path":["product","nameWithError"]}]}`, - }, }, }), ), @@ -1657,7 +1653,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, dataSources: dataSources, expectedResponse: `{"data":{"product":{}},"hasNext":true} -{"incremental":[{"data":null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]},{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]}]}],"hasNext":false} +{"incremental":[{"data":null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]}]}],"hasNext":false} `, }, withStreamingResponse())) @@ -1668,7 +1664,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, dataSources: dataSources, expectedResponse: `{"data":{"product":{}},"hasNext":true} -{"incremental":[{"data":{"nameWithError":null},"path":["product"],"errors":[{"message":"Failed to fetch from Subgraph 'id-1'."},{"message":"Cannot return null for non-nullable field 'Query.product.nameWithError'.","path":["product","nameWithError"]},{"message":"Cannot return null for non-nullable field 'Query.product.nameWithError'.","path":["product","nameWithError"]}]}],"hasNext":false} +{"incremental":[{"data":{"nameWithError":null},"path":["product"],"errors":[{"message":"Failed to fetch from Subgraph 'id-1'."}]}],"hasNext":false} `, }, withStreamingResponse())) @@ -1679,7 +1675,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, dataSources: dataSources, expectedResponse: `{"data":{"product":{}},"hasNext":true} -{"incremental":[{"data": null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]},{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]}]}],"hasNext":false} +{"incremental":[{"data":null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]}]}],"hasNext":false} `, }, withStreamingResponse())) @@ -1690,7 +1686,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, dataSources: dataSources, expectedResponse: `{"data":{"product":{}},"hasNext":true} -{"incremental":[{"data":null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]},{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]}]}],"hasNext":false} +{"incremental":[{"data":null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.name'.","path":["product","name"]}]}],"hasNext":false} `, }, withStreamingResponse())) @@ -1701,7 +1697,18 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }, dataSources: dataSources, expectedResponse: `{"data":{"product":{}},"hasNext":true} -{"incremental":[{"data": null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]},{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]}]}],"hasNext":false} +{"incremental":[{"data":null,"path":["product"],"errors":[{"message":"Cannot return null for non-nullable field 'Query.product.price'.","path":["product","price"]}]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer error halts subsequent defers - nameWithError then price", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ product { ... @defer { nameWithError } ... @defer { price } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"product":{}},"hasNext":true} +{"incremental":[{"data":{"nameWithError":null},"path":["product"],"errors":[{"message":"Failed to fetch from Subgraph 'id-1'."}]}],"hasNext":false} `, }, withStreamingResponse())) diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index fe7d55148f..4599d9a64b 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -70,6 +70,7 @@ type Resolvable struct { actualListSizes map[string]int incrementalItemWritten bool + deferItemDataNull bool } type ResolvableOptions struct { @@ -123,6 +124,7 @@ func (r *Resolvable) Reset() { r.deferID = "" r.enableDeferRender = false r.incrementalItemWritten = false + r.deferItemDataNull = false } func (r *Resolvable) Init(ctx *Context, initialData []byte, operationType ast.OperationType) (err error) { @@ -274,6 +276,7 @@ func (r *Resolvable) ResolveDefer(rootData *Object, out io.Writer, hasNext bool) r.deferMode = true r.enableDeferRender = false r.incrementalItemWritten = false + r.deferItemDataNull = false _ = r.walkObject(rootData, r.data) if r.authorizationError != nil { @@ -283,8 +286,7 @@ func (r *Resolvable) ResolveDefer(rootData *Object, out io.Writer, hasNext bool) // Second pass: render the incremental response r.enableRender = true r.incrementalItemWritten = false - // deferMode stays true - // enableDeferRender starts false, will be toggled in walkObject when match found + r.enableDeferRender = false // reset: first pass may have left it true on early return r.printBytes(lBrace) r.printBytes(quote) @@ -297,16 +299,7 @@ func (r *Resolvable) ResolveDefer(rootData *Object, out io.Writer, hasNext bool) r.printBytes(rBrack) - r.printHasNext(hasNext) - - if r.hasErrors() { - r.printBytes(comma) - r.printBytes(quote) - r.printBytes(literalErrors) - r.printBytes(quote) - r.printBytes(colon) - r.printNode(r.errors) - } + r.printHasNext(hasNext && !r.hasErrors()) r.printBytes(rBrace) @@ -372,6 +365,41 @@ func (r *Resolvable) printDeferEnvelopeClose() { r.printBytes(quote) r.printBytes(colon) r.renderPath() + if r.hasErrors() { + r.printBytes(comma) + r.printBytes(quote) + r.printBytes(literalErrors) + r.printBytes(quote) + r.printBytes(colon) + r.printNode(r.errors) + } + r.printBytes(rBrace) +} + +func (r *Resolvable) printDeferEnvelopeNullData() { + if !r.render() { + return + } + r.printBytes(lBrace) + r.printBytes(quote) + r.printBytes(literalData) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(null) + r.printBytes(comma) + r.printBytes(quote) + r.printBytes(literalPath) + r.printBytes(quote) + r.printBytes(colon) + r.renderPath() + if r.hasErrors() { + r.printBytes(comma) + r.printBytes(quote) + r.printBytes(literalErrors) + r.printBytes(quote) + r.printBytes(colon) + r.printNode(r.errors) + } r.printBytes(rBrace) } @@ -795,22 +823,36 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { } if r.deferID != "" { + if r.deferItemDataNull { + // Pre-walk detected null propagating through non-nullable chain; + // render {"data":null,"path":[...],"errors":[...]} without walking fields. + r.printDeferEnvelopeNullData() + r.incrementalItemWritten = true + r.enableDeferRender = false + return true + } r.printDeferEnvelopeOpen() } } // render initial batch of fields - if r.walkFields(obj, value, parent, walkFieldsFilter{deferFields: deferFields, seek: false, enabled: true}) { - return true - } + hasErrors := r.walkFields(obj, value, parent, walkFieldsFilter{deferFields: deferFields, seek: false, enabled: true}) if startedRender { if r.deferID != "" { + if !r.enableRender && hasErrors { + // Pre-walk: null propagated through non-nullable chain; signal render pass. + r.deferItemDataNull = true + } r.printDeferEnvelopeClose() r.incrementalItemWritten = true } r.enableDeferRender = false } + + if hasErrors { + return true + } } if r.deferID != "" && len(seekFiels) > 0 { @@ -982,6 +1024,17 @@ func (r *Resolvable) walkFields(obj *Object, value *astjson.Value, parent *astjs r.currentFieldInfo = obj.Fields[i].Info err := r.walkNode(obj.Fields[i].Value, value) if err { + if r.render() { + // Field key already written; complete with null to produce valid JSON. + r.printBytes(null) + if obj.Nullable { + // Nullable parent: absorb the error, render null, continue to next field. + addComma = true + continue + } + // Non-nullable parent: propagate error; caller closes the envelope. + return err + } if obj.Nullable { if len(obj.Path) > 0 { astjson.SetNull(r.astjsonArena, parent, obj.Path...) From c941db5788c76b51e1fae52bf7e317de8eeea4be Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 26 Mar 2026 01:36:48 +0200 Subject: [PATCH 64/79] add print helper --- v2/pkg/engine/resolve/resolvable.go | 32 +++++++++++------------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 4599d9a64b..5594977fa6 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -353,13 +353,7 @@ func (r *Resolvable) printDeferEnvelopeOpen() { r.printBytes(lBrace) } -func (r *Resolvable) printDeferEnvelopeClose() { - if !r.render() { - return - } - - r.printBytes(rBrace) - r.printBytes(comma) +func (r *Resolvable) printDeferPathAndErrors() { r.printBytes(quote) r.printBytes(literalPath) r.printBytes(quote) @@ -373,6 +367,16 @@ func (r *Resolvable) printDeferEnvelopeClose() { r.printBytes(colon) r.printNode(r.errors) } +} + +func (r *Resolvable) printDeferEnvelopeClose() { + if !r.render() { + return + } + + r.printBytes(rBrace) + r.printBytes(comma) + r.printDeferPathAndErrors() r.printBytes(rBrace) } @@ -387,19 +391,7 @@ func (r *Resolvable) printDeferEnvelopeNullData() { r.printBytes(colon) r.printBytes(null) r.printBytes(comma) - r.printBytes(quote) - r.printBytes(literalPath) - r.printBytes(quote) - r.printBytes(colon) - r.renderPath() - if r.hasErrors() { - r.printBytes(comma) - r.printBytes(quote) - r.printBytes(literalErrors) - r.printBytes(quote) - r.printBytes(colon) - r.printNode(r.errors) - } + r.printDeferPathAndErrors() r.printBytes(rBrace) } From c40756dceb907a271b75a4d34f61323caa953e06 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 26 Mar 2026 15:07:12 +0200 Subject: [PATCH 65/79] add arrays test --- .../engine/execution_engine_defer_test.go | 370 ++++++++++++++++++ 1 file changed, 370 insertions(+) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index c8e0100f7a..35f2fd4854 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -1714,4 +1714,374 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }) + t.Run("nested list entities", func(t *testing.T) { + definition := ` + type Query { items: [Item!]! } + type Item { + id: ID! + name: String! + title: String! + subItems: [SubItem!]! + } + type SubItem { + id: ID! + description: String! + } + ` + schema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + + // Sub1: owns Query.items, Item.{id,name,subItems}, SubItem.id + firstSubgraphSDL := ` + type Query { items: [Item!]! } + type Item @key(fields: "id") { + id: ID! + name: String! + subItems: [SubItem!]! + } + type SubItem @key(fields: "id") { + id: ID! + } + ` + firstSubgraphDS := mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"{items {___typename: __typename __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"___typename":"Item","__typename":"Item","id":"1"},{"___typename":"Item","__typename":"Item","id":"2"}]}}`, + }, + `{"query":"{items {name}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"name":"ItemOne"},{"name":"ItemTwo"}]}}`, + }, + `{"query":"{items {___typename: __typename}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"___typename":"Item"},{"___typename":"Item"}]}}`, + }, + `{"query":"{items {subItems {___typename: __typename __typename id}}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"subItems":[{"___typename":"SubItem","__typename":"SubItem","id":"s1"},{"___typename":"SubItem","__typename":"SubItem","id":"s2"}]},{"subItems":[{"___typename":"SubItem","__typename":"SubItem","id":"s3"}]}]}}`, + }, + `{"query":"{items {id}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, + }, + `{"query":"{items {id name}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"id":"1","name":"ItemOne"},{"id":"2","name":"ItemTwo"}]}}`, + }, + `{"query":"{items {subItems {id __typename __internal_id: id}}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"subItems":[{"id":"s1","__typename":"SubItem","__internal_id":"s1"},{"id":"s2","__typename":"SubItem","__internal_id":"s2"}]},{"subItems":[{"id":"s3","__typename":"SubItem","__internal_id":"s3"}]}]}}`, + }, + `{"query":"{items {___typename: __typename __typename __internal_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"___typename":"Item","__typename":"Item","__internal_id":"1"},{"___typename":"Item","__typename":"Item","__internal_id":"2"}]}}`, + }, + `{"query":"{items {id __typename __internal_id: id}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"id":"1","__typename":"Item","__internal_id":"1"},{"id":"2","__typename":"Item","__internal_id":"2"}]}}`, + }, + `{"query":"{items {id subItems {id __typename __internal_id: id}}}"}`: { + statusCode: 200, + body: `{"data":{"items":[{"id":"1","subItems":[{"id":"s1","__typename":"SubItem","__internal_id":"s1"},{"id":"s2","__typename":"SubItem","__internal_id":"s2"}]},{"id":"2","subItems":[{"id":"s3","__typename":"SubItem","__internal_id":"s3"}]}]}}`, + }, + }, + })), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"items"}}, + {TypeName: "Item", FieldNames: []string{"id", "name", "subItems"}}, + {TypeName: "SubItem", FieldNames: []string{"id"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "SubItem", FieldNames: []string{"id"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Item", SelectionSet: "id"}, + {TypeName: "SubItem", SelectionSet: "id"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: "https://first/", Method: "POST"}, + SchemaConfiguration: mustSchemaConfig(t, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: firstSubgraphSDL}, + firstSubgraphSDL, + ), + }), + ) + + // Sub2: extends Item with title + secondSubgraphSDL := ` + type Item @key(fields: "id") { + id: ID! + title: String! + } + ` + secondSubgraphDS := mustGraphqlDataSourceConfiguration(t, + "id-2", + mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, + expectedHost: "second", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Item {__typename title}}}","variables":{"representations":[{"__typename":"Item","id":"1"},{"__typename":"Item","id":"2"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"Item","title":"TitleOne"},{"__typename":"Item","title":"TitleTwo"}]}}`, + }, + }, + })), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Item", FieldNames: []string{"id", "title"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Item", SelectionSet: "id"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: "https://second/", Method: "POST"}, + SchemaConfiguration: mustSchemaConfig(t, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: secondSubgraphSDL}, + secondSubgraphSDL, + ), + }), + ) + + // Sub3: extends SubItem with description + thirdSubgraphSDL := ` + type SubItem @key(fields: "id") { + id: ID! + description: String! + } + ` + thirdSubgraphDS := mustGraphqlDataSourceConfiguration(t, + "id-3", + mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, + expectedHost: "third", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on SubItem {__typename description}}}","variables":{"representations":[{"__typename":"SubItem","id":"s1"},{"__typename":"SubItem","id":"s2"},{"__typename":"SubItem","id":"s3"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"SubItem","description":"Desc1"},{"__typename":"SubItem","description":"Desc2"},{"__typename":"SubItem","description":"Desc3"}]}}`, + }, + }, + })), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "SubItem", FieldNames: []string{"id", "description"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "SubItem", SelectionSet: "id"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: "https://third/", Method: "POST"}, + SchemaConfiguration: mustSchemaConfig(t, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: thirdSubgraphSDL}, + thirdSubgraphSDL, + ), + }), + ) + + dataSources := []plan.DataSource{firstSubgraphDS, secondSubgraphDS, thirdSubgraphDS} + + t.Run("category A - no id in initial response", func(t *testing.T) { + t.Run("defer name from sub1", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items { ... @defer { name } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"items":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"name":"ItemOne"},"path":["items",0]},{"data":{"name":"ItemTwo"},"path":["items",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer title from sub2", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items { ... @defer { title } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"items":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"title":"TitleOne"},"path":["items",0]},{"data":{"title":"TitleTwo"},"path":["items",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer subItems description from sub3", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items { subItems { ... @defer { description } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"items":[{"subItems":[{},{}]},{"subItems":[{}]}]},"hasNext":true} +{"incremental":[{"data":{"description":"Desc1"},"path":["items",0,"subItems",0]},{"data":{"description":"Desc2"},"path":["items",0,"subItems",1]},{"data":{"description":"Desc3"},"path":["items",1,"subItems",0]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("items subItems and description all in separate nested defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ ... @defer { items { id ... @defer { subItems { id ... @defer { description } } } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"items":[{"id":"1"},{"id":"2"}]},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"subItems":[{"id":"s1"},{"id":"s2"}]},"path":["items",0]},{"data":{"subItems":[{"id":"s3"}]},"path":["items",1]}],"hasNext":true} +{"incremental":[{"data":{"description":"Desc1"},"path":["items",0,"subItems",0]},{"data":{"description":"Desc2"},"path":["items",0,"subItems",1]},{"data":{"description":"Desc3"},"path":["items",1,"subItems",0]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) + + t.Run("category B - id deferred with parallel defers", func(t *testing.T) { + t.Run("defer id only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items { ... @defer { id } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"items":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["items",0]},{"data":{"id":"2"},"path":["items",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer id and name together", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items { ... @defer { id name } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"items":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"id":"1","name":"ItemOne"},"path":["items",0]},{"data":{"id":"2","name":"ItemTwo"},"path":["items",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer id in parallel with name", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items { ... @defer { id } ... @defer { name } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"items":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["items",0]},{"data":{"id":"2"},"path":["items",1]}],"hasNext":true} +{"incremental":[{"data":{"name":"ItemOne"},"path":["items",0]},{"data":{"name":"ItemTwo"},"path":["items",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("defer id in parallel with title (cross-subgraph)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items { ... @defer { id } ... @defer { title } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"items":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"id":"1"},"path":["items",0]},{"data":{"id":"2"},"path":["items",1]}],"hasNext":true} +{"incremental":[{"data":{"title":"TitleOne"},"path":["items",0]},{"data":{"title":"TitleTwo"},"path":["items",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("parallel defers on subItems id and description", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items { id ... @defer { subItems { id } } ... @defer { subItems { description } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"items":[{"id":"1"},{"id":"2"}]},"hasNext":true} +{"incremental":[{"data":{"subItems":[{"id":"s1"},{"id":"s2"}]},"path":["items",0]},{"data":{"subItems":[{"id":"s3"}]},"path":["items",1]}],"hasNext":true} +{"incremental":[{"data":{"description":"Desc1"},"path":["items",0,"subItems",0]},{"data":{"description":"Desc2"},"path":["items",0,"subItems",1]},{"data":{"description":"Desc3"},"path":["items",1,"subItems",0]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) + + t.Run("parallel root defers", func(t *testing.T) { + t.Run("subItems id then description", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ ... @defer { items { subItems { id } } } ... @defer { items { subItems { description } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"items":[{"subItems":[{"id":"s1"},{"id":"s2"}]},{"subItems":[{"id":"s3"}]}]},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"description":"Desc1"},"path":["items",0,"subItems",0]},{"data":{"description":"Desc2"},"path":["items",0,"subItems",1]},{"data":{"description":"Desc3"},"path":["items",1,"subItems",0]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) + + t.Run("category C - nested defers", func(t *testing.T) { + t.Run("outer defer items, inner defer name", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ ... @defer { items { id ... @defer { name } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"items":[{"id":"1"},{"id":"2"}]},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"name":"ItemOne"},"path":["items",0]},{"data":{"name":"ItemTwo"},"path":["items",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("outer defer items, inner defer title (cross-subgraph)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ ... @defer { items { id ... @defer { title } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"items":[{"id":"1"},{"id":"2"}]},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"title":"TitleOne"},"path":["items",0]},{"data":{"title":"TitleTwo"},"path":["items",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("outer defer items with subItems, inner defer description", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ ... @defer { items { id subItems { id ... @defer { description } } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"items":[{"id":"1","subItems":[{"id":"s1"},{"id":"s2"}]},{"id":"2","subItems":[{"id":"s3"}]}]},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"description":"Desc1"},"path":["items",0,"subItems",0]},{"data":{"description":"Desc2"},"path":["items",0,"subItems",1]},{"data":{"description":"Desc3"},"path":["items",1,"subItems",0]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("three-level defer: query to items to subItems", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ ... @defer { items { id ... @defer { subItems { id ... @defer { description } } } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"items":[{"id":"1"},{"id":"2"}]},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"subItems":[{"id":"s1"},{"id":"s2"}]},"path":["items",0]},{"data":{"subItems":[{"id":"s3"}]},"path":["items",1]}],"hasNext":true} +{"incremental":[{"data":{"description":"Desc1"},"path":["items",0,"subItems",0]},{"data":{"description":"Desc2"},"path":["items",0,"subItems",1]},{"data":{"description":"Desc3"},"path":["items",1,"subItems",0]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("three-level defer with cross-subgraph at middle level", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ ... @defer { items { id ... @defer { title subItems { id ... @defer { description } } } } } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{},"hasNext":true} +{"incremental":[{"data":{"items":[{"id":"1"},{"id":"2"}]},"path":[]}],"hasNext":true} +{"incremental":[{"data":{"title":"TitleOne","subItems":[{"id":"s1"},{"id":"s2"}]},"path":["items",0]},{"data":{"title":"TitleTwo","subItems":[{"id":"s3"}]},"path":["items",1]}],"hasNext":true} +{"incremental":[{"data":{"description":"Desc1"},"path":["items",0,"subItems",0]},{"data":{"description":"Desc2"},"path":["items",0,"subItems",1]},{"data":{"description":"Desc3"},"path":["items",1,"subItems",0]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) + }) + } From 065636d0727ec1f3e7c8472b4c133785f9588d28 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 26 Mar 2026 18:05:09 +0200 Subject: [PATCH 66/79] add test with defer inside fragments and defer applied to fragment spreads --- .../engine/execution_engine_defer_test.go | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 35f2fd4854..db6233b96b 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -2079,6 +2079,237 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { {"incremental":[{"data":{"items":[{"id":"1"},{"id":"2"}]},"path":[]}],"hasNext":true} {"incremental":[{"data":{"title":"TitleOne","subItems":[{"id":"s1"},{"id":"s2"}]},"path":["items",0]},{"data":{"title":"TitleTwo","subItems":[{"id":"s3"}]},"path":["items",1]}],"hasNext":true} {"incremental":[{"data":{"description":"Desc1"},"path":["items",0,"subItems",0]},{"data":{"description":"Desc2"},"path":["items",0,"subItems",1]},{"data":{"description":"Desc3"},"path":["items",1,"subItems",0]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) + }) + + t.Run("named fragments with defer", func(t *testing.T) { + definition := ` + type Query { products: [Product!]! } + type Product { + id: ID! + sku: String! + name: String! + price: Float! + } + ` + schema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + + firstSubgraphSDL := ` + type Query { products: [Product!]! } + type Product @key(fields: "id") { + id: ID! + sku: String! + } + ` + firstSubgraphDS := mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, + expectedHost: "first", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"{products {___typename: __typename __typename id}}"}`: { + statusCode: 200, + body: `{"data":{"products":[{"___typename":"Product","__typename":"Product","id":"1"},{"___typename":"Product","__typename":"Product","id":"2"}]}}`, + }, + `{"query":"{products {___typename: __typename}}"}`: { + statusCode: 200, + body: `{"data":{"products":[{"___typename":"Product"},{"___typename":"Product"}]}}`, + }, + `{"query":"{products {id}}"}`: { + statusCode: 200, + body: `{"data":{"products":[{"id":"1"},{"id":"2"}]}}`, + }, + `{"query":"{products {sku}}"}`: { + statusCode: 200, + body: `{"data":{"products":[{"sku":"sku-1"},{"sku":"sku-2"}]}}`, + }, + `{"query":"{products {id sku}}"}`: { + statusCode: 200, + body: `{"data":{"products":[{"id":"1","sku":"sku-1"},{"id":"2","sku":"sku-2"}]}}`, + }, + `{"query":"{products {id __typename}}"}`: { + statusCode: 200, + body: `{"data":{"products":[{"id":"1","__typename":"Product"},{"id":"2","__typename":"Product"}]}}`, + }, + }, + })), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"products"}}, + {TypeName: "Product", FieldNames: []string{"id", "sku"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Product", SelectionSet: "id"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: "https://first/", Method: "POST"}, + SchemaConfiguration: mustSchemaConfig(t, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: firstSubgraphSDL}, + firstSubgraphSDL, + ), + }), + ) + + secondSubgraphSDL := ` + type Product @key(fields: "id") { + id: ID! + name: String! + price: Float! + } + ` + secondSubgraphDS := mustGraphqlDataSourceConfiguration(t, + "id-2", + mustFactory(t, testConditionalNetHttpClient(t, conditionalTestCase{ + reportUnused: true, + expectedHost: "second", + expectedPath: "/", + responses: map[string]sendResponse{ + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename name}}}","variables":{"representations":[{"__typename":"Product","id":"1"},{"__typename":"Product","id":"2"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"Product","name":"Product One"},{"__typename":"Product","name":"Product Two"}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename price}}}","variables":{"representations":[{"__typename":"Product","id":"1"},{"__typename":"Product","id":"2"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"Product","price":9.99},{"__typename":"Product","price":19.99}]}}`, + }, + `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename name price}}}","variables":{"representations":[{"__typename":"Product","id":"1"},{"__typename":"Product","id":"2"}]}}`: { + statusCode: 200, + body: `{"data":{"_entities":[{"__typename":"Product","name":"Product One","price":9.99},{"__typename":"Product","name":"Product Two","price":19.99}]}}`, + }, + }, + })), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Product", FieldNames: []string{"id", "name", "price"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Product", SelectionSet: "id"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: "https://second/", Method: "POST"}, + SchemaConfiguration: mustSchemaConfig(t, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: secondSubgraphSDL}, + secondSubgraphSDL, + ), + }), + ) + + dataSources := []plan.DataSource{firstSubgraphDS, secondSubgraphDS} + + t.Run("category A - defer on named fragment spread", func(t *testing.T) { + t.Run("A1 - defer sub1 field sku via fragment spread", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment SkuFields on Product { sku } { products { ...SkuFields @defer } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"sku":"sku-1"},"path":["products",0]},{"data":{"sku":"sku-2"},"path":["products",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("A2 - defer sub2 field name via fragment spread", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment NameFields on Product { name } { products { ...NameFields @defer } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"name":"Product One"},"path":["products",0]},{"data":{"name":"Product Two"},"path":["products",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("A3 - id non-deferred, sub2 name and price deferred via fragment", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment DetailFields on Product { name price } { products { id ...DetailFields @defer } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{"id":"1"},{"id":"2"}]},"hasNext":true} +{"incremental":[{"data":{"name":"Product One","price":9.99},"path":["products",0]},{"data":{"name":"Product Two","price":19.99},"path":["products",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("A4 - parallel fragment spreads from different subgraphs, both deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment SkuFrag on Product { sku } fragment NameFrag on Product { name } { products { ...SkuFrag @defer ...NameFrag @defer } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"sku":"sku-1"},"path":["products",0]},{"data":{"sku":"sku-2"},"path":["products",1]}],"hasNext":true} +{"incremental":[{"data":{"name":"Product One"},"path":["products",0]},{"data":{"name":"Product Two"},"path":["products",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) + + t.Run("category B - defer inside named fragment definition", func(t *testing.T) { + t.Run("B1 - defer sub1 field sku inside named fragment", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment ProductFrag on Product { id ... @defer { sku } } { products { ...ProductFrag } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{"id":"1"},{"id":"2"}]},"hasNext":true} +{"incremental":[{"data":{"sku":"sku-1"},"path":["products",0]},{"data":{"sku":"sku-2"},"path":["products",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("B2 - defer sub2 field name inside named fragment", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment ProductFrag on Product { id ... @defer { name } } { products { ...ProductFrag } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{"id":"1"},{"id":"2"}]},"hasNext":true} +{"incremental":[{"data":{"name":"Product One"},"path":["products",0]},{"data":{"name":"Product Two"},"path":["products",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("B3 - parallel sub1 and sub2 defers inside named fragment", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment ProductFrag on Product { id ... @defer { sku } ... @defer { name } } { products { ...ProductFrag } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{"id":"1"},{"id":"2"}]},"hasNext":true} +{"incremental":[{"data":{"sku":"sku-1"},"path":["products",0]},{"data":{"sku":"sku-2"},"path":["products",1]}],"hasNext":true} +{"incremental":[{"data":{"name":"Product One"},"path":["products",0]},{"data":{"name":"Product Two"},"path":["products",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + }) + + t.Run("category C - defer on spread containing inner defers", func(t *testing.T) { + t.Run("C1 - multiple sub1 fields id and sku bundled in single deferred spread", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment SkuIdFrag on Product { id sku } { products { ...SkuIdFrag @defer } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{},{}]},"hasNext":true} +{"incremental":[{"data":{"id":"1","sku":"sku-1"},"path":["products",0]},{"data":{"id":"2","sku":"sku-2"},"path":["products",1]}],"hasNext":false} +`, + }, withStreamingResponse())) + + t.Run("C2 - outer spread deferred delivering sub1 sku, with nested inner sub2 name defer", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `fragment SkuWithName on Product { sku ... @defer { name } } { products { id ...SkuWithName @defer } }`} + }, + dataSources: dataSources, + expectedResponse: `{"data":{"products":[{"id":"1"},{"id":"2"}]},"hasNext":true} +{"incremental":[{"data":{"sku":"sku-1"},"path":["products",0]},{"data":{"sku":"sku-2"},"path":["products",1]}],"hasNext":true} +{"incremental":[{"data":{"name":"Product One"},"path":["products",0]},{"data":{"name":"Product Two"},"path":["products",1]}],"hasNext":false} `, }, withStreamingResponse())) }) From e8ac9df93cab859e917334df6d004da988b32f0b Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 26 Mar 2026 22:20:06 +0200 Subject: [PATCH 67/79] add draft of defer response plan --- v2/pkg/engine/resolve/response.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/v2/pkg/engine/resolve/response.go b/v2/pkg/engine/resolve/response.go index dd90a2af53..cb116a3fff 100644 --- a/v2/pkg/engine/resolve/response.go +++ b/v2/pkg/engine/resolve/response.go @@ -1,7 +1,9 @@ package resolve import ( + "fmt" "io" + "strings" "github.com/gobwas/ws" @@ -61,6 +63,30 @@ type GraphQLDeferResponse struct { Defers []*DeferFetchGroup } +func (r *GraphQLDeferResponse) QueryPlanString() string { + indent := func(s string) string { + return strings.ReplaceAll(s, "\n", "\n ") + } + + primary := indent(r.Response.Fetches.QueryPlan().PrettyPrint()) + var secondary []string + + for _, g := range r.Defers { + secondary = append(secondary, strings.ReplaceAll(g.Fetches.QueryPlan().PrettyPrint(), "\n", "\n ")) + } + + return fmt.Sprintf(` +QueryPlan { + Primary { + %s + } + Deferred [ + %s + ] +} +`, primary, strings.Join(secondary, "\n")) +} + type DeferFetchGroup struct { DeferID string Fetches *FetchTreeNode From 69f4f4bf36947c5e46732b84646c804833693808 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 26 Mar 2026 23:04:01 +0200 Subject: [PATCH 68/79] move internal defer helpers into ast --- v2/pkg/ast/ast_argument.go | 13 ++++++ v2/pkg/ast/ast_field.go | 43 +++++++++++++++++++ .../astnormalization/defer_ensure_typename.go | 35 +++------------ v2/pkg/engine/plan/required_fields_visitor.go | 43 +------------------ 4 files changed, 63 insertions(+), 71 deletions(-) diff --git a/v2/pkg/ast/ast_argument.go b/v2/pkg/ast/ast_argument.go index f8d6b8aadf..5089a4f9d4 100644 --- a/v2/pkg/ast/ast_argument.go +++ b/v2/pkg/ast/ast_argument.go @@ -194,3 +194,16 @@ func (d *Document) ImportVariableValueArgument(argName, variableName ByteSlice) return } + +func (d *Document) AddStringArgument(name, value string) int { + strValueRef := d.AddStringValue(StringValue{ + Content: d.Input.AppendInputString(value), + }) + + arg := Argument{ + Name: d.Input.AppendInputString(name), + Value: Value{Kind: ValueKindString, Ref: strValueRef}, + } + + return d.AddArgument(arg) +} diff --git a/v2/pkg/ast/ast_field.go b/v2/pkg/ast/ast_field.go index 1ff394de74..4f9afc2aad 100644 --- a/v2/pkg/ast/ast_field.go +++ b/v2/pkg/ast/ast_field.go @@ -216,3 +216,46 @@ func (d *Document) MergeFieldsDefer(left, right int) { } } } + +// AddDeferInternalDirectiveToField attaches @__defer_internal(id: id, label: label, parentID: parentID) to the given field. +func (d *Document) AddDeferInternalDirectiveToField(fieldRef int, id, label, parentID string) { + if id == "" { + return + } + + var argRefs []int + + argRefs = append(argRefs, d.AddStringArgument("id", id)) + + if label != "" { + argRefs = append(argRefs, d.AddStringArgument("label", label)) + } + if parentID != "" { + argRefs = append(argRefs, d.AddStringArgument("parentDeferId", parentID)) + } + + directiveRef := d.AddDirective(Directive{ + Name: d.Input.AppendInputBytes(literal.DEFER_INTERNAL), + HasArguments: len(argRefs) > 0, + Arguments: ArgumentList{ + Refs: argRefs, + }, + }) + + d.AddDirectiveToNode(directiveRef, Node{ + Kind: NodeKindField, + Ref: fieldRef, + }) +} + +func (d *Document) FieldInternalDeferID(fieldRef int) (id string, exists bool) { + directiveRef, exists := d.Fields[fieldRef].Directives.HasDirectiveByNameBytes(d, literal.DEFER_INTERNAL) + if !exists { + return "", false + } + idValue, exists := d.DirectiveArgumentValueByName(directiveRef, []byte("id")) + if !exists { + return "", false + } + return d.StringValueContentString(idValue.Ref), true +} diff --git a/v2/pkg/astnormalization/defer_ensure_typename.go b/v2/pkg/astnormalization/defer_ensure_typename.go index 3c2cabbfd5..32b7039666 100644 --- a/v2/pkg/astnormalization/defer_ensure_typename.go +++ b/v2/pkg/astnormalization/defer_ensure_typename.go @@ -104,7 +104,7 @@ func (f *deferEnsureTypenameVisitor) EnterSelectionSet(ref int) { // no intersection: add a placeholder annotated with the parent's defer id // so it is planned in the parent field defer scope fieldRef := addInternalTypeNamePlaceholder(f.operation, ref) - f.addDeferInternalDirective(fieldRef, parentDeferID) + f.operation.AddDeferInternalDirectiveToField(fieldRef, parentDeferID, "", "") } // parentFieldDeferID returns the defer id of the nearest enclosing field that @@ -115,36 +115,11 @@ func (f *deferEnsureTypenameVisitor) parentFieldDeferID() string { if ancestor.Kind != ast.NodeKindField { continue } - directiveRef, exists := f.operation.Fields[ancestor.Ref].Directives.HasDirectiveByNameBytes(f.operation, literal.DEFER_INTERNAL) - if !exists { - return "" - } - idValue, ok := f.operation.DirectiveArgumentValueByName(directiveRef, []byte("id")) - if !ok { - return "" + + id, exist := f.operation.FieldInternalDeferID(ancestor.Ref) + if exist { + return id } - return f.operation.StringValueContentString(idValue.Ref) } return "" } - -// addDeferInternalDirective attaches @__defer_internal(id: deferID) to the given field. -func (f *deferEnsureTypenameVisitor) addDeferInternalDirective(fieldRef int, deferID string) { - strValueRef := f.operation.AddStringValue(ast.StringValue{ - Content: f.operation.Input.AppendInputString(deferID), - }) - argRef := f.operation.AddArgument(ast.Argument{ - Name: f.operation.Input.AppendInputString("id"), - Value: ast.Value{Kind: ast.ValueKindString, Ref: strValueRef}, - }) - directive := ast.Directive{ - Name: f.operation.Input.AppendInputBytes(literal.DEFER_INTERNAL), - HasArguments: true, - Arguments: ast.ArgumentList{Refs: []int{argRef}}, - } - directiveRef := f.operation.AddDirective(directive) - f.operation.AddDirectiveToNode(directiveRef, ast.Node{ - Kind: ast.NodeKindField, - Ref: fieldRef, - }) -} diff --git a/v2/pkg/engine/plan/required_fields_visitor.go b/v2/pkg/engine/plan/required_fields_visitor.go index 9c3e7b3b24..0fa2a2dc0f 100644 --- a/v2/pkg/engine/plan/required_fields_visitor.go +++ b/v2/pkg/engine/plan/required_fields_visitor.go @@ -585,7 +585,7 @@ func (v *requiredFieldsVisitor) applyDeferInternalDirective(fieldRef int) { if !v.config.isKey { // required fields should land in the same scope as the current field // to be fetched in the same defer group, but not in the parent scope - v.addDeferInternalDirective(fieldRef, v.config.deferInfo) + v.config.operation.AddDeferInternalDirectiveToField(fieldRef, v.config.deferInfo.ID, v.config.deferInfo.Label, v.config.deferInfo.ParentID) return } @@ -595,48 +595,9 @@ func (v *requiredFieldsVisitor) applyDeferInternalDirective(fieldRef int) { // for key fields: use parentFieldDeferID as the id // key should be in scope of the parent defer id, not be the deferred inside the same fragment, // otherwise it can't be planned properly - v.addDeferInternalDirective(fieldRef, &DeferInfo{ID: v.config.parentFieldDeferID}) + v.config.operation.AddDeferInternalDirectiveToField(fieldRef, v.config.parentFieldDeferID, "", "") } // if the parent field does not have a defer id, // fields should be unscoped, as is the parent field itself } - -func (v *requiredFieldsVisitor) addDeferInternalDirective(fieldRef int, deferInfo *DeferInfo) { - var argRefs []int - - argRefs = append(argRefs, v.addStringArgument("id", deferInfo.ID)) - - if deferInfo.Label != "" { - argRefs = append(argRefs, v.addStringArgument("label", deferInfo.Label)) - } - if deferInfo.ParentID != "" { - argRefs = append(argRefs, v.addStringArgument("parentDeferId", deferInfo.ParentID)) - } - - directive := ast.Directive{ - Name: v.config.operation.Input.AppendInputBytes(literal.DEFER_INTERNAL), - HasArguments: len(argRefs) > 0, - Arguments: ast.ArgumentList{ - Refs: argRefs, - }, - } - directiveRef := v.config.operation.AddDirective(directive) - v.config.operation.AddDirectiveToNode(directiveRef, ast.Node{ - Kind: ast.NodeKindField, - Ref: fieldRef, - }) -} - -func (v *requiredFieldsVisitor) addStringArgument(name, value string) int { - strValueRef := v.config.operation.AddStringValue(ast.StringValue{ - Content: v.config.operation.Input.AppendInputString(value), - }) - - arg := ast.Argument{ - Name: v.config.operation.Input.AppendInputString(name), - Value: ast.Value{Kind: ast.ValueKindString, Ref: strValueRef}, - } - - return v.config.operation.AddArgument(arg) -} From 9bcf37e50d4b83db40b336b5ef0f1967890ab90e Mon Sep 17 00:00:00 2001 From: spetrunin Date: Thu, 26 Mar 2026 23:27:15 +0200 Subject: [PATCH 69/79] modify abstract selection rewriter to preserve internal defer ids --- .../plan/abstract_selection_rewriter.go | 39 +-- .../abstract_selection_rewriter_helpers.go | 9 +- .../plan/abstract_selection_rewriter_info.go | 9 +- .../plan/abstract_selection_rewriter_test.go | 273 ++++++++++++++++++ 4 files changed, 297 insertions(+), 33 deletions(-) diff --git a/v2/pkg/engine/plan/abstract_selection_rewriter.go b/v2/pkg/engine/plan/abstract_selection_rewriter.go index 2cf814ee5e..1864ca5ecd 100644 --- a/v2/pkg/engine/plan/abstract_selection_rewriter.go +++ b/v2/pkg/engine/plan/abstract_selection_rewriter.go @@ -260,8 +260,6 @@ func (r *fieldSelectionRewriter) unionFieldSelectionNeedsRewrite(selectionSetInf func (r *fieldSelectionRewriter) rewriteUnionSelection(fieldRef int, fieldInfo selectionSetInfo, unionTypeNames []string) error { newSelectionRefs := make([]int, 0, len(unionTypeNames)+1) // 1 for __typename - r.preserveTypeNameSelection(fieldInfo, &newSelectionRefs) - r.flattenFragmentOnUnion(fieldInfo, unionTypeNames, &newSelectionRefs) return r.replaceFieldSelections(fieldRef, newSelectionRefs) @@ -276,10 +274,14 @@ func (r *fieldSelectionRewriter) replaceFieldSelections(fieldRef int, newSelecti } if len(newSelectionRefs) == 0 { + deferID, _ := r.operation.FieldInternalDeferID(fieldRef) // we have to add __typename selection in case there is no other selections - typeNameSelectionRef, typeNameFieldRef := r.typeNameSelection() + typeNameSelectionRef, typeNameFieldRef := r.typeNameSelection(deferID) r.skipFieldRefs = append(r.skipFieldRefs, typeNameFieldRef) r.operation.AddSelectionRefToSelectionSet(fieldSelectionSetRef, typeNameSelectionRef) + + // if there is no other selections we could skip normalization + return nil } normalizer := astnormalization.NewAbstractFieldNormalizer(r.operation, r.definition, fieldRef) @@ -579,7 +581,8 @@ func (r *fieldSelectionRewriter) rewriteInterfaceSelection(fieldRef int, fieldIn // When we have fragments on concrete types, // And we do not have __typename selection - we are adding it if fieldInfo.isInterfaceObject && !fieldInfo.hasTypeNameSelection && fieldInfo.hasInlineFragmentsOnObjects { - typeNameSelectionRef, typeNameFieldRef := r.typeNameSelection() + deferID, _ := r.operation.FieldInternalDeferID(fieldRef) + typeNameSelectionRef, typeNameFieldRef := r.typeNameSelection(deferID) r.skipFieldRefs = append(r.skipFieldRefs, typeNameFieldRef) newSelectionRefs = append(newSelectionRefs, typeNameSelectionRef) } @@ -608,35 +611,15 @@ func (r *fieldSelectionRewriter) flattenFragmentOnInterface(selectionSetInfo sel } } - for _, inlineFragmentInfo := range selectionSetInfo.inlineFragmentsOnObjects { - // for object fragments it is necessary to check if inline fragment type is allowed - if !slices.Contains(allowedImplementingTypes, inlineFragmentInfo.typeName) { - // remove fragment which not allowed - continue - } - - r.flattenFragmentOnObject(inlineFragmentInfo.selectionSetInfo, inlineFragmentInfo.typeName, selectionRefs) - } - - for _, inlineFragmentInfo := range selectionSetInfo.inlineFragmentsOnInterfaces { - // We do not check if interface fragment type not exists in the current datasource - // in case of interfaces the only thing which is matter is an interception of implementing types - // and parent allowed types - - r.flattenFragmentOnInterface(inlineFragmentInfo.selectionSetInfo, inlineFragmentInfo.typeNamesImplementingInterface, allowedImplementingTypes, selectionRefs) - } - - for _, inlineFragmentInfo := range selectionSetInfo.inlineFragmentsOnUnions { - // We do not check if union fragment type not exists in the current datasource - // in case of unions the only thing which is matter is an interception of implementing types - // and parent allowed types - r.flattenFragmentOnUnion(inlineFragmentInfo.selectionSetInfo, allowedImplementingTypes, selectionRefs) - } + r.flattenFragments(selectionSetInfo, allowedImplementingTypes, selectionRefs) } func (r *fieldSelectionRewriter) flattenFragmentOnUnion(selectionSetInfo selectionSetInfo, allowedTypeNames []string, selectionRefs *[]int) { r.preserveTypeNameSelection(selectionSetInfo, selectionRefs) + r.flattenFragments(selectionSetInfo, allowedTypeNames, selectionRefs) +} +func (r *fieldSelectionRewriter) flattenFragments(selectionSetInfo selectionSetInfo, allowedTypeNames []string, selectionRefs *[]int) { for _, inlineFragmentInfo := range selectionSetInfo.inlineFragmentsOnObjects { // for object fragments it is necessary to check if inline fragment type is allowed if !slices.Contains(allowedTypeNames, inlineFragmentInfo.typeName) { diff --git a/v2/pkg/engine/plan/abstract_selection_rewriter_helpers.go b/v2/pkg/engine/plan/abstract_selection_rewriter_helpers.go index b0f78a1edb..9674636d47 100644 --- a/v2/pkg/engine/plan/abstract_selection_rewriter_helpers.go +++ b/v2/pkg/engine/plan/abstract_selection_rewriter_helpers.go @@ -437,10 +437,15 @@ func (r *fieldSelectionRewriter) createFragmentSelection(typeName string, fields }) } -func (r *fieldSelectionRewriter) typeNameSelection() (selectionRef int, fieldRef int) { +func (r *fieldSelectionRewriter) typeNameSelection(deferID string) (selectionRef int, fieldRef int) { field := r.operation.AddField(ast.Field{ Name: r.operation.Input.AppendInputString("__typename"), }) + + if deferID != "" { + r.operation.AddDeferInternalDirectiveToField(field.Ref, deferID, "", "") + } + return r.operation.AddSelectionToDocument(ast.Selection{ Ref: field.Ref, Kind: ast.SelectionKindField, @@ -453,7 +458,7 @@ func (r *fieldSelectionRewriter) preserveTypeNameSelection(selectionSetInfo sele return } - selectionRef, _ := r.typeNameSelection() + selectionRef, _ := r.typeNameSelection(selectionSetInfo.typenameFieldDeferId) *selectionRefs = append(*selectionRefs, selectionRef) } diff --git a/v2/pkg/engine/plan/abstract_selection_rewriter_info.go b/v2/pkg/engine/plan/abstract_selection_rewriter_info.go index 905ce2aa52..622bc48c6b 100644 --- a/v2/pkg/engine/plan/abstract_selection_rewriter_info.go +++ b/v2/pkg/engine/plan/abstract_selection_rewriter_info.go @@ -18,6 +18,7 @@ type selectionSetInfo struct { hasInlineFragmentsOnInterfaces bool inlineFragmentsOnUnions []inlineFragmentSelectionOnUnion hasInlineFragmentsOnUnions bool + typenameFieldDeferId string } type fieldSelection struct { @@ -62,7 +63,7 @@ func (s *inlineFragmentSelection) isFragmentOnInterface() bool { return s.definitionNodeKind == ast.NodeKindInterfaceTypeDefinition } -func (r *fieldSelectionRewriter) selectionSetFieldSelections(selectionSetRef int) (fieldSelections []fieldSelection, hasTypename bool) { +func (r *fieldSelectionRewriter) selectionSetFieldSelections(selectionSetRef int) (fieldSelections []fieldSelection, hasTypename bool, typeNameFieldDeferID string) { fieldSelectionRefs := r.operation.SelectionSetFieldSelections(selectionSetRef) fieldSelections = make([]fieldSelection, 0, len(fieldSelectionRefs)) for _, fieldSelectionRef := range fieldSelectionRefs { @@ -71,6 +72,7 @@ func (r *fieldSelectionRewriter) selectionSetFieldSelections(selectionSetRef int if fieldName == "__typename" { hasTypename = true + typeNameFieldDeferID, _ = r.operation.FieldInternalDeferID(fieldRef) } fieldSelections = append(fieldSelections, fieldSelection{ @@ -79,7 +81,7 @@ func (r *fieldSelectionRewriter) selectionSetFieldSelections(selectionSetRef int }) } - return fieldSelections, hasTypename + return fieldSelections, hasTypename, typeNameFieldDeferID } func (r *fieldSelectionRewriter) collectFieldInformation(fieldRef int) (selectionSetInfo, error) { @@ -185,7 +187,7 @@ func (r *fieldSelectionRewriter) collectInlineFragmentInformation( } func (r *fieldSelectionRewriter) collectSelectionSetInformation(selectionSetRef int) (selectionSetInfo, error) { - fieldSelections, hasSharedTypename := r.selectionSetFieldSelections(selectionSetRef) + fieldSelections, hasSharedTypename, typenameFieldDeferId := r.selectionSetFieldSelections(selectionSetRef) inlineFragmentSelectionRefs := r.operation.SelectionSetInlineFragmentSelections(selectionSetRef) inlineFragmentSelectionsOnObjects := make([]inlineFragmentSelection, 0, len(inlineFragmentSelectionRefs)) @@ -203,6 +205,7 @@ func (r *fieldSelectionRewriter) collectSelectionSetInformation(selectionSetRef fields: fieldSelections, hasFields: len(fieldSelections) > 0, hasTypeNameSelection: hasSharedTypename, + typenameFieldDeferId: typenameFieldDeferId, inlineFragmentsOnObjects: inlineFragmentSelectionsOnObjects, hasInlineFragmentsOnObjects: len(inlineFragmentSelectionsOnObjects) > 0, inlineFragmentsOnInterfaces: inlineFragmentsOnInterfaces, diff --git a/v2/pkg/engine/plan/abstract_selection_rewriter_test.go b/v2/pkg/engine/plan/abstract_selection_rewriter_test.go index 61cdf08cd5..344b674ffb 100644 --- a/v2/pkg/engine/plan/abstract_selection_rewriter_test.go +++ b/v2/pkg/engine/plan/abstract_selection_rewriter_test.go @@ -4147,6 +4147,279 @@ func TestInterfaceSelectionRewriter_RewriteOperation(t *testing.T) { }`, shouldRewrite: true, }, + { + name: "union selection with deferred __typename - preserves defer directive on __typename after rewrite", + fieldName: "accounts", + definition: definition, + upstreamDefinition: ` + type User { + id: ID! + name: String! + isUser: Boolean! + } + + type Admin { + id: ID! + } + + union Account = User | Admin + + type Query { + accounts: [Account!]! + } + `, + dsBuilder: dsb(). + RootNode("Query", "iface"). + RootNode("User", "id", "name", "isUser"). + RootNode("Admin", "id"). + KeysMetadata(FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + { + TypeName: "Admin", + SelectionSet: "id", + }, + }), + operation: ` + query { + accounts { + __typename @__defer_internal(id: "defer-1") + ... on Node { + name + } + } + }`, + expectedOperation: ` + query { + accounts { + __typename @__defer_internal(id: "defer-1") + ... on Admin { + name + } + ... on User { + name + } + } + }`, + shouldRewrite: true, + }, + { + name: "interface selection with deferred __typename - preserves defer directive when shared field is copied into fragments", + definition: definition, + upstreamDefinition: ` + interface Node { + id: ID! + name: String! + } + + type User implements Node { + id: ID! + name: String! + isUser: Boolean! + } + + type Admin implements Node { + id: ID! + } + + type Query { + iface: Node! + } + `, + dsBuilder: dsb(). + RootNode("Query", "iface"). + RootNode("User", "id", "isUser"). + RootNode("Admin", "id"). + KeysMetadata(FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + { + TypeName: "Admin", + SelectionSet: "id", + }, + }), + operation: ` + query { + iface { + __typename @__defer_internal(id: "defer-1") + name + ... on User { + isUser + } + ... on Admin { + id + } + } + }`, + expectedOperation: ` + query { + iface { + ... on Admin { + __typename @__defer_internal(id: "defer-1") + name + id + } + ... on User { + __typename @__defer_internal(id: "defer-1") + name + isUser + } + } + }`, + shouldRewrite: true, + }, + { + name: "interface field with defer directive - fallback __typename inherits defer directive when all fragments are removed", + definition: ` + interface Node { + id: ID! + name: String! + } + + type User implements Node { + id: ID! + name: String! + isUser: Boolean! + } + + type Admin implements Node { + id: ID! + name: String! + } + + type Moderator implements Node { + id: ID! + name: String! + isModerator: Boolean! + } + + type Query { + iface: Node! + } + `, + upstreamDefinition: ` + interface Node { + id: ID! + name: String! + } + + type User implements Node { + id: ID! + name: String! + isUser: Boolean! + } + + type Admin implements Node { + id: ID! + name: String! + } + + type Query { + iface: Node! + } + `, + dsBuilder: dsb(). + RootNode("Query", "iface"). + RootNode("User", "id", "name", "isUser"). + RootNode("Admin", "id"). + KeysMetadata(FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + { + TypeName: "Admin", + SelectionSet: "id", + }, + }), + operation: ` + query { + iface @__defer_internal(id: "defer-1") { + ... on Moderator { + isModerator + } + } + }`, + expectedOperation: ` + query { + iface @__defer_internal(id: "defer-1") { + __typename @__defer_internal(id: "defer-1") + } + }`, + shouldRewrite: true, + }, + { + name: "interface object field with defer directive - added __typename inherits defer directive from field", + definition: ` + type User implements Account { + id: ID! + name: String! + } + + type Admin implements Account { + id: ID! + name: String! + login: String! + } + + interface Account { + id: ID! + name: String! + } + + type Query { + user: Account! + }`, + upstreamDefinition: ` + type Account @key(fields: "id") @interfaceObject { + id: ID! + name: String! + } + + type Query { + user: Account! + }`, + dsBuilder: dsb(). + RootNode("Query", "user"). + RootNode("Account", "id", "name"). + WithMetadata(func(m *FederationMetaData) { + m.InterfaceObjects = []EntityInterfaceConfiguration{ + { + InterfaceTypeName: "Account", + ConcreteTypeNames: []string{"Admin", "User"}, + }, + } + m.Keys = []FederationFieldConfiguration{ + { + TypeName: "Account", + SelectionSet: "id", + }, + } + }), + fieldName: "user", + operation: ` + query { + user @__defer_internal(id: "defer-1") { + ... on Admin { + id + } + } + }`, + expectedOperation: ` + query { + user @__defer_internal(id: "defer-1") { + __typename @__defer_internal(id: "defer-1") + ... on Admin { + id + } + } + }`, + shouldRewrite: true, + }, } for _, testCase := range testCases { From a41c4e31ff4f850c805a9fb721fe17f43401d874 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 27 Mar 2026 01:41:14 +0200 Subject: [PATCH 70/79] fix adding defer info to typename field --- v2/pkg/engine/plan/path_builder_visitor.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 87c06961cd..f5ec8235f7 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -1367,6 +1367,8 @@ func (c *pathBuilderVisitor) addPlannerPathForTypename( fragmentRef: ast.InvalidRef, dsHash: c.planners[plannerIndex].DataSourceConfiguration().Hash(), pathType: PathTypeField, + deferID: field.deferID, + deferredField: field.deferField, }) return true } From bf85cbcb5b07dd660d35e6105916457c729f05eb Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 27 Mar 2026 02:28:42 +0200 Subject: [PATCH 71/79] fix detecting nearest root node for the case of self jump --- .../datasource_filter_node_suggestions.go | 27 ++++++++++++++++--- v2/pkg/engine/plan/node_selection_builder.go | 5 +--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go index 8415bfee25..6da2b73dd7 100644 --- a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go +++ b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go @@ -160,7 +160,7 @@ func NewNodeSuggestionsWithSize(size int) *NodeSuggestions { } } -func (f *NodeSuggestions) ProcessDefer() { +func (f *NodeSuggestions) ProcessDefer(fieldRequirementsConfigs map[fieldIndexKey][]FederationFieldConfiguration) { for i := range f.items { if !f.items[i].Selected { continue @@ -170,14 +170,33 @@ func (f *NodeSuggestions) ProcessDefer() { continue } - f.propagateDeferParentsUpToRootNode(i) + f.propagateDeferParentsUpToRootNode(i, fieldRequirementsConfigs) } } -func (f *NodeSuggestions) propagateDeferParentsUpToRootNode(i int) { +func (f *NodeSuggestions) propagateDeferParentsUpToRootNode(i int, fieldRequirementsConfigs map[fieldIndexKey][]FederationFieldConfiguration) { // if the item is a root node and requires a key we are already able to jump from here, // so we skip propagating defer id - if f.items[i].IsRootNode && f.items[i].requiresKey != nil { + + hasKeyDependency := false + hasRequiresKey := f.items[i].requiresKey != nil + + // when the deffered field is on the entity and the parent field is on the same datasource + // we won't have hasRequiresKey set. + // but in case this field has requires directive it will be resolved by entity call, + // and it will have requires key configuration + if !hasRequiresKey && fieldRequirementsConfigs != nil { + requirements, ok := fieldRequirementsConfigs[fieldIndexKey{fieldRef: f.items[i].FieldRef, dsHash: f.items[i].DataSourceHash}] + if ok { + for _, r := range requirements { + if r.FieldName == "" { + hasKeyDependency = true + } + } + } + } + + if f.items[i].IsRootNode && hasRequiresKey || hasKeyDependency { return } diff --git a/v2/pkg/engine/plan/node_selection_builder.go b/v2/pkg/engine/plan/node_selection_builder.go index 7e3bf4859b..4bfb71dfd5 100644 --- a/v2/pkg/engine/plan/node_selection_builder.go +++ b/v2/pkg/engine/plan/node_selection_builder.go @@ -194,10 +194,7 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, } } - // NOTE: this is not enough - // If the deffered field is on the entity and entity is jumpable to itself, we need to request keys? - - p.nodeSelectionsVisitor.nodeSuggestions.ProcessDefer() + p.nodeSelectionsVisitor.nodeSuggestions.ProcessDefer(p.nodeSelectionsVisitor.fieldRequirementsConfigs) return &NodeSelectionResult{ dataSources: p.nodeSelectionsVisitor.dataSources, From b068c04d6a54ce2ec300d83477e68e8e61eb1c14 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Fri, 27 Mar 2026 03:05:29 +0200 Subject: [PATCH 72/79] fix sorting defer ids in a proper order --- v2/pkg/engine/postprocess/extract_defer_fetches.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/v2/pkg/engine/postprocess/extract_defer_fetches.go b/v2/pkg/engine/postprocess/extract_defer_fetches.go index 3a3a2bcca9..20f8ca9c4b 100644 --- a/v2/pkg/engine/postprocess/extract_defer_fetches.go +++ b/v2/pkg/engine/postprocess/extract_defer_fetches.go @@ -1,8 +1,10 @@ package postprocess import ( + "cmp" "maps" "slices" + "strconv" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" @@ -24,7 +26,12 @@ func (d *extractDeferFetches) Process(deferPlan *plan.DeferResponsePlan) { ChildNodes: root, } - deferIds := slices.Sorted(maps.Keys(fetchGroups)) + // sort defer ids in direct natural order + deferIds := slices.SortedFunc(maps.Keys(fetchGroups), func(a, b string) int { + an, _ := strconv.Atoi(a) + bn, _ := strconv.Atoi(b) + return cmp.Compare(an, bn) + }) for _, deferID := range deferIds { fetches := fetchGroups[deferID] From 62d241758a5679696c53a7101686d0cbc3155420 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 6 Apr 2026 19:51:56 +0300 Subject: [PATCH 73/79] fix compilation error after rebase --- .../engine/execution_engine_defer_test.go | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index db6233b96b..7ac446844e 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -382,7 +382,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { schema, err := graphql.NewSchemaFromString(tc.definition) require.NoError(t, err) - t.Run("single deffered field", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("single deffered field", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -404,7 +404,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("single deffered field between regular fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("single deffered field between regular fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -427,7 +427,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("multiple deffered fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("multiple deffered fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -450,7 +450,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("multiple deffered fields - all object fields deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("multiple deffered fields - all object fields deferred", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -473,7 +473,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("nested defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("nested defers", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -499,7 +499,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("nested defers variation", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("nested defers variation", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -522,7 +522,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("parallel defers", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -548,7 +548,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer nested object", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer nested object", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -573,7 +573,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer nested object with duplicated non defered object", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer nested object with duplicated non defered object", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -601,7 +601,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer nested object fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer nested object fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -626,7 +626,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("extensive parallel defers across all possible fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("extensive parallel defers across all possible fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -661,7 +661,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("extensive fully nested defers across all possible fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("extensive fully nested defers across all possible fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1054,7 +1054,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { })), } - t.Run("non-defer - name only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("non-defer - name only", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1065,7 +1065,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedResponse: `{"data":{"user":{"name":"Alice"}}}`, })) - t.Run("non-defer - account requires billing and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("non-defer - account requires billing and settings", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1076,7 +1076,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedResponse: `{"data":{"user":{"account":{"type":"premium"}}}}`, })) - t.Run("non-defer - notifications requires name and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("non-defer - notifications requires name and settings", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1087,7 +1087,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedResponse: `{"data":{"user":{"notifications":["msg1","msg2"]}}}`, })) - t.Run("non-defer - both requires fields together", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("non-defer - both requires fields together", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1098,7 +1098,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedResponse: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, })) - t.Run("non-defer - all fields including raw billing and settings", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("non-defer - all fields including raw billing and settings", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1109,7 +1109,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedResponse: `{"data":{"user":{"name":"Alice","billing":{"plan":"pro"},"settings":{"region":"us-east"},"account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, })) - t.Run("defer - account field deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - account field deferred", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1131,7 +1131,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - notifications field deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - notifications field deferred", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1153,7 +1153,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - all user fields deferred in single block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - all user fields deferred in single block", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1176,7 +1176,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("all user fields without defer", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("all user fields without defer", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1195,7 +1195,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedResponse: `{"data":{"user":{"name":"Alice","account":{"type":"premium"},"notifications":["msg1","msg2"]}}}`, })) - t.Run("defer - parallel defers on both cross-subgraph requires fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - parallel defers on both cross-subgraph requires fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1221,7 +1221,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - nested defers: outer has account, inner has notifications", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - nested defers: outer has account, inner has notifications", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1247,7 +1247,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - parallel defers on raw entity fields alongside requires", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - parallel defers on raw entity fields alongside requires", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1274,7 +1274,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - deeply nested requires: account outer, notifications inner, with raw fields", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - deeply nested requires: account outer, notifications inner, with raw fields", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1306,7 +1306,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { // Defer versions of each non-defer test — verify @defer doesn't break @requires resolution. - t.Run("defer - name only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - name only", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1325,7 +1325,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - only account deferred (no other immediate fields)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - only account deferred (no other immediate fields)", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1344,7 +1344,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - only notifications deferred (no other immediate fields)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - only notifications deferred (no other immediate fields)", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1363,7 +1363,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - all fields in single defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - all fields in single defer block", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1391,7 +1391,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { // Tests mixing requires-source fields (billing, settings) with derived @requires fields // (account, notifications) in same or parallel defer blocks. - t.Run("defer - requires source (billing) and derived field (account) in same defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - requires source (billing) and derived field (account) in same defer block", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1414,7 +1414,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - requires source (billing) and derived field (account) in parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - requires source (billing) and derived field (account) in parallel defers", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1436,7 +1436,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - requires source (settings) and derived field (notifications) in same defer block", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - requires source (settings) and derived field (notifications) in same defer block", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1459,7 +1459,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - requires source (settings) and derived field (notifications) in parallel defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - requires source (settings) and derived field (notifications) in parallel defers", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1481,7 +1481,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - all requires sources deferred together, then derived fields deferred in parallel", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - all requires sources deferred together, then derived fields deferred in parallel", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1509,7 +1509,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer - requires sources immediate, both derived fields deferred in parallel", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer - requires sources immediate, both derived fields deferred in parallel", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{ @@ -1646,7 +1646,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { schema, err := graphql.NewSchemaFromString(definition) require.NoError(t, err) - t.Run("defer from first subgraph - null non-nullable field", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer from first subgraph - null non-nullable field", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ product { ... @defer { name } } }`} @@ -1657,7 +1657,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer from first subgraph - null field with upstream error", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer from first subgraph - null field with upstream error", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ product { ... @defer { nameWithError } } }`} @@ -1668,7 +1668,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer from second subgraph - null non-nullable field", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer from second subgraph - null non-nullable field", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ product { ... @defer { price } } }`} @@ -1679,7 +1679,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer from both subgraphs - null non-nullable fields - name first", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer from both subgraphs - null non-nullable fields - name first", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ product { ... @defer { name } ... @defer { price } } }`} @@ -1690,7 +1690,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer from both subgraphs - null non-nullable fields - price first", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer from both subgraphs - null non-nullable fields - price first", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ product { ... @defer { price } ... @defer { name } } }`} @@ -1701,7 +1701,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer error halts subsequent defers - nameWithError then price", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer error halts subsequent defers - nameWithError then price", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ product { ... @defer { nameWithError } ... @defer { price } } }`} @@ -1898,7 +1898,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { dataSources := []plan.DataSource{firstSubgraphDS, secondSubgraphDS, thirdSubgraphDS} t.Run("category A - no id in initial response", func(t *testing.T) { - t.Run("defer name from sub1", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer name from sub1", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ items { ... @defer { name } } }`} @@ -1909,7 +1909,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer title from sub2", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer title from sub2", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ items { ... @defer { title } } }`} @@ -1920,7 +1920,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer subItems description from sub3", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer subItems description from sub3", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ items { subItems { ... @defer { description } } } }`} @@ -1931,7 +1931,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("items subItems and description all in separate nested defers", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("items subItems and description all in separate nested defers", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ ... @defer { items { id ... @defer { subItems { id ... @defer { description } } } } } }`} @@ -1946,7 +1946,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }) t.Run("category B - id deferred with parallel defers", func(t *testing.T) { - t.Run("defer id only", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer id only", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ items { ... @defer { id } } }`} @@ -1957,7 +1957,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer id and name together", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer id and name together", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ items { ... @defer { id name } } }`} @@ -1968,7 +1968,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer id in parallel with name", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer id in parallel with name", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ items { ... @defer { id } ... @defer { name } } }`} @@ -1980,7 +1980,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("defer id in parallel with title (cross-subgraph)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("defer id in parallel with title (cross-subgraph)", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ items { ... @defer { id } ... @defer { title } } }`} @@ -1992,7 +1992,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("parallel defers on subItems id and description", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("parallel defers on subItems id and description", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ items { id ... @defer { subItems { id } } ... @defer { subItems { description } } } }`} @@ -2006,7 +2006,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }) t.Run("parallel root defers", func(t *testing.T) { - t.Run("subItems id then description", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("subItems id then description", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ ... @defer { items { subItems { id } } } ... @defer { items { subItems { description } } } }`} @@ -2020,7 +2020,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }) t.Run("category C - nested defers", func(t *testing.T) { - t.Run("outer defer items, inner defer name", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("outer defer items, inner defer name", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ ... @defer { items { id ... @defer { name } } } }`} @@ -2032,7 +2032,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("outer defer items, inner defer title (cross-subgraph)", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("outer defer items, inner defer title (cross-subgraph)", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ ... @defer { items { id ... @defer { title } } } }`} @@ -2044,7 +2044,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("outer defer items with subItems, inner defer description", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("outer defer items with subItems, inner defer description", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ ... @defer { items { id subItems { id ... @defer { description } } } } }`} @@ -2056,7 +2056,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("three-level defer: query to items to subItems", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("three-level defer: query to items to subItems", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ ... @defer { items { id ... @defer { subItems { id ... @defer { description } } } } } }`} @@ -2069,7 +2069,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("three-level defer with cross-subgraph at middle level", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("three-level defer with cross-subgraph at middle level", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `{ ... @defer { items { id ... @defer { title subItems { id ... @defer { description } } } } } }`} @@ -2207,7 +2207,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { dataSources := []plan.DataSource{firstSubgraphDS, secondSubgraphDS} t.Run("category A - defer on named fragment spread", func(t *testing.T) { - t.Run("A1 - defer sub1 field sku via fragment spread", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("A1 - defer sub1 field sku via fragment spread", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment SkuFields on Product { sku } { products { ...SkuFields @defer } }`} @@ -2218,7 +2218,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("A2 - defer sub2 field name via fragment spread", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("A2 - defer sub2 field name via fragment spread", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment NameFields on Product { name } { products { ...NameFields @defer } }`} @@ -2229,7 +2229,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("A3 - id non-deferred, sub2 name and price deferred via fragment", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("A3 - id non-deferred, sub2 name and price deferred via fragment", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment DetailFields on Product { name price } { products { id ...DetailFields @defer } }`} @@ -2240,7 +2240,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("A4 - parallel fragment spreads from different subgraphs, both deferred", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("A4 - parallel fragment spreads from different subgraphs, both deferred", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment SkuFrag on Product { sku } fragment NameFrag on Product { name } { products { ...SkuFrag @defer ...NameFrag @defer } }`} @@ -2254,7 +2254,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }) t.Run("category B - defer inside named fragment definition", func(t *testing.T) { - t.Run("B1 - defer sub1 field sku inside named fragment", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("B1 - defer sub1 field sku inside named fragment", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment ProductFrag on Product { id ... @defer { sku } } { products { ...ProductFrag } }`} @@ -2265,7 +2265,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("B2 - defer sub2 field name inside named fragment", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("B2 - defer sub2 field name inside named fragment", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment ProductFrag on Product { id ... @defer { name } } { products { ...ProductFrag } }`} @@ -2276,7 +2276,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("B3 - parallel sub1 and sub2 defers inside named fragment", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("B3 - parallel sub1 and sub2 defers inside named fragment", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment ProductFrag on Product { id ... @defer { sku } ... @defer { name } } { products { ...ProductFrag } }`} @@ -2290,7 +2290,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { }) t.Run("category C - defer on spread containing inner defers", func(t *testing.T) { - t.Run("C1 - multiple sub1 fields id and sku bundled in single deferred spread", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("C1 - multiple sub1 fields id and sku bundled in single deferred spread", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment SkuIdFrag on Product { id sku } { products { ...SkuIdFrag @defer } }`} @@ -2301,7 +2301,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `, }, withStreamingResponse())) - t.Run("C2 - outer spread deferred delivering sub1 sku, with nested inner sub2 name defer", runExecutionEngineTestWithoutError(ExecutionEngineTestCase{ + t.Run("C2 - outer spread deferred delivering sub1 sku, with nested inner sub2 name defer", runWithoutError(ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { return graphql.Request{Query: `fragment SkuWithName on Product { sku ... @defer { name } } { products { id ...SkuWithName @defer } }`} From f59a327a6134e1a5298535b2895b1c23ef40f780 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Mon, 6 Apr 2026 20:10:46 +0300 Subject: [PATCH 74/79] fix defer tests after rebase --- .../engine/execution_engine_defer_test.go | 18 ++++-------------- execution/engine/execution_engine_test.go | 1 + 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/execution/engine/execution_engine_defer_test.go b/execution/engine/execution_engine_defer_test.go index 7ac446844e..67104b6959 100644 --- a/execution/engine/execution_engine_defer_test.go +++ b/execution/engine/execution_engine_defer_test.go @@ -808,11 +808,10 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedHost: "first", expectedPath: "/", responses: map[string]sendResponse{ - // Direct root queries (non-defer, no entity deps) `{"query":"{user {name}}"}`: { statusCode: 200, body: `{"data":{"user":{"name":"Alice"}}}`, - }, // Initial root query when only entity key is needed (account @requires billing+settings from sub2/sub3) + }, `{"query":"{user {__typename id}}"}`: { statusCode: 200, body: `{"data":{"user":{"__typename":"User","id":"1"}}}`, @@ -828,10 +827,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename account {type}}}}","variables":{"representations":[{"__typename":"User","billing":{"plan":"pro"},"settings":{"region":"us-east"},"id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","account":{"type":"premium"}}]}}`, - }, // Direct root queries for deferred account/name fields - `{"query":"{user {account {type}}}"}`: { - statusCode: 200, - body: `{"data":{"user":{"account":{"type":"premium"}}}}`, }, `{"query":"{user {__internal_name: name}}"}`: { statusCode: 200, @@ -844,7 +839,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `{"query":"{user {___typename: __typename __typename id}}"}`: { statusCode: 200, body: `{"data":{"user":{"___typename":"User","__typename":"User","id":"1"}}}`, - }, // Deferred sub1 root fetch without redundant plain name (covered by __internal_name alias) + }, `{"query":"{user {account {type} __internal_name: name}}"}`: { statusCode: 200, body: `{"data":{"user":{"account":{"type":"premium"},"__internal_name":"Alice"}}}`, @@ -906,7 +901,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { expectedHost: "second", expectedPath: "/", responses: map[string]sendResponse{ - // Entity fetches for billing.plan (needed as @requires input for sub1 account) `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename billing {plan}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","billing":{"plan":"pro"}}]}}`, @@ -987,7 +981,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_2_settings: settings {language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","__internal_2_settings":{"language":"en"}}]}}`, - }, // New alias format: simple __internal_settings for first defer scope + }, `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_settings: settings {region}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","__internal_settings":{"region":"us-east"}}]}}`, @@ -1011,7 +1005,7 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename settings {region language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","settings":{"region":"us-east","language":"en"}}]}}`, - }, // Combined region+language with simple alias (new format) + }, `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename __internal_settings: settings {region language}}}}","variables":{"representations":[{"__typename":"User","id":"1"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"User","__internal_settings":{"region":"us-east","language":"en"}}]}}`, @@ -2175,10 +2169,6 @@ func TestExecutionEngine_Execute_Defer(t *testing.T) { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"Product","name":"Product One"},{"__typename":"Product","name":"Product Two"}]}}`, }, - `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename price}}}","variables":{"representations":[{"__typename":"Product","id":"1"},{"__typename":"Product","id":"2"}]}}`: { - statusCode: 200, - body: `{"data":{"_entities":[{"__typename":"Product","price":9.99},{"__typename":"Product","price":19.99}]}}`, - }, `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename name price}}}","variables":{"representations":[{"__typename":"Product","id":"1"},{"__typename":"Product","id":"2"}]}}`: { statusCode: 200, body: `{"data":{"_entities":[{"__typename":"Product","name":"Product One","price":9.99},{"__typename":"Product","name":"Product Two","price":19.99}]}}`, diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 5e715e7027..85dc1bef85 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -111,6 +111,7 @@ func runExecutionTest(testCase ExecutionEngineTestCase, withError bool, expected if opts.streamingResponse { resultWriter.SetFlushCallback(func(data []byte) { streamingBuf.Write(data) + streamingBuf.Write([]byte{'\n'}) }) } From fb43df7d234971db025c06bcd206af7381cf7b20 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 7 Apr 2026 14:51:30 +0300 Subject: [PATCH 75/79] update base schema and introspection fixtures --- v2/pkg/asttransform/base.graphql | 31 ++- v2/pkg/asttransform/baseschema.go | 12 +- v2/pkg/asttransform/baseschema_test.go | 15 +- v2/pkg/asttransform/defer.graphql | 16 -- v2/pkg/asttransform/fixtures/complete.golden | 31 ++- .../fixtures/custom_query_name.golden | 31 ++- .../fixtures/mutation_only.golden | 31 ++- ...olden => mutation_only_no_internal.golden} | 38 ++-- .../fixtures/schema_missing.golden | 31 ++- v2/pkg/asttransform/fixtures/simple.golden | 31 ++- .../fixtures/subscription_only.golden | 31 ++- .../fixtures/subscription_renamed.golden | 31 ++- .../with_mutation_subscription.golden | 31 ++- v2/pkg/asttransform/internal.graphql | 6 + .../fixtures/schema_introspection.golden | 45 +++++ ...on_with_custom_root_operation_types.golden | 45 +++++ .../fixtures/federated_schema.golden | 22 +- v2/pkg/federation/schema.go | 2 +- .../fixtures/starwars_introspected.golden | 76 ++++++- .../testdata/starwars.schema.graphql | 189 +----------------- 20 files changed, 416 insertions(+), 329 deletions(-) delete mode 100644 v2/pkg/asttransform/defer.graphql rename v2/pkg/asttransform/fixtures/{mutation_only_internal_defer.golden => mutation_only_no_internal.golden} (87%) create mode 100644 v2/pkg/asttransform/internal.graphql diff --git a/v2/pkg/asttransform/base.graphql b/v2/pkg/asttransform/base.graphql index 410606e525..e8105b4fb5 100644 --- a/v2/pkg/asttransform/base.graphql +++ b/v2/pkg/asttransform/base.graphql @@ -1,23 +1,30 @@ -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." + +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." + +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." + +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." + +"""The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID.""" scalar ID + "Directs the executor to include this field or fragment only when the argument is true." directive @include( "Included when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + "Directs the executor to skip this field or fragment when the argument is true." directive @skip( "Skipped when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + "Marks an element of a GraphQL schema as no longer supported." directive @deprecated( """ @@ -28,7 +35,11 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE -directive @specifiedBy(url: String!) on SCALAR +"Exposes a URL that specifies the behavior of this scalar" +directive @specifiedBy( + "The URL that specifies the behavior of this scalar." + url: String! +) on SCALAR """ The @oneOf built-in directive marks an input object as a OneOf Input Object. @@ -37,6 +48,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/baseschema.go b/v2/pkg/asttransform/baseschema.go index 5d09e16e86..5bf6fd0025 100644 --- a/v2/pkg/asttransform/baseschema.go +++ b/v2/pkg/asttransform/baseschema.go @@ -13,8 +13,8 @@ var ( //go:embed base.graphql baseSchema []byte - //go:embed defer.graphql - deferDefinition []byte + //go:embed internal.graphql + internalDefinition []byte ) type Options struct { @@ -22,8 +22,14 @@ type Options struct { } func MergeDefinitionWithBaseSchema(definition *ast.Document) error { + return MergeDefinitionWithBaseSchemaWithInternal(definition, true) +} + +func MergeDefinitionWithBaseSchemaWithInternal(definition *ast.Document, includeInternal bool) error { definition.Input.AppendInputBytes(baseSchema) - definition.Input.AppendInputBytes(deferDefinition) + if includeInternal { + definition.Input.AppendInputBytes(internalDefinition) + } parser := astparser.NewParser() report := operationreport.Report{} diff --git a/v2/pkg/asttransform/baseschema_test.go b/v2/pkg/asttransform/baseschema_test.go index 552c97c039..a30f02cb0e 100644 --- a/v2/pkg/asttransform/baseschema_test.go +++ b/v2/pkg/asttransform/baseschema_test.go @@ -13,13 +13,18 @@ import ( ) func runTestMerge(definition, fixtureName string) func(t *testing.T) { - return runTestMergeWithDefer(definition, fixtureName, false) + return runTestMergeWithInternal(definition, fixtureName, true) } -func runTestMergeWithDefer(definition, fixtureName string, internalDefer bool) func(t *testing.T) { +func runTestMergeWithInternal(definition, fixtureName string, includeInternal bool) func(t *testing.T) { return func(t *testing.T) { doc := unsafeparser.ParseGraphqlDocumentString(definition) - err := asttransform.MergeDefinitionWithBaseSchema(&doc) + var err error + if includeInternal { + err = asttransform.MergeDefinitionWithBaseSchema(&doc) + } else { + err = asttransform.MergeDefinitionWithBaseSchemaWithInternal(&doc, false) + } require.NoError(t, err) buf := bytes.Buffer{} err = astprinter.PrintIndent(&doc, []byte(" "), &buf) @@ -48,11 +53,11 @@ func TestMergeDefinitionWithBaseSchema(t *testing.T) { m: String! } `, "mutation_only")) - t.Run("mutation only - internal defer", runTestMergeWithDefer(` + t.Run("mutation only - no internal", runTestMergeWithInternal(` type Mutation { m: String! } - `, "mutation_only_internal_defer", true)) + `, "mutation_only_no_internal", false)) t.Run("schema with mutation", runTestMerge(` schema { mutation: Mutation diff --git a/v2/pkg/asttransform/defer.graphql b/v2/pkg/asttransform/defer.graphql deleted file mode 100644 index d3b6e09d5d..0000000000 --- a/v2/pkg/asttransform/defer.graphql +++ /dev/null @@ -1,16 +0,0 @@ -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( - "A unique identifier for the results." - label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT - -directive @__defer_internal( - id: String! - parentDeferId: String - "A unique identifier for the results." - label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) repeatable on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/complete.golden b/v2/pkg/asttransform/fixtures/complete.golden index a528465a30..4473c49199 100644 --- a/v2/pkg/asttransform/fixtures/complete.golden +++ b/v2/pkg/asttransform/fixtures/complete.golden @@ -16,19 +16,21 @@ type Hello { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -53,7 +55,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -64,6 +68,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -231,10 +243,9 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( +directive @__defer_internal( + id: String! + parentDeferId: String "A unique identifier for the results." label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file +) repeatable on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/custom_query_name.golden b/v2/pkg/asttransform/fixtures/custom_query_name.golden index 1fb2f6d0c9..afe609ca4a 100644 --- a/v2/pkg/asttransform/fixtures/custom_query_name.golden +++ b/v2/pkg/asttransform/fixtures/custom_query_name.golden @@ -16,19 +16,21 @@ type Hello { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -53,7 +55,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -64,6 +68,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -231,10 +243,9 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( +directive @__defer_internal( + id: String! + parentDeferId: String "A unique identifier for the results." label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file +) repeatable on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/mutation_only.golden b/v2/pkg/asttransform/fixtures/mutation_only.golden index 3e3e194b13..96955dc89a 100644 --- a/v2/pkg/asttransform/fixtures/mutation_only.golden +++ b/v2/pkg/asttransform/fixtures/mutation_only.golden @@ -8,19 +8,21 @@ type Mutation { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -45,7 +47,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -56,6 +60,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -223,13 +235,12 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( +directive @__defer_internal( + id: String! + parentDeferId: String "A unique identifier for the results." label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT +) repeatable on FIELD type Query { __schema: __Schema! diff --git a/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden b/v2/pkg/asttransform/fixtures/mutation_only_no_internal.golden similarity index 87% rename from v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden rename to v2/pkg/asttransform/fixtures/mutation_only_no_internal.golden index 9ac63efe0f..d24e1a244d 100644 --- a/v2/pkg/asttransform/fixtures/mutation_only_internal_defer.golden +++ b/v2/pkg/asttransform/fixtures/mutation_only_no_internal.golden @@ -8,19 +8,21 @@ type Mutation { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -45,10 +47,27 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -216,15 +235,6 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @__defer_internal( - id: String! - "A unique identifier for the results." - label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) repeatable on FIELD - type Query { __schema: __Schema! __type(name: String!): __Type diff --git a/v2/pkg/asttransform/fixtures/schema_missing.golden b/v2/pkg/asttransform/fixtures/schema_missing.golden index a528465a30..4473c49199 100644 --- a/v2/pkg/asttransform/fixtures/schema_missing.golden +++ b/v2/pkg/asttransform/fixtures/schema_missing.golden @@ -16,19 +16,21 @@ type Hello { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -53,7 +55,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -64,6 +68,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -231,10 +243,9 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( +directive @__defer_internal( + id: String! + parentDeferId: String "A unique identifier for the results." label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file +) repeatable on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/simple.golden b/v2/pkg/asttransform/fixtures/simple.golden index a528465a30..4473c49199 100644 --- a/v2/pkg/asttransform/fixtures/simple.golden +++ b/v2/pkg/asttransform/fixtures/simple.golden @@ -16,19 +16,21 @@ type Hello { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -53,7 +55,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -64,6 +68,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -231,10 +243,9 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( +directive @__defer_internal( + id: String! + parentDeferId: String "A unique identifier for the results." label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file +) repeatable on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/fixtures/subscription_only.golden b/v2/pkg/asttransform/fixtures/subscription_only.golden index 8fae3d3ef6..ea69924675 100644 --- a/v2/pkg/asttransform/fixtures/subscription_only.golden +++ b/v2/pkg/asttransform/fixtures/subscription_only.golden @@ -7,19 +7,21 @@ type Subscription { s: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -44,7 +46,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -55,6 +59,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -222,13 +234,12 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( +directive @__defer_internal( + id: String! + parentDeferId: String "A unique identifier for the results." label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT +) repeatable on FIELD type Query { __schema: __Schema! diff --git a/v2/pkg/asttransform/fixtures/subscription_renamed.golden b/v2/pkg/asttransform/fixtures/subscription_renamed.golden index 10f8497a4e..775b2da8b2 100644 --- a/v2/pkg/asttransform/fixtures/subscription_renamed.golden +++ b/v2/pkg/asttransform/fixtures/subscription_renamed.golden @@ -7,19 +7,21 @@ type Sub { s: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -44,7 +46,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -55,6 +59,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -222,13 +234,12 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( +directive @__defer_internal( + id: String! + parentDeferId: String "A unique identifier for the results." label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT +) repeatable on FIELD type Query { __schema: __Schema! diff --git a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden index d0a5175ba3..145bf47d95 100644 --- a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden +++ b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden @@ -27,19 +27,21 @@ type Hello { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -64,7 +66,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -75,6 +79,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -242,10 +254,9 @@ enum __TypeKind { NON_NULL } -"Directs the executor to defer this fragment when the if argument is true or undefined." -directive @defer( +directive @__defer_internal( + id: String! + parentDeferId: String "A unique identifier for the results." label: String - "Controls whether the fragment will be deferred, usually via a variable." - if: Boolean! = true -) on FRAGMENT_SPREAD | INLINE_FRAGMENT \ No newline at end of file +) repeatable on FIELD \ No newline at end of file diff --git a/v2/pkg/asttransform/internal.graphql b/v2/pkg/asttransform/internal.graphql new file mode 100644 index 0000000000..013856d99c --- /dev/null +++ b/v2/pkg/asttransform/internal.graphql @@ -0,0 +1,6 @@ +directive @__defer_internal( + id: String! + parentDeferId: String + "A unique identifier for the results." + label: String +) repeatable on FIELD \ No newline at end of file diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden index d6f62343c4..ba48c95bae 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden @@ -360,6 +360,51 @@ "args": [], "isRepeatable": false, "__typename": "__Directive" + }, + { + "name": "defer", + "description": "Directs the executor to defer this fragment when the if argument is true or undefined.", + "locations": [ + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "label", + "description": "A unique identifier for the results.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + "__typename": "__Type" + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null, + "__typename": "__InputValue" + }, + { + "name": "if", + "description": "Controls whether the fragment will be deferred, usually via a variable.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null, + "__typename": "__Type" + }, + "__typename": "__Type" + }, + "defaultValue": "true", + "isDeprecated": false, + "deprecationReason": null, + "__typename": "__InputValue" + } + ], + "isRepeatable": false, + "__typename": "__Directive" } ], "__typename": "__Schema" diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden index f56fee360b..71fe9498b1 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden @@ -508,6 +508,51 @@ "args": [], "isRepeatable": false, "__typename": "__Directive" + }, + { + "name": "defer", + "description": "Directs the executor to defer this fragment when the if argument is true or undefined.", + "locations": [ + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "label", + "description": "A unique identifier for the results.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + "__typename": "__Type" + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null, + "__typename": "__InputValue" + }, + { + "name": "if", + "description": "Controls whether the fragment will be deferred, usually via a variable.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null, + "__typename": "__Type" + }, + "__typename": "__Type" + }, + "defaultValue": "true", + "isDeprecated": false, + "deprecationReason": null, + "__typename": "__InputValue" + } + ], + "isRepeatable": false, + "__typename": "__Directive" } ], "__typename": "__Schema" diff --git a/v2/pkg/federation/fixtures/federated_schema.golden b/v2/pkg/federation/fixtures/federated_schema.golden index 40ac93d20d..48d4c08354 100644 --- a/v2/pkg/federation/fixtures/federated_schema.golden +++ b/v2/pkg/federation/fixtures/federated_schema.golden @@ -56,19 +56,21 @@ type User { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The `Boolean` scalar type represents `true` or `false`." scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -93,7 +95,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -104,6 +108,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/federation/schema.go b/v2/pkg/federation/schema.go index 010c19e311..a4c19d2ad5 100644 --- a/v2/pkg/federation/schema.go +++ b/v2/pkg/federation/schema.go @@ -48,7 +48,7 @@ func (s *schemaBuilder) extendQueryTypeWithFederationFields(schema string, hasEn return schema } - if err := asttransform.MergeDefinitionWithBaseSchema(doc); err != nil { + if err := asttransform.MergeDefinitionWithBaseSchemaWithInternal(doc, false); err != nil { return schema } diff --git a/v2/pkg/introspection/fixtures/starwars_introspected.golden b/v2/pkg/introspection/fixtures/starwars_introspected.golden index 5bb9e05621..5cc12293e5 100644 --- a/v2/pkg/introspection/fixtures/starwars_introspected.golden +++ b/v2/pkg/introspection/fixtures/starwars_introspected.golden @@ -1817,7 +1817,7 @@ { "kind": "SCALAR", "name": "Boolean", - "description": "The `Boolean` scalar type represents `true` or `false` .", + "description": "The `Boolean` scalar type represents `true` or `false`.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -1826,7 +1826,7 @@ { "kind": "SCALAR", "name": "ID", - "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `4`) or integer (such as 4) input value will be accepted as an ID.", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"4\") or integer (such as 4) input value will be accepted as an ID.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -1918,8 +1918,8 @@ "__typename": "__Type" }, "defaultValue": "\"No longer supported\"", - "isDeprecated": true, - "deprecationReason": "No longer supported", + "isDeprecated": false, + "deprecationReason": null, "__typename": "__InputValue" } ], @@ -1927,16 +1927,15 @@ "__typename": "__Directive" }, { - "name": "delegateField", - "description": "", + "name": "specifiedBy", + "description": "Exposes a URL that specifies the behavior of this scalar", "locations": [ - "OBJECT", - "INTERFACE" + "SCALAR" ], "args": [ { - "name": "name", - "description": "", + "name": "url", + "description": "The URL that specifies the behavior of this scalar.", "type": { "kind": "NON_NULL", "name": null, @@ -1954,7 +1953,62 @@ "__typename": "__InputValue" } ], - "isRepeatable": true, + "isRepeatable": false, + "__typename": "__Directive" + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [], + "isRepeatable": false, + "__typename": "__Directive" + }, + { + "name": "defer", + "description": "Directs the executor to defer this fragment when the if argument is true or undefined.", + "locations": [ + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "label", + "description": "A unique identifier for the results.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + "__typename": "__Type" + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null, + "__typename": "__InputValue" + }, + { + "name": "if", + "description": "Controls whether the fragment will be deferred, usually via a variable.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null, + "__typename": "__Type" + }, + "__typename": "__Type" + }, + "defaultValue": "true", + "isDeprecated": false, + "deprecationReason": null, + "__typename": "__InputValue" + } + ], + "isRepeatable": false, "__typename": "__Directive" } ], diff --git a/v2/pkg/introspection/testdata/starwars.schema.graphql b/v2/pkg/introspection/testdata/starwars.schema.graphql index e777756cad..d59f1a427b 100644 --- a/v2/pkg/introspection/testdata/starwars.schema.graphql +++ b/v2/pkg/introspection/testdata/starwars.schema.graphql @@ -164,191 +164,4 @@ type Starship { "The union represents combined return result which could be on of the types: Human, Droid, Starship" union SearchResult = Human | Droid | Starship -scalar DateTime @specifiedBy(url: "https://scalars.graphql.org/andimarek/date-time") - -"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." -scalar Int -"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." -scalar Float -"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." -scalar String -"The `Boolean` scalar type represents `true` or `false` ." -scalar Boolean -"The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `4`) or integer (such as 4) input value will be accepted as an ID." -scalar ID -"Directs the executor to include this field or fragment only when the argument is true." -directive @include( - "Included when true." - if: Boolean! -) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -"Directs the executor to skip this field or fragment when the argument is true." -directive @skip( - "Skipped when true." - if: Boolean! -) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -"Marks an element of a GraphQL schema as no longer supported." -directive @deprecated( - """ - Explains why this element was deprecated, usually also including a suggestion - for how to access supported similar data. Formatted in - [Markdown](https://daringfireball.net/projects/markdown/). - """ - reason: String = "No longer supported" @deprecated -) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE -directive @delegateField( - name: String! -) repeatable on OBJECT | INTERFACE - -""" -A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. -In some cases, you need to provide options to alter GraphQL's execution behavior -in ways field arguments will not suffice, such as conditionally including or -skipping a field. Directives provide this by describing additional information -to the executor. -""" -type __Directive { - name: String! - description: String - locations: [__DirectiveLocation!]! - args: [__InputValue!]! - isRepeatable: Boolean! -} - -""" -A Directive can be adjacent to many parts of the GraphQL language, a -__DirectiveLocation describes one such possible adjacencies. -""" -enum __DirectiveLocation { - "Location adjacent to a query operation." - QUERY - "Location adjacent to a mutation operation." - MUTATION - "Location adjacent to a subscription operation." - SUBSCRIPTION - "Location adjacent to a field." - FIELD - "Location adjacent to a fragment definition." - FRAGMENT_DEFINITION - "Location adjacent to a fragment spread." - FRAGMENT_SPREAD - "Location adjacent to an inline fragment." - INLINE_FRAGMENT - "Location adjacent to a schema definition." - SCHEMA - "Location adjacent to a scalar definition." - SCALAR - "Location adjacent to an object type definition." - OBJECT - "Location adjacent to a field definition." - FIELD_DEFINITION - "Location adjacent to an argument definition." - ARGUMENT_DEFINITION - "Location adjacent to an interface definition." - INTERFACE - "Location adjacent to a union definition." - UNION - "Location adjacent to an enum definition." - ENUM - "Location adjacent to an enum value definition." - ENUM_VALUE - "Location adjacent to an input object type definition." - INPUT_OBJECT - "Location adjacent to an input object field definition." - INPUT_FIELD_DEFINITION -} -""" -One possible value for a given Enum. Enum values are unique values, not a -placeholder for a string or numeric value. However an Enum value is returned in -a JSON response as a string. -""" -type __EnumValue { - name: String! - description: String - isDeprecated: Boolean! - deprecationReason: String -} - -""" -Object and Interface types are described by a list of Fields, each of which has -a name, potentially a list of arguments, and a return type. -""" -type __Field { - name: String! - description: String - args: [__InputValue!]! - type: __Type! - isDeprecated: Boolean! - deprecationReason: String -} - -"""Arguments provided to Fields or Directives and the input fields of an -InputObject are represented as Input Values which describe their type and -optionally a default value. -""" -type __InputValue { - name: String! - description: String - type: __Type! - "A GraphQL-formatted string representing the default value for this input value." - defaultValue: String -} - -""" -A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all -available types and directives on the server, as well as the entry points for -query, mutation, and subscription operations. -""" -type __Schema { - "A list of all types supported by this server." - types: [__Type!]! - "The type that query operations will be rooted at." - queryType: __Type! - "If this server supports mutation, the type that mutation operations will be rooted at." - mutationType: __Type - "If this server support subscription, the type that subscription operations will be rooted at." - subscriptionType: __Type - "A list of all directives supported by this server." - directives: [__Directive!]! -} - -""" -The fundamental unit of any GraphQL Schema is the type. There are many kinds of -types in GraphQL as represented by the `__TypeKind` enum. - -Depending on the kind of a type, certain fields describe information about that -type. Scalar types provide no information beyond a name and description, while -Enum types provide their values. Object and Interface types provide the fields -they describe. Abstract types, Union and Interface, provide the Object types -possible at runtime. List and NonNull types compose other types. -""" -type __Type { - kind: __TypeKind! - name: String - description: String - fields(includeDeprecated: Boolean = false): [__Field!] - interfaces: [__Type!] - possibleTypes: [__Type!] - enumValues(includeDeprecated: Boolean = false): [__EnumValue!] - inputFields: [__InputValue!] - ofType: __Type -} - -"An enum describing what kind of type a given `__Type` is." -enum __TypeKind { - "Indicates this type is a scalar." - SCALAR - "Indicates this type is an object. `fields` and `interfaces` are valid fields." - OBJECT - "Indicates this type is an interface. `fields` ` and ` `possibleTypes` are valid fields." - INTERFACE - "Indicates this type is a union. `possibleTypes` is a valid field." - UNION - "Indicates this type is an enum. `enumValues` is a valid field." - ENUM - "Indicates this type is an input object. `inputFields` is a valid field." - INPUT_OBJECT - "Indicates this type is a list. `ofType` is a valid field." - LIST - "Indicates this type is a non-null. `ofType` is a valid field." - NON_NULL -} +scalar DateTime @specifiedBy(url: "https://scalars.graphql.org/andimarek/date-time") \ No newline at end of file From 6ee4915277d0f915eea2f51a40ac54adf969fe19 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 7 Apr 2026 18:40:09 +0300 Subject: [PATCH 76/79] improve comments --- v2/pkg/ast/ast_field.go | 8 +++++--- v2/pkg/astnormalization/field_deduplication.go | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/v2/pkg/ast/ast_field.go b/v2/pkg/ast/ast_field.go index 4f9afc2aad..779a3d2c35 100644 --- a/v2/pkg/ast/ast_field.go +++ b/v2/pkg/ast/ast_field.go @@ -192,9 +192,11 @@ func (d *Document) MergeFieldsDefer(left, right int) { d.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef) d.Fields[left].HasDirectives = len(d.Fields[left].Directives.Refs) > 0 case !leftDeferExists: - // do nothing, right will be discarded + // do nothing, as we are merging right into left + // and left do not have the defer, + // so right will be discarded default: - // both have the defer, wins defer will smaller id + // both have the defer; defer with smaller id wins leftDeferIdValue, _ := d.DirectiveArgumentValueByName(leftDeferDirectiveRef, []byte("id")) rightDeferIdValue, _ := d.DirectiveArgumentValueByName(rightDeferDirectiveRef, []byte("id")) @@ -207,7 +209,7 @@ func (d *Document) MergeFieldsDefer(left, right int) { case leftId == rightId: // do nothing, they are equal case leftId < rightId: - // left wins, remove right + // left wins, right discarded case leftId > rightId: d.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef) // append a right defer to the left diff --git a/v2/pkg/astnormalization/field_deduplication.go b/v2/pkg/astnormalization/field_deduplication.go index 68afcbcec0..307ae58db2 100644 --- a/v2/pkg/astnormalization/field_deduplication.go +++ b/v2/pkg/astnormalization/field_deduplication.go @@ -51,8 +51,8 @@ func (d *deduplicateFieldsVisitor) EnterSelectionSet(ref int) { if d.operation.Fields[right].HasSelections { continue } - // here we will check full directive equality if they are not equal we won't deduplicate - // order of directives doesn't matter if they are fully equal + // here we will check full directive equality if they are not equal, we won't deduplicate. + // the order of directives doesn't matter if they are fully equal. if d.operation.FieldsAreEqualFlat(left, right, true) { d.operation.MergeFieldsDefer(left, right) d.operation.RemoveFromSelectionSet(ref, b) From 3d8c4c5c34268f3fc449b39b606d6a17ccd535d7 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 7 Apr 2026 18:42:35 +0300 Subject: [PATCH 77/79] fix introspection datasource fixtures --- .../fixtures/schema_introspection.golden | 14 +++++++------- ...pection_with_custom_root_operation_types.golden | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden index ba48c95bae..0ed5151ec8 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden @@ -185,7 +185,7 @@ { "kind": "SCALAR", "name": "Int", - "description": "The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -194,7 +194,7 @@ { "kind": "SCALAR", "name": "Float", - "description": "The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -203,7 +203,7 @@ { "kind": "SCALAR", "name": "String", - "description": "The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -212,7 +212,7 @@ { "kind": "SCALAR", "name": "Boolean", - "description": "The 'Boolean' scalar type represents 'true' or 'false' .", + "description": "The `Boolean` scalar type represents `true` or `false`.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -221,7 +221,7 @@ { "kind": "SCALAR", "name": "ID", - "description": "The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID.", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"4\") or integer (such as 4) input value will be accepted as an ID.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -323,14 +323,14 @@ }, { "name": "specifiedBy", - "description": "", + "description": "Exposes a URL that specifies the behavior of this scalar", "locations": [ "SCALAR" ], "args": [ { "name": "url", - "description": "", + "description": "The URL that specifies the behavior of this scalar.", "type": { "kind": "NON_NULL", "name": null, diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden index 71fe9498b1..567ff556de 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden @@ -333,7 +333,7 @@ { "kind": "SCALAR", "name": "Int", - "description": "The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -342,7 +342,7 @@ { "kind": "SCALAR", "name": "Float", - "description": "The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -351,7 +351,7 @@ { "kind": "SCALAR", "name": "String", - "description": "The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -360,7 +360,7 @@ { "kind": "SCALAR", "name": "Boolean", - "description": "The 'Boolean' scalar type represents 'true' or 'false' .", + "description": "The `Boolean` scalar type represents `true` or `false`.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -369,7 +369,7 @@ { "kind": "SCALAR", "name": "ID", - "description": "The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID.", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"4\") or integer (such as 4) input value will be accepted as an ID.", "inputFields": [], "interfaces": [], "possibleTypes": [], @@ -471,14 +471,14 @@ }, { "name": "specifiedBy", - "description": "", + "description": "Exposes a URL that specifies the behavior of this scalar", "locations": [ "SCALAR" ], "args": [ { "name": "url", - "description": "", + "description": "The URL that specifies the behavior of this scalar.", "type": { "kind": "NON_NULL", "name": null, From 7ae50089fe2e8312a0147fb047c470096b2b5308 Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 7 Apr 2026 20:38:55 +0300 Subject: [PATCH 78/79] fix introspection tests --- execution/engine/config_factory_proxy_test.go | 1 + execution/engine/engine_config_test.go | 31 ++++++++--- .../engine/testdata/full_introspection.json | 48 ++++++++++++++--- .../full_introspection_with_deprecated.json | 48 ++++++++++++++--- .../full_introspection_with_typenames.json | 53 ++++++++++++++++--- 5 files changed, 154 insertions(+), 27 deletions(-) diff --git a/execution/engine/config_factory_proxy_test.go b/execution/engine/config_factory_proxy_test.go index 4cddfef40f..ce7b2b0a16 100644 --- a/execution/engine/config_factory_proxy_test.go +++ b/execution/engine/config_factory_proxy_test.go @@ -132,6 +132,7 @@ func TestProxyEngineConfigFactory_EngineConfiguration(t *testing.T) { expectedConfig.SetFieldConfigurations(expectedFieldConfigs) sortFieldConfigurations(config.FieldConfigurations()) + assert.Equal(t, graphqlGeneratorFullSchema, string(config.Schema().RawSchema())) assert.Equal(t, expectedConfig, config) }) diff --git a/execution/engine/engine_config_test.go b/execution/engine/engine_config_test.go index db6427d70b..44bd8eeea8 100644 --- a/execution/engine/engine_config_test.go +++ b/execution/engine/engine_config_test.go @@ -358,19 +358,21 @@ type Language { __typename: String! } -"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." +"The ` + "`Int`" + ` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." scalar Int -"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." +"The ` + "`Float`" + ` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point)." scalar Float -"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." +"The ` + "`String`" + ` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." scalar String -"The 'Boolean' scalar type represents 'true' or 'false' ." +"The ` + "`Boolean` scalar type represents `true` or `false`." + `" scalar Boolean -"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID." +""" +The ` + "`ID`" + ` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID. +""" scalar ID "Directs the executor to include this field or fragment only when the argument is true." @@ -395,7 +397,9 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"Exposes a URL that specifies the behavior of this scalar" directive @specifiedBy( + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR @@ -406,6 +410,14 @@ All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT +"Directs the executor to defer this fragment when the if argument is true or undefined." +directive @defer( + "A unique identifier for the results." + label: String + "Controls whether the fragment will be deferred, usually via a variable." + if: Boolean! = true +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior @@ -571,4 +583,11 @@ enum __TypeKind { LIST "Indicates this type is a non-null. 'ofType' is a valid field." NON_NULL -}` +} + +directive @__defer_internal( + id: String! + parentDeferId: String + "A unique identifier for the results." + label: String +) repeatable on FIELD` diff --git a/execution/engine/testdata/full_introspection.json b/execution/engine/testdata/full_introspection.json index 8473834888..ee3242e238 100644 --- a/execution/engine/testdata/full_introspection.json +++ b/execution/engine/testdata/full_introspection.json @@ -573,7 +573,7 @@ { "kind": "SCALAR", "name": "Int", - "description": "The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", "fields": null, "inputFields": [], "interfaces": [], @@ -583,7 +583,7 @@ { "kind": "SCALAR", "name": "Float", - "description": "The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", "fields": null, "inputFields": [], "interfaces": [], @@ -593,7 +593,7 @@ { "kind": "SCALAR", "name": "String", - "description": "The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "fields": null, "inputFields": [], "interfaces": [], @@ -603,7 +603,7 @@ { "kind": "SCALAR", "name": "Boolean", - "description": "The 'Boolean' scalar type represents 'true' or 'false' .", + "description": "The `Boolean` scalar type represents `true` or `false`.", "fields": null, "inputFields": [], "interfaces": [], @@ -613,7 +613,7 @@ { "kind": "SCALAR", "name": "ID", - "description": "The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID.", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"4\") or integer (such as 4) input value will be accepted as an ID.", "fields": null, "inputFields": [], "interfaces": [], @@ -715,14 +715,14 @@ }, { "name": "specifiedBy", - "description": "", + "description": "Exposes a URL that specifies the behavior of this scalar", "locations": [ "SCALAR" ], "args": [ { "name": "url", - "description": "", + "description": "The URL that specifies the behavior of this scalar.", "type": { "kind": "NON_NULL", "name": null, @@ -743,6 +743,40 @@ "INPUT_OBJECT" ], "args": [] + }, + { + "name": "defer", + "description": "Directs the executor to defer this fragment when the if argument is true or undefined.", + "locations": [ + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "label", + "description": "A unique identifier for the results.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "if", + "description": "Controls whether the fragment will be deferred, usually via a variable.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": "true" + } + ] } ] } diff --git a/execution/engine/testdata/full_introspection_with_deprecated.json b/execution/engine/testdata/full_introspection_with_deprecated.json index 74f8fa552f..a885c3759e 100644 --- a/execution/engine/testdata/full_introspection_with_deprecated.json +++ b/execution/engine/testdata/full_introspection_with_deprecated.json @@ -597,7 +597,7 @@ { "kind": "SCALAR", "name": "Int", - "description": "The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", "fields": null, "inputFields": [], "interfaces": [], @@ -607,7 +607,7 @@ { "kind": "SCALAR", "name": "Float", - "description": "The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", "fields": null, "inputFields": [], "interfaces": [], @@ -617,7 +617,7 @@ { "kind": "SCALAR", "name": "String", - "description": "The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "fields": null, "inputFields": [], "interfaces": [], @@ -627,7 +627,7 @@ { "kind": "SCALAR", "name": "Boolean", - "description": "The 'Boolean' scalar type represents 'true' or 'false' .", + "description": "The `Boolean` scalar type represents `true` or `false`.", "fields": null, "inputFields": [], "interfaces": [], @@ -637,7 +637,7 @@ { "kind": "SCALAR", "name": "ID", - "description": "The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID.", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"4\") or integer (such as 4) input value will be accepted as an ID.", "fields": null, "inputFields": [], "interfaces": [], @@ -749,14 +749,14 @@ }, { "name": "specifiedBy", - "description": "", + "description": "Exposes a URL that specifies the behavior of this scalar", "locations": [ "SCALAR" ], "args": [ { "name": "url", - "description": "", + "description": "The URL that specifies the behavior of this scalar.", "type": { "kind": "NON_NULL", "name": null, @@ -777,6 +777,40 @@ "INPUT_OBJECT" ], "args": [] + }, + { + "name": "defer", + "description": "Directs the executor to defer this fragment when the if argument is true or undefined.", + "locations": [ + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "label", + "description": "A unique identifier for the results.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "if", + "description": "Controls whether the fragment will be deferred, usually via a variable.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": "true" + } + ] } ] } diff --git a/execution/engine/testdata/full_introspection_with_typenames.json b/execution/engine/testdata/full_introspection_with_typenames.json index 2eaf5e37e9..04017ea4f1 100644 --- a/execution/engine/testdata/full_introspection_with_typenames.json +++ b/execution/engine/testdata/full_introspection_with_typenames.json @@ -650,7 +650,7 @@ "__typename": "__Type", "kind": "SCALAR", "name": "Int", - "description": "The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", "fields": null, "inputFields": [], "interfaces": [], @@ -661,7 +661,7 @@ "__typename": "__Type", "kind": "SCALAR", "name": "Float", - "description": "The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", "fields": null, "inputFields": [], "interfaces": [], @@ -672,7 +672,7 @@ "__typename": "__Type", "kind": "SCALAR", "name": "String", - "description": "The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "fields": null, "inputFields": [], "interfaces": [], @@ -683,7 +683,7 @@ "__typename": "__Type", "kind": "SCALAR", "name": "Boolean", - "description": "The 'Boolean' scalar type represents 'true' or 'false' .", + "description": "The `Boolean` scalar type represents `true` or `false`.", "fields": null, "inputFields": [], "interfaces": [], @@ -694,7 +694,7 @@ "__typename": "__Type", "kind": "SCALAR", "name": "ID", - "description": "The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID.", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \"4\") or integer (such as 4) input value will be accepted as an ID.", "fields": null, "inputFields": [], "interfaces": [], @@ -809,7 +809,7 @@ { "__typename": "__Directive", "name": "specifiedBy", - "description": "", + "description": "Exposes a URL that specifies the behavior of this scalar", "locations": [ "SCALAR" ], @@ -817,7 +817,7 @@ { "__typename": "__InputValue", "name": "url", - "description": "", + "description": "The URL that specifies the behavior of this scalar.", "type": { "__typename": "__Type", "kind": "NON_NULL", @@ -840,6 +840,45 @@ "INPUT_OBJECT" ], "args": [] + }, + { + "__typename": "__Directive", + "name": "defer", + "description": "Directs the executor to defer this fragment when the if argument is true or undefined.", + "locations": [ + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "__typename": "__InputValue", + "name": "label", + "description": "A unique identifier for the results.", + "type": { + "__typename": "__Type", + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "__typename": "__InputValue", + "name": "if", + "description": "Controls whether the fragment will be deferred, usually via a variable.", + "type": { + "__typename": "__Type", + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": "true" + } + ] } ] } From 081cac6b40ade72233632d8d0ead8c8fe9ca77ad Mon Sep 17 00:00:00 2001 From: spetrunin Date: Tue, 7 Apr 2026 20:42:32 +0300 Subject: [PATCH 79/79] chore: gci --- v2/pkg/astnormalization/defer_ensure_typename_test.go | 2 +- v2/pkg/engine/plan/abstract_selection_rewriter_test.go | 6 +++--- v2/pkg/engine/plan/visitor.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/v2/pkg/astnormalization/defer_ensure_typename_test.go b/v2/pkg/astnormalization/defer_ensure_typename_test.go index 5b05f4d264..ee9945c428 100644 --- a/v2/pkg/astnormalization/defer_ensure_typename_test.go +++ b/v2/pkg/astnormalization/defer_ensure_typename_test.go @@ -88,4 +88,4 @@ func TestDeferEnsureTypename(t *testing.T) { }`) }) -} \ No newline at end of file +} diff --git a/v2/pkg/engine/plan/abstract_selection_rewriter_test.go b/v2/pkg/engine/plan/abstract_selection_rewriter_test.go index 344b674ffb..a4d156d2f4 100644 --- a/v2/pkg/engine/plan/abstract_selection_rewriter_test.go +++ b/v2/pkg/engine/plan/abstract_selection_rewriter_test.go @@ -4148,8 +4148,8 @@ func TestInterfaceSelectionRewriter_RewriteOperation(t *testing.T) { shouldRewrite: true, }, { - name: "union selection with deferred __typename - preserves defer directive on __typename after rewrite", - fieldName: "accounts", + name: "union selection with deferred __typename - preserves defer directive on __typename after rewrite", + fieldName: "accounts", definition: definition, upstreamDefinition: ` type User { @@ -4206,7 +4206,7 @@ func TestInterfaceSelectionRewriter_RewriteOperation(t *testing.T) { shouldRewrite: true, }, { - name: "interface selection with deferred __typename - preserves defer directive when shared field is copied into fragments", + name: "interface selection with deferred __typename - preserves defer directive when shared field is copied into fragments", definition: definition, upstreamDefinition: ` interface Node { diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 8f5ac44c99..2c6967090d 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -204,7 +204,7 @@ func (v *Visitor) AllowVisitor(kind astvisitor.VisitorKind, ref int, visitor any } shouldWalkFieldsOnPath := - // check if the field path has type condition and matches the enclosing type + // check if the field path has type condition and matches the enclosing type config.ShouldWalkFieldsOnPath(path, enclosingTypeName) || // check if the planner has path without type condition // this could happen in case of union type