From a8977848cffa855bbd93fa19de71ecd3fd69fee2 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:18:02 +0100 Subject: [PATCH 01/19] fix: create message for field selection sets on inline fragments --- .../grpc_datasource/execution_plan_visitor.go | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) 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..52269f2884 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -57,6 +57,8 @@ type rpcPlanVisitor struct { resolverFields []resolverField // contains information about the resolver fields. These are used to create the resolver calls when leaving the document. fieldPath ast.Path + + inlineFragmentRef int } type rpcPlanVisitorConfig struct { @@ -77,6 +79,7 @@ func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { resolverFields: make([]resolverField, 0), fieldResolverAncestors: newStack[int](0), fieldPath: make(ast.Path, 0), + inlineFragmentRef: ast.InvalidRef, } walker.RegisterDocumentVisitor(visitor) @@ -84,10 +87,19 @@ func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { walker.RegisterFieldVisitor(visitor) walker.RegisterSelectionSetVisitor(visitor) walker.RegisterEnterArgumentVisitor(visitor) + walker.RegisterInlineFragmentVisitor(visitor) return visitor } +func (r *rpcPlanVisitor) EnterInlineFragment(ref int) { + r.inlineFragmentRef = ref +} + +func (r *rpcPlanVisitor) LeaveInlineFragment(ref int) { + r.inlineFragmentRef = ast.InvalidRef +} + func (r *rpcPlanVisitor) PlanOperation(operation, definition *ast.Document) (*RPCExecutionPlan, error) { report := &operationreport.Report{} r.walker.Walk(operation, definition, report) @@ -193,6 +205,8 @@ func (r *rpcPlanVisitor) EnterArgument(ref int) { // EnterSelectionSet implements astvisitor.EnterSelectionSetVisitor. // Checks if this is in the root level below the operation definition. func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { + + fmt.Println("DEBUG:", r.walker.Ancestor().NameString(r.operation)) if r.walker.Ancestor().Kind == ast.NodeKindOperationDefinition { return } @@ -225,16 +239,32 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { return } - lastIndex := len(r.planInfo.currentResponseMessage.Fields) - 1 + if r.inlineFragmentRef == ast.InvalidRef { + 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) - } + // 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) + } + + // 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 + } else { + // We have the problem here, that r.InlineFragmentRef could be + + inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(r.inlineFragmentRef) + lastIndex := len(r.planInfo.currentResponseMessage.FieldSelectionSet[inlineFragmentName]) - 1 - // 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 + // In nested selection sets, a new message needs to be created, which will be added to the current response message. + if r.planInfo.currentResponseMessage.FieldSelectionSet[inlineFragmentName][lastIndex].Message == nil { + r.planInfo.currentResponseMessage.FieldSelectionSet[inlineFragmentName][lastIndex].Message = r.planCtx.newMessageFromSelectionSet(r.walker.EnclosingTypeDefinition, ref) + } + + // 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.FieldSelectionSet[inlineFragmentName][lastIndex].Message + } // Check if the ancestor type is a composite type (interface or union) // and set the oneof type and member types. From 1b06df676c2741ee30dd7463cc9c36030245ee19 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:53:11 +0100 Subject: [PATCH 02/19] chore: add tests to verify the bug --- .../execution_plan_composite_test.go | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) 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..a7c743a832 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 @@ -591,6 +591,391 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, }, + { + // query CatOwnerQuery { + // randomPet { + // name + // kind + // ... on Cat { + // meowVolume + // owner { + // id + // name + // } + // } + // } + // } + name: "Should create an execution plan for a nested object inside an inline fragment with shared fields present", + 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", + }, + }, + FieldSelectionSet: 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", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + // query CatOwnerQuery { + // randomPet { + // name + // ... on Cat { + // meowVolume + // owner { + // id + // name + // } + // } + // kind + // } + // } + name: "Should create an execution plan for a nested object inside an inline fragment with shared fields present #2", + 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", + }, + }, + FieldSelectionSet: 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", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + // { + // // query CatBreedQuery { + // // randomPet { + // // ... on Cat { + // // breed { + // // name + // // origin + // // } + // // } + // // } + // // } + // name: "Should create an execution plan for a nested object inside an inline fragment", + // 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{}, + // FieldSelectionSet: 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", + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // { + // // query CatBreedWithSharedQuery { + // // randomPet { + // // id + // // ... on Cat { + // // breed { + // // name + // // } + // // } + // // } + // // } + // name: "Should create an execution plan for shared fields mixed with a nested object inside an inline fragment", + // query: "query CatBreedWithSharedQuery { randomPet { id ... on Cat { breed { 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: "id", + // ProtoTypeName: DataTypeString, + // JSONPath: "id", + // }, + // }, + // FieldSelectionSet: RPCFieldSelectionSet{ + // "Cat": { + // { + // Name: "breed", + // ProtoTypeName: DataTypeMessage, + // JSONPath: "breed", + // Message: &RPCMessage{ + // Name: "CatBreed", + // Fields: []RPCField{ + // { + // Name: "name", + // ProtoTypeName: DataTypeString, + // JSONPath: "name", + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // { + // // query CatBreedCharacteristicsQuery { + // // randomPet { + // // ... on Cat { + // // breed { + // // characteristics { + // // temperament + // // } + // // } + // // } + // // } + // // } + // name: "Should create an execution plan for deeply nested objects 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{}, + // FieldSelectionSet: 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", + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, } for _, tt := range tests { From f8ab09e87e2fca0ced1ddd38afffec6a18138d90 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:28:48 +0100 Subject: [PATCH 03/19] chore: Rename FieldSelectionSet to FragmentFields --- .../grpc_datasource/execution_plan.go | 14 +- .../execution_plan_composite_test.go | 446 +++++++++--------- .../execution_plan_federation_test.go | 8 +- .../execution_plan_field_resolvers_test.go | 16 +- .../grpc_datasource/execution_plan_test.go | 8 +- .../grpc_datasource/execution_plan_visitor.go | 20 +- .../execution_plan_visitor_federation.go | 6 +- .../grpc_datasource/json_builder.go | 2 +- 8 files changed, 261 insertions(+), 259 deletions(-) diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go index eef14671ce..2e1aa33ff1 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 @@ -769,11 +769,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 +1081,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 +1094,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 a7c743a832..25fc21e99a 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", @@ -641,7 +641,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { JSONPath: "kind", }, }, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "meow_volume", @@ -728,7 +728,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { JSONPath: "kind", }, }, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "Cat": { { Name: "meow_volume", @@ -765,217 +765,217 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, }, - // { - // // query CatBreedQuery { - // // randomPet { - // // ... on Cat { - // // breed { - // // name - // // origin - // // } - // // } - // // } - // // } - // name: "Should create an execution plan for a nested object inside an inline fragment", - // 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{}, - // FieldSelectionSet: 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", - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // { - // // query CatBreedWithSharedQuery { - // // randomPet { - // // id - // // ... on Cat { - // // breed { - // // name - // // } - // // } - // // } - // // } - // name: "Should create an execution plan for shared fields mixed with a nested object inside an inline fragment", - // query: "query CatBreedWithSharedQuery { randomPet { id ... on Cat { breed { 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: "id", - // ProtoTypeName: DataTypeString, - // JSONPath: "id", - // }, - // }, - // FieldSelectionSet: RPCFieldSelectionSet{ - // "Cat": { - // { - // Name: "breed", - // ProtoTypeName: DataTypeMessage, - // JSONPath: "breed", - // Message: &RPCMessage{ - // Name: "CatBreed", - // Fields: []RPCField{ - // { - // Name: "name", - // ProtoTypeName: DataTypeString, - // JSONPath: "name", - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // { - // // query CatBreedCharacteristicsQuery { - // // randomPet { - // // ... on Cat { - // // breed { - // // characteristics { - // // temperament - // // } - // // } - // // } - // // } - // // } - // name: "Should create an execution plan for deeply nested objects 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{}, - // FieldSelectionSet: 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", - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, + { + // query CatBreedQuery { + // randomPet { + // ... on Cat { + // breed { + // name + // origin + // } + // } + // } + // } + name: "Should create an execution plan for a nested object inside an inline fragment", + 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", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + // query CatBreedWithSharedQuery { + // randomPet { + // id + // ... on Cat { + // breed { + // name + // } + // } + // } + // } + name: "Should create an execution plan for shared fields mixed with a nested object inside an inline fragment", + query: "query CatBreedWithSharedQuery { randomPet { id ... on Cat { breed { 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: "id", + ProtoTypeName: DataTypeString, + JSONPath: "id", + }, + }, + FragmentFields: RPCFieldSelectionSet{ + "Cat": { + { + Name: "breed", + ProtoTypeName: DataTypeMessage, + JSONPath: "breed", + Message: &RPCMessage{ + Name: "CatBreed", + Fields: []RPCField{ + { + Name: "name", + ProtoTypeName: DataTypeString, + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + // query CatBreedCharacteristicsQuery { + // randomPet { + // ... on Cat { + // breed { + // characteristics { + // temperament + // } + // } + // } + // } + // } + name: "Should create an execution plan for deeply nested objects 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", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, } for _, tt := range tests { @@ -1068,7 +1068,7 @@ func TestMutationUnionExecutionPlan(t *testing.T) { "ActionError", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -1150,7 +1150,7 @@ func TestMutationUnionExecutionPlan(t *testing.T) { "ActionError", }, Fields: RPCFields{}, - FieldSelectionSet: RPCFieldSelectionSet{ + FragmentFields: RPCFieldSelectionSet{ "ActionSuccess": { { Name: "message", @@ -1220,7 +1220,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..7f20bec75f 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", 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..39cf061690 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", @@ -4054,7 +4054,7 @@ func TestExecutionPlanFieldResolvers_CustomSchemas(t *testing.T) { JSONPath: "fooResolver", Message: &RPCMessage{ Name: "Bar", - FieldSelectionSet: RPCFieldSelectionSet{"Baz": {}}, + FragmentFields: RPCFieldSelectionSet{"Baz": {}}, OneOfType: OneOfTypeInterface, MemberTypes: []string{"Baz"}, }, 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 52269f2884..612c4dd44e 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -240,6 +240,10 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { } if r.inlineFragmentRef == ast.InvalidRef { + // r.inlineFragmentRef can be stale. Its set via an inline fragment visitor + // but it's leave function is called only after we exit the fragment. + // We might still be inside an fragment but inside of that we could be in a complex type, + // in which case we do not want to enter the else state. 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. @@ -251,19 +255,17 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { r.planInfo.responseMessageAncestors = append(r.planInfo.responseMessageAncestors, r.planInfo.currentResponseMessage) r.planInfo.currentResponseMessage = r.planInfo.currentResponseMessage.Fields[lastIndex].Message } else { - // We have the problem here, that r.InlineFragmentRef could be - inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(r.inlineFragmentRef) - lastIndex := len(r.planInfo.currentResponseMessage.FieldSelectionSet[inlineFragmentName]) - 1 + lastIndex := len(r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName]) - 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.FieldSelectionSet[inlineFragmentName][lastIndex].Message == nil { - r.planInfo.currentResponseMessage.FieldSelectionSet[inlineFragmentName][lastIndex].Message = r.planCtx.newMessageFromSelectionSet(r.walker.EnclosingTypeDefinition, ref) + if r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message == nil { + r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message = r.planCtx.newMessageFromSelectionSet(r.walker.EnclosingTypeDefinition, ref) } // 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.FieldSelectionSet[inlineFragmentName][lastIndex].Message + r.planInfo.currentResponseMessage = r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message } // Check if the ancestor type is a composite type (interface or union) @@ -415,12 +417,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..aa0417bdfe 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 @@ -351,12 +351,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 From a59bd4db2874a05f7b32664f655e555114e216a4 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:33:53 +0100 Subject: [PATCH 04/19] fix: don't assume empty fields for inline fragments --- .../execution_plan_composite_test.go | 226 +++++++----------- .../execution_plan_field_resolvers_test.go | 6 +- .../grpc_datasource/execution_plan_visitor.go | 8 +- 3 files changed, 89 insertions(+), 151 deletions(-) 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 25fc21e99a..26a788df75 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 @@ -605,7 +605,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { // } // } // } - name: "Should create an execution plan for a nested object inside an inline fragment with shared fields present", + name: "#1", query: "query CatOwnerQuery { randomPet { name kind ... on Cat { meowVolume owner { id name } } } }", expectedPlan: &RPCExecutionPlan{ Calls: []RPCCall{ @@ -692,7 +692,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { // kind // } // } - name: "Should create an execution plan for a nested object inside an inline fragment with shared fields present #2", + name: "#2", query: "query CatOwnerQuery { randomPet { name ... on Cat { meowVolume owner { id name } } kind } }", expectedPlan: &RPCExecutionPlan{ Calls: []RPCCall{ @@ -776,7 +776,10 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { // } // } // } - name: "Should create an execution plan for a nested object inside an inline fragment", + // + // verifies that inline fragments are handled correctly when no other shared field on the same parent is accessed. + // fixes a bug on an a guard, which made sure to return early on empty fields for the current selection set + name: "#3", query: "query CatBreedQuery { randomPet { ... on Cat { breed { name origin } } } }", expectedPlan: &RPCExecutionPlan{ Calls: []RPCCall{ @@ -833,149 +836,80 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, }, - { - // query CatBreedWithSharedQuery { - // randomPet { - // id - // ... on Cat { - // breed { - // name - // } - // } - // } - // } - name: "Should create an execution plan for shared fields mixed with a nested object inside an inline fragment", - query: "query CatBreedWithSharedQuery { randomPet { id ... on Cat { breed { 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: "id", - ProtoTypeName: DataTypeString, - JSONPath: "id", - }, - }, - FragmentFields: RPCFieldSelectionSet{ - "Cat": { - { - Name: "breed", - ProtoTypeName: DataTypeMessage, - JSONPath: "breed", - Message: &RPCMessage{ - Name: "CatBreed", - Fields: []RPCField{ - { - Name: "name", - ProtoTypeName: DataTypeString, - JSONPath: "name", - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - { - // query CatBreedCharacteristicsQuery { - // randomPet { - // ... on Cat { - // breed { - // characteristics { - // temperament - // } - // } - // } - // } - // } - name: "Should create an execution plan for deeply nested objects 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", - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, + // { + // // query CatBreedCharacteristicsQuery { + // // randomPet { + // // ... on Cat { + // // breed { + // // characteristics { + // // temperament + // // } + // // } + // // } + // // } + // // } + // name: "#5", + // 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", + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, + // }, } for _, tt := range tests { 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 39cf061690..617279bd23 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 @@ -4053,10 +4053,10 @@ func TestExecutionPlanFieldResolvers_CustomSchemas(t *testing.T) { ProtoTypeName: DataTypeMessage, JSONPath: "fooResolver", Message: &RPCMessage{ - Name: "Bar", + Name: "Bar", FragmentFields: RPCFieldSelectionSet{"Baz": {}}, - OneOfType: OneOfTypeInterface, - MemberTypes: []string{"Baz"}, + OneOfType: OneOfTypeInterface, + MemberTypes: []string{"Baz"}, }, }, }, 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 612c4dd44e..b56cc78cf3 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -234,12 +234,16 @@ 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 select on a field, we can return. + if r.walker.Ancestor().Kind != ast.NodeKindField { return } if r.inlineFragmentRef == ast.InvalidRef { + // If we don't have any fields on complex types we can return. + if len(r.planInfo.currentResponseMessage.Fields) == 0 { + return + } // r.inlineFragmentRef can be stale. Its set via an inline fragment visitor // but it's leave function is called only after we exit the fragment. // We might still be inside an fragment but inside of that we could be in a complex type, From fcad0fc0204f7cfa5f60fa2d4d8acc8395161d89 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:17:17 +0100 Subject: [PATCH 05/19] fix: add stack for inline fragment refs r.inlineFragmentRef goes stale precisely when EnterSelectionSet descends into a nested message. Prevent this by stacking inline fragment access. --- .../execution_plan_composite_test.go | 154 +++++++++--------- .../grpc_datasource/execution_plan_visitor.go | 32 ++-- 2 files changed, 97 insertions(+), 89 deletions(-) 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 26a788df75..42fc7c0041 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 @@ -605,7 +605,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { // } // } // } - name: "#1", + 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{ @@ -692,7 +692,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { // kind // } // } - name: "#2", + 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{ @@ -779,7 +779,7 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { // // verifies that inline fragments are handled correctly when no other shared field on the same parent is accessed. // fixes a bug on an a guard, which made sure to return early on empty fields for the current selection set - name: "#3", + 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{ @@ -836,80 +836,80 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, }, - // { - // // query CatBreedCharacteristicsQuery { - // // randomPet { - // // ... on Cat { - // // breed { - // // characteristics { - // // temperament - // // } - // // } - // // } - // // } - // // } - // name: "#5", - // 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", - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, - // }, + { + // query CatBreedCharacteristicsQuery { + // randomPet { + // ... on Cat { + // breed { + // characteristics { + // temperament + // } + // } + // } + // } + // } + 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", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, } for _, tt := range tests { 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 b56cc78cf3..ab41bc8d19 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -58,7 +58,8 @@ type rpcPlanVisitor struct { fieldPath ast.Path - inlineFragmentRef int + inlineFragmentRef int + inlineFragmentRefAncestors []int } type rpcPlanVisitorConfig struct { @@ -72,14 +73,15 @@ type rpcPlanVisitorConfig struct { func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { walker := astvisitor.NewWalker(48) visitor := &rpcPlanVisitor{ - walker: &walker, - plan: &RPCExecutionPlan{}, - subgraphName: cases.Title(language.Und, cases.NoLower).String(config.subgraphName), - mapping: config.mapping, - resolverFields: make([]resolverField, 0), - fieldResolverAncestors: newStack[int](0), - fieldPath: make(ast.Path, 0), - inlineFragmentRef: ast.InvalidRef, + walker: &walker, + plan: &RPCExecutionPlan{}, + subgraphName: cases.Title(language.Und, cases.NoLower).String(config.subgraphName), + mapping: config.mapping, + resolverFields: make([]resolverField, 0), + fieldResolverAncestors: newStack[int](0), + fieldPath: make(ast.Path, 0), + inlineFragmentRef: ast.InvalidRef, + inlineFragmentRefAncestors: make([]int, 0), } walker.RegisterDocumentVisitor(visitor) @@ -234,7 +236,7 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { return } - // If we select on a field, we can return. + // If we don't select on a field, we can return. if r.walker.Ancestor().Kind != ast.NodeKindField { return } @@ -255,9 +257,11 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { r.planInfo.currentResponseMessage.Fields[lastIndex].Message = r.planCtx.newMessageFromSelectionSet(r.walker.EnclosingTypeDefinition, ref) } - // Add the current response message to the ancestors and set the current response message to the current field message + // Save inlineFragmentRef and descend into the nested message. + r.inlineFragmentRefAncestors = append(r.inlineFragmentRefAncestors, r.inlineFragmentRef) r.planInfo.responseMessageAncestors = append(r.planInfo.responseMessageAncestors, r.planInfo.currentResponseMessage) r.planInfo.currentResponseMessage = r.planInfo.currentResponseMessage.Fields[lastIndex].Message + r.inlineFragmentRef = ast.InvalidRef } else { inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(r.inlineFragmentRef) lastIndex := len(r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName]) - 1 @@ -267,9 +271,11 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message = r.planCtx.newMessageFromSelectionSet(r.walker.EnclosingTypeDefinition, ref) } - // Add the current response message to the ancestors and set the current response message to the current field message + // Save inlineFragmentRef and descend into the nested message. + r.inlineFragmentRefAncestors = append(r.inlineFragmentRefAncestors, r.inlineFragmentRef) r.planInfo.responseMessageAncestors = append(r.planInfo.responseMessageAncestors, r.planInfo.currentResponseMessage) r.planInfo.currentResponseMessage = r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message + r.inlineFragmentRef = ast.InvalidRef } // Check if the ancestor type is a composite type (interface or union) @@ -329,6 +335,8 @@ func (r *rpcPlanVisitor) LeaveSelectionSet(ref int) { 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.inlineFragmentRef = r.inlineFragmentRefAncestors[len(r.inlineFragmentRefAncestors)-1] + r.inlineFragmentRefAncestors = r.inlineFragmentRefAncestors[:len(r.inlineFragmentRefAncestors)-1] } } From 72de85fac699093e3abf0b2ceecfe814e4c39000 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:35:39 +0100 Subject: [PATCH 06/19] chore: add schema field to test nested inline fragments --- .../engine/datasource/grpc_datasource/mapping_test_helper.go | 3 +++ v2/pkg/grpctest/mapping/mapping.go | 3 +++ v2/pkg/grpctest/product.proto | 1 + v2/pkg/grpctest/testdata/products.graphqls | 1 + 4 files changed, 8 insertions(+) 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..facc349c5d 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go +++ b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go @@ -1071,6 +1071,9 @@ func testMapping() *GRPCMapping { "contact": { TargetName: "contact", }, + "pet": { + TargetName: "pet", + }, }, "ContactInfo": { "email": { 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..28e42e92b0 100644 --- a/v2/pkg/grpctest/testdata/products.graphqls +++ b/v2/pkg/grpctest/testdata/products.graphqls @@ -188,6 +188,7 @@ type Owner { id: ID! name: String! contact: ContactInfo! + pet: Animal! } type ContactInfo { From 542877b0177d692447e600e312b7d9d13cd2f2eb Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:27:43 +0100 Subject: [PATCH 07/19] chore: add test to verify nested inline fragments --- .../execution_plan_composite_test.go | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) 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 42fc7c0041..ec49a7c833 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 @@ -910,6 +910,130 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, }, + { + // query OwnerPetQuery { + // randomPet { + // ... on Cat { + // owner { + // name + // pet { + // ... on Cat { + // breed { + // name + // origin + // } + // } + // ... on Dog { + // barkVolume + // } + // } + // } + // } + // } + // } + // + // Verifies that r.inlineFragmentRef does not become stale when descending into a + // nested message inside a fragment. The outer Cat fragment ref must be saved and + // cleared when entering owner's selection set so that the inner fields (name, pet) + // are treated as regular Owner fields, not as Cat fragment fields. The inner + // Animal interface on pet must then correctly handle its own nested inline fragments. + 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 { From 25794969687958edf2645c7e9ee4bd4de0e399c8 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:22:34 +0100 Subject: [PATCH 08/19] chore: move response descend into plan context + add federation visitor tests --- .../grpc_datasource/execution_plan.go | 51 ++ .../execution_plan_federation_test.go | 574 ++++++++++++++++++ .../grpc_datasource/execution_plan_visitor.go | 76 +-- .../execution_plan_visitor_federation.go | 39 +- 4 files changed, 661 insertions(+), 79 deletions(-) diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go index 2e1aa33ff1..60ef48a6f5 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go @@ -453,6 +453,57 @@ func (r *rpcPlanningContext) resolveRPCMethodMapping(operationType ast.Operation return rpcConfig, nil } +// descendIntoResponseField handles descending into a nested response message +// when entering a selection set. It branches on whether we are inside an inline +// fragment or not. Returns true if it descended into a nested message, +// false if there was nothing to descend into. +func (r *rpcPlanningContext) descendIntoResponseField(info *planningInfo, enclosingTypeNode ast.Node, selectionSetRef int) bool { + if info.inlineFragmentRef == ast.InvalidRef { + if len(info.currentResponseMessage.Fields) == 0 { + return false + } + + lastIndex := len(info.currentResponseMessage.Fields) - 1 + + if info.currentResponseMessage.Fields[lastIndex].Message == nil { + info.currentResponseMessage.Fields[lastIndex].Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) + } + + info.inlineFragmentRefAncestors = append(info.inlineFragmentRefAncestors, info.inlineFragmentRef) + info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) + info.currentResponseMessage = info.currentResponseMessage.Fields[lastIndex].Message + info.inlineFragmentRef = ast.InvalidRef + } else { + inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(info.inlineFragmentRef) + fragmentFields := info.currentResponseMessage.FragmentFields[inlineFragmentName] + if len(fragmentFields) == 0 { + return false + } + lastIndex := len(fragmentFields) - 1 + + if fragmentFields[lastIndex].Message == nil { + info.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) + } + + info.inlineFragmentRefAncestors = append(info.inlineFragmentRefAncestors, info.inlineFragmentRef) + info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) + info.currentResponseMessage = info.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message + info.inlineFragmentRef = ast.InvalidRef + } + return true +} + +// ascendFromResponseField pops the response message ancestors and restores the +// inline fragment ref when leaving a selection set. +func (r *rpcPlanningContext) ascendFromResponseField(info *planningInfo) { + if len(info.responseMessageAncestors) > 0 { + info.currentResponseMessage = info.responseMessageAncestors[len(info.responseMessageAncestors)-1] + info.responseMessageAncestors = info.responseMessageAncestors[:len(info.responseMessageAncestors)-1] + info.inlineFragmentRef = info.inlineFragmentRefAncestors[len(info.inlineFragmentRefAncestors)-1] + info.inlineFragmentRefAncestors = info.inlineFragmentRefAncestors[:len(info.inlineFragmentRefAncestors)-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{ 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 7f20bec75f..2b58eb8c05 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 @@ -2094,6 +2094,580 @@ 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) + } +} + func runFederationTest(t *testing.T, tt struct { name string query string 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 ab41bc8d19..89206d2358 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -23,6 +23,9 @@ type planningInfo struct { responseMessageAncestors []*RPCMessage currentResponseMessage *RPCMessage + + inlineFragmentRef int + inlineFragmentRefAncestors []int } type contextField struct { @@ -57,9 +60,6 @@ type rpcPlanVisitor struct { resolverFields []resolverField // contains information about the resolver fields. These are used to create the resolver calls when leaving the document. fieldPath ast.Path - - inlineFragmentRef int - inlineFragmentRefAncestors []int } type rpcPlanVisitorConfig struct { @@ -73,15 +73,17 @@ type rpcPlanVisitorConfig struct { func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { walker := astvisitor.NewWalker(48) visitor := &rpcPlanVisitor{ - walker: &walker, - plan: &RPCExecutionPlan{}, - subgraphName: cases.Title(language.Und, cases.NoLower).String(config.subgraphName), - mapping: config.mapping, - resolverFields: make([]resolverField, 0), - fieldResolverAncestors: newStack[int](0), - fieldPath: make(ast.Path, 0), - inlineFragmentRef: ast.InvalidRef, - inlineFragmentRefAncestors: make([]int, 0), + walker: &walker, + plan: &RPCExecutionPlan{}, + subgraphName: cases.Title(language.Und, cases.NoLower).String(config.subgraphName), + mapping: config.mapping, + resolverFields: make([]resolverField, 0), + fieldResolverAncestors: newStack[int](0), + fieldPath: make(ast.Path, 0), + planInfo: planningInfo{ + inlineFragmentRef: ast.InvalidRef, + inlineFragmentRefAncestors: make([]int, 0), + }, } walker.RegisterDocumentVisitor(visitor) @@ -95,11 +97,11 @@ func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { } func (r *rpcPlanVisitor) EnterInlineFragment(ref int) { - r.inlineFragmentRef = ref + r.planInfo.inlineFragmentRef = ref } func (r *rpcPlanVisitor) LeaveInlineFragment(ref int) { - r.inlineFragmentRef = ast.InvalidRef + r.planInfo.inlineFragmentRef = ast.InvalidRef } func (r *rpcPlanVisitor) PlanOperation(operation, definition *ast.Document) (*RPCExecutionPlan, error) { @@ -207,8 +209,6 @@ func (r *rpcPlanVisitor) EnterArgument(ref int) { // EnterSelectionSet implements astvisitor.EnterSelectionSetVisitor. // Checks if this is in the root level below the operation definition. func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { - - fmt.Println("DEBUG:", r.walker.Ancestor().NameString(r.operation)) if r.walker.Ancestor().Kind == ast.NodeKindOperationDefinition { return } @@ -241,41 +241,8 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { return } - if r.inlineFragmentRef == ast.InvalidRef { - // If we don't have any fields on complex types we can return. - if len(r.planInfo.currentResponseMessage.Fields) == 0 { - return - } - // r.inlineFragmentRef can be stale. Its set via an inline fragment visitor - // but it's leave function is called only after we exit the fragment. - // We might still be inside an fragment but inside of that we could be in a complex type, - // in which case we do not want to enter the else state. - 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) - } - - // Save inlineFragmentRef and descend into the nested message. - r.inlineFragmentRefAncestors = append(r.inlineFragmentRefAncestors, r.inlineFragmentRef) - r.planInfo.responseMessageAncestors = append(r.planInfo.responseMessageAncestors, r.planInfo.currentResponseMessage) - r.planInfo.currentResponseMessage = r.planInfo.currentResponseMessage.Fields[lastIndex].Message - r.inlineFragmentRef = ast.InvalidRef - } else { - inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(r.inlineFragmentRef) - lastIndex := len(r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName]) - 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.FragmentFields[inlineFragmentName][lastIndex].Message == nil { - r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message = r.planCtx.newMessageFromSelectionSet(r.walker.EnclosingTypeDefinition, ref) - } - - // Save inlineFragmentRef and descend into the nested message. - r.inlineFragmentRefAncestors = append(r.inlineFragmentRefAncestors, r.inlineFragmentRef) - r.planInfo.responseMessageAncestors = append(r.planInfo.responseMessageAncestors, r.planInfo.currentResponseMessage) - r.planInfo.currentResponseMessage = r.planInfo.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message - r.inlineFragmentRef = ast.InvalidRef + if !r.planCtx.descendIntoResponseField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref) { + return } // Check if the ancestor type is a composite type (interface or union) @@ -332,12 +299,7 @@ func (r *rpcPlanVisitor) LeaveSelectionSet(ref int) { 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.inlineFragmentRef = r.inlineFragmentRefAncestors[len(r.inlineFragmentRefAncestors)-1] - r.inlineFragmentRefAncestors = r.inlineFragmentRefAncestors[:len(r.inlineFragmentRefAncestors)-1] - } + r.planCtx.ascendFromResponseField(&r.planInfo) } func (r *rpcPlanVisitor) handleRootField(isRootField bool, ref int) error { 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 aa0417bdfe..3a9221a925 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 @@ -70,6 +70,10 @@ func newRPCPlanVisitorFederation(config rpcPlanVisitorConfig) *rpcPlanVisitorFed entityRootFieldRef: ast.InvalidRef, entityInlineFragmentRef: ast.InvalidRef, }, + planInfo: planningInfo{ + inlineFragmentRef: ast.InvalidRef, + inlineFragmentRefAncestors: make([]int, 0), + }, federationConfigData: parseFederationConfigData(config.federationConfigs), resolverFields: make([]resolverField, 0), fieldResolverAncestors: newStack[int](0), @@ -135,6 +139,7 @@ func (r *rpcPlanVisitorFederation) EnterInlineFragment(ref int) { fragmentName := r.operation.InlineFragmentTypeConditionNameString(ref) fc, ok := r.FederationConfigDataByEntityTypeName(fragmentName) if !ok { + r.planInfo.inlineFragmentRef = ref return } @@ -162,7 +167,7 @@ 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 + r.planInfo.inlineFragmentRef = ast.InvalidRef return } @@ -170,11 +175,13 @@ func (r *rpcPlanVisitorFederation) LeaveInlineFragment(ref int) { r.currentCall = &RPCCall{} r.planInfo = planningInfo{ - operationType: r.planInfo.operationType, - operationFieldName: r.planInfo.operationFieldName, - currentRequestMessage: &RPCMessage{}, - currentResponseMessage: &RPCMessage{}, - responseMessageAncestors: []*RPCMessage{}, + operationType: r.planInfo.operationType, + operationFieldName: r.planInfo.operationFieldName, + currentRequestMessage: &RPCMessage{}, + currentResponseMessage: &RPCMessage{}, + responseMessageAncestors: []*RPCMessage{}, + inlineFragmentRef: ast.InvalidRef, + inlineFragmentRefAncestors: make([]int, 0), } r.entityInfo.entityInlineFragmentRef = ast.InvalidRef @@ -209,22 +216,14 @@ 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) + if !r.planCtx.descendIntoResponseField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref) { + 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 { @@ -233,7 +232,6 @@ func (r *rpcPlanVisitorFederation) EnterSelectionSet(ref int) { r.walker.StopWithInternalErr(err) return } - } func (r *rpcPlanVisitorFederation) handleCompositeType(node ast.Node) error { @@ -278,10 +276,7 @@ func (r *rpcPlanVisitorFederation) LeaveSelectionSet(ref int) { 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.ascendFromResponseField(&r.planInfo) } // EnterField implements astvisitor.FieldVisitor. From 0851f2f62639356bf41a8c281dba9ccad5b1d336 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:48:38 +0100 Subject: [PATCH 09/19] chore: use stack type for ancestor refs --- .../engine/datasource/grpc_datasource/execution_plan.go | 8 ++++---- .../datasource/grpc_datasource/execution_plan_visitor.go | 4 ++-- .../grpc_datasource/execution_plan_visitor_federation.go | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go index 60ef48a6f5..93054c010e 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go @@ -469,7 +469,7 @@ func (r *rpcPlanningContext) descendIntoResponseField(info *planningInfo, enclos info.currentResponseMessage.Fields[lastIndex].Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) } - info.inlineFragmentRefAncestors = append(info.inlineFragmentRefAncestors, info.inlineFragmentRef) + info.inlineFragmentRefAncestors.push(info.inlineFragmentRef) info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) info.currentResponseMessage = info.currentResponseMessage.Fields[lastIndex].Message info.inlineFragmentRef = ast.InvalidRef @@ -485,7 +485,7 @@ func (r *rpcPlanningContext) descendIntoResponseField(info *planningInfo, enclos info.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) } - info.inlineFragmentRefAncestors = append(info.inlineFragmentRefAncestors, info.inlineFragmentRef) + info.inlineFragmentRefAncestors.push(info.inlineFragmentRef) info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) info.currentResponseMessage = info.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message info.inlineFragmentRef = ast.InvalidRef @@ -499,8 +499,8 @@ func (r *rpcPlanningContext) ascendFromResponseField(info *planningInfo) { if len(info.responseMessageAncestors) > 0 { info.currentResponseMessage = info.responseMessageAncestors[len(info.responseMessageAncestors)-1] info.responseMessageAncestors = info.responseMessageAncestors[:len(info.responseMessageAncestors)-1] - info.inlineFragmentRef = info.inlineFragmentRefAncestors[len(info.inlineFragmentRefAncestors)-1] - info.inlineFragmentRefAncestors = info.inlineFragmentRefAncestors[:len(info.inlineFragmentRefAncestors)-1] + info.inlineFragmentRef = info.inlineFragmentRefAncestors.peek() + info.inlineFragmentRefAncestors.pop() } } 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 89206d2358..76bcfc22db 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -25,7 +25,7 @@ type planningInfo struct { currentResponseMessage *RPCMessage inlineFragmentRef int - inlineFragmentRefAncestors []int + inlineFragmentRefAncestors stack[int] } type contextField struct { @@ -82,7 +82,7 @@ func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { fieldPath: make(ast.Path, 0), planInfo: planningInfo{ inlineFragmentRef: ast.InvalidRef, - inlineFragmentRefAncestors: make([]int, 0), + inlineFragmentRefAncestors: newStack[int](0), }, } 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 3a9221a925..493baada7a 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 @@ -72,7 +72,7 @@ func newRPCPlanVisitorFederation(config rpcPlanVisitorConfig) *rpcPlanVisitorFed }, planInfo: planningInfo{ inlineFragmentRef: ast.InvalidRef, - inlineFragmentRefAncestors: make([]int, 0), + inlineFragmentRefAncestors: newStack[int](0), }, federationConfigData: parseFederationConfigData(config.federationConfigs), resolverFields: make([]resolverField, 0), @@ -181,7 +181,7 @@ func (r *rpcPlanVisitorFederation) LeaveInlineFragment(ref int) { currentResponseMessage: &RPCMessage{}, responseMessageAncestors: []*RPCMessage{}, inlineFragmentRef: ast.InvalidRef, - inlineFragmentRefAncestors: make([]int, 0), + inlineFragmentRefAncestors: newStack[int](0), } r.entityInfo.entityInlineFragmentRef = ast.InvalidRef From 4e38cf00f7745ae3b2abe05c30856aa452c66d32 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:09:23 +0100 Subject: [PATCH 10/19] chore: add test for another bug --- .../execution_plan_composite_test.go | 65 ++++++++++++++ .../grpc_datasource/mapping_test_helper.go | 90 +++++++++++-------- v2/pkg/grpctest/mapping/mapping.go | 14 +++ v2/pkg/grpctest/product.proto | 7 ++ v2/pkg/grpctest/testdata/products.graphqls | 8 ++ 5 files changed, 146 insertions(+), 38 deletions(-) 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 ec49a7c833..9c157086b8 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 @@ -1034,6 +1034,71 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, }, + { + name: "Should create an execution plan for a query with a nested inner fragment followed by a complex field on the outer interface fragment", + query: "query NestedInterfaceFragmentQuery { randomPet { ... on Animal { ... on Cat { meowVolume } habitat { region climate } } } }", + 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: "meow_volume", + ProtoTypeName: DataTypeInt32, + JSONPath: "meowVolume", + }, + }, + "Animal": { + { + Name: "habitat", + ProtoTypeName: DataTypeMessage, + JSONPath: "habitat", + Message: &RPCMessage{ + Name: "AnimalHabitat", + Fields: RPCFields{ + { + Name: "region", + ProtoTypeName: DataTypeString, + JSONPath: "region", + }, + { + Name: "climate", + ProtoTypeName: DataTypeString, + JSONPath: "climate", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, } for _, tt := range tests { 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 facc349c5d..a15c2a08b5 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go +++ b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go @@ -1021,46 +1021,60 @@ func testMapping() *GRPCMapping { TargetName: "related_category", }, }, - "Cat": { - "id": { - TargetName: "id", - }, - "name": { - TargetName: "name", - }, - "kind": { - TargetName: "kind", - }, - "meowVolume": { - TargetName: "meow_volume", - }, - "owner": { - TargetName: "owner", - }, - "breed": { - TargetName: "breed", - }, + "Cat": { + "id": { + TargetName: "id", }, - "Dog": { - "id": { - TargetName: "id", - }, - "name": { - TargetName: "name", - }, - "kind": { - TargetName: "kind", - }, - "barkVolume": { - TargetName: "bark_volume", - }, - "owner": { - TargetName: "owner", - }, - "breed": { - TargetName: "breed", - }, + "name": { + TargetName: "name", + }, + "kind": { + TargetName: "kind", + }, + "meowVolume": { + TargetName: "meow_volume", + }, + "owner": { + TargetName: "owner", + }, + "breed": { + TargetName: "breed", + }, + "habitat": { + TargetName: "habitat", + }, + }, + "Dog": { + "id": { + TargetName: "id", }, + "name": { + TargetName: "name", + }, + "kind": { + TargetName: "kind", + }, + "barkVolume": { + TargetName: "bark_volume", + }, + "owner": { + TargetName: "owner", + }, + "breed": { + TargetName: "breed", + }, + "habitat": { + TargetName: "habitat", + }, + }, + "AnimalHabitat": { + "region": { + TargetName: "region", + }, + "climate": { + TargetName: "climate", + }, + }, "Owner": { "id": { TargetName: "id", diff --git a/v2/pkg/grpctest/mapping/mapping.go b/v2/pkg/grpctest/mapping/mapping.go index d8137f5f46..3ed286d4a4 100644 --- a/v2/pkg/grpctest/mapping/mapping.go +++ b/v2/pkg/grpctest/mapping/mapping.go @@ -1047,6 +1047,9 @@ func DefaultGRPCMapping() *grpcdatasource.GRPCMapping { "breed": { TargetName: "breed", }, + "habitat": { + TargetName: "habitat", + }, }, "Dog": { "id": { @@ -1067,6 +1070,17 @@ func DefaultGRPCMapping() *grpcdatasource.GRPCMapping { "breed": { TargetName: "breed", }, + "habitat": { + TargetName: "habitat", + }, + }, + "AnimalHabitat": { + "region": { + TargetName: "region", + }, + "climate": { + TargetName: "climate", + }, }, "Owner": { "id": { diff --git a/v2/pkg/grpctest/product.proto b/v2/pkg/grpctest/product.proto index 0a96ec23c1..f90b970587 100644 --- a/v2/pkg/grpctest/product.proto +++ b/v2/pkg/grpctest/product.proto @@ -1328,6 +1328,7 @@ message Cat { int32 meow_volume = 4; Owner owner = 5; CatBreed breed = 6; + AnimalHabitat habitat = 7; } message Dog { @@ -1337,6 +1338,12 @@ message Dog { int32 bark_volume = 4; Owner owner = 5; DogBreed breed = 6; + AnimalHabitat habitat = 7; +} + +message AnimalHabitat { + string region = 1; + string climate = 2; } message Owner { diff --git a/v2/pkg/grpctest/testdata/products.graphqls b/v2/pkg/grpctest/testdata/products.graphqls index 28e42e92b0..f107638753 100644 --- a/v2/pkg/grpctest/testdata/products.graphqls +++ b/v2/pkg/grpctest/testdata/products.graphqls @@ -164,6 +164,7 @@ interface Animal { id: ID! name: String! kind: String! + habitat: AnimalHabitat! } type Cat implements Animal { @@ -173,6 +174,7 @@ type Cat implements Animal { meowVolume: Int! owner: Owner! breed: CatBreed! + habitat: AnimalHabitat! } type Dog implements Animal { @@ -182,6 +184,12 @@ type Dog implements Animal { barkVolume: Int! owner: Owner! breed: DogBreed! + habitat: AnimalHabitat! +} + +type AnimalHabitat { + region: String! + climate: String! } type Owner { From 15aa25be447b6d2e164a38b1bb6b4f4b016f586f Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:59:35 +0100 Subject: [PATCH 11/19] chore: calculate inline fragment ref statelessly --- .../grpc_datasource/execution_plan.go | 22 +++++--------- .../grpc_datasource/execution_plan_visitor.go | 24 +++++---------- .../execution_plan_visitor_federation.go | 29 ++++++++++--------- .../engine/datasource/grpc_datasource/util.go | 23 +++++++++++++++ 4 files changed, 53 insertions(+), 45 deletions(-) diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go index 93054c010e..287b87cfb8 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go @@ -454,11 +454,12 @@ func (r *rpcPlanningContext) resolveRPCMethodMapping(operationType ast.Operation } // descendIntoResponseField handles descending into a nested response message -// when entering a selection set. It branches on whether we are inside an inline -// fragment or not. Returns true if it descended into a nested message, -// false if there was nothing to descend into. -func (r *rpcPlanningContext) descendIntoResponseField(info *planningInfo, enclosingTypeNode ast.Node, selectionSetRef int) bool { - if info.inlineFragmentRef == ast.InvalidRef { +// 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) descendIntoResponseField(info *planningInfo, enclosingTypeNode ast.Node, selectionSetRef int, inlineFragmentRef int) bool { + if inlineFragmentRef == ast.InvalidRef { if len(info.currentResponseMessage.Fields) == 0 { return false } @@ -469,12 +470,10 @@ func (r *rpcPlanningContext) descendIntoResponseField(info *planningInfo, enclos info.currentResponseMessage.Fields[lastIndex].Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) } - info.inlineFragmentRefAncestors.push(info.inlineFragmentRef) info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) info.currentResponseMessage = info.currentResponseMessage.Fields[lastIndex].Message - info.inlineFragmentRef = ast.InvalidRef } else { - inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(info.inlineFragmentRef) + inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(inlineFragmentRef) fragmentFields := info.currentResponseMessage.FragmentFields[inlineFragmentName] if len(fragmentFields) == 0 { return false @@ -485,22 +484,17 @@ func (r *rpcPlanningContext) descendIntoResponseField(info *planningInfo, enclos info.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) } - info.inlineFragmentRefAncestors.push(info.inlineFragmentRef) info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) info.currentResponseMessage = info.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message - info.inlineFragmentRef = ast.InvalidRef } return true } -// ascendFromResponseField pops the response message ancestors and restores the -// inline fragment ref when leaving a selection set. +// ascendFromResponseField pops the response message ancestors when leaving a selection set. func (r *rpcPlanningContext) ascendFromResponseField(info *planningInfo) { if len(info.responseMessageAncestors) > 0 { info.currentResponseMessage = info.responseMessageAncestors[len(info.responseMessageAncestors)-1] info.responseMessageAncestors = info.responseMessageAncestors[:len(info.responseMessageAncestors)-1] - info.inlineFragmentRef = info.inlineFragmentRefAncestors.peek() - info.inlineFragmentRefAncestors.pop() } } 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 76bcfc22db..4894800133 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -23,9 +23,6 @@ type planningInfo struct { responseMessageAncestors []*RPCMessage currentResponseMessage *RPCMessage - - inlineFragmentRef int - inlineFragmentRefAncestors stack[int] } type contextField struct { @@ -80,10 +77,7 @@ func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { resolverFields: make([]resolverField, 0), fieldResolverAncestors: newStack[int](0), fieldPath: make(ast.Path, 0), - planInfo: planningInfo{ - inlineFragmentRef: ast.InvalidRef, - inlineFragmentRefAncestors: newStack[int](0), - }, + planInfo: planningInfo{}, } walker.RegisterDocumentVisitor(visitor) @@ -91,19 +85,10 @@ func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { walker.RegisterFieldVisitor(visitor) walker.RegisterSelectionSetVisitor(visitor) walker.RegisterEnterArgumentVisitor(visitor) - walker.RegisterInlineFragmentVisitor(visitor) return visitor } -func (r *rpcPlanVisitor) EnterInlineFragment(ref int) { - r.planInfo.inlineFragmentRef = ref -} - -func (r *rpcPlanVisitor) LeaveInlineFragment(ref int) { - r.planInfo.inlineFragmentRef = ast.InvalidRef -} - func (r *rpcPlanVisitor) PlanOperation(operation, definition *ast.Document) (*RPCExecutionPlan, error) { report := &operationreport.Report{} r.walker.Walk(operation, definition, report) @@ -241,7 +226,12 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { return } - if !r.planCtx.descendIntoResponseField(&r.planInfo, 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.descendIntoResponseField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref, inlineFragmentRef) { 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 493baada7a..35d2aeeb77 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 @@ -70,10 +70,7 @@ func newRPCPlanVisitorFederation(config rpcPlanVisitorConfig) *rpcPlanVisitorFed entityRootFieldRef: ast.InvalidRef, entityInlineFragmentRef: ast.InvalidRef, }, - planInfo: planningInfo{ - inlineFragmentRef: ast.InvalidRef, - inlineFragmentRefAncestors: newStack[int](0), - }, + planInfo: planningInfo{}, federationConfigData: parseFederationConfigData(config.federationConfigs), resolverFields: make([]resolverField, 0), fieldResolverAncestors: newStack[int](0), @@ -139,7 +136,6 @@ func (r *rpcPlanVisitorFederation) EnterInlineFragment(ref int) { fragmentName := r.operation.InlineFragmentTypeConditionNameString(ref) fc, ok := r.FederationConfigDataByEntityTypeName(fragmentName) if !ok { - r.planInfo.inlineFragmentRef = ref return } @@ -167,7 +163,6 @@ func (r *rpcPlanVisitorFederation) EnterInlineFragment(ref int) { // LeaveInlineFragment implements astvisitor.InlineFragmentVisitor. func (r *rpcPlanVisitorFederation) LeaveInlineFragment(ref int) { if r.entityInfo.entityInlineFragmentRef != ref { - r.planInfo.inlineFragmentRef = ast.InvalidRef return } @@ -175,13 +170,11 @@ func (r *rpcPlanVisitorFederation) LeaveInlineFragment(ref int) { r.currentCall = &RPCCall{} r.planInfo = planningInfo{ - operationType: r.planInfo.operationType, - operationFieldName: r.planInfo.operationFieldName, - currentRequestMessage: &RPCMessage{}, - currentResponseMessage: &RPCMessage{}, - responseMessageAncestors: []*RPCMessage{}, - inlineFragmentRef: ast.InvalidRef, - inlineFragmentRefAncestors: newStack[int](0), + operationType: r.planInfo.operationType, + operationFieldName: r.planInfo.operationFieldName, + currentRequestMessage: &RPCMessage{}, + currentResponseMessage: &RPCMessage{}, + responseMessageAncestors: []*RPCMessage{}, } r.entityInfo.entityInlineFragmentRef = ast.InvalidRef @@ -220,7 +213,15 @@ func (r *rpcPlanVisitorFederation) EnterSelectionSet(ref int) { return } - if !r.planCtx.descendIntoResponseField(&r.planInfo, 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 + } + + if !r.planCtx.descendIntoResponseField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref, inlineFragmentRef) { return } 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 +} From 482f4ca5286f44706cfc0dce6832823f76113f6c Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:01:13 +0100 Subject: [PATCH 12/19] chore: remove test which does not occur in reality --- .../execution_plan_composite_test.go | 65 ------------------- 1 file changed, 65 deletions(-) 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 9c157086b8..ec49a7c833 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 @@ -1034,71 +1034,6 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, }, - { - name: "Should create an execution plan for a query with a nested inner fragment followed by a complex field on the outer interface fragment", - query: "query NestedInterfaceFragmentQuery { randomPet { ... on Animal { ... on Cat { meowVolume } habitat { region climate } } } }", - 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: "meow_volume", - ProtoTypeName: DataTypeInt32, - JSONPath: "meowVolume", - }, - }, - "Animal": { - { - Name: "habitat", - ProtoTypeName: DataTypeMessage, - JSONPath: "habitat", - Message: &RPCMessage{ - Name: "AnimalHabitat", - Fields: RPCFields{ - { - Name: "region", - ProtoTypeName: DataTypeString, - JSONPath: "region", - }, - { - Name: "climate", - ProtoTypeName: DataTypeString, - JSONPath: "climate", - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, } for _, tt := range tests { From 3deb55612d88a07997800d90c9265a80dc8e2b6d Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:06:58 +0100 Subject: [PATCH 13/19] chore: gofmt --- .../grpc_datasource/mapping_test_helper.go | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) 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 a15c2a08b5..f11f0eaba1 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go +++ b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go @@ -1021,60 +1021,60 @@ func testMapping() *GRPCMapping { TargetName: "related_category", }, }, - "Cat": { - "id": { - TargetName: "id", - }, - "name": { - TargetName: "name", - }, - "kind": { - TargetName: "kind", - }, - "meowVolume": { - TargetName: "meow_volume", - }, - "owner": { - TargetName: "owner", - }, - "breed": { - TargetName: "breed", - }, - "habitat": { - TargetName: "habitat", - }, - }, - "Dog": { - "id": { - TargetName: "id", - }, - "name": { - TargetName: "name", - }, - "kind": { - TargetName: "kind", - }, - "barkVolume": { - TargetName: "bark_volume", - }, - "owner": { - TargetName: "owner", - }, - "breed": { - TargetName: "breed", - }, - "habitat": { - TargetName: "habitat", + "Cat": { + "id": { + TargetName: "id", + }, + "name": { + TargetName: "name", + }, + "kind": { + TargetName: "kind", + }, + "meowVolume": { + TargetName: "meow_volume", + }, + "owner": { + TargetName: "owner", + }, + "breed": { + TargetName: "breed", + }, + "habitat": { + TargetName: "habitat", + }, }, - }, - "AnimalHabitat": { - "region": { - TargetName: "region", + "Dog": { + "id": { + TargetName: "id", + }, + "name": { + TargetName: "name", + }, + "kind": { + TargetName: "kind", + }, + "barkVolume": { + TargetName: "bark_volume", + }, + "owner": { + TargetName: "owner", + }, + "breed": { + TargetName: "breed", + }, + "habitat": { + TargetName: "habitat", + }, }, - "climate": { - TargetName: "climate", + "AnimalHabitat": { + "region": { + TargetName: "region", + }, + "climate": { + TargetName: "climate", + }, }, - }, "Owner": { "id": { TargetName: "id", From 4f55660df539b3eadb651b58cbbb5145d3e01219 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:24:11 +0100 Subject: [PATCH 14/19] chore: refactor --- .../grpc_datasource/execution_plan.go | 48 ++++++++++--------- .../grpc_datasource/execution_plan_visitor.go | 1 - .../execution_plan_visitor_federation.go | 1 - 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go index 287b87cfb8..80edcf8369 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go @@ -458,36 +458,38 @@ func (r *rpcPlanningContext) resolveRPCMethodMapping(operationType ast.Operation // 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) descendIntoResponseField(info *planningInfo, enclosingTypeNode ast.Node, selectionSetRef int, inlineFragmentRef int) bool { - if inlineFragmentRef == ast.InvalidRef { - if len(info.currentResponseMessage.Fields) == 0 { - return false - } +func (r *rpcPlanningContext) descendIntoResponseField(info *planningInfo, enclosingTypeNode ast.Node, selectionSetRef, inlineFragmentRef int) bool { + lastField := r.lastResponseField(info.currentResponseMessage, inlineFragmentRef) + if lastField == nil { + return false + } - lastIndex := len(info.currentResponseMessage.Fields) - 1 + if lastField.Message == nil { + lastField.Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) + } - if info.currentResponseMessage.Fields[lastIndex].Message == nil { - info.currentResponseMessage.Fields[lastIndex].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 { + var fields RPCFields - info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) - info.currentResponseMessage = info.currentResponseMessage.Fields[lastIndex].Message + if inlineFragmentRef == ast.InvalidRef { + fields = msg.Fields } else { inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(inlineFragmentRef) - fragmentFields := info.currentResponseMessage.FragmentFields[inlineFragmentName] - if len(fragmentFields) == 0 { - return false - } - lastIndex := len(fragmentFields) - 1 - - if fragmentFields[lastIndex].Message == nil { - info.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message = r.newMessageFromSelectionSet(enclosingTypeNode, selectionSetRef) - } + fields = msg.FragmentFields[inlineFragmentName] + } - info.responseMessageAncestors = append(info.responseMessageAncestors, info.currentResponseMessage) - info.currentResponseMessage = info.currentResponseMessage.FragmentFields[inlineFragmentName][lastIndex].Message + if len(fields) == 0 { + return nil } - return true + + return &fields[len(fields)-1] } // ascendFromResponseField pops the response message ancestors when leaving a selection set. 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 4894800133..7a5ccbdb4d 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -77,7 +77,6 @@ func newRPCPlanVisitor(config rpcPlanVisitorConfig) *rpcPlanVisitor { resolverFields: make([]resolverField, 0), fieldResolverAncestors: newStack[int](0), fieldPath: make(ast.Path, 0), - planInfo: planningInfo{}, } walker.RegisterDocumentVisitor(visitor) 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 35d2aeeb77..59ef80c30b 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 @@ -70,7 +70,6 @@ func newRPCPlanVisitorFederation(config rpcPlanVisitorConfig) *rpcPlanVisitorFed entityRootFieldRef: ast.InvalidRef, entityInlineFragmentRef: ast.InvalidRef, }, - planInfo: planningInfo{}, federationConfigData: parseFederationConfigData(config.federationConfigs), resolverFields: make([]resolverField, 0), fieldResolverAncestors: newStack[int](0), From c1f3df2740d128a6ad3f0b636f35c555d330c057 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:30:40 +0100 Subject: [PATCH 15/19] chore: remove unused fields from test schema --- .../grpc_datasource/mapping_test_helper.go | 14 -------------- v2/pkg/grpctest/mapping/mapping.go | 14 -------------- v2/pkg/grpctest/product.proto | 7 ------- v2/pkg/grpctest/testdata/products.graphqls | 8 -------- 4 files changed, 43 deletions(-) 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 f11f0eaba1..facc349c5d 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go +++ b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go @@ -1040,9 +1040,6 @@ func testMapping() *GRPCMapping { "breed": { TargetName: "breed", }, - "habitat": { - TargetName: "habitat", - }, }, "Dog": { "id": { @@ -1063,17 +1060,6 @@ func testMapping() *GRPCMapping { "breed": { TargetName: "breed", }, - "habitat": { - TargetName: "habitat", - }, - }, - "AnimalHabitat": { - "region": { - TargetName: "region", - }, - "climate": { - TargetName: "climate", - }, }, "Owner": { "id": { diff --git a/v2/pkg/grpctest/mapping/mapping.go b/v2/pkg/grpctest/mapping/mapping.go index 3ed286d4a4..d8137f5f46 100644 --- a/v2/pkg/grpctest/mapping/mapping.go +++ b/v2/pkg/grpctest/mapping/mapping.go @@ -1047,9 +1047,6 @@ func DefaultGRPCMapping() *grpcdatasource.GRPCMapping { "breed": { TargetName: "breed", }, - "habitat": { - TargetName: "habitat", - }, }, "Dog": { "id": { @@ -1070,17 +1067,6 @@ func DefaultGRPCMapping() *grpcdatasource.GRPCMapping { "breed": { TargetName: "breed", }, - "habitat": { - TargetName: "habitat", - }, - }, - "AnimalHabitat": { - "region": { - TargetName: "region", - }, - "climate": { - TargetName: "climate", - }, }, "Owner": { "id": { diff --git a/v2/pkg/grpctest/product.proto b/v2/pkg/grpctest/product.proto index f90b970587..0a96ec23c1 100644 --- a/v2/pkg/grpctest/product.proto +++ b/v2/pkg/grpctest/product.proto @@ -1328,7 +1328,6 @@ message Cat { int32 meow_volume = 4; Owner owner = 5; CatBreed breed = 6; - AnimalHabitat habitat = 7; } message Dog { @@ -1338,12 +1337,6 @@ message Dog { int32 bark_volume = 4; Owner owner = 5; DogBreed breed = 6; - AnimalHabitat habitat = 7; -} - -message AnimalHabitat { - string region = 1; - string climate = 2; } message Owner { diff --git a/v2/pkg/grpctest/testdata/products.graphqls b/v2/pkg/grpctest/testdata/products.graphqls index f107638753..28e42e92b0 100644 --- a/v2/pkg/grpctest/testdata/products.graphqls +++ b/v2/pkg/grpctest/testdata/products.graphqls @@ -164,7 +164,6 @@ interface Animal { id: ID! name: String! kind: String! - habitat: AnimalHabitat! } type Cat implements Animal { @@ -174,7 +173,6 @@ type Cat implements Animal { meowVolume: Int! owner: Owner! breed: CatBreed! - habitat: AnimalHabitat! } type Dog implements Animal { @@ -184,12 +182,6 @@ type Dog implements Animal { barkVolume: Int! owner: Owner! breed: DogBreed! - habitat: AnimalHabitat! -} - -type AnimalHabitat { - region: String! - climate: String! } type Owner { From 6308734043c2bbd7bdfdc6320db0652f1ccba77c Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:59:24 +0100 Subject: [PATCH 16/19] chore: remove comments --- .../execution_plan_composite_test.go | 76 ------------------- 1 file changed, 76 deletions(-) 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 ec49a7c833..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 @@ -592,19 +592,6 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, { - // query CatOwnerQuery { - // randomPet { - // name - // kind - // ... on Cat { - // meowVolume - // owner { - // id - // name - // } - // } - // } - // } 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{ @@ -679,19 +666,6 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, { - // query CatOwnerQuery { - // randomPet { - // name - // ... on Cat { - // meowVolume - // owner { - // id - // name - // } - // } - // kind - // } - // } 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{ @@ -766,19 +740,6 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, { - // query CatBreedQuery { - // randomPet { - // ... on Cat { - // breed { - // name - // origin - // } - // } - // } - // } - // - // verifies that inline fragments are handled correctly when no other shared field on the same parent is accessed. - // fixes a bug on an a guard, which made sure to return early on empty fields for the current selection set 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{ @@ -837,17 +798,6 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, { - // query CatBreedCharacteristicsQuery { - // randomPet { - // ... on Cat { - // breed { - // characteristics { - // temperament - // } - // } - // } - // } - // } 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{ @@ -911,32 +861,6 @@ func TestCompositeTypeExecutionPlan(t *testing.T) { }, }, { - // query OwnerPetQuery { - // randomPet { - // ... on Cat { - // owner { - // name - // pet { - // ... on Cat { - // breed { - // name - // origin - // } - // } - // ... on Dog { - // barkVolume - // } - // } - // } - // } - // } - // } - // - // Verifies that r.inlineFragmentRef does not become stale when descending into a - // nested message inside a fragment. The outer Cat fragment ref must be saved and - // cleared when entering owner's selection set so that the inner fields (name, pet) - // are treated as regular Owner fields, not as Cat fragment fields. The inner - // Animal interface on pet must then correctly handle its own nested inline fragments. 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{ From 3bd0a59840a0ff36039c5445b94363ea9c62cb69 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:14:08 +0100 Subject: [PATCH 17/19] chore: do pull request suggestions --- .../grpc_datasource/execution_plan.go | 31 ++++++++++--------- .../grpc_datasource/execution_plan_visitor.go | 4 +-- .../execution_plan_visitor_federation.go | 4 +-- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go index 80edcf8369..5d523446d7 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go @@ -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,12 +462,12 @@ func (r *rpcPlanningContext) resolveRPCMethodMapping(operationType ast.Operation return rpcConfig, nil } -// descendIntoResponseField handles descending into a nested response message +// 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) descendIntoResponseField(info *planningInfo, enclosingTypeNode ast.Node, selectionSetRef, inlineFragmentRef int) bool { +func (r *rpcPlanningContext) enterNestedField(info *planningInfo, enclosingTypeNode ast.Node, selectionSetRef, inlineFragmentRef int) bool { lastField := r.lastResponseField(info.currentResponseMessage, inlineFragmentRef) if lastField == nil { return false @@ -476,24 +485,16 @@ func (r *rpcPlanningContext) descendIntoResponseField(info *planningInfo, enclos // 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 { - var fields RPCFields - if inlineFragmentRef == ast.InvalidRef { - fields = msg.Fields - } else { - inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(inlineFragmentRef) - fields = msg.FragmentFields[inlineFragmentName] - } - - if len(fields) == 0 { - return nil + return msg.Fields.Last() } - return &fields[len(fields)-1] + inlineFragmentName := r.operation.InlineFragmentTypeConditionNameString(inlineFragmentRef) + return msg.FragmentFields[inlineFragmentName].Last() } -// ascendFromResponseField pops the response message ancestors when leaving a selection set. -func (r *rpcPlanningContext) ascendFromResponseField(info *planningInfo) { +// 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] 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 7a5ccbdb4d..123dbe1f3b 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -230,7 +230,7 @@ func (r *rpcPlanVisitor) EnterSelectionSet(ref int) { // 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.descendIntoResponseField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref, inlineFragmentRef) { + if !r.planCtx.enterNestedField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref, inlineFragmentRef) { return } @@ -288,7 +288,7 @@ func (r *rpcPlanVisitor) LeaveSelectionSet(ref int) { return } - r.planCtx.ascendFromResponseField(&r.planInfo) + r.planCtx.leaveNestedField(&r.planInfo) } func (r *rpcPlanVisitor) handleRootField(isRootField bool, ref int) error { 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 59ef80c30b..938f9838eb 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 @@ -220,7 +220,7 @@ func (r *rpcPlanVisitorFederation) EnterSelectionSet(ref int) { inlineFragmentRef = ast.InvalidRef } - if !r.planCtx.descendIntoResponseField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref, inlineFragmentRef) { + if !r.planCtx.enterNestedField(&r.planInfo, r.walker.EnclosingTypeDefinition, ref, inlineFragmentRef) { return } @@ -276,7 +276,7 @@ func (r *rpcPlanVisitorFederation) LeaveSelectionSet(ref int) { return } - r.planCtx.ascendFromResponseField(&r.planInfo) + r.planCtx.leaveNestedField(&r.planInfo) } // EnterField implements astvisitor.FieldVisitor. From 143e99dc3bf4f595c456ccdadeb1319a3a1011e5 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:26:35 +0100 Subject: [PATCH 18/19] fix: don't leave nested field when never entered it --- .../execution_plan_federation_test.go | 314 ++++++++++++++++++ .../execution_plan_field_resolvers_test.go | 178 ++++++++++ .../grpc_datasource/execution_plan_visitor.go | 3 + .../execution_plan_visitor_federation.go | 3 + .../grpc_datasource/mapping_test_helper.go | 71 ++-- v2/pkg/grpctest/testdata/products.graphqls | 1 + 6 files changed, 543 insertions(+), 27 deletions(-) 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 2b58eb8c05..bf313dfd7b 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 @@ -2668,6 +2668,320 @@ func TestEntityLookupWithNestedInlineFragments(t *testing.T) { } } +// 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 617279bd23..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 @@ -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_visitor.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go index 123dbe1f3b..f04d44f47a 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -284,6 +284,9 @@ 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 } 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 938f9838eb..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 @@ -272,6 +272,9 @@ 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 } 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 facc349c5d..76f6d79595 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go +++ b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go @@ -378,19 +378,30 @@ func testMapping() *GRPCMapping { Response: "ResolveProductProductDetailsResponse", }, }, - "Subcategory": { - "itemCount": { - FieldMappingData: FieldMapData{ - TargetName: "item_count", - ArgumentMappings: FieldArgumentMap{ - "filters": "filters", - }, + "Subcategory": { + "itemCount": { + FieldMappingData: FieldMapData{ + TargetName: "item_count", + ArgumentMappings: FieldArgumentMap{ + "filters": "filters", }, - RPC: "ResolveSubcategoryItemCount", - Request: "ResolveSubcategoryItemCountRequest", - Response: "ResolveSubcategoryItemCountResponse", }, + RPC: "ResolveSubcategoryItemCount", + Request: "ResolveSubcategoryItemCountRequest", + Response: "ResolveSubcategoryItemCountResponse", }, + "featuredCategory": { + FieldMappingData: FieldMapData{ + TargetName: "featured_category", + ArgumentMappings: FieldArgumentMap{ + "includeChildren": "include_children", + }, + }, + RPC: "ResolveSubcategoryFeaturedCategory", + Request: "ResolveSubcategoryFeaturedCategoryRequest", + Response: "ResolveSubcategoryFeaturedCategoryResponse", + }, + }, "TestContainer": { "details": { FieldMappingData: FieldMapData{ @@ -975,26 +986,32 @@ func testMapping() *GRPCMapping { }, }, }, - "Subcategory": { - "id": { - TargetName: "id", - }, - "name": { - TargetName: "name", - }, - "description": { - TargetName: "description", - }, - "isActive": { - TargetName: "is_active", + "Subcategory": { + "id": { + TargetName: "id", + }, + "name": { + TargetName: "name", + }, + "description": { + TargetName: "description", + }, + "isActive": { + TargetName: "is_active", + }, + "itemCount": { + TargetName: "item_count", + ArgumentMappings: FieldArgumentMap{ + "filters": "filters", }, - "itemCount": { - TargetName: "item_count", - ArgumentMappings: FieldArgumentMap{ - "filters": "filters", - }, + }, + "featuredCategory": { + TargetName: "featured_category", + ArgumentMappings: FieldArgumentMap{ + "includeChildren": "include_children", }, }, + }, "CategoryMetrics": { "id": { TargetName: "id", diff --git a/v2/pkg/grpctest/testdata/products.graphqls b/v2/pkg/grpctest/testdata/products.graphqls index 28e42e92b0..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 { From 7274c3f5e87ece4cbfd1ea02a94ea04bab1498d6 Mon Sep 17 00:00:00 2001 From: Dominik Korittki <23359034+dkorittki@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:31:34 +0100 Subject: [PATCH 19/19] chore: gofmt --- .../execution_plan_federation_test.go | 4 +- .../grpc_datasource/mapping_test_helper.go | 84 +++++++++---------- 2 files changed, 44 insertions(+), 44 deletions(-) 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 bf313dfd7b..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 @@ -2739,8 +2739,8 @@ var complexResolverInNestedMessageFederationMapping = &GRPCMapping{ "specs": {TargetName: "specs"}, }, "ProductSpecs": { - "id": {TargetName: "id"}, - "weight": {TargetName: "weight"}, + "id": {TargetName: "id"}, + "weight": {TargetName: "weight"}, "dimensions": {TargetName: "dimensions"}, "relatedProduct": { TargetName: "related_product", 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 76f6d79595..949457a89b 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go +++ b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go @@ -378,30 +378,30 @@ func testMapping() *GRPCMapping { Response: "ResolveProductProductDetailsResponse", }, }, - "Subcategory": { - "itemCount": { - FieldMappingData: FieldMapData{ - TargetName: "item_count", - ArgumentMappings: FieldArgumentMap{ - "filters": "filters", + "Subcategory": { + "itemCount": { + FieldMappingData: FieldMapData{ + TargetName: "item_count", + ArgumentMappings: FieldArgumentMap{ + "filters": "filters", + }, }, + RPC: "ResolveSubcategoryItemCount", + Request: "ResolveSubcategoryItemCountRequest", + Response: "ResolveSubcategoryItemCountResponse", }, - RPC: "ResolveSubcategoryItemCount", - Request: "ResolveSubcategoryItemCountRequest", - Response: "ResolveSubcategoryItemCountResponse", - }, - "featuredCategory": { - FieldMappingData: FieldMapData{ - TargetName: "featured_category", - ArgumentMappings: FieldArgumentMap{ - "includeChildren": "include_children", + "featuredCategory": { + FieldMappingData: FieldMapData{ + TargetName: "featured_category", + ArgumentMappings: FieldArgumentMap{ + "includeChildren": "include_children", + }, }, + RPC: "ResolveSubcategoryFeaturedCategory", + Request: "ResolveSubcategoryFeaturedCategoryRequest", + Response: "ResolveSubcategoryFeaturedCategoryResponse", }, - RPC: "ResolveSubcategoryFeaturedCategory", - Request: "ResolveSubcategoryFeaturedCategoryRequest", - Response: "ResolveSubcategoryFeaturedCategoryResponse", }, - }, "TestContainer": { "details": { FieldMappingData: FieldMapData{ @@ -986,32 +986,32 @@ func testMapping() *GRPCMapping { }, }, }, - "Subcategory": { - "id": { - TargetName: "id", - }, - "name": { - TargetName: "name", - }, - "description": { - TargetName: "description", - }, - "isActive": { - TargetName: "is_active", - }, - "itemCount": { - TargetName: "item_count", - ArgumentMappings: FieldArgumentMap{ - "filters": "filters", + "Subcategory": { + "id": { + TargetName: "id", }, - }, - "featuredCategory": { - TargetName: "featured_category", - ArgumentMappings: FieldArgumentMap{ - "includeChildren": "include_children", + "name": { + TargetName: "name", + }, + "description": { + TargetName: "description", + }, + "isActive": { + TargetName: "is_active", + }, + "itemCount": { + TargetName: "item_count", + ArgumentMappings: FieldArgumentMap{ + "filters": "filters", + }, + }, + "featuredCategory": { + TargetName: "featured_category", + ArgumentMappings: FieldArgumentMap{ + "includeChildren": "include_children", + }, }, }, - }, "CategoryMetrics": { "id": { TargetName: "id",