diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go index eef14671ce..5d523446d7 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go @@ -102,8 +102,8 @@ type RPCMessage struct { Name string // Fields is a list of fields in the message Fields RPCFields - // FieldSelectionSet are field selections based on inline fragments - FieldSelectionSet RPCFieldSelectionSet + // FragmentFields are field selections based on inline fragments + FragmentFields RPCFieldSelectionSet // OneOfType indicates the type of the oneof field OneOfType OneOfType // MemberTypes provides the names of the types that are implemented by the Interface or Union @@ -275,6 +275,15 @@ func (r RPCFields) Exists(name, alias string) bool { return false } +// Last returns the last element of r or nil if r has no elements. +func (r RPCFields) Last() *RPCField { + if len(r) == 0 { + return nil + } + + return &r[len(r)-1] +} + func (r *RPCExecutionPlan) String() string { var result strings.Builder @@ -453,6 +462,45 @@ func (r *rpcPlanningContext) resolveRPCMethodMapping(operationType ast.Operation return rpcConfig, nil } +// enterNestedField handles descending into a nested response message +// when entering a selection set. inlineFragmentRef identifies the inline fragment +// that directly contains the field being descended into (ast.InvalidRef if none). +// Returns true if it descended into a nested message, false if there was nothing +// to descend into. +func (r *rpcPlanningContext) enterNestedField(info *planningInfo, enclosingTypeNode ast.Node, selectionSetRef, inlineFragmentRef int) bool { + lastField := r.lastResponseField(info.currentResponseMessage, inlineFragmentRef) + if lastField == nil { + return false + } + + if lastField.Message == nil { + lastField.Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) + } + + info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) + info.currentResponseMessage = lastField.Message + return true +} + +// lastResponseField returns a pointer to the last field (or fragment field) of the message, +// or nil if there are no fields. +func (r *rpcPlanningContext) lastResponseField(msg *RPCMessage, inlineFragmentRef int) *RPCField { + if inlineFragmentRef == ast.InvalidRef { + return msg.Fields.Last() + } + + inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(inlineFragmentRef) + return msg.FragmentFields[inlineFragmentName].Last() +} + +// leaveNestedField pops the response message ancestors when leaving a selection set. +func (r *rpcPlanningContext) leaveNestedField(info *planningInfo) { + if len(info.responseMessageAncestors) > 0 { + info.currentResponseMessage = info.responseMessageAncestors[len(info.responseMessageAncestors)-1] + info.responseMessageAncestors = info.responseMessageAncestors[:len(info.responseMessageAncestors)-1] + } +} + // newMessageFromSelectionSet creates a new message from the enclosing type node and the selection set reference. func (r *rpcPlanningContext) newMessageFromSelectionSet(enclosingTypeNode ast.Node, selectSetRef int) *RPCMessage { message := &RPCMessage{ @@ -769,11 +817,11 @@ func (r *rpcPlanningContext) buildFieldMessage(fieldTypeNode ast.Node, fieldRef return nil, err } - if message.FieldSelectionSet == nil { - message.FieldSelectionSet = make(RPCFieldSelectionSet) + if message.FragmentFields == nil { + message.FragmentFields = make(RPCFieldSelectionSet) } - message.FieldSelectionSet.Add(typeName, fields...) + message.FragmentFields.Add(typeName, fields...) } for _, fieldRef := range fieldRefs { @@ -1081,7 +1129,7 @@ func (r *rpcPlanningContext) buildFieldResolverTypeMessage(typeName string, reso // If the resolved field returns a composite type we need to handle the selection set for the inline fragment. if len(resolverField.fragmentSelections) > 0 { - message.FieldSelectionSet = make(RPCFieldSelectionSet, len(resolverField.fragmentSelections)) + message.FragmentFields = make(RPCFieldSelectionSet, len(resolverField.fragmentSelections)) for _, fragmentSelection := range resolverField.fragmentSelections { inlineFragmentTypeNode, found := r.definition.NodeByNameStr(fragmentSelection.typeName) @@ -1094,7 +1142,7 @@ func (r *rpcPlanningContext) buildFieldResolverTypeMessage(typeName string, reso return nil, err } - message.FieldSelectionSet[fragmentSelection.typeName] = fields + message.FragmentFields[fragmentSelection.typeName] = fields } } diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_composite_test.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_composite_test.go index 1066d47000..761b735e4c 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_composite_test.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_composite_test.go @@ -43,7 +43,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { "Cat", "Dog", }, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "meow_volume", @@ -102,7 +102,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { "Cat", "Dog", }, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "meow_volume", @@ -169,7 +169,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { "Cat", "Dog", }, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "meow_volume", @@ -237,7 +237,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { "Dog", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Animal": { { Name: "id", @@ -365,7 +365,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { "Category", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Product": { { Name: "id", @@ -449,7 +449,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { "Category", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Product": { { Name: "id", @@ -557,7 +557,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { "Category", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Product": { { Name: "id", @@ -591,6 +591,373 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, }, + { + name: "Should create an execution plan for a nested message inside an interface fragment with common fields", + query: "query CatOwnerQuery { randomPet { name kind ... on Cat { meowVolume owner { id name } } } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryRandomPet", + Request: RPCMessage{ + Name: "QueryRandomPetRequest", + }, + Response: RPCMessage{ + Name: "QueryRandomPetResponse", + Fields: []RPCField{ + { + Name: "random_pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "randomPet", + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "kind", + ProtoTypeName: DataTypeString, + JSONPath: "kind", + }, + }, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "meow_volume", + ProtoTypeName: DataTypeInt32, + JSONPath: "meowVolume", + }, + { + Name: "owner", + ProtoTypeName: DataTypeMessage, + JSONPath: "owner", + Message: &RPCMessage{ + Name: "Owner", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a nested message inside an interface fragment with common fields on both sides", + query: "query CatOwnerQuery { randomPet { name ... on Cat { meowVolume owner { id name } } kind } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryRandomPet", + Request: RPCMessage{ + Name: "QueryRandomPetRequest", + }, + Response: RPCMessage{ + Name: "QueryRandomPetResponse", + Fields: []RPCField{ + { + Name: "random_pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "randomPet", + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "kind", + ProtoTypeName: DataTypeString, + JSONPath: "kind", + }, + }, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "meow_volume", + ProtoTypeName: DataTypeInt32, + JSONPath: "meowVolume", + }, + { + Name: "owner", + ProtoTypeName: DataTypeMessage, + JSONPath: "owner", + Message: &RPCMessage{ + Name: "Owner", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a nested message inside an interface fragment without common fields", + query: "query CatBreedQuery { randomPet { ... on Cat { breed { name origin } } } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryRandomPet", + Request: RPCMessage{ + Name: "QueryRandomPetRequest", + }, + Response: RPCMessage{ + Name: "QueryRandomPetResponse", + Fields: []RPCField{ + { + Name: "random_pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "randomPet", + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: RPCFields{}, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "breed", + ProtoTypeName: DataTypeMessage, + JSONPath: "breed", + Message: &RPCMessage{ + Name: "CatBreed", + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "origin", + ProtoTypeName: DataTypeString, + JSONPath: "origin", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a deeply nested message inside an inline fragment", + query: "query CatBreedCharacteristicsQuery { randomPet { ... on Cat { breed { characteristics { temperament } } } } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryRandomPet", + Request: RPCMessage{ + Name: "QueryRandomPetRequest", + }, + Response: RPCMessage{ + Name: "QueryRandomPetResponse", + Fields: []RPCField{ + { + Name: "random_pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "randomPet", + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: RPCFields{}, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "breed", + ProtoTypeName: DataTypeMessage, + JSONPath: "breed", + Message: &RPCMessage{ + Name: "CatBreed", + Fields: []RPCField{ + { + Name: "characteristics", + ProtoTypeName: DataTypeMessage, + JSONPath: "characteristics", + Message: &RPCMessage{ + Name: "BreedCharacteristics", + Fields: []RPCField{ + { + Name: "temperament", + ProtoTypeName: DataTypeString, + JSONPath: "temperament", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for nested inline fragments through an intermediate regular message", + query: "query OwnerPetQuery { randomPet { ... on Cat { owner { name pet { ... on Cat { breed { name origin } } ... on Dog { barkVolume } } } } } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryRandomPet", + Request: RPCMessage{ + Name: "QueryRandomPetRequest", + }, + Response: RPCMessage{ + Name: "QueryRandomPetResponse", + Fields: []RPCField{ + { + Name: "random_pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "randomPet", + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: RPCFields{}, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "owner", + ProtoTypeName: DataTypeMessage, + JSONPath: "owner", + Message: &RPCMessage{ + Name: "Owner", + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "pet", + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: RPCFields{}, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "breed", + ProtoTypeName: DataTypeMessage, + JSONPath: "breed", + Message: &RPCMessage{ + Name: "CatBreed", + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "origin", + ProtoTypeName: DataTypeString, + JSONPath: "origin", + }, + }, + }, + }, + }, + "Dog": { + { + Name: "bark_volume", + ProtoTypeName: DataTypeInt32, + JSONPath: "barkVolume", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, } for _, tt := range tests { @@ -683,7 +1050,7 @@ func TestMutationUnionExecutionPlan(t *testing.T) { "ActionError", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -765,7 +1132,7 @@ func TestMutationUnionExecutionPlan(t *testing.T) { "ActionError", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -835,7 +1202,7 @@ func TestMutationUnionExecutionPlan(t *testing.T) { "ActionError", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionError": { { Name: "message", diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_federation_test.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_federation_test.go index d858b173fb..e2cde3c829 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_federation_test.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_federation_test.go @@ -1598,7 +1598,7 @@ func TestEntityLookupWithFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "Animal", OneOfType: OneOfTypeInterface, MemberTypes: []string{"Cat", "Dog"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "name", @@ -1781,7 +1781,7 @@ func TestEntityLookupWithFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "ActionResult", OneOfType: OneOfTypeUnion, MemberTypes: []string{"ActionSuccess", "ActionError"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -1987,7 +1987,7 @@ func TestEntityLookupWithFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "Animal", OneOfType: OneOfTypeInterface, MemberTypes: []string{"Cat", "Dog"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "name", @@ -2023,7 +2023,7 @@ func TestEntityLookupWithFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "ActionResult", OneOfType: OneOfTypeUnion, MemberTypes: []string{"ActionSuccess", "ActionError"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -2094,6 +2094,894 @@ func TestEntityLookupWithFieldResolvers_WithCompositeTypes(t *testing.T) { } } +var nestedInlineFragmentFederationSchema = testFederationSchemaString(` + type Query { + _entities(representations: [_Any!]!): [_Entity]! + } + type User @key(fields: "id") { + id: ID! + name: String! + pet: Animal + } + interface Animal { + id: ID! + name: String! + kind: String! + } + type Cat implements Animal { + id: ID! + name: String! + kind: String! + meowVolume: Int! + owner: Owner! + breed: CatBreed! + } + type Dog implements Animal { + id: ID! + name: String! + kind: String! + barkVolume: Int! + } + type Owner { + id: ID! + name: String! + pet: Animal! + } + type CatBreed { + id: ID! + name: String! + origin: String! + characteristics: BreedCharacteristics! + } + type BreedCharacteristics { + temperament: String! + } + `, []string{"User"}) + +var nestedInlineFragmentFederationMapping = &GRPCMapping{ + Service: "Products", + EntityRPCs: map[string][]EntityRPCConfig{ + "User": { + { + Key: "id", + RPCConfig: RPCConfig{ + RPC: "LookupUserById", + Request: "LookupUserByIdRequest", + Response: "LookupUserByIdResponse", + }, + }, + }, + }, + Fields: map[string]FieldMap{ + "Cat": { + "meowVolume": {TargetName: "meow_volume"}, + }, + "Dog": { + "barkVolume": {TargetName: "bark_volume"}, + }, + }, +} + +var nestedInlineFragmentFederationConfigs = plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, +} + +func TestEntityLookupWithNestedInlineFragments(t *testing.T) { + t.Parallel() + tests := []struct { + name string + query string + schema string + expectedPlan *RPCExecutionPlan + mapping *GRPCMapping + federationConfigs plan.FederationFieldConfigurations + }{ + { + name: "Should create an execution plan for a nested message inside an entity with interface fragment and common fields", + query: `query EntityLookup($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { __typename id name pet { name kind ... on Cat { meowVolume owner { id name } } } } } }`, + schema: nestedInlineFragmentFederationSchema, + mapping: nestedInlineFragmentFederationMapping, + federationConfigs: nestedInlineFragmentFederationConfigs, + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "LookupUserById", + Kind: CallKindEntity, + Request: RPCMessage{ + Name: "LookupUserByIdRequest", + Fields: []RPCField{ + { + Name: "keys", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "representations", + Message: &RPCMessage{ + Name: "LookupUserByIdKey", + MemberTypes: []string{"User"}, + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + }, + }, + }, + }, + }, + Response: RPCMessage{ + Name: "LookupUserByIdResponse", + Fields: []RPCField{ + { + Name: "result", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "_entities", + Message: &RPCMessage{ + Name: "User", + Fields: []RPCField{ + { + Name: "__typename", + ProtoTypeName: DataTypeString, + JSONPath: "__typename", + StaticValue: "User", + }, + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "pet", + Optional: true, + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "kind", + ProtoTypeName: DataTypeString, + JSONPath: "kind", + }, + }, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "meow_volume", + ProtoTypeName: DataTypeInt32, + JSONPath: "meowVolume", + }, + { + Name: "owner", + ProtoTypeName: DataTypeMessage, + JSONPath: "owner", + Message: &RPCMessage{ + Name: "Owner", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a nested message inside an entity with interface fragment without common fields", + query: `query EntityLookup($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { __typename id pet { ... on Cat { breed { name origin } } } } } }`, + schema: nestedInlineFragmentFederationSchema, + mapping: nestedInlineFragmentFederationMapping, + federationConfigs: nestedInlineFragmentFederationConfigs, + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "LookupUserById", + Kind: CallKindEntity, + Request: RPCMessage{ + Name: "LookupUserByIdRequest", + Fields: []RPCField{ + { + Name: "keys", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "representations", + Message: &RPCMessage{ + Name: "LookupUserByIdKey", + MemberTypes: []string{"User"}, + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + }, + }, + }, + }, + }, + Response: RPCMessage{ + Name: "LookupUserByIdResponse", + Fields: []RPCField{ + { + Name: "result", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "_entities", + Message: &RPCMessage{ + Name: "User", + Fields: []RPCField{ + { + Name: "__typename", + ProtoTypeName: DataTypeString, + JSONPath: "__typename", + StaticValue: "User", + }, + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "pet", + Optional: true, + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: RPCFields{}, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "breed", + ProtoTypeName: DataTypeMessage, + JSONPath: "breed", + Message: &RPCMessage{ + Name: "CatBreed", + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "origin", + ProtoTypeName: DataTypeString, + JSONPath: "origin", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a deeply nested message inside an entity with inline fragment", + query: `query EntityLookup($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { __typename id pet { ... on Cat { breed { characteristics { temperament } } } } } } }`, + schema: nestedInlineFragmentFederationSchema, + mapping: nestedInlineFragmentFederationMapping, + federationConfigs: nestedInlineFragmentFederationConfigs, + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "LookupUserById", + Kind: CallKindEntity, + Request: RPCMessage{ + Name: "LookupUserByIdRequest", + Fields: []RPCField{ + { + Name: "keys", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "representations", + Message: &RPCMessage{ + Name: "LookupUserByIdKey", + MemberTypes: []string{"User"}, + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + }, + }, + }, + }, + }, + Response: RPCMessage{ + Name: "LookupUserByIdResponse", + Fields: []RPCField{ + { + Name: "result", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "_entities", + Message: &RPCMessage{ + Name: "User", + Fields: []RPCField{ + { + Name: "__typename", + ProtoTypeName: DataTypeString, + JSONPath: "__typename", + StaticValue: "User", + }, + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "pet", + Optional: true, + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: RPCFields{}, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "breed", + ProtoTypeName: DataTypeMessage, + JSONPath: "breed", + Message: &RPCMessage{ + Name: "CatBreed", + Fields: []RPCField{ + { + Name: "characteristics", + ProtoTypeName: DataTypeMessage, + JSONPath: "characteristics", + Message: &RPCMessage{ + Name: "BreedCharacteristics", + Fields: []RPCField{ + { + Name: "temperament", + ProtoTypeName: DataTypeString, + JSONPath: "temperament", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for nested inline fragments through an intermediate regular message in entity", + query: `query EntityLookup($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { __typename id pet { ... on Cat { owner { name pet { ... on Cat { breed { name origin } } ... on Dog { barkVolume } } } } } } } }`, + schema: nestedInlineFragmentFederationSchema, + mapping: nestedInlineFragmentFederationMapping, + federationConfigs: nestedInlineFragmentFederationConfigs, + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "LookupUserById", + Kind: CallKindEntity, + Request: RPCMessage{ + Name: "LookupUserByIdRequest", + Fields: []RPCField{ + { + Name: "keys", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "representations", + Message: &RPCMessage{ + Name: "LookupUserByIdKey", + MemberTypes: []string{"User"}, + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + }, + }, + }, + }, + }, + Response: RPCMessage{ + Name: "LookupUserByIdResponse", + Fields: []RPCField{ + { + Name: "result", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "_entities", + Message: &RPCMessage{ + Name: "User", + Fields: []RPCField{ + { + Name: "__typename", + ProtoTypeName: DataTypeString, + JSONPath: "__typename", + StaticValue: "User", + }, + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "pet", + Optional: true, + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: RPCFields{}, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "owner", + ProtoTypeName: DataTypeMessage, + JSONPath: "owner", + Message: &RPCMessage{ + Name: "Owner", + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "pet", + ProtoTypeName: DataTypeMessage, + JSONPath: "pet", + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + Fields: RPCFields{}, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "breed", + ProtoTypeName: DataTypeMessage, + JSONPath: "breed", + Message: &RPCMessage{ + Name: "CatBreed", + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + { + Name: "origin", + ProtoTypeName: DataTypeString, + JSONPath: "origin", + }, + }, + }, + }, + }, + "Dog": { + { + Name: "bark_volume", + ProtoTypeName: DataTypeInt32, + JSONPath: "barkVolume", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + runFederationTest(t, tt) + } +} + +// complexResolverInNestedMessageFederationSchema defines an entity Product that has a +// regular (non-resolver) nested field "specs: ProductSpecs!". ProductSpecs contains a +// resolver field "relatedProduct" that returns another Product (complex return type), +// followed by a plain scalar field "dimensions". This combination is used to reproduce +// the bug where LeaveSelectionSet incorrectly pops the responseMessageAncestors stack +// for the resolver's selection set, causing "dimensions" to land in the wrong message. +var complexResolverInNestedMessageFederationSchema = ` +scalar connect__FieldSet +directive @connect__fieldResolver(context: connect__FieldSet!) on FIELD_DEFINITION + +schema { + query: Query +} + +type Query { + _entities(representations: [_Any!]!): [_Entity]! +} + +type Product @key(fields: "id") { + id: ID! + name: String! + specs: ProductSpecs! +} + +type ProductSpecs { + id: ID! + weight: Float! + relatedProduct(category: String!): Product @connect__fieldResolver(context: "id") + dimensions: String! +} + +union _Entity = Product +scalar _Any +` + +var complexResolverInNestedMessageFederationMapping = &GRPCMapping{ + Service: "Products", + EntityRPCs: map[string][]EntityRPCConfig{ + "Product": { + { + Key: "id", + RPCConfig: RPCConfig{ + RPC: "LookupProductById", + Request: "LookupProductByIdRequest", + Response: "LookupProductByIdResponse", + }, + }, + }, + }, + ResolveRPCs: RPCConfigMap[ResolveRPCMapping]{ + "ProductSpecs": { + "relatedProduct": ResolveRPCTypeField{ + FieldMappingData: FieldMapData{ + TargetName: "related_product", + ArgumentMappings: FieldArgumentMap{ + "category": "category", + }, + }, + RPC: "ResolveProductSpecsRelatedProduct", + Request: "ResolveProductSpecsRelatedProductRequest", + Response: "ResolveProductSpecsRelatedProductResponse", + }, + }, + }, + Fields: map[string]FieldMap{ + "Product": { + "id": {TargetName: "id"}, + "name": {TargetName: "name"}, + "specs": {TargetName: "specs"}, + }, + "ProductSpecs": { + "id": {TargetName: "id"}, + "weight": {TargetName: "weight"}, + "dimensions": {TargetName: "dimensions"}, + "relatedProduct": { + TargetName: "related_product", + ArgumentMappings: FieldArgumentMap{ + "category": "category", + }, + }, + }, + }, +} + +var complexResolverInNestedMessageFederationConfigs = plan.FederationFieldConfigurations{ + { + TypeName: "Product", + SelectionSet: "id", + }, +} + +// TestEntityLookupWithFieldResolvers_ComplexResolverInNestedMessage tests that fields +// following a complex-return-type resolver inside a nested message of an entity are placed +// into the correct parent message. This is a regression test for a bug where +// LeaveSelectionSet in the federation visitor incorrectly called leaveNestedField for +// a resolver field whose selection set never called enterNestedField. +// +// With the bug, the "dimensions" field that comes after "relatedProduct" in the +// "specs" selection set ends up in Product.Fields instead of ProductSpecs.Fields. +func TestEntityLookupWithFieldResolvers_ComplexResolverInNestedMessage(t *testing.T) { + t.Parallel() + + query := `query EntityLookup($representations: [_Any!]!, $category: String!) { + _entities(representations: $representations) { + ... on Product { + __typename + id + specs { + weight + relatedProduct(category: $category) { + id + name + } + dimensions + } + } + } + }` + + expectedPlan := &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "LookupProductById", + Kind: CallKindEntity, + Request: RPCMessage{ + Name: "LookupProductByIdRequest", + Fields: []RPCField{ + { + Name: "keys", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "representations", + Message: &RPCMessage{ + Name: "LookupProductByIdKey", + MemberTypes: []string{"Product"}, + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + }, + }, + }, + }, + }, + Response: RPCMessage{ + Name: "LookupProductByIdResponse", + Fields: []RPCField{ + { + Name: "result", + ProtoTypeName: DataTypeMessage, + Repeated: true, + JSONPath: "_entities", + Message: &RPCMessage{ + Name: "Product", + Fields: []RPCField{ + { + Name: "__typename", + ProtoTypeName: DataTypeString, + JSONPath: "__typename", + StaticValue: "Product", + }, + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "specs", + ProtoTypeName: DataTypeMessage, + JSONPath: "specs", + // Both "weight" and "dimensions" must be in ProductSpecs.Fields. + // The bug causes "dimensions" to be placed in Product.Fields + // instead, because LeaveSelectionSet for the relatedProduct + // resolver selection set incorrectly pops the ProductSpecs + // message off responseMessageAncestors. + Message: &RPCMessage{ + Name: "ProductSpecs", + Fields: []RPCField{ + { + Name: "weight", + ProtoTypeName: DataTypeDouble, + JSONPath: "weight", + }, + { + Name: "dimensions", + ProtoTypeName: DataTypeString, + JSONPath: "dimensions", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + ID: 1, + DependentCalls: []int{0}, + ServiceName: "Products", + MethodName: "ResolveProductSpecsRelatedProduct", + Kind: CallKindResolve, + ResponsePath: buildPath("_entities.specs.relatedProduct"), + Request: RPCMessage{ + Name: "ResolveProductSpecsRelatedProductRequest", + Fields: []RPCField{ + { + Name: "context", + ProtoTypeName: DataTypeMessage, + Repeated: true, + Message: &RPCMessage{ + Name: "ResolveProductSpecsRelatedProductContext", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + ResolvePath: buildPath("result.specs.id"), + }, + }, + }, + }, + { + Name: "field_args", + ProtoTypeName: DataTypeMessage, + Message: &RPCMessage{ + Name: "ResolveProductSpecsRelatedProductArgs", + Fields: []RPCField{ + { + Name: "category", + ProtoTypeName: DataTypeString, + JSONPath: "category", + }, + }, + }, + }, + }, + }, + Response: RPCMessage{ + Name: "ResolveProductSpecsRelatedProductResponse", + Fields: []RPCField{ + { + Name: "result", + ProtoTypeName: DataTypeMessage, + JSONPath: "result", + Repeated: true, + Message: &RPCMessage{ + Name: "ResolveProductSpecsRelatedProductResult", + Fields: []RPCField{ + { + Name: "related_product", + ProtoTypeName: DataTypeMessage, + JSONPath: "relatedProduct", + Optional: true, + Message: &RPCMessage{ + Name: "Product", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + t.Run("Should place fields after a complex resolver correctly in the parent message", func(t *testing.T) { + t.Parallel() + + definition := unsafeparser.ParseGraphqlDocumentStringWithBaseSchema(complexResolverInNestedMessageFederationSchema) + report := operationreport.Report{} + astvalidation.DefaultDefinitionValidator().Validate(&definition, &report) + if report.HasErrors() { + t.Fatalf("failed to validate schema: %s", report.Error()) + } + + operation, report := astparser.ParseGraphqlDocumentString(query) + if report.HasErrors() { + t.Fatalf("failed to parse query: %s", report.Error()) + } + + planner, err := NewPlanner("Products", complexResolverInNestedMessageFederationMapping, complexResolverInNestedMessageFederationConfigs) + if err != nil { + t.Fatalf("failed to create planner: %s", err) + } + plan, err := planner.PlanOperation(&operation, &definition) + if err != nil { + t.Fatalf("failed to plan operation: %s", err) + } + + diff := cmp.Diff(expectedPlan, plan) + if diff != "" { + t.Fatalf("execution plan mismatch: %s", diff) + } + }) +} + func runFederationTest(t *testing.T, tt struct { name string query string diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_field_resolvers_test.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_field_resolvers_test.go index 43ca958dc4..a2c8fc64f3 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_field_resolvers_test.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_field_resolvers_test.go @@ -2966,7 +2966,7 @@ func TestExecutionPlanFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "Animal", OneOfType: OneOfTypeInterface, MemberTypes: []string{"Cat", "Dog"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "name", @@ -3087,7 +3087,7 @@ func TestExecutionPlanFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "ActionResult", OneOfType: OneOfTypeUnion, MemberTypes: []string{"ActionSuccess", "ActionError"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -3247,7 +3247,7 @@ func TestExecutionPlanFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "Animal", OneOfType: OneOfTypeInterface, MemberTypes: []string{"Cat", "Dog"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "name", @@ -3410,7 +3410,7 @@ func TestExecutionPlanFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "ActionResult", OneOfType: OneOfTypeUnion, MemberTypes: []string{"ActionSuccess", "ActionError"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -3573,7 +3573,7 @@ func TestExecutionPlanFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "Animal", OneOfType: OneOfTypeInterface, MemberTypes: []string{"Cat", "Dog"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "name", @@ -3609,7 +3609,7 @@ func TestExecutionPlanFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "ActionResult", OneOfType: OneOfTypeUnion, MemberTypes: []string{"ActionSuccess", "ActionError"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -3772,7 +3772,7 @@ func TestExecutionPlanFieldResolvers_WithCompositeTypes(t *testing.T) { Name: "Animal", OneOfType: OneOfTypeInterface, MemberTypes: []string{"Cat", "Dog"}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "id", @@ -4053,10 +4053,10 @@ func TestExecutionPlanFieldResolvers_CustomSchemas(t *testing.T) { ProtoTypeName: DataTypeMessage, JSONPath: "fooResolver", Message: &RPCMessage{ - Name: "Bar", - FieldSelectionSet: RPCFieldSelectionSet{"Baz": {}}, - OneOfType: OneOfTypeInterface, - MemberTypes: []string{"Baz"}, + Name: "Bar", + FragmentFields: RPCFieldSelectionSet{"Baz": {}}, + OneOfType: OneOfTypeInterface, + MemberTypes: []string{"Baz"}, }, }, }, @@ -4377,6 +4377,184 @@ func TestExecutionPlanFieldResolvers_CustomSchemas(t *testing.T) { } } +// TestExecutionPlanFieldResolvers_ComplexResolverInNestedMessage tests that fields +// following a complex-return-type resolver inside a nested message are placed into +// the correct parent message. This is a regression test for a bug where +// LeaveSelectionSet incorrectly calls leaveNestedField for resolver fields whose +// selection set never called enterNestedField, corrupting the responseMessageAncestors +// stack and causing subsequent sibling fields to be added to the wrong message. +func TestExecutionPlanFieldResolvers_ComplexResolverInNestedMessage(t *testing.T) { + t.Parallel() + + // The query fetches categories -> subcategories, where inside the subcategory + // selection there is a complex-return-type field resolver (featuredCategory) + // followed by a regular scalar field (name). + // + // With the bug, LeaveSelectionSet for featuredCategory's selection set pops + // the Subcategory message off responseMessageAncestors even though + // enterNestedField was never called for it. As a result, the "name" field that + // comes after featuredCategory ends up in Category.Fields instead of + // Subcategory.Fields. + runTest(t, testCase{ + query: `query SubcategoryComplexResolverBug($includeChildren: Boolean!) { + categories { + id + subcategories { + id + featuredCategory(includeChildren: $includeChildren) { + id + name + } + name + } + } + }`, + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryCategories", + Request: RPCMessage{ + Name: "QueryCategoriesRequest", + }, + Response: RPCMessage{ + Name: "QueryCategoriesResponse", + Fields: []RPCField{ + { + Name: "categories", + ProtoTypeName: DataTypeMessage, + JSONPath: "categories", + Repeated: true, + Message: &RPCMessage{ + Name: "Category", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "subcategories", + ProtoTypeName: DataTypeMessage, + JSONPath: "subcategories", + IsListType: true, + Optional: true, + ListMetadata: &ListMetadata{ + NestingLevel: 1, + LevelInfo: []LevelInfo{{Optional: true}}, + }, + // Both "id" and "name" must be in Subcategory.Fields. + // The bug causes "name" to be placed in Category.Fields + // instead, because LeaveSelectionSet for the + // featuredCategory resolver selection set incorrectly + // pops the Subcategory message from the ancestor stack. + Message: &RPCMessage{ + Name: "Subcategory", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + ID: 1, + DependentCalls: []int{0}, + ServiceName: "Products", + MethodName: "ResolveSubcategoryFeaturedCategory", + Kind: CallKindResolve, + ResponsePath: buildPath("categories.subcategories.featuredCategory"), + Request: RPCMessage{ + Name: "ResolveSubcategoryFeaturedCategoryRequest", + Fields: []RPCField{ + { + Name: "context", + ProtoTypeName: DataTypeMessage, + Repeated: true, + Message: &RPCMessage{ + Name: "ResolveSubcategoryFeaturedCategoryContext", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + ResolvePath: buildPath("categories.@subcategories.id"), + }, + }, + }, + }, + { + Name: "field_args", + ProtoTypeName: DataTypeMessage, + Message: &RPCMessage{ + Name: "ResolveSubcategoryFeaturedCategoryArgs", + Fields: []RPCField{ + { + Name: "include_children", + ProtoTypeName: DataTypeBool, + JSONPath: "includeChildren", + }, + }, + }, + }, + }, + }, + Response: RPCMessage{ + Name: "ResolveSubcategoryFeaturedCategoryResponse", + Fields: []RPCField{ + { + Name: "result", + ProtoTypeName: DataTypeMessage, + JSONPath: "result", + Repeated: true, + Message: &RPCMessage{ + Name: "ResolveSubcategoryFeaturedCategoryResult", + Fields: []RPCField{ + { + Name: "featured_category", + ProtoTypeName: DataTypeMessage, + JSONPath: "featuredCategory", + Optional: true, + Message: &RPCMessage{ + Name: "Category", + Fields: []RPCField{ + { + Name: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) +} + func schemaWithNestedResolverAndCompositeType(t *testing.T) ast.Document { schema := ` diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_test.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_test.go index a80896fb89..074c2967de 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_test.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_test.go @@ -1808,7 +1808,7 @@ func TestProductExecutionPlanWithAliases(t *testing.T) { "Cat", "Dog", }, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "meow_volume", @@ -1882,7 +1882,7 @@ func TestProductExecutionPlanWithAliases(t *testing.T) { "Category", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Product": { { Name: "id", @@ -2382,7 +2382,7 @@ func TestProductExecutionPlanWithAliases(t *testing.T) { "Cat", "Dog", }, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "meow_volume", @@ -2471,7 +2471,7 @@ func TestProductExecutionPlanWithAliases(t *testing.T) { "Category", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Product": { { Name: "id", diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go index 85f1878dd2..f04d44f47a 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -220,22 +220,20 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { return } - // If we don't have any fields or selecting on a field, we can return. - if len(r.planInfo.currentResponseMessage.Fields) == 0 || r.walker.Ancestor().Kind != ast.NodeKindField { + // If we don't select on a field, we can return. + if r.walker.Ancestor().Kind != ast.NodeKindField { return } - lastIndex := len(r.planInfo.currentResponseMessage.Fields) - 1 - - // In nested selection sets, a new message needs to be created, which will be added to the current response message. - if r.planInfo.currentResponseMessage.Fields[lastIndex].Message == nil { - r.planInfo.currentResponseMessage.Fields[lastIndex].Message = r.planCtx.newMessageFromSelectionSet(r.walker.EnclosingTypeDefinition, ref) + // Determine which inline fragment directly contains the field we are about + // to descend into. When entering a field's selection set, the walker Ancestors + // are: [..., (maybe inline fragment), parent selection set, field]. + // Ancestors[-3] is therefore the inline fragment directly wrapping the field, if any. + inlineFragmentRef := inlineFragmentRefFromAncestors(r.walker.Ancestors) + if !r.planCtx.enterNestedField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref, inlineFragmentRef) { + return } - // Add the current response message to the ancestors and set the current response message to the current field message - r.planInfo.responseMessageAncestors = append(r.planInfo.responseMessageAncestors, r.planInfo.currentResponseMessage) - r.planInfo.currentResponseMessage = r.planInfo.currentResponseMessage.Fields[lastIndex].Message - // Check if the ancestor type is a composite type (interface or union) // and set the oneof type and member types. if err := r.handleCompositeType(r.walker.Ancestor()); err != nil { @@ -286,14 +284,14 @@ func (r *rpcPlanVisitor) handleCompositeType(node ast.Node) error { // It updates the current response field index and response message ancestors. // If the ancestor is an operation definition, it adds the current call to the group. func (r *rpcPlanVisitor) LeaveSelectionSet(ref int) { + if r.fieldResolverAncestors.len() > 0 && r.walker.Ancestor().Kind == ast.NodeKindField { + return + } if r.walker.Ancestor().Kind == ast.NodeKindInlineFragment { return } - if len(r.planInfo.responseMessageAncestors) > 0 { - r.planInfo.currentResponseMessage = r.planInfo.responseMessageAncestors[len(r.planInfo.responseMessageAncestors)-1] - r.planInfo.responseMessageAncestors = r.planInfo.responseMessageAncestors[:len(r.planInfo.responseMessageAncestors)-1] - } + r.planCtx.leaveNestedField(&r.planInfo) } func (r *rpcPlanVisitor) handleRootField(isRootField bool, ref int) error { @@ -385,12 +383,12 @@ func (r *rpcPlanVisitor) EnterField(ref int) { // check if we are inside of an inline fragment if ref, ok := r.walker.ResolveInlineFragment(); ok { - if r.planInfo.currentResponseMessage.FieldSelectionSet == nil { - r.planInfo.currentResponseMessage.FieldSelectionSet = make(RPCFieldSelectionSet) + if r.planInfo.currentResponseMessage.FragmentFields == nil { + r.planInfo.currentResponseMessage.FragmentFields = make(RPCFieldSelectionSet) } inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(ref) - r.planInfo.currentResponseMessage.FieldSelectionSet.Add(inlineFragmentName, field) + r.planInfo.currentResponseMessage.FragmentFields.Add(inlineFragmentName, field) return } diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor_federation.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor_federation.go index 2481dc224f..f7553c782c 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor_federation.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor_federation.go @@ -162,7 +162,6 @@ func (r *rpcPlanVisitorFederation) EnterInlineFragment(ref int) { // LeaveInlineFragment implements astvisitor.InlineFragmentVisitor. func (r *rpcPlanVisitorFederation) LeaveInlineFragment(ref int) { if r.entityInfo.entityInlineFragmentRef != ref { - // We only handle the entity inline fragment return } @@ -209,21 +208,21 @@ func (r *rpcPlanVisitorFederation) EnterSelectionSet(ref int) { return } - if r.planInfo.currentRequestMessage == nil || len(r.planInfo.currentResponseMessage.Fields) == 0 || r.walker.Ancestor().Kind != ast.NodeKindField { + if r.planInfo.currentRequestMessage == nil || r.walker.Ancestor().Kind != ast.NodeKindField { return } - // We ignore selection sets from inline fragments or fragment spreads. - lastIndex := len(r.planInfo.currentResponseMessage.Fields) - 1 - - // In nested selection sets, a new message needs to be created, which will be added to the current response message. - if r.planInfo.currentResponseMessage.Fields[lastIndex].Message == nil { - r.planInfo.currentResponseMessage.Fields[lastIndex].Message = r.planCtx.newMessageFromSelectionSet(r.walker.EnclosingTypeDefinition, ref) + // Determine which inline fragment directly contains the field we are about + // to descend into, excluding the entity inline fragment whose fields are + // treated as regular (non-fragment) fields. + inlineFragmentRef := inlineFragmentRefFromAncestors(r.walker.Ancestors) + if inlineFragmentRef == r.entityInfo.entityInlineFragmentRef { + inlineFragmentRef = ast.InvalidRef } - // Add the current response message to the ancestors and set the current response message to the current field message - r.planInfo.responseMessageAncestors = append(r.planInfo.responseMessageAncestors, r.planInfo.currentResponseMessage) - r.planInfo.currentResponseMessage = r.planInfo.currentResponseMessage.Fields[lastIndex].Message + if !r.planCtx.enterNestedField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref, inlineFragmentRef) { + return + } // Check if the ancestor type is a composite type (interface or union) // and set the oneof type and member types. @@ -233,7 +232,6 @@ func (r *rpcPlanVisitorFederation) EnterSelectionSet(ref int) { r.walker.StopWithInternalErr(err) return } - } func (r *rpcPlanVisitorFederation) handleCompositeType(node ast.Node) error { @@ -274,14 +272,14 @@ func (r *rpcPlanVisitorFederation) handleCompositeType(node ast.Node) error { // LeaveSelectionSet implements astvisitor.SelectionSetVisitor. func (r *rpcPlanVisitorFederation) LeaveSelectionSet(ref int) { + if r.fieldResolverAncestors.len() > 0 && r.walker.Ancestor().Kind == ast.NodeKindField { + return + } if r.walker.Ancestor().Kind == ast.NodeKindInlineFragment { return } - if len(r.planInfo.responseMessageAncestors) > 0 { - r.planInfo.currentResponseMessage = r.planInfo.responseMessageAncestors[len(r.planInfo.responseMessageAncestors)-1] - r.planInfo.responseMessageAncestors = r.planInfo.responseMessageAncestors[:len(r.planInfo.responseMessageAncestors)-1] - } + r.planCtx.leaveNestedField(&r.planInfo) } // EnterField implements astvisitor.FieldVisitor. @@ -351,12 +349,12 @@ func (r *rpcPlanVisitorFederation) EnterField(ref int) { // check if we are inside of an inline fragment and not the entity inline fragment if ref, ok := r.walker.ResolveInlineFragment(); ok && r.entityInfo.entityInlineFragmentRef != ref { - if r.planInfo.currentResponseMessage.FieldSelectionSet == nil { - r.planInfo.currentResponseMessage.FieldSelectionSet = make(RPCFieldSelectionSet) + if r.planInfo.currentResponseMessage.FragmentFields == nil { + r.planInfo.currentResponseMessage.FragmentFields = make(RPCFieldSelectionSet) } inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(ref) - r.planInfo.currentResponseMessage.FieldSelectionSet.Add(inlineFragmentName, field) + r.planInfo.currentResponseMessage.FragmentFields.Add(inlineFragmentName, field) return } diff --git a/v2/pkg/engine/datasource/grpc_datasource/json_builder.go b/v2/pkg/engine/datasource/grpc_datasource/json_builder.go index 95475720c2..8e604a03d2 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/json_builder.go +++ b/v2/pkg/engine/datasource/grpc_datasource/json_builder.go @@ -350,7 +350,7 @@ func (j *jsonBuilder) marshalResponseJSON(message *RPCMessage, data protoref.Mes validFields := message.Fields if message.IsOneOf() { // For oneOf types, add type-specific fields based on the actual concrete type - validFields = append(validFields, message.FieldSelectionSet.SelectFieldsForTypes(message.SelectValidTypes(string(data.Type().Descriptor().Name())))...) + validFields = append(validFields, message.FragmentFields.SelectFieldsForTypes(message.SelectValidTypes(string(data.Type().Descriptor().Name())))...) } // Process each field in the message diff --git a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go index 8b36b7b98f..949457a89b 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go +++ b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go @@ -390,6 +390,17 @@ func testMapping() *GRPCMapping { Request: "ResolveSubcategoryItemCountRequest", Response: "ResolveSubcategoryItemCountResponse", }, + "featuredCategory": { + FieldMappingData: FieldMapData{ + TargetName: "featured_category", + ArgumentMappings: FieldArgumentMap{ + "includeChildren": "include_children", + }, + }, + RPC: "ResolveSubcategoryFeaturedCategory", + Request: "ResolveSubcategoryFeaturedCategoryRequest", + Response: "ResolveSubcategoryFeaturedCategoryResponse", + }, }, "TestContainer": { "details": { @@ -994,6 +1005,12 @@ func testMapping() *GRPCMapping { "filters": "filters", }, }, + "featuredCategory": { + TargetName: "featured_category", + ArgumentMappings: FieldArgumentMap{ + "includeChildren": "include_children", + }, + }, }, "CategoryMetrics": { "id": { @@ -1071,6 +1088,9 @@ func testMapping() *GRPCMapping { "contact": { TargetName: "contact", }, + "pet": { + TargetName: "pet", + }, }, "ContactInfo": { "email": { diff --git a/v2/pkg/engine/datasource/grpc_datasource/util.go b/v2/pkg/engine/datasource/grpc_datasource/util.go index 48dc37d44b..3d98da493b 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/util.go +++ b/v2/pkg/engine/datasource/grpc_datasource/util.go @@ -1,5 +1,7 @@ package grpcdatasource +import "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + // initializeSlice initializes a slice with a given length and a given value. func initializeSlice[T any](len int, zero T) []T { s := make([]T, len) @@ -47,3 +49,24 @@ func (a *stack[T]) len() int { func (a *stack[T]) capacity() int { return cap(*a) } + +// inlineFragmentRefFromAncestors returns the inline fragment ref for the field +// at the top of the walker's ancestor stack, or ast.InvalidRef if the field is +// not a direct child of an inline fragment. +// +// When entering a field's selection set, the walker Ancestors slice has the shape: +// +// [..., (maybe inline fragment), parent selection set, field] +// +// Ancestors[-3] is therefore the node that directly contains the parent selection +// set — an inline fragment if and only if the field is a direct child of one. +func inlineFragmentRefFromAncestors(ancestors []ast.Node) int { + if len(ancestors) < 3 { + return ast.InvalidRef + } + ancestor := ancestors[len(ancestors)-3] + if ancestor.Kind != ast.NodeKindInlineFragment { + return ast.InvalidRef + } + return ancestor.Ref +} diff --git a/v2/pkg/grpctest/mapping/mapping.go b/v2/pkg/grpctest/mapping/mapping.go index c3ff0f8425..d8137f5f46 100644 --- a/v2/pkg/grpctest/mapping/mapping.go +++ b/v2/pkg/grpctest/mapping/mapping.go @@ -1078,6 +1078,9 @@ func DefaultGRPCMapping() *grpcdatasource.GRPCMapping { "contact": { TargetName: "contact", }, + "pet": { + TargetName: "pet", + }, }, "ContactInfo": { "email": { diff --git a/v2/pkg/grpctest/product.proto b/v2/pkg/grpctest/product.proto index f58706ffbe..0a96ec23c1 100644 --- a/v2/pkg/grpctest/product.proto +++ b/v2/pkg/grpctest/product.proto @@ -1343,6 +1343,7 @@ message Owner { string id = 1; string name = 2; ContactInfo contact = 3; + Animal pet = 4; } message ContactInfo { diff --git a/v2/pkg/grpctest/testdata/products.graphqls b/v2/pkg/grpctest/testdata/products.graphqls index 531d431168..87316114a6 100644 --- a/v2/pkg/grpctest/testdata/products.graphqls +++ b/v2/pkg/grpctest/testdata/products.graphqls @@ -140,6 +140,7 @@ type Subcategory { description: String isActive: Boolean! itemCount(filters: SubcategoryItemFilter): Int! @connect__fieldResolver(context: "id") + featuredCategory(includeChildren: Boolean!): Category @connect__fieldResolver(context: "id") } type CategoryMetrics { @@ -188,6 +189,7 @@ type Owner { id: ID! name: String! contact: ContactInfo! + pet: Animal! } type ContactInfo {