diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index f6722fd69f..97dd2ae59e 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -4224,6 +4224,214 @@ func TestExecutionEngine_Execute(t *testing.T) { )) }) }) + + t.Run("execute operation with nested fetch on one of the types", func(t *testing.T) { + + definition := ` + type User implements Node { + id: ID! + title: String! + some: User! + } + + type Admin implements Node { + id: ID! + title: String! + some: User! + } + + interface Node { + id: ID! + title: String! + some: User! + } + + type Query { + accounts: [Node!]! + }` + + firstSubgraphSDL := ` + type User implements Node @key(fields: "id") { + id: ID! + title: String! @external + some: User! + } + + type Admin implements Node @key(fields: "id") { + id: ID! + title: String! @external + some: User! + } + + interface Node { + id: ID! + title: String! + some: User! + } + + type Query { + accounts: [Node!]! + } + ` + secondSubgraphSDL := ` + type User @key(fields: "id") { + id: ID! + name: String! + title: String! + } + + type Admin @key(fields: "id") { + id: ID! + adminName: String! + title: String! + } + ` + + datasources := []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "first", + expectedPath: "/", + expectedBody: `{"query":"{accounts {__typename ... on User {some {__typename id}} ... on Admin {some {__typename id}}}}"}`, + sendResponseBody: `{"data":{"accounts":[{"__typename":"User","some":{"__typename":"User","id":"1"}},{"__typename":"Admin","some":{"__typename":"User","id":"2"}},{"__typename":"User","some":{"__typename":"User","id":"3"}}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"accounts"}, + }, + { + TypeName: "User", + FieldNames: []string{"id", "some"}, + }, + { + TypeName: "Admin", + FieldNames: []string{"id", "some"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Node", + FieldNames: []string{"id", "title", "some"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + { + TypeName: "Admin", + SelectionSet: "id", + }, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://first/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: firstSubgraphSDL, + }, + firstSubgraphSDL, + ), + }), + ), + mustGraphqlDataSourceConfiguration(t, + "id-2", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "second", + expectedPath: "/", + expectedBody: `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[{"__typename":"User","id":"1"},{"__typename":"User","id":"3"}]}}`, + sendResponseBody: `{"data":{"_entities":[{"__typename":"User","title":"User1"},{"__typename":"User","title":"User3"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "User", + FieldNames: []string{"id", "name", "title"}, + }, + { + TypeName: "Admin", + FieldNames: []string{"id", "adminName", "title"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + { + TypeName: "Admin", + SelectionSet: "id", + }, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://second/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: secondSubgraphSDL, + }, + secondSubgraphSDL, + ), + }), + ), + } + + t.Run("run", runWithoutError(ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "Accounts", + Query: ` + query Accounts { + accounts { + ... on User { + some { + title + } + } + ... on Admin { + some { + __typename + id + } + } + } + }`, + } + }, + dataSources: datasources, + expectedResponse: `{"data":{"accounts":[{"some":{"title":"User1"}},{"some":{"__typename":"User","id":"2"}},{"some":{"title":"User3"}}]}}`, + })) + }) } func testNetHttpClient(t *testing.T, testCase roundTripperTestCase) *http.Client { diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go index faff6700ee..65df7c6479 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go @@ -10444,6 +10444,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { type Query { account: Node! + accounts: [Node!]! } ` @@ -10468,6 +10469,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { type Query { account: Node + accounts: [Node!]! } ` @@ -10479,7 +10481,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "Query", - FieldNames: []string{"account"}, + FieldNames: []string{"account", "accounts"}, }, { TypeName: "User", @@ -10932,6 +10934,334 @@ func TestGraphQLDataSourceFederation(t *testing.T) { WithDefaultPostProcessor(), ) }) + + t.Run("query with nested fetch on a same path but different type", func(t *testing.T) { + RunWithPermutations( + t, + definition, + ` + query Accounts { + accounts { + ... on User { + some { + title + some { + title + } + } + } + ... on Admin { + some { + title + } + } + } + } + `, + "Accounts", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{accounts {__typename ... on User {some {some {__typename id} __typename id}} ... on Admin {some {__typename id}}}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: true, + RequiresEntityFetch: false, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + PostProcessing: EntitiesPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "accounts.@.some", resolve.ArrayPath("accounts"), resolve.PathElementWithTypeNames(resolve.ObjectPath("some"), []string{"Admin", "User"})), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + DependsOnFetchIDs: []int{0}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: true, + RequiresEntityFetch: false, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + PostProcessing: EntitiesPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "accounts.@.some.some", resolve.ArrayPath("accounts"), resolve.PathElementWithTypeNames(resolve.ObjectPath("some"), []string{"User"}), resolve.ObjectPath("some")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("accounts"), + Value: &resolve.Array{ + Path: []string{"accounts"}, + Item: &resolve.Object{ + Nullable: false, + PossibleTypes: map[string]struct{}{ + "Admin": {}, + "User": {}, + }, + TypeName: "Node", + Fields: []*resolve.Field{ + { + Name: []byte("some"), + Value: &resolve.Object{ + Path: []string{"some"}, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("title"), + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + { + Name: []byte("some"), + Value: &resolve.Object{ + Path: []string{"some"}, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("title"), + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + }, + }, + }, + }, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("some"), + Value: &resolve.Object{ + Path: []string{"some"}, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("title"), + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + }, + }, + OnTypeNames: [][]byte{[]byte("Admin")}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + ) + }) + + t.Run("query with nested fetch only on one of the types", func(t *testing.T) { + RunWithPermutations( + t, + definition, + ` + query Accounts { + accounts { + ... on User { + some { + title + } + } + ... on Admin { + some { + id + } + } + } + } + `, + "Accounts", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://first.service","body":{"query":"{accounts {__typename ... on User {some {__typename id}} ... on Admin {some {id}}}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: true, + RequiresEntityFetch: false, + Input: `{"method":"POST","url":"http://second.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + SetTemplateOutputToNullOnVariableNull: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + PostProcessing: EntitiesPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "accounts.@.some", resolve.ArrayPath("accounts"), resolve.PathElementWithTypeNames(resolve.ObjectPath("some"), []string{"User"})), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("accounts"), + Value: &resolve.Array{ + Path: []string{"accounts"}, + Item: &resolve.Object{ + Nullable: false, + PossibleTypes: map[string]struct{}{ + "Admin": {}, + "User": {}, + }, + TypeName: "Node", + Fields: []*resolve.Field{ + { + Name: []byte("some"), + Value: &resolve.Object{ + Path: []string{"some"}, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("title"), + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + }, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("some"), + Value: &resolve.Object{ + Path: []string{"some"}, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + }, + }, + }, + OnTypeNames: [][]byte{[]byte("Admin")}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + ) + }) }) t.Run("union + interface - union member is not implementing interface in one subgraph", func(t *testing.T) { @@ -11751,7 +12081,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { }, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, "accounts.@.address", resolve.ArrayPath("accounts"), resolve.ObjectPath("address")), + }, "accounts.@.address", resolve.ArrayPath("accounts"), resolve.PathElementWithTypeNames(resolve.ObjectPath("address"), []string{"Moderator"})), ), Data: &resolve.Object{ Fields: []*resolve.Field{ diff --git a/v2/pkg/engine/plan/path_builder.go b/v2/pkg/engine/plan/path_builder.go index e4d657e6d9..e9d8e306f3 100644 --- a/v2/pkg/engine/plan/path_builder.go +++ b/v2/pkg/engine/plan/path_builder.go @@ -64,7 +64,7 @@ func (p *PathBuilder) CreatePlanningPaths(operation, definition *ast.Document, r return nil } // we have to populate missing paths after the walk - p.visitor.populateMissingPahts() + p.visitor.populateMissingPaths() // walk ends in 2 cases: // - we have finished visiting document @@ -86,7 +86,7 @@ func (p *PathBuilder) CreatePlanningPaths(operation, definition *ast.Document, r return nil } // we have to populate missing paths after the walk - p.visitor.populateMissingPahts() + p.visitor.populateMissingPaths() if p.config.Debug.PrintPlanningPaths { debugMessage(fmt.Sprintf("Create planning paths run #%d", i)) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 08af83e12f..bd4d6b2aec 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -36,14 +36,14 @@ type pathBuilderVisitor struct { nodeSuggestions *NodeSuggestions // nodeSuggestions holds information about suggested data sources for each field - parentTypeNodes []ast.Node // parentTypeNodes is a stack of parent type nodes - used to determine if the parent is abstract - arrayFields []arrayField // arrayFields is a stack of array fields - used to plan nested queries - selectionSetRefs []int // selectionSetRefs is a stack of selection set refs - used to add a required fields - skipFieldsRefs []int // skipFieldsRefs holds required field refs added by planner and should not be added to user response - missingPathTracker map[string]struct{} // missingPathTracker is a map of paths which will be added on secondary runs - potentiallyMissingPathTracker map[string]struct{} // potentiallyMissingPathTracker is a map of paths which will be added on secondary runs - addedPathTracker []pathConfiguration // addedPathTracker is a list of paths which were added - addedPathTrackerIndex map[string][]int // addedPathTrackerIndex is a map of path to index in addedPathTracker + parentTypeNodes []ast.Node // parentTypeNodes is a stack of parent type nodes - used to determine if the parent is abstract + arrayFields []arrayField // arrayFields is a stack of array fields - used to plan nested queries + selectionSetRefs []selectionSetTypeInfo // selectionSetRefs is a stack of selection set refs - used to add a required fields + skipFieldsRefs []int // skipFieldsRefs holds required field refs added by planner and should not be added to user response + missingPathTracker map[string]struct{} // missingPathTracker is a map of paths which will be added on secondary runs + potentiallyMissingPathTracker map[string]struct{} // potentiallyMissingPathTracker is a map of paths which will be added on secondary runs + addedPathTracker []pathConfiguration // addedPathTracker is a list of paths which were added + addedPathTrackerIndex map[string][]int // addedPathTrackerIndex is a map of path to index in addedPathTracker fieldDependenciesForPlanners map[int][]int // fieldDependenciesForPlanners is a map[FieldRef][]plannerIdx holds list of planner ids which depends on a field ref. Used for @key dependencies fieldsPlannedOn map[int][]int // fieldsPlannedOn is a map[fieldRef][]plannerIdx holds list of planner ids which planned a field ref @@ -99,6 +99,11 @@ type arrayField struct { fieldPath string } +type selectionSetTypeInfo struct { + ref int + typeNames []string +} + type objectFetchConfiguration struct { filter *resolve.SubscriptionFilter planner DataSourceFetchPlanner @@ -114,12 +119,12 @@ type objectFetchConfiguration struct { operationType ast.OperationType } -func (c *pathBuilderVisitor) currentSelectionSet() int { +func (c *pathBuilderVisitor) currentSelectionSetInfo() (info selectionSetTypeInfo, ok bool) { if len(c.selectionSetRefs) == 0 { - return ast.InvalidRef + return selectionSetTypeInfo{ref: ast.InvalidRef}, false } - return c.selectionSetRefs[len(c.selectionSetRefs)-1] + return c.selectionSetRefs[len(c.selectionSetRefs)-1], true } func (c *pathBuilderVisitor) plannerPathType(path string) PlannerPathType { @@ -236,8 +241,8 @@ func (c *pathBuilderVisitor) hasMissingPaths() bool { return len(c.missingPathTracker) > 0 } -// handleMissingPath - checks if the path was planned and if not, adds the path to the missing path tracker -func (c *pathBuilderVisitor) populateMissingPahts() { +// populateMissingPaths - checks if the path was planned and if not, adds the path to the missing path tracker +func (c *pathBuilderVisitor) populateMissingPaths() { for path := range c.potentiallyMissingPathTracker { if _, ok := c.addedPathDSHash(path); ok { continue @@ -263,7 +268,7 @@ func (c *pathBuilderVisitor) debugPrint(args ...any) { func (c *pathBuilderVisitor) EnterDocument(operation, definition *ast.Document) { if c.selectionSetRefs == nil { - c.selectionSetRefs = make([]int, 0, 8) + c.selectionSetRefs = make([]selectionSetTypeInfo, 0, 8) } else { c.selectionSetRefs = c.selectionSetRefs[:0] } @@ -341,7 +346,6 @@ func (c *pathBuilderVisitor) EnterOperationDefinition(ref int) { func (c *pathBuilderVisitor) EnterSelectionSet(ref int) { c.debugPrint("EnterSelectionSet ref:", ref) - c.selectionSetRefs = append(c.selectionSetRefs, ref) c.parentTypeNodes = append(c.parentTypeNodes, c.walker.EnclosingTypeDefinition) // When selection is the inline fragment @@ -353,6 +357,9 @@ func (c *pathBuilderVisitor) EnterSelectionSet(ref int) { // when all paths for the query are planned. It happens in Planner.removeUnnecessaryFragmentPaths method ancestor := c.walker.Ancestor() if ancestor.Kind != ast.NodeKindInlineFragment { + c.selectionSetRefs = append(c.selectionSetRefs, selectionSetTypeInfo{ + ref: ref, + }) return } @@ -360,6 +367,29 @@ func (c *pathBuilderVisitor) EnterSelectionSet(ref int) { currentPath := c.walker.Path.DotDelimitedString() typeName := c.operation.InlineFragmentTypeConditionNameString(ancestor.Ref) + node, ok := c.definition.NodeByNameStr(typeName) + if !ok { + c.walker.StopWithInternalErr(fmt.Errorf("inline fragment type condition %q is not defined in the schema", typeName)) + return + } + + // get possible type names for the inline fragment + var typeNames []string + switch node.Kind { + case ast.NodeKindObjectTypeDefinition: + typeNames = []string{c.definition.ObjectTypeDefinitionNameString(node.Ref)} + case ast.NodeKindInterfaceTypeDefinition: + typeNames, _ = c.definition.InterfaceTypeDefinitionImplementedByObjectWithNames(node.Ref) + case ast.NodeKindUnionTypeDefinition: + typeNames, _ = c.definition.UnionTypeDefinitionMemberTypeNames(node.Ref) + default: + } + + c.selectionSetRefs = append(c.selectionSetRefs, selectionSetTypeInfo{ + ref: ref, + typeNames: typeNames, + }) + for i, planner := range c.planners { if !planner.HasPath(parentPath) { continue @@ -986,9 +1016,15 @@ func (c *pathBuilderVisitor) pushResponsePath(fieldRef int, fieldName string) { return } + var typeNames []string + if info, ok := c.currentSelectionSetInfo(); ok { + typeNames = info.typeNames + } + c.currentFetchPath = append(c.currentFetchPath, resolve.FetchItemPathElement{ - Kind: resolve.FetchItemPathElementKindObject, - Path: []string{fieldName}, + Kind: resolve.FetchItemPathElementKindObject, + Path: []string{fieldName}, + TypeNames: typeNames, }) } diff --git a/v2/pkg/engine/postprocess/deduplicate_single_fetches.go b/v2/pkg/engine/postprocess/deduplicate_single_fetches.go index 92c6685585..9fb8c1c3df 100644 --- a/v2/pkg/engine/postprocess/deduplicate_single_fetches.go +++ b/v2/pkg/engine/postprocess/deduplicate_single_fetches.go @@ -1,6 +1,8 @@ package postprocess import ( + "slices" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) @@ -15,9 +17,30 @@ func (d *deduplicateSingleFetches) ProcessFetchTree(root *resolve.FetchTreeNode) for i := range root.ChildNodes { for j := i + 1; j < len(root.ChildNodes); j++ { if root.ChildNodes[i].Item.Equals(root.ChildNodes[j].Item) { + root.ChildNodes[i].Item.FetchPath = d.mergeFetchPath(root.ChildNodes[i].Item.FetchPath, root.ChildNodes[j].Item.FetchPath) + root.ChildNodes = append(root.ChildNodes[:j], root.ChildNodes[j+1:]...) j-- } } } } + +func (d *deduplicateSingleFetches) mergeFetchPath(left, right []resolve.FetchItemPathElement) []resolve.FetchItemPathElement { + for i := range left { + left[i].TypeNames = d.mergeTypeNames(left[i].TypeNames, right[i].TypeNames) + } + + return left +} + +func (d *deduplicateSingleFetches) mergeTypeNames(left []string, right []string) []string { + if len(left) == 0 || len(right) == 0 { + return nil // if either side is empty, fetch is unscoped + } + + out := append(left, right...) + + slices.Sort(out) + return slices.Compact(out) // removes consecutive duplicates from the sorted slice +} diff --git a/v2/pkg/engine/resolve/fetch.go b/v2/pkg/engine/resolve/fetch.go index 83ed141f05..5a0efbebc2 100644 --- a/v2/pkg/engine/resolve/fetch.go +++ b/v2/pkg/engine/resolve/fetch.go @@ -72,8 +72,9 @@ func (f *FetchItem) Equals(other *FetchItem) bool { } type FetchItemPathElement struct { - Kind FetchItemPathElementKind - Path []string + Kind FetchItemPathElementKind + Path []string + TypeNames []string } type FetchItemPathElementKind string diff --git a/v2/pkg/engine/resolve/fetchtree.go b/v2/pkg/engine/resolve/fetchtree.go index f69060f1b0..cdea7029f4 100644 --- a/v2/pkg/engine/resolve/fetchtree.go +++ b/v2/pkg/engine/resolve/fetchtree.go @@ -44,6 +44,11 @@ func ObjectPath(path ...string) FetchItemPathElement { } } +func PathElementWithTypeNames(element FetchItemPathElement, typeNames []string) FetchItemPathElement { + element.TypeNames = typeNames + return element +} + func ArrayPath(path ...string) FetchItemPathElement { return FetchItemPathElement{ Kind: FetchItemPathElementKindArray, diff --git a/v2/pkg/engine/resolve/loader.go b/v2/pkg/engine/resolve/loader.go index b83e78f319..79bdcc30be 100644 --- a/v2/pkg/engine/resolve/loader.go +++ b/v2/pkg/engine/resolve/loader.go @@ -331,57 +331,41 @@ func (l *Loader) selectItemsForPath(path []FetchItemPathElement) []*astjson.Valu if len(items) == 0 { break } - if path[i].Kind == FetchItemPathElementKindObject { - items = l.selectObjectItems(items, path[i].Path) - } - if path[i].Kind == FetchItemPathElementKindArray { - items = l.selectArrayItems(items, path[i].Path) - } + items = l.selectItems(items, path[i]) } return items } -func (l *Loader) selectObjectItems(items []*astjson.Value, path []string) []*astjson.Value { - if len(items) == 0 { - return nil - } - if len(path) == 0 { - return items +func (l *Loader) isItemAllowedByTypename(obj *astjson.Value, typeNames []string) bool { + if len(typeNames) == 0 { + return true } - if len(items) == 1 { - field := items[0].Get(path...) - if field == nil { - return nil - } - if field.Type() == astjson.TypeArray { - return field.GetArray() - } - return []*astjson.Value{field} + if obj == nil || obj.Type() != astjson.TypeObject { + return true } - selected := make([]*astjson.Value, 0, len(items)) - for _, item := range items { - field := item.Get(path...) - if field == nil { - continue - } - if field.Type() == astjson.TypeArray { - selected = append(selected, field.GetArray()...) - continue - } - selected = append(selected, field) + __typeName := obj.GetStringBytes("__typename") + if __typeName == nil { + return true } - return selected + + __typeNameStr := string(__typeName) + return slices.Contains(typeNames, __typeNameStr) } -func (l *Loader) selectArrayItems(items []*astjson.Value, path []string) []*astjson.Value { +func (l *Loader) selectItems(items []*astjson.Value, element FetchItemPathElement) []*astjson.Value { if len(items) == 0 { return nil } - if len(path) == 0 { + if len(element.Path) == 0 { return items } + if len(items) == 1 { - field := items[0].Get(path...) + if !l.isItemAllowedByTypename(items[0], element.TypeNames) { + return nil + } + + field := items[0].Get(element.Path...) if field == nil { return nil } @@ -392,7 +376,10 @@ func (l *Loader) selectArrayItems(items []*astjson.Value, path []string) []*astj } selected := make([]*astjson.Value, 0, len(items)) for _, item := range items { - field := item.Get(path...) + if !l.isItemAllowedByTypename(item, element.TypeNames) { + continue + } + field := item.Get(element.Path...) if field == nil { continue } @@ -403,39 +390,6 @@ func (l *Loader) selectArrayItems(items []*astjson.Value, path []string) []*astj selected = append(selected, field) } return selected - -} - -func (l *Loader) selectNodeItems(parentItems []*astjson.Value, path []string) (items []*astjson.Value) { - if parentItems == nil { - return nil - } - if len(path) == 0 { - return parentItems - } - if len(parentItems) == 1 { - field := parentItems[0].Get(path...) - if field == nil { - return nil - } - if field.Type() == astjson.TypeArray { - return field.GetArray() - } - return []*astjson.Value{field} - } - items = make([]*astjson.Value, 0, len(parentItems)) - for _, parent := range parentItems { - field := parent.Get(path...) - if field == nil { - continue - } - if field.Type() == astjson.TypeArray { - items = append(items, field.GetArray()...) - continue - } - items = append(items, field) - } - return } func (l *Loader) itemsData(items []*astjson.Value) *astjson.Value { diff --git a/v2/pkg/engine/resolve/resolve_federation_test.go b/v2/pkg/engine/resolve/resolve_federation_test.go index fc282c9517..2547c6d104 100644 --- a/v2/pkg/engine/resolve/resolve_federation_test.go +++ b/v2/pkg/engine/resolve/resolve_federation_test.go @@ -2831,4 +2831,161 @@ func TestResolveGraphQLResponse_Federation(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"accounts":[{"name":"User"},{"type":"super"},{"subject":"posts"}]}}` })) + + t.Run("nested fetch should apply to only single type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + + accountsService := mockedDS(t, ctrl, + `{"method":"POST","url":"http://accounts","body":{"query":"{accounts {__typename ... on User {some {__typename id}} ... on Admin {some {__typename id}}}}"}}`, + `{"accounts":[{"__typename":"User","some":{"__typename":"User","id":"1"}},{"__typename":"Admin","some":{"__typename":"User","id":"2"}},{"__typename":"User","some":{"__typename":"User","id":"3"}}]}`) + + namesService := mockedDS(t, ctrl, + `{"method":"POST","url":"http://names","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[{"__typename":"User","id":"1"},{"__typename":"User","id":"3"}]}}}`, + `{"_entities":[{"__typename":"User","title":"User1"},{"__typename":"User","title":"User3"}]}`) + + return &GraphQLResponse{ + Fetches: Sequence( + SingleWithPath(&SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://accounts","body":{"query":"{accounts {__typename ... on User {some {__typename id}} ... on Admin {some {__typename id}}}}"}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: accountsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }, "query"), + SingleWithPath(&BatchEntityFetch{ + Input: BatchInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://names","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + }, + }), + }, + }, + }, + }, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: namesService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, "accounts.@.some", ArrayPath("accounts"), PathElementWithTypeNames(ObjectPath("some"), []string{"User"})), + ), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("accounts"), + Value: &Array{ + Path: []string{"accounts"}, + Item: &Object{ + Nullable: false, + PossibleTypes: map[string]struct{}{ + "Admin": {}, + "User": {}, + }, + TypeName: "Node", + Fields: []*Field{ + { + Name: []byte("some"), + Value: &Object{ + Path: []string{"some"}, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*Field{ + { + Name: []byte("title"), + Value: &String{ + Path: []string{"title"}, + }, + }, + }, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("some"), + Value: &Object{ + Path: []string{"some"}, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + IsTypeName: true, + }, + }, + { + Name: []byte("id"), + Value: &Scalar{ + Path: []string{"id"}, + }, + }, + }, + }, + OnTypeNames: [][]byte{[]byte("Admin")}, + }, + }, + }, + }, + }, + }, + }, + }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"accounts":[{"some":{"title":"User1"}},{"some":{"__typename":"User","id":"2"}},{"some":{"title":"User3"}}]}}` + })) }