diff --git a/execution/engine/child_type_mismatch_test.go b/execution/engine/child_type_mismatch_test.go new file mode 100644 index 0000000000..214549c855 --- /dev/null +++ b/execution/engine/child_type_mismatch_test.go @@ -0,0 +1,392 @@ +package engine + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/jensneuse/abstractlogger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/execution/graphql" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// This reproduces The Guild's federation gateway audit suite "child-type-mismatch". +// +// Two subgraphs: +// a: type User { id: ID @shareable } Query.users: [User!]! +// b: union Account = User | Admin +// type User @key(fields:"id") { id: ID! name: String similarAccounts: [Account!]! } +// type Admin { id: ID name: String @shareable similarAccounts: [Account!]! } +// Query.accounts: [Account!]! +// +// User.id is nullable (ID) in subgraph a and non-null (ID!) in subgraph b. The union +// Account is traversed up to three levels deep through similarAccounts. +// +// Audit data: one user u1 (name "u1-name"); accounts is [User u1, Admin a1]; similarAccounts +// always returns that same [User u1, Admin a1] list. + +const childTypeMismatchSchema = ` +type Query { + users: [User!]! + accounts: [Account!]! +} + +union Account = User | Admin + +type User { + id: ID + name: String + similarAccounts: [Account!]! +} + +type Admin { + id: ID + name: String + similarAccounts: [Account!]! +} +` + +const childTypeMismatchSubgraphASDL = ` +type User @shareable { + id: ID +} + +type Query { + users: [User!]! +} +` + +const childTypeMismatchSubgraphBSDL = ` +union Account = User | Admin + +type User @key(fields: "id") { + id: ID! + name: String + similarAccounts: [Account!]! +} + +type Admin { + id: ID + name: String @shareable + similarAccounts: [Account!]! +} + +type Query { + accounts: [Account!]! +} +` + +type childTypeMismatchUpstreamBody struct { + Query string `json:"query"` + Variables struct { + Representations []struct { + Typename string `json:"__typename"` + ID string `json:"id"` + } `json:"representations"` + } `json:"variables"` +} + +// childTypeMismatchRecord is one union member in the audit data set. +type childTypeMismatchRecord struct { + typename string + id string + name string +} + +// childTypeMismatchAccountList mirrors the audit's resolvers: accounts (and every level of +// similarAccounts) resolve to the same [User u1, Admin a1] list. +func childTypeMismatchAccountList() []childTypeMismatchRecord { + return []childTypeMismatchRecord{ + {typename: "User", id: "u1", name: "u1-name"}, + {typename: "Admin", id: "a1", name: "a1-name"}, + } +} + +func childTypeMismatchFind(typename, id string) childTypeMismatchRecord { + for _, rec := range childTypeMismatchAccountList() { + if rec.typename == typename && rec.id == id { + return rec + } + } + return childTypeMismatchRecord{typename: typename, id: id, name: id + "-name"} +} + +// childTypeMismatchResolveObject is a minimal, alias-aware GraphQL executor over the audit data. +// It deliberately keys the response by each field's *alias* (not its name), so the planner's +// per-member disambiguation aliases (e.g. __sg_merge_Admin_id) are exercised end to end: if the +// planner failed to alias, or the resolver failed to read the alias back, the JSON would not match. +func childTypeMismatchResolveObject(doc *ast.Document, selectionSetRef int, rec childTypeMismatchRecord) map[string]any { + obj := map[string]any{} + for _, selectionRef := range doc.SelectionSets[selectionSetRef].SelectionRefs { + selection := doc.Selections[selectionRef] + switch selection.Kind { + case ast.SelectionKindField: + fieldRef := selection.Ref + responseKey := doc.FieldAliasOrNameString(fieldRef) + switch doc.FieldNameString(fieldRef) { + case "__typename": + obj[responseKey] = rec.typename + case "id": + obj[responseKey] = rec.id + case "name": + obj[responseKey] = rec.name + case "similarAccounts": + if nestedSet, ok := doc.FieldSelectionSet(fieldRef); ok { + obj[responseKey] = childTypeMismatchResolveList(doc, nestedSet) + } + } + case ast.SelectionKindInlineFragment: + typeCondition := doc.InlineFragmentTypeConditionNameString(selection.Ref) + if typeCondition != "" && typeCondition != rec.typename { + continue + } + if nestedSet, ok := doc.InlineFragmentSelectionSet(selection.Ref); ok { + for key, value := range childTypeMismatchResolveObject(doc, nestedSet, rec) { + obj[key] = value + } + } + } + } + return obj +} + +func childTypeMismatchResolveList(doc *ast.Document, selectionSetRef int) []any { + records := childTypeMismatchAccountList() + out := make([]any, 0, len(records)) + for _, rec := range records { + out = append(out, childTypeMismatchResolveObject(doc, selectionSetRef, rec)) + } + return out +} + +// childTypeMismatchHandler is a faithful subgraph: it parses and validates each incoming operation +// and resolves only the root fields allowed for that subgraph. Parsing real GraphQL means a query +// that this engine wrongly leaves un-aliased (which a real subgraph would reject) cannot silently +// pass here either. +func childTypeMismatchHandler(t *testing.T, allowedRoots map[string]bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var body childTypeMismatchUpstreamBody + require.NoError(t, json.Unmarshal(raw, &body)) + + doc, report := astparser.ParseGraphqlDocumentString(body.Query) + require.Falsef(t, report.HasErrors(), "subgraph received an unparseable query %q: %s", body.Query, report.Error()) + + data := map[string]any{} + for _, rootNode := range doc.RootNodes { + if rootNode.Kind != ast.NodeKindOperationDefinition { + continue + } + operation := doc.OperationDefinitions[rootNode.Ref] + if !operation.HasSelections { + continue + } + for _, selectionRef := range doc.SelectionSets[operation.SelectionSet].SelectionRefs { + selection := doc.Selections[selectionRef] + if selection.Kind != ast.SelectionKindField { + continue + } + fieldRef := selection.Ref + fieldName := doc.FieldNameString(fieldRef) + require.Truef(t, allowedRoots[fieldName], "subgraph received unexpected root field %q", fieldName) + + responseKey := doc.FieldAliasOrNameString(fieldRef) + selectionSet, _ := doc.FieldSelectionSet(fieldRef) + switch fieldName { + case "users": + data[responseKey] = []any{ + childTypeMismatchResolveObject(&doc, selectionSet, childTypeMismatchRecord{typename: "User", id: "u1", name: "u1-name"}), + } + case "accounts": + data[responseKey] = childTypeMismatchResolveList(&doc, selectionSet) + case "_entities": + entities := make([]any, 0, len(body.Variables.Representations)) + for _, rep := range body.Variables.Representations { + entities = append(entities, childTypeMismatchResolveObject(&doc, selectionSet, childTypeMismatchFind(rep.Typename, rep.ID))) + } + data[responseKey] = entities + } + } + } + + w.Header().Set("Content-Type", "application/json") + out, err := json.Marshal(map[string]any{"data": data}) + require.NoError(t, err) + _, _ = w.Write(out) + } +} + +func newChildTypeMismatchEngine(t *testing.T, ctx context.Context, aURL, bURL string) (*ExecutionEngine, *graphql.Schema) { + t.Helper() + + subscriptionClient := graphql_datasource.NewGraphQLSubscriptionClient(ctx, + graphql_datasource.WithUpgradeClient(httpclient.DefaultNetHttpClient), + graphql_datasource.WithStreamingClient(httpclient.DefaultNetHttpClient), + ) + factory, err := graphql_datasource.NewFactory(ctx, httpclient.DefaultNetHttpClient, subscriptionClient) + require.NoError(t, err) + + // subgraph a + aSchemaConfig, err := graphql_datasource.NewSchemaConfiguration( + childTypeMismatchSubgraphASDL, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: childTypeMismatchSubgraphASDL}, + ) + require.NoError(t, err) + aConfig, err := graphql_datasource.NewConfiguration(graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: aURL, Method: http.MethodPost}, + SchemaConfiguration: aSchemaConfig, + }) + require.NoError(t, err) + aDataSource, err := plan.NewDataSourceConfiguration[graphql_datasource.Configuration]( + "subgraph-a", + factory, + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"users"}}, + {TypeName: "User", FieldNames: []string{"id"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }, + }, + }, + aConfig, + ) + require.NoError(t, err) + + // subgraph b + bSchemaConfig, err := graphql_datasource.NewSchemaConfiguration( + childTypeMismatchSubgraphBSDL, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: childTypeMismatchSubgraphBSDL}, + ) + require.NoError(t, err) + bConfig, err := graphql_datasource.NewConfiguration(graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: bURL, Method: http.MethodPost}, + SchemaConfiguration: bSchemaConfig, + }) + require.NoError(t, err) + bDataSource, err := plan.NewDataSourceConfiguration[graphql_datasource.Configuration]( + "subgraph-b", + factory, + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"accounts"}}, + {TypeName: "User", FieldNames: []string{"id", "name", "similarAccounts"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "Admin", FieldNames: []string{"id", "name", "similarAccounts"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }, + }, + }, + bConfig, + ) + require.NoError(t, err) + + schema, err := graphql.NewSchemaFromString(childTypeMismatchSchema) + require.NoError(t, err) + + engineConfig := NewConfiguration(schema) + engineConfig.AddDataSource(aDataSource) + engineConfig.AddDataSource(bDataSource) + + engine, err := NewExecutionEngine(ctx, abstractlogger.Noop{}, engineConfig, resolve.ResolverOptions{ + MaxConcurrency: 1024, + }) + require.NoError(t, err) + + return engine, schema +} + +func runChildTypeMismatch(t *testing.T, engine *ExecutionEngine, schema *graphql.Schema, operationName, query string) string { + t.Helper() + req := &graphql.Request{OperationName: operationName, Query: query} + validationResult, err := req.ValidateForSchema(schema) + require.NoError(t, err) + require.True(t, validationResult.Valid, "operation invalid: %+v", validationResult.Errors) + + writer := graphql.NewEngineResultWriter() + require.NoError(t, engine.Execute(t.Context(), req, &writer)) + return writer.String() +} + +func TestChildTypeMismatchAudit(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + subgraphA := httptest.NewServer(childTypeMismatchHandler(t, map[string]bool{"users": true})) + t.Cleanup(subgraphA.Close) + subgraphB := httptest.NewServer(childTypeMismatchHandler(t, map[string]bool{"accounts": true, "_entities": true})) + t.Cleanup(subgraphB.Close) + + engine, schema := newChildTypeMismatchEngine(t, ctx, subgraphA.URL, subgraphB.URL) + + // The four operations below are The Guild's audit suite verbatim. The first three select `id` + // on both union members and therefore hit the nullability conflict (these are the 3 Cosmo + // failures); the last selects only `name` (String in both) and was never affected. + + t.Run("flat", func(t *testing.T) { + got := runChildTypeMismatch(t, engine, schema, "", ` + { + users { id name } + accounts { + ... on User { id name } + ... on Admin { id name } + } + } + `) + assert.Equal(t, `{"data":{"users":[{"id":"u1","name":"u1-name"}],"accounts":[{"id":"u1","name":"u1-name"},{"id":"a1","name":"a1-name"}]}}`, got) + }) + + t.Run("nested one level", func(t *testing.T) { + got := runChildTypeMismatch(t, engine, schema, "NestedInternalAlias", ` + query NestedInternalAlias { + users { id name } + accounts { + ... on User { id name similarAccounts { ... on User { id name } ... on Admin { id name } } } + ... on Admin { id name similarAccounts { ... on User { id name } ... on Admin { id name } } } + } + } + `) + assert.Equal(t, `{"data":{"users":[{"id":"u1","name":"u1-name"}],"accounts":[{"id":"u1","name":"u1-name","similarAccounts":[{"id":"u1","name":"u1-name"},{"id":"a1","name":"a1-name"}]},{"id":"a1","name":"a1-name","similarAccounts":[{"id":"u1","name":"u1-name"},{"id":"a1","name":"a1-name"}]}]}}`, got) + }) + + t.Run("deeply nested", func(t *testing.T) { + got := runChildTypeMismatch(t, engine, schema, "DeeplyNestedInternalAlias", ` + query DeeplyNestedInternalAlias { + accounts { + ... on User { id name similarAccounts { ... on User { id name similarAccounts { ... on User { id name } ... on Admin { id name } } } ... on Admin { id name similarAccounts { ... on User { id name } ... on Admin { id name } } } } } + ... on Admin { id name similarAccounts { ... on User { id name similarAccounts { ... on User { id name } ... on Admin { id name } } } ... on Admin { id name similarAccounts { ... on User { id name } ... on Admin { id name } } } } } + } + } + `) + assert.Equal(t, `{"data":{"accounts":[{"id":"u1","name":"u1-name","similarAccounts":[{"id":"u1","name":"u1-name","similarAccounts":[{"id":"u1","name":"u1-name"},{"id":"a1","name":"a1-name"}]},{"id":"a1","name":"a1-name","similarAccounts":[{"id":"u1","name":"u1-name"},{"id":"a1","name":"a1-name"}]}]},{"id":"a1","name":"a1-name","similarAccounts":[{"id":"u1","name":"u1-name","similarAccounts":[{"id":"u1","name":"u1-name"},{"id":"a1","name":"a1-name"}]},{"id":"a1","name":"a1-name","similarAccounts":[{"id":"u1","name":"u1-name"},{"id":"a1","name":"a1-name"}]}]}]}}`, got) + }) + + t.Run("deeply nested name only", func(t *testing.T) { + got := runChildTypeMismatch(t, engine, schema, "DeeplyNested", ` + query DeeplyNested { + accounts { + ... on User { name similarAccounts { ... on User { name similarAccounts { ... on User { name } ... on Admin { name } } } ... on Admin { name similarAccounts { ... on User { name } ... on Admin { name } } } } } + ... on Admin { name similarAccounts { ... on User { name similarAccounts { ... on User { name } ... on Admin { name } } } ... on Admin { name similarAccounts { ... on User { name } ... on Admin { name } } } } } + } + } + `) + assert.Equal(t, `{"data":{"accounts":[{"name":"u1-name","similarAccounts":[{"name":"u1-name","similarAccounts":[{"name":"u1-name"},{"name":"a1-name"}]},{"name":"a1-name","similarAccounts":[{"name":"u1-name"},{"name":"a1-name"}]}]},{"name":"a1-name","similarAccounts":[{"name":"u1-name","similarAccounts":[{"name":"u1-name"},{"name":"a1-name"}]},{"name":"a1-name","similarAccounts":[{"name":"u1-name"},{"name":"a1-name"}]}]}]}}`, got) + }) +} diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index b99b60ff20..084a68b9d2 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -5748,10 +5748,12 @@ func TestExecutionEngine_Execute(t *testing.T) { mustGraphqlDataSourceConfiguration(t, "ds-id", mustFactory(t, testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"entity":{"__typename":"User","email":"user@test.com"}}}`, + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // The engine disambiguates the conflicting `email` selection per member, + // so a real subgraph returns it under the generated alias. + sendResponseBody: `{"data":{"entity":{"__typename":"User","__sg_merge_User_email":"user@test.com"}}}`, sendStatusCode: 200, }), ), @@ -5783,7 +5785,7 @@ func TestExecutionEngine_Execute(t *testing.T) { expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"entity":{"__typename":"Organization","email":null}}}`, + sendResponseBody: `{"data":{"entity":{"__typename":"Organization","__sg_merge_Organization_email":null}}}`, sendStatusCode: 200, }), ), diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go index b58bed6058..92e1b8c136 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go @@ -21,6 +21,7 @@ import ( "google.golang.org/grpc" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astimport" "github.com/wundergraph/graphql-go-tools/v2/pkg/astminify" "github.com/wundergraph/graphql-go-tools/v2/pkg/astnormalization" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" @@ -735,12 +736,75 @@ func (p *Planner[T]) EnterField(ref int) { } func (p *Planner[T]) addFieldArguments(upstreamFieldRef int, fieldRef int, fieldConfiguration *plan.FieldConfiguration) { + configuredArguments := 0 if fieldConfiguration != nil { for i := range fieldConfiguration.Arguments { + configuredArguments++ argumentConfiguration := fieldConfiguration.Arguments[i] p.configureArgument(upstreamFieldRef, fieldRef, *fieldConfiguration, argumentConfiguration) } } + if configuredArguments > 0 || !p.visitor.Operation.FieldHasArguments(fieldRef) { + return + } + + importer := astimport.Importer{} + importedArgs := importer.ImportArguments(p.visitor.Operation.FieldArguments(fieldRef), p.visitor.Operation, p.upstreamOperation) + for _, arg := range importedArgs { + p.upstreamOperation.AddArgumentToField(upstreamFieldRef, arg) + } + for _, arg := range p.visitor.Operation.FieldArguments(fieldRef) { + p.addVariableDefinitionsForRawArgumentValue(p.visitor.Operation.Arguments[arg].Value) + } +} + +func (p *Planner[T]) addVariableDefinitionsForRawArgumentValue(value ast.Value) { + switch value.Kind { + case ast.ValueKindObject: + for _, objectFieldRef := range p.visitor.Operation.ObjectValues[value.Ref].Refs { + p.addVariableDefinitionsForRawArgumentValue(p.visitor.Operation.ObjectFields[objectFieldRef].Value) + } + return + case ast.ValueKindList: + for _, valueRef := range p.visitor.Operation.ListValues[value.Ref].Refs { + p.addVariableDefinitionsForRawArgumentValue(p.visitor.Operation.Values[valueRef]) + } + return + case ast.ValueKindVariable: + default: + return + } + + variableName := p.visitor.Operation.VariableValueNameBytes(value.Ref) + variableNameStr := p.visitor.Operation.VariableValueNameString(value.Ref) + variableDefinition, exists := p.visitor.Operation.VariableDefinitionByNameAndOperation(p.visitor.Walker.Ancestors[0].Ref, variableName) + if !exists { + return + } + + variableDefinitionTypeRef := p.visitor.Operation.VariableDefinitions[variableDefinition].Type + variableDefinitionTypeName := p.visitor.Operation.ResolveTypeNameString(variableDefinitionTypeRef) + variableDefinitionTypeName = p.visitor.Config.Types.RenameTypeNameOnMatchStr(variableDefinitionTypeName) + + contextVariable := &resolve.ContextVariable{ + Path: []string{variableNameStr}, + Renderer: resolve.NewJSONVariableRenderer(), + } + contextVariableName, variableExists := p.variables.AddVariable(contextVariable) + if variableExists { + return + } + + importedVariableDefinition := p.visitor.Importer.ImportVariableDefinitionWithRename(variableDefinition, p.visitor.Operation, p.upstreamOperation, variableDefinitionTypeName) + p.upstreamOperation.AddImportedVariableDefinitionToOperationDefinition(p.nodes[0].Ref, importedVariableDefinition) + + if add, ok := p.addDirectivesToVariableDefinitions[variableDefinition]; ok { + for _, directive := range add { + p.addDirectiveToNode(directive, ast.Node{Kind: ast.NodeKindVariableDefinition, Ref: variableDefinition}) + } + } + + p.upstreamVariables, _ = sjson.SetRawBytes(p.upstreamVariables, variableNameStr, []byte(contextVariableName)) } func (p *Planner[T]) addCustomField(ref int) (upstreamFieldRef int) { @@ -780,6 +844,12 @@ func (p *Planner[T]) LeaveField(ref int) { // This is 3rd step of checks in addition to: planning path and skipFor functionality // if field is __typename, it is always allowed func (p *Planner[T]) allowField(ref int) bool { + // Response-only fields (partial-union members unique to the resolving subgraph) + // must appear in the response as null but must NOT be sent upstream. + if p.visitor.IsResponseOnlyField(ref) { + return false + } + fieldAliasOrName := p.visitor.Operation.FieldAliasOrNameString(ref) // In addition, we skip field if its path are equal to planner parent path diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_child_type_mismatch_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_child_type_mismatch_test.go new file mode 100644 index 0000000000..95d3d703aa --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_child_type_mismatch_test.go @@ -0,0 +1,165 @@ +package graphql_datasource + +import ( + "testing" + + . "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasourcetesting" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// Reproduces The Guild's federation gateway audit suite "child-type-mismatch": a union whose +// members select the same field (`id`) which is non-null in one subgraph (User.id: ID!) and +// nullable in another (Admin.id: ID). The generated subgraph operation must alias the field per +// member, otherwise both this engine's validator and the subgraph itself reject it. +func TestChildTypeMismatchUnion(t *testing.T) { + definition := ` + union Account = User | Admin + + type User { + id: ID + name: String + } + + type Admin { + id: ID + name: String + } + + type Query { + accounts: [Account!]! + } + ` + + subgraphSDL := ` + union Account = User | Admin + + type User @key(fields: "id") { + id: ID! + name: String + } + + type Admin { + id: ID + name: String @shareable + } + + type Query { + accounts: [Account!]! + } + ` + + datasourceConfiguration := mustDataSourceConfiguration( + t, + "accounts-service", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"accounts"}}, + {TypeName: "User", FieldNames: []string{"id", "name"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "Admin", FieldNames: []string{"id", "name"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://accounts.service"}, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{Enabled: true, ServiceSDL: subgraphSDL}, + subgraphSDL, + ), + }, + ), + ) + + planConfiguration := plan.Configuration{ + DataSources: []plan.DataSource{datasourceConfiguration}, + DisableResolveFieldPositions: true, + } + + t.Run("conflicting id across union members is aliased per member", RunTest( + definition, + ` + query Accounts { + accounts { + ... on User { id name } + ... on Admin { id name } + } + }`, + "Accounts", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://accounts.service","body":{"query":"{accounts {__typename ... on User {__sg_merge_User_id: id name} ... on Admin {__sg_merge_Admin_id: id name}}}"}}`, + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("accounts"), + Value: &resolve.Array{ + Path: []string{"accounts"}, + Nullable: false, + Item: &resolve.Object{ + Nullable: false, + PossibleTypes: map[string]struct{}{ + "Admin": {}, + "User": {}, + }, + TypeName: "Account", + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"__sg_merge_User_id"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("User")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"__sg_merge_Admin_id"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("Admin")}, + }, + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("Admin")}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + )) +} diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_interface_provides_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_interface_provides_test.go new file mode 100644 index 0000000000..7b67febc5b --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_interface_provides_test.go @@ -0,0 +1,450 @@ +package graphql_datasource + +import ( + "testing" + + "github.com/wundergraph/astjson" + + . "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasourcetesting" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +func TestGraphQLDataSourceFederation_InterfaceTypedProvides(t *testing.T) { + planConfiguration := plan.Configuration{ + DisableResolveFieldPositions: true, + DataSources: []plan.DataSource{ + interfaceProvidesDatasourceA(t), + interfaceProvidesDatasourceB(t), + interfaceProvidesDatasourceC(t), + }, + } + + t.Run("provided interface fields", RunTest( + interfaceProvidesGraphSchema, + `{ media { id animals { id name } } }`, + "", + interfaceProvidesPlan(), + planConfiguration, + WithDefaultPostProcessor(), + )) + + t.Run("provided interface fields with concrete extension", RunTest( + interfaceProvidesGraphSchema, + `{ media { id animals { id name ... on Cat { age } } } }`, + "", + interfaceProvidesWithCatAgePlan(), + planConfiguration, + WithDefaultPostProcessor(), + )) +} + +func interfaceProvidesPlan() *plan.SynchronousResponsePlan { + return &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence(resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://localhost:4250/provides-on-interface/b","body":{"query":"{media {__typename ... on Book {id animals {id name}}}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + })), + Data: interfaceProvidesResponseData([]*resolve.Field{ + interfaceProvidesAnimalIDField(nil), + interfaceProvidesAnimalNameField(nil), + }), + }, + } +} + +func interfaceProvidesWithCatAgePlan() *plan.SynchronousResponsePlan { + return &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://localhost:4250/provides-on-interface/b","body":{"query":"{media {__typename ... on Book {id animals {__typename ... on Cat {id name __typename} ... on Dog {id name}}}}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://localhost:4250/provides-on-interface/c","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Cat {__typename age}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + PostProcessing: EntitiesPostProcessingConfiguration, + RequiresEntityBatchFetch: true, + SetTemplateOutputToNullOnVariableNull: true, + Variables: resolve.NewVariables(resolve.NewResolvableObjectVariable(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("Cat")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("Cat")}, + }, + }, + })), + }, + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "media.animals", resolve.ObjectPath("media"), resolve.ArrayPath("animals")), + ), + Data: interfaceProvidesResponseData([]*resolve.Field{ + interfaceProvidesAnimalIDField([][]byte{[]byte("Cat")}), + interfaceProvidesAnimalNameField([][]byte{[]byte("Cat")}), + { + Name: []byte("age"), + Value: &resolve.Integer{ + Path: []string{"age"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("Cat")}, + }, + interfaceProvidesAnimalIDField([][]byte{[]byte("Dog")}), + interfaceProvidesAnimalNameField([][]byte{[]byte("Dog")}), + }), + }, + } +} + +func interfaceProvidesResponseData(animalFields []*resolve.Field) *resolve.Object { + return &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("media"), + Value: &resolve.Object{ + Path: []string{"media"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("Book")}, + }, + { + Name: []byte("animals"), + Value: &resolve.Array{ + Path: []string{"animals"}, + Nullable: true, + Item: &resolve.Object{ + Nullable: true, + Fields: animalFields, + PossibleTypes: map[string]struct{}{ + "Cat": {}, + "Dog": {}, + }, + TypeName: "Animal", + }, + SkipItem: func(ctx *resolve.Context, value *astjson.Value) bool { + return false + }, + }, + OnTypeNames: [][]byte{[]byte("Book")}, + }, + }, + PossibleTypes: map[string]struct{}{ + "Book": {}, + }, + TypeName: "Media", + }, + }, + }, + } +} + +func interfaceProvidesAnimalIDField(onTypeNames [][]byte) *resolve.Field { + return &resolve.Field{ + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: onTypeNames, + } +} + +func interfaceProvidesAnimalNameField(onTypeNames [][]byte) *resolve.Field { + return &resolve.Field{ + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + Nullable: true, + }, + OnTypeNames: onTypeNames, + } +} + +func interfaceProvidesDatasourceA(t *testing.T) plan.DataSource { + t.Helper() + + return mustDataSourceConfiguration( + t, + "provides-on-interface-a", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"media", "book"}}, + {TypeName: "Book", FieldNames: []string{"id", "animals"}}, + {TypeName: "Dog", ExternalFieldNames: []string{"id", "name"}}, + {TypeName: "Cat", ExternalFieldNames: []string{"id"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "Media", FieldNames: []string{"id"}}, + {TypeName: "Animal", FieldNames: []string{"id"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Book", SelectionSet: "id"}, + {TypeName: "Dog", SelectionSet: "id"}, + {TypeName: "Cat", SelectionSet: "id"}, + }, + Provides: plan.FederationFieldConfigurations{ + {TypeName: "Query", FieldName: "book", SelectionSet: "animals { ... on Dog { name } }"}, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://localhost:4250/provides-on-interface/a"}, + SchemaConfiguration: mustSchema(t, &FederationConfiguration{ + Enabled: true, + ServiceSDL: interfaceProvidesSubgraphASDL, + }, interfaceProvidesSubgraphASDL), + }), + ) +} + +func interfaceProvidesDatasourceB(t *testing.T) plan.DataSource { + t.Helper() + + return mustDataSourceConfiguration( + t, + "provides-on-interface-b", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"media"}}, + {TypeName: "Book", FieldNames: []string{"id"}, ExternalFieldNames: []string{"animals"}}, + {TypeName: "Dog", ExternalFieldNames: []string{"id", "name"}}, + {TypeName: "Cat", ExternalFieldNames: []string{"id", "name"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "Media", FieldNames: []string{"id", "animals"}}, + {TypeName: "Animal", FieldNames: []string{"id", "name"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Book", SelectionSet: "id", DisableEntityResolver: true}, + }, + Provides: plan.FederationFieldConfigurations{ + {TypeName: "Query", FieldName: "media", SelectionSet: "animals { id name }"}, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://localhost:4250/provides-on-interface/b"}, + SchemaConfiguration: mustSchema(t, &FederationConfiguration{ + Enabled: true, + ServiceSDL: interfaceProvidesSubgraphBSDL, + }, interfaceProvidesSubgraphBSDL), + }), + ) +} + +func interfaceProvidesDatasourceC(t *testing.T) plan.DataSource { + t.Helper() + + return mustDataSourceConfiguration( + t, + "provides-on-interface-c", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Book", FieldNames: []string{"id", "animals"}}, + {TypeName: "Dog", FieldNames: []string{"id", "name", "age"}}, + {TypeName: "Cat", FieldNames: []string{"id", "name", "age"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "Media", FieldNames: []string{"id", "animals"}}, + {TypeName: "Animal", FieldNames: []string{"id", "name"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Book", SelectionSet: "id"}, + {TypeName: "Dog", SelectionSet: "id"}, + {TypeName: "Cat", SelectionSet: "id"}, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://localhost:4250/provides-on-interface/c"}, + SchemaConfiguration: mustSchema(t, &FederationConfiguration{ + Enabled: true, + ServiceSDL: interfaceProvidesSubgraphCSDL, + }, interfaceProvidesSubgraphCSDL), + }), + ) +} + +const interfaceProvidesGraphSchema = ` +schema { + query: Query +} + +type Query { + media: Media + book: Book +} + +interface Media { + id: ID! + animals: [Animal] +} + +interface Animal { + id: ID! + name: String +} + +type Book implements Media { + id: ID! + animals: [Animal] +} + +type Dog implements Animal { + id: ID! + name: String + age: Int +} + +type Cat implements Animal { + id: ID! + name: String + age: Int +} +` + +const interfaceProvidesSubgraphASDL = ` +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external", "@provides"] + ) + +type Query { + media: Media @shareable + book: Book @provides(fields: "animals { ... on Dog { name } }") +} + +interface Media { + id: ID! +} + +interface Animal { + id: ID! +} + +type Book implements Media @key(fields: "id") { + id: ID! + animals: [Animal] @shareable +} + +type Dog implements Animal @key(fields: "id") { + id: ID! @external + name: String @external +} + +type Cat implements Animal @key(fields: "id") { + id: ID! @external +} +` + +const interfaceProvidesSubgraphBSDL = ` +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@provides", "@external"] + ) + +type Query { + media: Media @shareable @provides(fields: "animals { id name }") +} + +interface Media { + id: ID! + animals: [Animal] +} + +interface Animal { + id: ID! + name: String +} + +type Book implements Media { + id: ID! @shareable + animals: [Animal] @external +} + +type Dog implements Animal { + id: ID! @external + name: String @external +} + +type Cat implements Animal { + id: ID! @external + name: String @external +} +` + +const interfaceProvidesSubgraphCSDL = ` +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +interface Media { + id: ID! + animals: [Animal] +} + +interface Animal { + id: ID! + name: String +} + +type Book implements Media @key(fields: "id") { + id: ID! + animals: [Animal] @shareable +} + +type Dog implements Animal @key(fields: "id") { + id: ID! + name: String @shareable + age: Int +} + +type Cat implements Animal @key(fields: "id") { + id: ID! + name: String @shareable + age: Int +} +` diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_provides_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_provides_test.go index 1b4947969f..1955a15704 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_provides_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_provides_test.go @@ -3,6 +3,8 @@ package graphql_datasource import ( "testing" + "github.com/wundergraph/astjson" + . "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasourcetesting" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" @@ -443,3 +445,212 @@ func TestGraphQLDataSourceFederation_NestedRequiresProvides(t *testing.T) { }) } + +func TestGraphQLDataSourceFederation_ProvidesFieldSetOverUnionTypedField(t *testing.T) { + definition := ` + type Query { + media: [Media] + } + + union Media = Book | Movie + + type Book { + id: ID! + title: String! + } + + type Movie { + id: ID! + } + ` + + service1SDL := ` + type Query { + media: [Media] @shareable @provides(fields: "... on Book { title }") + } + + union Media = Book | Movie + + type Book @key(fields: "id") { + id: ID! + title: String! @external + } + + type Movie @key(fields: "id") { + id: ID! + } + ` + + service1DataSourceConfig := mustDataSourceConfiguration( + t, + "service1", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"media"}}, + {TypeName: "Book", FieldNames: []string{"id"}, ExternalFieldNames: []string{"title"}}, + {TypeName: "Movie", FieldNames: []string{"id"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Book", SelectionSet: "id"}, + {TypeName: "Movie", SelectionSet: "id"}, + }, + Provides: plan.FederationFieldConfigurations{ + {TypeName: "Query", FieldName: "media", SelectionSet: "... on Book { title }"}, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://service1", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: service1SDL, + }, + service1SDL, + ), + }, + ), + ) + + service2SDL := ` + type Query { + _empty: String + } + + type Book @key(fields: "id") { + id: ID! + title: String! + } + + type Movie @key(fields: "id") { + id: ID! + } + ` + + service2DataSourceConfig := mustDataSourceConfiguration( + t, + "service2", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"_empty"}}, + {TypeName: "Book", FieldNames: []string{"id", "title"}}, + {TypeName: "Movie", FieldNames: []string{"id"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Book", SelectionSet: "id"}, + {TypeName: "Movie", SelectionSet: "id"}, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://service2", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: service2SDL, + }, + service2SDL, + ), + }, + ), + ) + + planConfiguration := plan.Configuration{ + DisableResolveFieldPositions: true, + DataSources: []plan.DataSource{ + service1DataSourceConfig, + service2DataSourceConfig, + }, + } + + t.Run("query provided union member field inline", RunTest( + definition, + ` + query ProvidesUnion { + media { + ... on Book { + id + title + } + ... on Movie { + id + } + } + } + `, + "ProvidesUnion", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence(resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://service1","body":{"query":"{media {__typename ... on Book {id title} ... on Movie {id}}}"}}`, + DataSource: &Source{}, + PostProcessing: resolve.PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + })), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("media"), + Value: &resolve.Array{ + Path: []string{"media"}, + Nullable: true, + Item: &resolve.Object{ + Nullable: true, + PossibleTypes: map[string]struct{}{ + "Book": {}, + "Movie": {}, + }, + TypeName: "Media", + Fields: []*resolve.Field{ + { + Name: []byte("id"), + OnTypeNames: [][]byte{[]byte("Book")}, + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("title"), + OnTypeNames: [][]byte{[]byte("Book")}, + Value: &resolve.String{ + Path: []string{"title"}, + }, + }, + { + Name: []byte("id"), + OnTypeNames: [][]byte{[]byte("Movie")}, + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + }, + }, + }, + SkipItem: func(ctx *resolve.Context, arrayItem *astjson.Value) bool { + return false + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + )) +} diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_requires_arguments_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_requires_arguments_test.go new file mode 100644 index 0000000000..4bc6ef7fa7 --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_requires_arguments_test.go @@ -0,0 +1,307 @@ +package graphql_datasource + +import ( + "testing" + + . "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasourcetesting" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +func TestGraphQLDataSourceFederation_RequiresSameFieldWithDifferentArguments(t *testing.T) { + definition := ` + type Query { + products: [Product] + } + + type Product { + upc: String! + weight: Int + price(currency: String!): Int + estimateA: Int + estimateB: Int + } + ` + + catalogSDL := ` + type Query { + products: [Product] + } + + type Product @key(fields: "upc") { + upc: String! + weight: Int + price(currency: String!): Int + } + ` + + inventorySDL := ` + type Product @key(fields: "upc") { + upc: String! + weight: Int @external + price(currency: String!): Int @external + estimateA: Int @requires(fields: "price(currency: \"USD\") weight") + estimateB: Int @requires(fields: "price(currency: \"EUR\") weight") + } + ` + + catalog := mustDataSourceConfiguration( + t, + "catalog", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"products"}, + }, + { + TypeName: "Product", + FieldNames: []string{"upc", "weight", "price"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "Product", + SelectionSet: "upc", + }, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://catalog.service", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: catalogSDL, + }, + catalogSDL, + ), + }), + ) + + inventory := mustDataSourceConfiguration( + t, + "inventory", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Product", + FieldNames: []string{"upc", "estimateA", "estimateB"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "Product", + SelectionSet: "upc", + }, + }, + Requires: plan.FederationFieldConfigurations{ + { + TypeName: "Product", + FieldName: "estimateA", + SelectionSet: `price(currency: "USD") weight`, + }, + { + TypeName: "Product", + FieldName: "estimateB", + SelectionSet: `price(currency: "EUR") weight`, + }, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://inventory.service", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: inventorySDL, + }, + inventorySDL, + ), + }), + ) + + RunWithPermutations( + t, + definition, + ` + query Products { + products { + upc + estimateA + estimateB + } + }`, + "Products", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://catalog.service","body":{"query":"query($a: String!, $b: String!){products {upc price(currency: $a) weight __internal_price: price(currency: $b) __typename}}","variables":{"a":"USD","b":"EUR"}}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, + FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: true, + Input: `{"method":"POST","url":"http://inventory.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename estimateA}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + PostProcessing: EntitiesPostProcessingConfiguration, + SetTemplateOutputToNullOnVariableNull: true, + Variables: resolve.NewVariables( + resolve.NewResolvableObjectVariable(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + { + Name: []byte("price"), + Value: &resolve.Integer{ + Path: []string{"price"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + { + Name: []byte("weight"), + Value: &resolve.Integer{ + Path: []string{"weight"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + { + Name: []byte("upc"), + Value: &resolve.String{ + Path: []string{"upc"}, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + }, + }), + ), + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "products", resolve.ArrayPath("products")), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + DependsOnFetchIDs: []int{0}, + }, + FetchConfiguration: resolve.FetchConfiguration{ + RequiresEntityBatchFetch: true, + Input: `{"method":"POST","url":"http://inventory.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename estimateB}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + PostProcessing: EntitiesPostProcessingConfiguration, + SetTemplateOutputToNullOnVariableNull: true, + Variables: resolve.NewVariables( + resolve.NewResolvableObjectVariable(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + { + Name: []byte("price"), + Value: &resolve.Integer{ + Path: []string{"__internal_price"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + { + Name: []byte("weight"), + Value: &resolve.Integer{ + Path: []string{"weight"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + { + Name: []byte("upc"), + Value: &resolve.String{ + Path: []string{"upc"}, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + }, + }), + ), + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "products", resolve.ArrayPath("products")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("products"), + Value: &resolve.Array{ + Path: []string{"products"}, + Nullable: true, + Item: &resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("upc"), + Value: &resolve.String{ + Path: []string{"upc"}, + }, + }, + { + Name: []byte("estimateA"), + Value: &resolve.Integer{ + Path: []string{"estimateA"}, + Nullable: true, + }, + }, + { + Name: []byte("estimateB"), + Value: &resolve.Integer{ + Path: []string{"estimateB"}, + Nullable: true, + }, + }, + }, + TypeName: "Product", + PossibleTypes: map[string]struct{}{ + "Product": {}, + }, + }, + }, + }, + }, + }, + }, + }, + plan.Configuration{ + DataSources: []plan.DataSource{ + catalog, + inventory, + }, + DisableResolveFieldPositions: true, + }, + WithDefaultPostProcessor(), + ) +} diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_partial_union_complex_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_partial_union_complex_test.go new file mode 100644 index 0000000000..e8dc6dd716 --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_partial_union_complex_test.go @@ -0,0 +1,333 @@ +package graphql_datasource + +import ( + "fmt" + "testing" + + . "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasourcetesting" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// Reproduces The Guild federation-gateway-audit "partial-union-complex" suite. +// +// A union appears below shareable fields on a shared entity. The union has +// different members per subgraph: +// +// subgraph A: union Action = Common | OnlyA +// subgraph B: union Action = Common | OnlyB +// +// The planner must restrict the inline fragments it sends to a subgraph to the +// union members that subgraph actually defines. Sending `... on OnlyB` to A (or +// `... on OnlyA` to B) makes the subgraph reject the query (HTTP 400/500). +func partialUnionComplexPlanConfiguration(t *testing.T) plan.Configuration { + subgraphASDL := ` + type Query { + rootA: Container + shared: Container @shareable + } + + type Container @key(fields: "id") { + id: ID! + wrapper: Wrapper @shareable + } + + type Wrapper @shareable { + actions: [Action!]! @shareable + } + + union Action = Common | OnlyA + + type Common @shareable { + label: String + } + + type OnlyA { + a: String + } + ` + + subgraphA := mustDataSourceConfiguration( + t, + "subgraph-a", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"rootA", "shared"}}, + {TypeName: "Container", FieldNames: []string{"id", "wrapper"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "Wrapper", FieldNames: []string{"actions"}}, + {TypeName: "Common", FieldNames: []string{"label"}}, + {TypeName: "OnlyA", FieldNames: []string{"a"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Container", SelectionSet: "id"}, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://subgraph-a"}, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{Enabled: true, ServiceSDL: subgraphASDL}, + subgraphASDL, + ), + }, + ), + ) + + subgraphBSDL := ` + type Query { + rootB: Container + shared: Container @shareable + } + + type Container @key(fields: "id") { + id: ID! + wrapper: Wrapper @shareable + bWrapper: Wrapper + } + + type Wrapper @shareable { + actions: [Action!]! @shareable + } + + union Action = Common | OnlyB + + type Common @shareable { + label: String + } + + type OnlyB { + b: String + } + ` + + subgraphB := mustDataSourceConfiguration( + t, + "subgraph-b", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"rootB", "shared"}}, + {TypeName: "Container", FieldNames: []string{"id", "wrapper", "bWrapper"}}, + }, + ChildNodes: []plan.TypeField{ + {TypeName: "Wrapper", FieldNames: []string{"actions"}}, + {TypeName: "Common", FieldNames: []string{"label"}}, + {TypeName: "OnlyB", FieldNames: []string{"b"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "Container", SelectionSet: "id"}, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://subgraph-b"}, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{Enabled: true, ServiceSDL: subgraphBSDL}, + subgraphBSDL, + ), + }, + ), + ) + + return plan.Configuration{ + DataSources: []plan.DataSource{ + subgraphA, + subgraphB, + }, + DisableResolveFieldPositions: true, + } +} + +const partialUnionComplexDefinition = ` + type Container { + id: ID! + wrapper: Wrapper + bWrapper: Wrapper + } + + type Wrapper { + actions: [Action!]! + } + + union Action = Common | OnlyA | OnlyB + + type Common { + label: String + } + + type OnlyA { + a: String + } + + type OnlyB { + b: String + } + + type Query { + rootA: Container + rootB: Container + shared: Container + } +` + +func partialUnionTypenameField(onTypeNames ...string) *resolve.Field { + field := &resolve.Field{ + Name: []byte("__typename"), + Value: &resolve.String{Path: []string{"__typename"}, IsTypeName: true}, + } + for _, name := range onTypeNames { + field.OnTypeNames = append(field.OnTypeNames, []byte(name)) + } + return field +} + +func partialUnionStringField(name, onTypeName string) *resolve.Field { + return &resolve.Field{ + Name: []byte(name), + Value: &resolve.String{Path: []string{name}, Nullable: true}, + OnTypeNames: [][]byte{[]byte(onTypeName)}, + } +} + +// partialUnionSingleFetchPlan builds the expected plan for a query that resolves +// ` { wrapper { actions { ... } } }` in a single subgraph fetch. +func partialUnionSingleFetchPlan(url, rootField, query string, itemFields []*resolve.Field) *plan.SynchronousResponsePlan { + return &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: fmt.Sprintf(`{"method":"POST","url":"%s","body":{"query":%q}}`, url, query), + PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte(rootField), + Value: &resolve.Object{ + Path: []string{rootField}, + Nullable: true, + PossibleTypes: map[string]struct{}{"Container": {}}, + TypeName: "Container", + Fields: []*resolve.Field{ + { + Name: []byte("wrapper"), + Value: &resolve.Object{ + Path: []string{"wrapper"}, + Nullable: true, + PossibleTypes: map[string]struct{}{"Wrapper": {}}, + TypeName: "Wrapper", + Fields: []*resolve.Field{ + { + Name: []byte("actions"), + Value: &resolve.Array{ + Path: []string{"actions"}, + Item: &resolve.Object{ + PossibleTypes: map[string]struct{}{"Common": {}, "OnlyA": {}, "OnlyB": {}}, + TypeName: "Action", + Fields: itemFields, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// TestPartialUnionComplex reproduces The Guild federation-gateway-audit +// "partial-union-complex" suite. The union Action has different members per +// subgraph (A: Common|OnlyA, B: Common|OnlyB) and the members are non-entity value +// types reached via a @shareable path on the shared Container entity. +// +// When the union field is resolvable by multiple candidate subgraphs, members not +// common to all candidates must not change the response shape based on which +// candidate the planner picks. A member unique to the resolving subgraph is kept in +// the response as null but excluded from the upstream fetch; a foreign member is +// dropped entirely. (case 5 - an entity hop forced into a single subgraph - keeps +// that subgraph's members and is exercised end-to-end by the audit.) +func TestPartialUnionComplex(t *testing.T) { + planConfiguration := partialUnionComplexPlanConfiguration(t) + + t.Run("case 1 - rootA: own member OnlyA kept as null, foreign OnlyB dropped", RunTest( + partialUnionComplexDefinition, + `query { rootA { wrapper { actions { __typename ... on Common { label } ... on OnlyA { a } ... on OnlyB { b } } } } }`, + "", + partialUnionSingleFetchPlan( + "http://subgraph-a", "rootA", + "{rootA {wrapper {actions {__typename ... on Common {label} ... on OnlyA {__typename}}}}}", + []*resolve.Field{ + partialUnionTypenameField(), + partialUnionStringField("label", "Common"), + partialUnionStringField("a", "OnlyA"), + partialUnionTypenameField("OnlyA"), + }, + ), + planConfiguration, + WithDefaultPostProcessor(), + )) + + t.Run("case 2 - rootB: own member OnlyB kept as null, foreign OnlyA dropped", RunTest( + partialUnionComplexDefinition, + `query { rootB { wrapper { actions { __typename ... on Common { label } ... on OnlyA { a } ... on OnlyB { b } } } } }`, + "", + partialUnionSingleFetchPlan( + "http://subgraph-b", "rootB", + "{rootB {wrapper {actions {__typename ... on Common {label} ... on OnlyB {__typename}}}}}", + []*resolve.Field{ + partialUnionTypenameField(), + partialUnionStringField("label", "Common"), + partialUnionStringField("b", "OnlyB"), + partialUnionTypenameField("OnlyB"), + }, + ), + planConfiguration, + WithDefaultPostProcessor(), + )) + + t.Run("case 3 - rootA: only foreign OnlyB requested, pruned to __typename", RunTest( + partialUnionComplexDefinition, + `query { rootA { wrapper { actions { __typename ... on OnlyB { b } } } } }`, + "", + partialUnionSingleFetchPlan( + "http://subgraph-a", "rootA", + "{rootA {wrapper {actions {__typename}}}}", + []*resolve.Field{ + partialUnionTypenameField(), + }, + ), + planConfiguration, + WithDefaultPostProcessor(), + )) + + t.Run("case 4 - shared: resolvable in both, only common member kept", RunTest( + partialUnionComplexDefinition, + `query { shared { wrapper { actions { __typename ... on Common { label } ... on OnlyA { a } ... on OnlyB { b } } } } }`, + "", + partialUnionSingleFetchPlan( + "http://subgraph-a", "shared", + "{shared {wrapper {actions {__typename ... on Common {label}}}}}", + []*resolve.Field{ + partialUnionTypenameField(), + partialUnionStringField("label", "Common"), + }, + ), + planConfiguration, + WithDefaultPostProcessor(), + )) +} diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go index bde0f49289..ad06b69088 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go @@ -8283,7 +8283,7 @@ func TestGraphQLDataSource(t *testing.T) { resolve.Single(&resolve.SingleFetch{ FetchConfiguration: resolve.FetchConfiguration{ DataSource: &Source{}, - Input: `{"method":"POST","url":"https://example.com/graphql","body":{"query":"{entity {__typename ... on User {email} ... on Organization {email}}}"}}`, + Input: `{"method":"POST","url":"https://example.com/graphql","body":{"query":"{entity {__typename ... on User {__sg_merge_User_email: email} ... on Organization {__sg_merge_Organization_email: email}}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), @@ -8304,14 +8304,14 @@ func TestGraphQLDataSource(t *testing.T) { { Name: []byte("email"), Value: &resolve.String{ - Path: []string{"email"}, + Path: []string{"__sg_merge_User_email"}, }, OnTypeNames: [][]byte{[]byte("User")}, }, { Name: []byte("email"), Value: &resolve.String{ - Path: []string{"email"}, + Path: []string{"__sg_merge_Organization_email"}, Nullable: true, }, OnTypeNames: [][]byte{[]byte("Organization")}, diff --git a/v2/pkg/engine/datasource/graphql_datasource/multihop_compound_key_test.go b/v2/pkg/engine/datasource/graphql_datasource/multihop_compound_key_test.go new file mode 100644 index 0000000000..af6f594e5c --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/multihop_compound_key_test.go @@ -0,0 +1,236 @@ +package graphql_datasource + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astnormalization" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +func TestGraphQLDataSourceMultiHopCompoundKeyEntityRoute(t *testing.T) { + definition := unsafeparser.ParseGraphqlDocumentString(multiHopCompoundKeyDefinition) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&definition)) + + operation := unsafeparser.ParseGraphqlDocumentString(`query { + topProducts { + products { + id + pid + price { + price + } + category { + mainProduct { + id + } + id + tag + } + } + first { id } + selected { id } + } + }`) + + report := &operationreport.Report{} + astnormalization.NewNormalizer(true, true).NormalizeOperation(&operation, &definition, report) + require.False(t, report.HasErrors(), report.Error()) + + astvalidation.DefaultOperationValidator().Validate(&operation, &definition, report) + require.False(t, report.HasErrors(), report.Error()) + + planner, err := plan.NewPlanner(plan.Configuration{ + DataSources: multiHopCompoundKeyGraphQLDataSources(t), + DisableIncludeInfo: true, + DisableIncludeFieldDependencies: true, + }) + require.NoError(t, err) + + preparedPlan := planner.Plan(&operation, &definition, "", report) + require.False(t, report.HasErrors(), report.Error()) + + responsePlan, ok := preparedPlan.(*plan.SynchronousResponsePlan) + require.True(t, ok) + require.NotNil(t, responsePlan.Response) + require.Len(t, responsePlan.Response.RawFetches, 4) + + require.Equal(t, "", responsePlan.Response.RawFetches[0].ResponsePath) + require.Empty(t, responsePlan.Response.RawFetches[0].Fetch.Dependencies().DependsOnFetchIDs) + + require.Equal(t, "topProducts.products", responsePlan.Response.RawFetches[1].ResponsePath) + require.Equal(t, []int{0}, responsePlan.Response.RawFetches[1].Fetch.Dependencies().DependsOnFetchIDs) + + require.Equal(t, "topProducts", responsePlan.Response.RawFetches[2].ResponsePath) + require.Equal(t, []int{0, 1}, responsePlan.Response.RawFetches[2].Fetch.Dependencies().DependsOnFetchIDs) + + require.Equal(t, "topProducts.products", responsePlan.Response.RawFetches[3].ResponsePath) + require.Equal(t, []int{0, 1}, responsePlan.Response.RawFetches[3].Fetch.Dependencies().DependsOnFetchIDs) +} + +func multiHopCompoundKeyGraphQLDataSources(t *testing.T) []plan.DataSource { + t.Helper() + + return []plan.DataSource{ + mustMultiHopDataSourceConfiguration(t, "catalog", []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"topProducts"}}, + {TypeName: "ProductList", FieldNames: []string{"products"}}, + {TypeName: "Product", FieldNames: []string{"id", "category"}}, + {TypeName: "Category", FieldNames: []string{"mainProduct", "id", "tag"}}, + }, []plan.FederationFieldConfiguration{ + {TypeName: "ProductList", SelectionSet: "products { id }"}, + {TypeName: "Product", SelectionSet: "id"}, + {TypeName: "Category", SelectionSet: "id"}, + }, multiHopCatalogSubgraphSchema), + mustMultiHopDataSourceConfiguration(t, "link", []plan.TypeField{ + {TypeName: "Product", FieldNames: []string{"id", "pid"}}, + }, []plan.FederationFieldConfiguration{ + {TypeName: "Product", SelectionSet: "id"}, + {TypeName: "Product", SelectionSet: "id pid"}, + }, multiHopLinkSubgraphSchema), + mustMultiHopDataSourceConfiguration(t, "collection", []plan.TypeField{ + {TypeName: "ProductList", FieldNames: []string{"products", "first", "selected"}}, + {TypeName: "Product", FieldNames: []string{"id", "pid"}}, + }, []plan.FederationFieldConfiguration{ + {TypeName: "ProductList", SelectionSet: "products { id pid }"}, + {TypeName: "ProductList", SelectionSet: "products { id }", DisableEntityResolver: true}, + {TypeName: "Product", SelectionSet: "id pid"}, + {TypeName: "Product", SelectionSet: "id", DisableEntityResolver: true}, + }, multiHopCollectionSubgraphSchema), + mustMultiHopDataSourceConfiguration(t, "pricing", []plan.TypeField{ + {TypeName: "ProductList", FieldNames: []string{"products", "first", "selected"}}, + {TypeName: "Product", FieldNames: []string{"id", "price", "pid", "category"}}, + {TypeName: "Category", FieldNames: []string{"id", "tag"}}, + {TypeName: "Price", FieldNames: []string{"price"}}, + }, []plan.FederationFieldConfiguration{ + {TypeName: "ProductList", SelectionSet: "products { category { id tag } id pid } selected { id }"}, + {TypeName: "ProductList", SelectionSet: "products { id }", DisableEntityResolver: true}, + {TypeName: "ProductList", SelectionSet: "products { id pid }", DisableEntityResolver: true}, + {TypeName: "Product", SelectionSet: "category { id tag } id pid"}, + {TypeName: "Product", SelectionSet: "id", DisableEntityResolver: true}, + {TypeName: "Product", SelectionSet: "id pid", DisableEntityResolver: true}, + {TypeName: "Category", SelectionSet: "id tag"}, + {TypeName: "Category", SelectionSet: "id", DisableEntityResolver: true}, + }, multiHopPricingSubgraphSchema), + } +} + +func mustMultiHopDataSourceConfiguration(t *testing.T, id string, rootNodes []plan.TypeField, keys []plan.FederationFieldConfiguration, schema string) plan.DataSource { + t.Helper() + + ds, err := plan.NewDataSourceConfiguration[Configuration](id, &Factory[Configuration]{}, &plan.DataSourceMetadata{ + RootNodes: rootNodes, + FederationMetaData: plan.FederationMetaData{ + Keys: keys, + }, + }, mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "https://example.com/" + id, + }, + SchemaConfiguration: mustSchema(t, &FederationConfiguration{ + Enabled: true, + ServiceSDL: schema, + }, schema), + })) + require.NoError(t, err) + return ds +} + +const multiHopCompoundKeyDefinition = ` +type Query { + topProducts: ProductList! +} + +type ProductList { + products: [Product!]! + first: Product + selected: Product +} + +type Product { + id: String! + pid: String + category: Category + price: Price +} + +type Category { + mainProduct: Product! + id: String! + tag: String! +} + +type Price { + price: Float! +} +` + +const multiHopCatalogSubgraphSchema = ` +type Query { + topProducts: ProductList! +} + +type ProductList @key(fields: "products { id }") { + products: [Product!]! +} + +type Product @key(fields: "id") { + id: String! + category: Category +} + +type Category @key(fields: "id") { + mainProduct: Product! + id: String! + tag: String +} +` + +const multiHopCollectionSubgraphSchema = ` +type ProductList @key(fields: "products { id pid }") @key(fields: "products { id }", resolvable: false) { + products: [Product!]! + first: Product + selected: Product +} + +type Product @key(fields: "id pid") @key(fields: "id", resolvable: false) { + id: String! + pid: String +} +` + +const multiHopLinkSubgraphSchema = ` +type Product @key(fields: "id") @key(fields: "id pid") { + id: String! + pid: String! +} +` + +const multiHopPricingSubgraphSchema = ` +type ProductList @key(fields: "products { id pid category { id tag } } selected { id }") { + products: [Product!]! + first: Product + selected: Product +} + +type Product @key(fields: "id pid category { id tag }") { + id: String! + price: Price + pid: String + category: Category +} + +type Category @key(fields: "id tag") { + id: String! + tag: String +} + +type Price { + price: Float! +} +` diff --git a/v2/pkg/engine/datasource/graphql_datasource/raw_arguments_variable_definitions_test.go b/v2/pkg/engine/datasource/graphql_datasource/raw_arguments_variable_definitions_test.go new file mode 100644 index 0000000000..24c5a31544 --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/raw_arguments_variable_definitions_test.go @@ -0,0 +1,74 @@ +package graphql_datasource + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" +) + +func TestAddFieldArgumentsImportsVariableDefinitionsForRawCopiedArguments(t *testing.T) { + operation := unsafeparser.ParseGraphqlDocumentString(`query RawArgs($id: ID!, $term: String = "all") { + product(id: $id, filter: { term: $term }) { + name + } + }`) + operationDefinitionRef := operation.RootNodes[0].Ref + downstreamFieldRef := graphqlDatasourceTestFieldRef(t, &operation, "product") + + upstreamOperation := ast.NewDocument() + upstreamSelectionSet := upstreamOperation.AddSelectionSet() + upstreamOperationNode := upstreamOperation.AddOperationDefinitionToRootNodes(ast.OperationDefinition{ + OperationType: ast.OperationTypeQuery, + SelectionSet: upstreamSelectionSet.Ref, + HasSelections: true, + }) + upstreamField := upstreamOperation.AddField(ast.Field{ + Name: upstreamOperation.Input.AppendInputString("product"), + }) + upstreamOperation.AddSelection(upstreamSelectionSet.Ref, ast.Selection{ + Kind: ast.SelectionKindField, + Ref: upstreamField.Ref, + }) + + walker := astvisitor.NewWalker(4) + walker.Ancestors = append(walker.Ancestors, ast.Node{ + Kind: ast.NodeKindOperationDefinition, + Ref: operationDefinitionRef, + }) + + planner := &Planner[Configuration]{ + visitor: &plan.Visitor{ + Operation: &operation, + Walker: &walker, + }, + upstreamOperation: upstreamOperation, + nodes: []ast.Node{upstreamOperationNode}, + addDirectivesToVariableDefinitions: map[int][]int{}, + } + + planner.addFieldArguments(upstreamField.Ref, downstreamFieldRef, nil) + + got, err := astprinter.PrintString(upstreamOperation) + require.NoError(t, err) + assert.Equal(t, `query($id: ID!, $term: String = "all"){product(id: $id, filter: {term: $term})}`, got) + assert.Equal(t, `{"term":$$1$$,"id":$$0$$}`, string(planner.upstreamVariables)) + assert.Len(t, planner.variables, 2) +} + +func graphqlDatasourceTestFieldRef(t *testing.T, operation *ast.Document, fieldName string) int { + t.Helper() + for i := range operation.Fields { + if operation.FieldNameString(i) == fieldName { + return i + } + } + t.Fatalf("field %q not found", fieldName) + return ast.InvalidRef +} diff --git a/v2/pkg/engine/plan/abstract_provides_union_edge_cases_test.go b/v2/pkg/engine/plan/abstract_provides_union_edge_cases_test.go new file mode 100644 index 0000000000..45e7127354 --- /dev/null +++ b/v2/pkg/engine/plan/abstract_provides_union_edge_cases_test.go @@ -0,0 +1,32 @@ +package plan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" +) + +func TestIsProvidedFieldUsesInterfaceFragmentStrippedPath(t *testing.T) { + visitor := &collectNodesDSVisitor{ + providesEntries: map[string]struct{}{ + providedFieldKey("SomeInterface", "providedField", "query.node.providedField"): {}, + }, + } + + got := visitor.isProvidedField(fieldInfo{ + typeName: "SomeInterface", + fieldName: "providedField", + currentPath: "query.node.$0SomeInterface.providedField", + currentPathWithoutFragments: "query.node.providedField", + onFragment: true, + onInterfaceFragment: true, + enclosingTypeDefinition: ast.Node{ + Kind: ast.NodeKindInterfaceTypeDefinition, + Ref: 0, + }, + }) + + assert.True(t, got) +} diff --git a/v2/pkg/engine/plan/abstract_selection_field_alias.go b/v2/pkg/engine/plan/abstract_selection_field_alias.go new file mode 100644 index 0000000000..6a5e974718 --- /dev/null +++ b/v2/pkg/engine/plan/abstract_selection_field_alias.go @@ -0,0 +1,165 @@ +package plan + +import ( + "fmt" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" +) + +// fieldMergingAliasVisitor walks the operation before data source selection and assigns +// planner-generated aliases to fields that share a response name across concrete union/interface +// members but whose subgraph types differ only in nullability. Running before node selection keeps +// the response-name based paths consistent across every subsequent planning phase. +type fieldMergingAliasVisitor struct { + walker *astvisitor.Walker + dataSources []DataSource + rewriters []*fieldSelectionRewriter +} + +func (v *fieldMergingAliasVisitor) EnterDocument(operation, definition *ast.Document) { + v.rewriters = v.rewriters[:0] + for _, ds := range v.dataSources { + // A data source without an upstream schema cannot be the one that would reject the merge. + rewriter, err := newFieldSelectionRewriter(operation, definition, ds) + if err != nil { + continue + } + v.rewriters = append(v.rewriters, rewriter) + } +} + +func (v *fieldMergingAliasVisitor) EnterField(ref int) { + // Every data source is consulted: a field already aliased by an earlier one is skipped (its + // alias is now defined), so the pass is idempotent and still catches a conflict that only some + // subgraph's schema exhibits. + for _, rewriter := range v.rewriters { + if _, err := rewriter.aliasNullabilityConflictingMemberFields(ref); err != nil { + v.walker.StopWithInternalErr(fmt.Errorf("failed to alias conflicting member fields: %w", err)) + return + } + } +} + +// upstreamFieldMergingAliasPrefix marks an alias that the planner generated (not the client) +// to disambiguate fields which share a response name across non-overlapping concrete members +// of a union/interface but whose types differ in nullability in the subgraph schema +// (e.g. User.id: ID! vs Admin.id: ID). +// +// Both the strict GraphQL "OverlappingFieldsCanBeMerged" validation in this engine and a real +// subgraph (graphql-js) reject such a selection set +// +// accounts { ... on User { id } ... on Admin { id } } +// +// even though the two branches can never co-resolve. The spec-suggested remedy is to use a +// different alias on each field. We do exactly that, sending +// +// accounts { ... on User { __sg_merge_User_id: id } ... on Admin { __sg_merge_Admin_id: id } } +// +// and recover the original response name when building the resolve tree (see Visitor.EnterField). +const upstreamFieldMergingAliasPrefix = "__sg_merge_" + +// aliasNullabilityConflictingMemberFields detects fields that share a response name across +// concrete object-type members of the abstract field's selection set and whose subgraph types +// differ only in nullability. Each such field is given a deterministic, planner-generated alias so +// that the upstream operation is valid against the subgraph schema. It is a no-op unless a genuine +// nullability-only conflict exists, keeping the blast radius limited to that case. +func (r *fieldSelectionRewriter) aliasNullabilityConflictingMemberFields(fieldRef int) (changed bool, err error) { + // Scalar fields (and any field without a selection set) carry no member fragments to compare. + if !r.operation.FieldHasSelections(fieldRef) { + return false, nil + } + + info, err := r.collectFieldInformation(fieldRef) + if err != nil { + return false, err + } + + // Only concrete object-type members are mutually exclusive at runtime. We conservatively skip + // interface fragments, since a single concrete type could implement two interfaces and overlap. + if len(info.inlineFragmentsOnObjects) < 2 { + return false, nil + } + + type occurrence struct { + typeName string + fieldRef int + typeRef int + } + + occurrencesByName := make(map[string][]occurrence) + for _, fragment := range info.inlineFragmentsOnObjects { + node, hasNode := r.upstreamDefinition.NodeByNameStr(fragment.typeName) + if !hasNode { + continue + } + for _, field := range fragment.selectionSetInfo.fields { + if field.fieldName == typeNameField { + continue + } + memberFieldRef := r.operation.Selections[field.fieldSelectionRef].Ref + // A client-provided alias owns the response name; never overwrite it. + if r.operation.FieldAliasIsDefined(memberFieldRef) { + continue + } + fieldDefinitionRef, exists := r.upstreamDefinition.NodeFieldDefinitionByName(node, ast.ByteSlice(field.fieldName)) + if !exists { + continue + } + occurrencesByName[field.fieldName] = append(occurrencesByName[field.fieldName], occurrence{ + typeName: fragment.typeName, + fieldRef: memberFieldRef, + typeRef: r.upstreamDefinition.FieldDefinitionType(fieldDefinitionRef), + }) + } + } + + for fieldName, occurrences := range occurrencesByName { + if len(occurrences) < 2 { + continue + } + typeRefs := make([]int, len(occurrences)) + for i := range occurrences { + typeRefs[i] = occurrences[i].typeRef + } + if !r.memberFieldTypesNeedAlias(typeRefs) { + continue + } + for _, occurrence := range occurrences { + r.setGeneratedFieldAlias(occurrence.fieldRef, occurrence.typeName, fieldName) + } + changed = true + } + + return changed, nil +} + +// memberFieldTypesNeedAlias reports whether the member field types differ only in nullability. +// Aliasing is required (and safe) when every pair is compatible ignoring nullability but at least +// one pair is not byte-for-byte equal. A genuinely incompatible pair (e.g. Int vs String) is left +// untouched so it surfaces through the normal validation path rather than being silently masked. +func (r *fieldSelectionRewriter) memberFieldTypesNeedAlias(typeRefs []int) bool { + nullabilityDiffers := false + for i := range typeRefs { + for j := i + 1; j < len(typeRefs); j++ { + if r.upstreamDefinition.TypesAreEqualDeep(typeRefs[i], typeRefs[j]) { + continue + } + // Not equal: only safe to alias when the sole difference is nullability. + if !r.upstreamDefinition.TypesAreCompatibleIgnoringNullability(typeRefs[i], typeRefs[j]) { + return false + } + nullabilityDiffers = true + } + } + return nullabilityDiffers +} + +// setGeneratedFieldAlias assigns a deterministic planner-generated alias to a member field. +func (r *fieldSelectionRewriter) setGeneratedFieldAlias(fieldRef int, typeName, fieldName string) { + alias := upstreamFieldMergingAliasPrefix + typeName + "_" + fieldName + r.operation.Fields[fieldRef].Alias = ast.Alias{ + IsDefined: true, + Name: r.operation.Input.AppendInputBytes([]byte(alias)), + } +} diff --git a/v2/pkg/engine/plan/abstract_selection_partial_union.go b/v2/pkg/engine/plan/abstract_selection_partial_union.go new file mode 100644 index 0000000000..73ca08c65f --- /dev/null +++ b/v2/pkg/engine/plan/abstract_selection_partial_union.go @@ -0,0 +1,536 @@ +package plan + +import ( + "slices" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" +) + +// partialUnionFieldInfo captures, for a single field occurrence in the operation, +// the enclosing type and field name plus the datasources that hold a node for it. +// It is used to compute which datasources can actually resolve a union field. +type partialUnionFieldInfo struct { + typeName string + fieldName string + // nodeDataSources are the datasources that have a node for this field. Note this + // is a superset of the datasources that can actually reach it: a datasource may + // define a field on a type it can never produce on this path (e.g. it defines + // Wrapper.actions but cannot resolve the Wrapper because the producing field is + // owned by another subgraph). Reachability is resolved in reachableCandidates. + nodeDataSources map[DSHash]struct{} +} + +// prunePartialUnionMembers implements the "partial union" intersection rule used by +// spec-compliant federation routers (e.g. Hive Router's narrow_partial_union_paths). +// +// The same union can have different members in different subgraphs, for example: +// +// subgraph A: union Action = Common | OnlyA +// subgraph B: union Action = Common | OnlyB +// +// When a union field can still be resolved by multiple candidate subgraphs (a +// @shareable path on a shared entity), the response shape must not depend on which +// candidate the planner ultimately picks. Only members defined by EVERY candidate +// subgraph are guaranteed safe. For the remaining members: +// +// - a member unique to a subset but defined by the RESOLVING subgraph (the one the +// union field is selected on) is kept in the response shape but excluded from the +// upstream fetch, so it resolves to null (matching the reference behaviour); +// - a member not defined by the resolving subgraph (foreign) is dropped entirely - +// no element of that type can be returned, and fetching it is invalid. +// +// Without this, the planner marks a foreign member (e.g. OnlyB.b) as resolvable in +// the subgraph that defines it and creates an entity hop to fetch it. That is invalid +// for a non-keyed union value type reached via a shareable list - the entity hop +// re-resolves the whole list - and the dependent planner collapses to an empty +// selection set, producing an HTTP 500 at runtime. +// +// It returns the field refs that must be kept in the response but excluded from the +// upstream fetch, and whether the operation was modified. When modified, the caller +// must rebuild the datasource suggestions from the pruned operation. +func (p *NodeSelectionBuilder) prunePartialUnionMembers(operation, definition *ast.Document, suggestions *NodeSuggestions) (map[int]struct{}, bool) { + if suggestions == nil { + return nil, false + } + + // Index, per field ref, the enclosing type/field name and the datasources that + // have a node for it. Field refs are stable across datasources. + fields := make(map[int]*partialUnionFieldInfo) + for i := range suggestions.items { + item := suggestions.items[i] + if item.IsOrphan { + continue + } + info, ok := fields[item.FieldRef] + if !ok { + info = &partialUnionFieldInfo{ + typeName: item.TypeName, + fieldName: item.FieldName, + nodeDataSources: make(map[DSHash]struct{}), + } + fields[item.FieldRef] = info + } + info.nodeDataSources[item.DataSourceHash] = struct{}{} + } + + dataSourceByHash := make(map[DSHash]DataSource, len(p.config.DataSources)) + for _, ds := range p.config.DataSources { + dataSourceByHash[ds.Hash()] = ds + } + + pruner := &partialUnionPruner{ + operation: operation, + definition: definition, + fields: fields, + parentByFieldRef: buildParentFieldMap(operation), + dataSourceByHash: dataSourceByHash, + candidatesMemo: make(map[int]map[DSHash]struct{}), + hopFreeMemo: make(map[int]map[DSHash]struct{}), + responseOnly: make(map[int]struct{}), + } + + modified := false + for fieldRef := range fields { + candidates := pruner.reachableCandidates(fieldRef) + // A single reachable candidate has no cross-subgraph ambiguity: the + // per-datasource selection rewriter already prunes members the datasource + // does not define, so we leave it untouched to avoid changing plans that + // already work (e.g. a union reached through a hop forced into one subgraph). + if len(candidates) < 2 { + continue + } + if pruner.pruneUnionFieldToIntersection(fieldRef, candidates) { + modified = true + } + } + + if !modified { + return nil, false + } + return pruner.responseOnly, true +} + +type partialUnionPruner struct { + operation *ast.Document + definition *ast.Document + fields map[int]*partialUnionFieldInfo + parentByFieldRef map[int]int + dataSourceByHash map[DSHash]DataSource + candidatesMemo map[int]map[DSHash]struct{} + hopFreeMemo map[int]map[DSHash]struct{} + responseOnly map[int]struct{} +} + +// hopFreeCandidates returns the datasources that can resolve the field at fieldRef +// WITHOUT an entity jump - i.e. following the same-source parent chain all the way to +// a root field. Unlike reachableCandidates, it does not allow reaching a field via an +// entity jump into the enclosing type. When exactly one datasource is hop-free for a +// union field, that subgraph resolves the list inline, so its own non-shared members +// can be kept as response-only nulls; members it does not define are foreign and must +// be dropped (fetching them would require re-resolving the list via a hop). +func (u *partialUnionPruner) hopFreeCandidates(fieldRef int) map[DSHash]struct{} { + if cached, ok := u.hopFreeMemo[fieldRef]; ok { + return cached + } + u.hopFreeMemo[fieldRef] = map[DSHash]struct{}{} + + info, ok := u.fields[fieldRef] + if !ok { + return u.hopFreeMemo[fieldRef] + } + + parentRef, hasParent := u.parentByFieldRef[fieldRef] + var parentHopFree map[DSHash]struct{} + if hasParent && parentRef != ast.InvalidRef { + parentHopFree = u.hopFreeCandidates(parentRef) + } + + result := make(map[DSHash]struct{}, len(info.nodeDataSources)) + for dsHash := range info.nodeDataSources { + if !hasParent || parentRef == ast.InvalidRef { + // Root field: hop-free wherever the field node exists. + result[dsHash] = struct{}{} + continue + } + if _, ok := parentHopFree[dsHash]; ok { + result[dsHash] = struct{}{} + } + } + + u.hopFreeMemo[fieldRef] = result + return result +} + +// reachableCandidates returns the datasources that can actually resolve the field at +// fieldRef, accounting for federation reachability: a datasource is a candidate if it +// has a node for the field and either (a) the enclosing type is an entity it can be +// jumped into, or (b) the field is a root field, or (c) it is itself a reachable +// candidate for the parent field. This mirrors how a federation planner only keeps a +// subgraph in play for a path when there is a real route to it. +func (u *partialUnionPruner) reachableCandidates(fieldRef int) map[DSHash]struct{} { + if cached, ok := u.candidatesMemo[fieldRef]; ok { + return cached + } + // Guard against cycles in the memo while recursing. + u.candidatesMemo[fieldRef] = map[DSHash]struct{}{} + + info, ok := u.fields[fieldRef] + if !ok { + return u.candidatesMemo[fieldRef] + } + + parentRef, hasParent := u.parentByFieldRef[fieldRef] + var parentCandidates map[DSHash]struct{} + if hasParent && parentRef != ast.InvalidRef { + parentCandidates = u.reachableCandidates(parentRef) + } + + result := make(map[DSHash]struct{}, len(info.nodeDataSources)) + for dsHash := range info.nodeDataSources { + ds, ok := u.dataSourceByHash[dsHash] + if !ok { + continue + } + switch { + case ds.HasEntity(info.typeName) && ds.HasRootNodeWithTypename(info.typeName): + // The enclosing type is an entity in this datasource, so it can be + // reached via an entity jump regardless of the parent path. + result[dsHash] = struct{}{} + case !hasParent || parentRef == ast.InvalidRef: + // Root field: reachable wherever the field node exists. + result[dsHash] = struct{}{} + default: + if _, ok := parentCandidates[dsHash]; ok { + result[dsHash] = struct{}{} + } + } + } + + u.candidatesMemo[fieldRef] = result + return result +} + +// pruneUnionFieldToIntersection rewrites the union field's selection so that members +// not common to every candidate datasource are either kept as response-only (when the +// resolving subgraph defines them) or dropped (when foreign). Returns true if the +// operation was modified. +func (u *partialUnionPruner) pruneUnionFieldToIntersection(fieldRef int, candidates map[DSHash]struct{}) bool { + unionTypeName, allMemberSet, ok := u.unionFieldMembers(fieldRef) + if !ok { + return false + } + + intersection := u.memberIntersection(unionTypeName, candidates, allMemberSet) + // No conflict: every union member is shared by all candidates, nothing to prune. + if len(intersection) == len(allMemberSet) { + return false + } + + selectionSetRef, ok := u.operation.FieldSelectionSet(fieldRef) + if !ok { + return false + } + + resolvingMembers := u.resolvingSubgraphMembers(fieldRef, unionTypeName) + + kept, changed := u.rewriteUnionMembers(selectionSetRef, allMemberSet, intersection, resolvingMembers) + if !changed { + return false + } + + u.replaceSelectionSet(selectionSetRef, kept) + return true +} + +// unionFieldMembers resolves the field's union return type and the set of its members +// in the federated graph schema. It reports ok=false when the field does not return a +// union, or when any member is an entity - entity-member unions are resolved via the +// existing entity-hop mechanism (e.g. "union-intersection" / "union query on array") +// and must not be touched here; only non-entity value-type members, which cannot be +// resolved independently, need the partial-union treatment. +func (u *partialUnionPruner) unionFieldMembers(fieldRef int) (unionTypeName string, allMemberSet map[string]struct{}, ok bool) { + info, ok := u.fields[fieldRef] + if !ok { + return "", nil, false + } + enclosingNode, ok := u.definition.NodeByNameStr(info.typeName) + if !ok { + return "", nil, false + } + fieldTypeNode, ok := u.definition.FieldTypeNode([]byte(info.fieldName), enclosingNode) + if !ok || fieldTypeNode.Kind != ast.NodeKindUnionTypeDefinition { + return "", nil, false + } + allMembers, ok := u.definition.UnionTypeDefinitionMemberTypeNames(fieldTypeNode.Ref) + if !ok || slices.ContainsFunc(allMembers, u.isEntityType) { + return "", nil, false + } + + allMemberSet = make(map[string]struct{}, len(allMembers)) + for _, member := range allMembers { + allMemberSet[member] = struct{}{} + } + return u.definition.UnionTypeDefinitionNameString(fieldTypeNode.Ref), allMemberSet, true +} + +// memberIntersection returns the union members defined by EVERY candidate datasource, +// restricted to members of the federated union (allMemberSet). Each datasource carries +// its own union members in its upstream schema. +func (u *partialUnionPruner) memberIntersection(unionTypeName string, candidates map[DSHash]struct{}, allMemberSet map[string]struct{}) map[string]struct{} { + intersection := make(map[string]struct{}, len(allMemberSet)) + first := true + for dsHash := range candidates { + members := u.datasourceUnionMembers(dsHash, unionTypeName) + if first { + for _, member := range members { + if _, isMember := allMemberSet[member]; isMember { + intersection[member] = struct{}{} + } + } + first = false + continue + } + current := make(map[string]struct{}, len(members)) + for _, member := range members { + current[member] = struct{}{} + } + for member := range intersection { + if _, retained := current[member]; !retained { + delete(intersection, member) + } + } + } + return intersection +} + +// resolvingSubgraphMembers returns the union members defined by the subgraph that +// resolves the list inline - the unique hop-free candidate. Non-shared members defined +// there can be kept as response-only nulls; the rest are dropped. When the resolving +// subgraph is ambiguous (zero or multiple hop-free candidates), this returns an empty +// set so all non-shared members are dropped, which is the safe intersection behaviour. +func (u *partialUnionPruner) resolvingSubgraphMembers(fieldRef int, unionTypeName string) map[string]struct{} { + members := make(map[string]struct{}) + hopFree := u.hopFreeCandidates(fieldRef) + if len(hopFree) != 1 { + return members + } + for dsHash := range hopFree { + for _, member := range u.datasourceUnionMembers(dsHash, unionTypeName) { + members[member] = struct{}{} + } + } + return members +} + +// rewriteUnionMembers walks the union field's selections and decides each member +// fragment's fate: shared members and non-union fragments are kept untouched; a +// non-shared member is kept (as a response-only null) or dropped via +// classifyNonSharedMember. It returns the selection refs to keep and whether the set +// of fetched fields changed. +func (u *partialUnionPruner) rewriteUnionMembers(selectionSetRef int, allMemberSet, intersection, resolvingMembers map[string]struct{}) (kept []int, changed bool) { + selectionRefs := u.operation.SelectionSets[selectionSetRef].SelectionRefs + kept = make([]int, 0, len(selectionRefs)) + for _, selectionRef := range selectionRefs { + selection := u.operation.Selections[selectionRef] + if selection.Kind != ast.SelectionKindInlineFragment { + kept = append(kept, selectionRef) + continue + } + + member := string(u.operation.InlineFragmentTypeConditionName(selection.Ref)) + if _, isMember := allMemberSet[member]; !isMember { + // fragment on the union type itself or an interface - leave untouched. + kept = append(kept, selectionRef) + continue + } + if _, shared := intersection[member]; shared { + kept = append(kept, selectionRef) + continue + } + + keep, memberChanged := u.classifyNonSharedMember(selection.Ref, member, resolvingMembers) + if keep { + kept = append(kept, selectionRef) + } + if memberChanged { + changed = true + } + } + return kept, changed +} + +// classifyNonSharedMember decides the fate of an inline fragment on a non-shared union +// member. keep reports whether the fragment stays in the selection; changed reports +// whether this alters what is fetched. A member the resolving subgraph defines is kept +// as a response-only null (or, when it has only __typename, kept and fetched as-is); a +// foreign member, or one with nested selections that cannot be safely nulled, is dropped. +func (u *partialUnionPruner) classifyNonSharedMember(inlineFragmentRef int, member string, resolvingMembers map[string]struct{}) (keep, changed bool) { + if _, defined := resolvingMembers[member]; !defined { + return false, true // foreign - drop + } + marked, onlyTypename := u.tryMarkResponseOnly(inlineFragmentRef) + switch { + case marked: + return true, true // kept in the response, excluded from the upstream fetch + case onlyTypename: + return true, false // nothing but __typename - keep and fetch normally + default: + return false, true // nested selections we cannot safely null out - drop + } +} + +// replaceSelectionSet replaces the selections of selectionSetRef with kept, adding a +// __typename when pruning removed every selection so the set is never empty. +func (u *partialUnionPruner) replaceSelectionSet(selectionSetRef int, kept []int) { + u.operation.EmptySelectionSet(selectionSetRef) + for _, selectionRef := range kept { + u.operation.AddSelectionRefToSelectionSet(selectionSetRef, selectionRef) + } + if len(kept) == 0 { + u.operation.AddSelectionRefToSelectionSet(selectionSetRef, u.newTypenameSelection()) + } +} + +// datasourceUnionMembers returns the named union's members as defined by the +// datasource's upstream schema, or nil when the datasource is unknown. +func (u *partialUnionPruner) datasourceUnionMembers(dsHash DSHash, unionTypeName string) []string { + ds, ok := u.dataSourceByHash[dsHash] + if !ok { + return nil + } + return upstreamUnionMemberNames(ds, unionTypeName) +} + +// tryMarkResponseOnly attempts to keep an inline fragment in the response while +// excluding its leaf fields from the upstream fetch (so they resolve to null). It +// adds a __typename to the fragment when needed so the upstream fragment is never +// empty after the leaf fields are excluded, and records the leaf field refs as +// response-only. +// +// Returns marked=true when the fragment was successfully made response-only. +// Returns onlyTypename=true when the fragment contains only __typename (nothing to +// null out - the caller keeps it as a normal fetch). Both false means the fragment +// has nested selections that cannot be safely nulled and should be dropped. +func (u *partialUnionPruner) tryMarkResponseOnly(inlineFragmentRef int) (marked bool, onlyTypename bool) { + selectionSetRef, ok := u.operation.InlineFragmentSelectionSet(inlineFragmentRef) + if !ok { + return false, false + } + + allSelections := u.operation.SelectionSets[selectionSetRef].SelectionRefs + fieldSelections := u.operation.SelectionSetFieldSelections(selectionSetRef) + if len(allSelections) != len(fieldSelections) { + // nested inline fragments / fragment spreads - unsafe to null out. + return false, false + } + + leafFieldRefs := make([]int, 0, len(fieldSelections)) + hasTypename := false + for _, selectionRef := range fieldSelections { + fieldRef := u.operation.Selections[selectionRef].Ref + if _, hasChildren := u.operation.FieldSelectionSet(fieldRef); hasChildren { + // nested object selection - unsafe to null out. + return false, false + } + if u.operation.FieldNameString(fieldRef) == "__typename" { + hasTypename = true + continue + } + leafFieldRefs = append(leafFieldRefs, fieldRef) + } + + if len(leafFieldRefs) == 0 { + return false, true + } + + if !hasTypename { + u.operation.AddSelectionRefToSelectionSet(selectionSetRef, u.newTypenameSelection()) + } + + for _, fieldRef := range leafFieldRefs { + u.responseOnly[fieldRef] = struct{}{} + } + + return true, false +} + +// newTypenameSelection creates a __typename field selection in the operation document +// and returns its selection ref. +func (u *partialUnionPruner) newTypenameSelection() int { + field := u.operation.AddField(ast.Field{ + Name: u.operation.Input.AppendInputString("__typename"), + }) + return u.operation.AddSelectionToDocument(ast.Selection{ + Ref: field.Ref, + Kind: ast.SelectionKindField, + }) +} + +// buildParentFieldMap maps every field ref in the operation to its parent field ref +// (ast.InvalidRef for root fields). Inline fragments are transparent: a field inside +// `... on Member { ... }` keeps the union field as its parent, which is what matters +// for resolving the reachability of object-field chains. +func buildParentFieldMap(operation *ast.Document) map[int]int { + parentByFieldRef := make(map[int]int) + + var walkSelectionSet func(selectionSetRef, parentFieldRef int) + walkSelectionSet = func(selectionSetRef, parentFieldRef int) { + if selectionSetRef == ast.InvalidRef { + return + } + for _, selectionRef := range operation.SelectionSets[selectionSetRef].SelectionRefs { + selection := operation.Selections[selectionRef] + switch selection.Kind { + case ast.SelectionKindField: + fieldRef := selection.Ref + parentByFieldRef[fieldRef] = parentFieldRef + if childSelectionSetRef, ok := operation.FieldSelectionSet(fieldRef); ok { + walkSelectionSet(childSelectionSetRef, fieldRef) + } + case ast.SelectionKindInlineFragment: + if childSelectionSetRef, ok := operation.InlineFragmentSelectionSet(selection.Ref); ok { + walkSelectionSet(childSelectionSetRef, parentFieldRef) + } + } + } + } + + for i := range operation.OperationDefinitions { + operationDefinition := operation.OperationDefinitions[i] + if !operationDefinition.HasSelections { + continue + } + walkSelectionSet(operationDefinition.SelectionSet, ast.InvalidRef) + } + + return parentByFieldRef +} + +// isEntityType reports whether the named type is an entity (or otherwise an +// independently resolvable root node) in any datasource. Such types are reachable +// via entity hops and are handled by the existing planner, so the partial-union pass +// leaves unions with entity members untouched. +func (u *partialUnionPruner) isEntityType(typeName string) bool { + for _, ds := range u.dataSourceByHash { + if ds.HasEntity(typeName) || ds.HasRootNodeWithTypename(typeName) { + return true + } + } + return false +} + +// upstreamUnionMemberNames returns the member type names of the named union as +// defined by the datasource's upstream (subgraph) schema. Returns nil if the +// datasource has no upstream schema or does not define the union. +func upstreamUnionMemberNames(ds DataSource, unionTypeName string) []string { + upstreamDefinition, ok := ds.UpstreamSchema() + if !ok { + return nil + } + unionNode, ok := upstreamDefinition.NodeByNameStr(unionTypeName) + if !ok || unionNode.Kind != ast.NodeKindUnionTypeDefinition { + return nil + } + members, _ := upstreamDefinition.UnionTypeDefinitionMemberTypeNames(unionNode.Ref) + return members +} diff --git a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go index 82f05fddea..6b51dcf810 100644 --- a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go +++ b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go @@ -425,6 +425,8 @@ func (f *collectNodesDSVisitor) EnterField(fieldRef int, itemIds []int, treeNode return err } + isProvided := f.isProvidedField(info) + // For pubsub entities could also be a child node, so checking for only root nodes is not enough, so we check for entity keys existence // when we have no keys, it is still expensive to create an index entry for a seen key path, // so we skip check as a whole when there is no entity with such a name @@ -448,6 +450,9 @@ func (f *collectNodesDSVisitor) EnterField(fieldRef int, itemIds []int, treeNode } } } + if isProvided { + f.collectProvidedKeyForPath(fieldRef, info) + } // this is the check for the global suggestions if _, ok := f.hasSuggestionForFieldOnCurrentDataSource(itemIds, fieldRef); ok { @@ -460,8 +465,6 @@ func (f *collectNodesDSVisitor) EnterField(fieldRef int, itemIds []int, treeNode return nil } - _, isProvided := f.providesEntries[providedFieldKey(info.typeName, info.fieldName, info.currentPath)] - if info.isTypeName && f.isInterfaceObject(info.typeName) { // we should not add a typename on the interface object // to not select it during node suggestions calculation @@ -530,6 +533,70 @@ func (f *collectNodesDSVisitor) EnterField(fieldRef int, itemIds []int, treeNode return nil } +func (f *collectNodesDSVisitor) collectProvidedKeyForPath(fieldRef int, info fieldInfo) { + if !info.onFragment || info.currentPath == info.currentPathWithoutFragments { + return + } + if f.operation.FieldHasSelections(fieldRef) { + return + } + if info.isTypeName || f.dataSource.HasEntity(info.typeName) { + return + } + + f.keys = append(f.keys, DSKeyInfo{ + DSHash: f.dataSource.Hash(), + TypeName: info.typeName, + Path: info.parentPath, + Keys: []KeyInfo{ + { + DSHash: f.dataSource.Hash(), + Source: true, + Target: false, + TypeName: info.typeName, + SelectionSet: info.fieldName, + FieldPaths: []KeyInfoFieldPath{ + {Path: info.currentPath}, + }, + }, + }, + }) +} + +func (f *collectNodesDSVisitor) isProvidedField(info fieldInfo) bool { + if _, ok := f.providesEntries[providedFieldKey(info.typeName, info.fieldName, info.currentPath)]; ok { + return true + } + + if !info.onFragment || info.currentPath == info.currentPathWithoutFragments { + return false + } + + if info.onUnionFragment { + _, ok := f.providesEntries[providedFieldKey(info.typeName, info.fieldName, info.currentPathWithoutFragments)] + return ok + } + + if info.onInterfaceFragment { + if _, ok := f.providesEntries[providedFieldKey(info.typeName, info.fieldName, info.currentPathWithoutFragments)]; ok { + return true + } + } + + if info.enclosingTypeDefinition.Kind != ast.NodeKindObjectTypeDefinition { + return false + } + + for _, interfaceRef := range f.definition.ObjectTypeDefinitions[info.enclosingTypeDefinition.Ref].ImplementsInterfaces.Refs { + interfaceName := f.definition.ResolveTypeNameString(interfaceRef) + if _, ok := f.providesEntries[providedFieldKey(interfaceName, info.fieldName, info.currentPathWithoutFragments)]; ok { + return true + } + } + + return false +} + func (f *collectNodesDSVisitor) applySuggestions() { // copy local suggestions to the global nodes suggestions for _, suggestion := range f.localSuggestions { @@ -570,7 +637,7 @@ func IsMutationOrQueryRootType(typeName string) bool { type fieldInfo struct { typeName, fieldName, fieldAliasOrName, parentPath, currentPath string - onFragment, isTypeName bool + onFragment, onUnionFragment, onInterfaceFragment, isTypeName bool parentPathWithoutFragment string possibleTypeNames []string currentPathWithoutFragments string @@ -594,6 +661,8 @@ func (f *treeBuilderVisitor) collectFieldInfo(fieldRef int) { isTypeName := fieldName == typeNameField parentPath := f.walker.Path.DotDelimitedString() onFragment := f.walker.Path.EndsWithFragment() + onUnionFragment := f.isOnAbstractFragment(ast.NodeKindUnionTypeDefinition) + onInterfaceFragment := f.isOnAbstractFragment(ast.NodeKindInterfaceTypeDefinition) parentPathWithoutFragment := f.walker.Path.WithoutInlineFragmentNames().DotDelimitedString() currentPath := fmt.Sprintf("%s.%s", parentPath, fieldAliasOrName) @@ -607,9 +676,24 @@ func (f *treeBuilderVisitor) collectFieldInfo(fieldRef int) { parentPath: parentPath, currentPath: currentPath, onFragment: onFragment, + onUnionFragment: onUnionFragment, + onInterfaceFragment: onInterfaceFragment, parentPathWithoutFragment: parentPathWithoutFragment, currentPathWithoutFragments: currentPathWithoutFragments, isTypeName: isTypeName, enclosingTypeDefinition: f.walker.EnclosingTypeDefinition, } } + +func (f *treeBuilderVisitor) isOnAbstractFragment(kind ast.NodeKind) bool { + if f.walker.ResolveInlineFragment() == ast.InvalidRef { + return false + } + + if len(f.walker.TypeDefinitions) < 2 { + return false + } + + parentTypeDefinition := f.walker.TypeDefinitions[len(f.walker.TypeDefinitions)-2] + return parentTypeDefinition.Kind == kind +} diff --git a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go index 71ea3ebfc8..63d0426ca9 100644 --- a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go +++ b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go @@ -38,6 +38,8 @@ type NodeSuggestion struct { possibleTypeNames []string requiresKey *SourceConnection + + requiresFallbackKey bool } func (n *NodeSuggestion) treeNodeID() uint { @@ -214,19 +216,88 @@ func (f *NodeSuggestions) addProvidedField(key string, dsHash DSHash) { } func (f *NodeSuggestions) HasSuggestionForPath(typeName, fieldName, path string) (dsHash DSHash, ok bool) { - items, ok := f.pathSuggestions[path] + suggestion, ok := f.SelectedSuggestionForPath(typeName, fieldName, path) if !ok { return 0, false } + return suggestion.DataSourceHash, true +} + +func (f *NodeSuggestions) SelectedSuggestionForPath(typeName, fieldName, path string) (suggestion *NodeSuggestion, ok bool) { + items, ok := f.pathSuggestions[path] + if !ok { + return nil, false + } + for i := range items { if items[i].IsOrphan { continue } if typeName == items[i].TypeName && fieldName == items[i].FieldName && items[i].Selected { - return items[i].DataSourceHash, true + return items[i], true + } + } + + return nil, false +} + +func (f *NodeSuggestions) hasSelectedSuggestionForFieldRefOnDataSource(fieldRef int, dsHash DSHash) bool { + for _, item := range f.items { + if item.IsOrphan { + continue + } + if !item.Selected { + continue + } + if item.FieldRef == fieldRef && item.DataSourceHash == dsHash { + return true + } + } + + return false +} + +func (f *NodeSuggestions) firstNonTargetSuggestionForFieldRef(fieldRef int, target DSHash) (DSHash, bool) { + for _, item := range f.items { + if item.IsOrphan { + continue + } + if item.FieldRef != fieldRef { + continue + } + if item.DataSourceHash == target { + continue + } + if item.IsExternal && !item.IsProvided { + continue + } + if sourceConnectionRequiresMissingFallbackKeyField(item.requiresKey, item) { + continue + } + if item.Selected { + return item.DataSourceHash, true + } + } + + for _, item := range f.items { + if item.IsOrphan { + continue + } + if item.FieldRef != fieldRef { + continue + } + if item.DataSourceHash == target { + continue + } + if item.IsExternal && !item.IsProvided { + continue + } + if sourceConnectionRequiresMissingFallbackKeyField(item.requiresKey, item) { + continue } + return item.DataSourceHash, true } return 0, false diff --git a/v2/pkg/engine/plan/datasource_filter_resolvable_visitor.go b/v2/pkg/engine/plan/datasource_filter_resolvable_visitor.go index 8efbe79960..7dc556dc11 100644 --- a/v2/pkg/engine/plan/datasource_filter_resolvable_visitor.go +++ b/v2/pkg/engine/plan/datasource_filter_resolvable_visitor.go @@ -45,10 +45,21 @@ func (f *nodesResolvableVisitor) EnterField(ref int) { parentPath := f.walker.Path.DotDelimitedString() currentPath := parentPath + "." + fieldAliasOrName - _, found := f.nodes.HasSuggestionForPath(typeName, fieldName, currentPath) + suggestion, found := f.nodes.SelectedSuggestionForPath(typeName, fieldName, currentPath) if !found { f.walker.StopWithInternalErr(errors.Wrap(&errOperationFieldNotResolved{TypeName: typeName, FieldName: fieldName, Path: currentPath}, "nodesResolvableVisitor")) + return } + + if f.definition.Index.IsRootOperationTypeNameString(typeName) { + return + } + + if !suggestion.requiresFallbackKey { + return + } + + f.walker.StopWithInternalErr(errors.Wrap(&errOperationFieldNotResolved{TypeName: typeName, FieldName: fieldName, Path: currentPath}, "nodesResolvableVisitor")) } type errOperationFieldNotResolved struct { diff --git a/v2/pkg/engine/plan/datasource_filter_visitor.go b/v2/pkg/engine/plan/datasource_filter_visitor.go index 6dc9c59139..a3ed76eb63 100644 --- a/v2/pkg/engine/plan/datasource_filter_visitor.go +++ b/v2/pkg/engine/plan/datasource_filter_visitor.go @@ -25,6 +25,7 @@ type DataSourceFilter struct { newFieldRefs map[int]struct{} dataSources []DataSource + allowFallbackKeyJumps bool jumpsForPathForTypename map[KeyIndex]*DataSourceJumpsGraph dsHashesHavingKeys map[DSHash]struct{} @@ -47,6 +48,10 @@ func (f *DataSourceFilter) EnableSelectionReasons() { f.enableSelectionReasons = true } +func (f *DataSourceFilter) EnableFallbackKeyJumps() { + f.allowFallbackKeyJumps = true +} + // WithMaxDataSourceCollectorsConcurrency sets the maximum number of concurrent data source collectors func (f *DataSourceFilter) WithMaxDataSourceCollectorsConcurrency(maxConcurrency uint) *DataSourceFilter { f.maxDataSourceCollectorsConcurrency = maxConcurrency @@ -176,7 +181,7 @@ func (f *DataSourceFilter) collectNodes() { keysPerDS = make(map[DSHash][]KeyInfo) } - keysPerDS[keyInfo.DSHash] = keyInfo.Keys + keysPerDS[keyInfo.DSHash] = append(keysPerDS[keyInfo.DSHash], keyInfo.Keys...) keysForPathForTypename[keyIndex] = keysPerDS f.dsHashesHavingKeys[keyInfo.DSHash] = struct{}{} @@ -288,16 +293,27 @@ func (f *DataSourceFilter) selectUniqNodeParentsUpToRootNode(i int) { } } -func hasPathBetweenDs(jumps *DataSourceJumpsGraph, from, to DSHash) (bestPath *SourceConnection, exists bool) { - possiblePaths, exists := jumps.GetPaths(from, to) +func hasPathBetweenDs(jumps *DataSourceJumpsGraph, from, to DSHash, includeFallback bool) (bestPath *SourceConnection, exists bool) { + possiblePaths, exists := jumps.getPaths(from, to, includeFallback) if !exists { return nil, false } var directs []SourceConnection var indirects []SourceConnection + var fallbackDirects []SourceConnection + var fallbackIndirects []SourceConnection for _, path := range possiblePaths { + if sourceConnectionUsesFallback(path) { + if path.Type == SourceConnectionTypeDirect { + fallbackDirects = append(fallbackDirects, path) + continue + } + fallbackIndirects = append(fallbackIndirects, path) + continue + } + if path.Type == SourceConnectionTypeDirect { directs = append(directs, path) continue @@ -311,7 +327,25 @@ func hasPathBetweenDs(jumps *DataSourceJumpsGraph, from, to DSHash) (bestPath *S // TODO: indirect path should take into consideration existing nodes? - for _, path := range indirects { + if bestPath := shortestConnection(indirects); bestPath != nil { + return bestPath, true + } + + if len(fallbackDirects) > 0 { + return &fallbackDirects[0], true + } + + if bestPath := shortestConnection(fallbackIndirects); bestPath != nil { + return bestPath, true + } + + return nil, false +} + +func shortestConnection(paths []SourceConnection) *SourceConnection { + var bestPath *SourceConnection + + for _, path := range paths { if bestPath == nil { bestPath = &path continue @@ -322,7 +356,17 @@ func hasPathBetweenDs(jumps *DataSourceJumpsGraph, from, to DSHash) (bestPath *S } } - return bestPath, bestPath != nil + return bestPath +} + +func sourceConnectionUsesFallback(path SourceConnection) bool { + for _, jump := range path.Jumps { + if jump.Fallback { + return true + } + } + + return false } func (f *DataSourceFilter) jumpsForPathAndTypeName(path string, typeName string) (*DataSourceJumpsGraph, bool) { @@ -363,6 +407,12 @@ func (f *DataSourceFilter) assignKeys(itemIdx int, parentNodeIndexes []int) { if hasSelectedParentOnSameDataSource { return } + if len(selectedParentHashes) == 0 && currentNode.onFragment { + selectedParentHashes, hasSelectedParentOnSameDataSource = f.selectedAncestorHashes(itemIdx, currentNodeDsHash) + if hasSelectedParentOnSameDataSource { + return + } + } jumpsForTypename, exists := f.jumpsForPathAndTypeName(currentNode.ParentPath, currentNodeTypeName) if !exists { @@ -370,14 +420,49 @@ func (f *DataSourceFilter) assignKeys(itemIdx int, parentNodeIndexes []int) { } for _, selectedParentHash := range selectedParentHashes { - path, exists := hasPathBetweenDs(jumpsForTypename, selectedParentHash, currentNodeDsHash) + path, exists := hasPathBetweenDs(jumpsForTypename, selectedParentHash, currentNodeDsHash, f.allowFallbackKeyJumps) if exists { currentNode.requiresKey = path + currentNode.requiresFallbackKey = false break } + + if f.allowFallbackKeyJumps { + continue + } + fallbackPath, fallbackExists := hasPathBetweenDs(jumpsForTypename, selectedParentHash, currentNodeDsHash, true) + if !fallbackExists { + continue + } + if !sourceConnectionUsesFallback(*fallbackPath) { + continue + } + if sourceConnectionRequiresMissingFallbackKeyField(fallbackPath, currentNode) { + continue + } + currentNode.requiresFallbackKey = true } } +func (f *DataSourceFilter) selectedAncestorHashes(itemIdx int, currentNodeDsHash DSHash) (selectedParentHashes []DSHash, hasSelectedParentOnSameDataSource bool) { + node := f.nodes.treeNode(itemIdx) + for parent := node.GetParent(); parent != nil && parent.GetID() != treeRootID; parent = parent.GetParent() { + for _, parentIdx := range parent.GetData() { + if !f.nodes.items[parentIdx].Selected { + continue + } + if f.nodes.items[parentIdx].DataSourceHash == currentNodeDsHash { + return nil, true + } + selectedParentHashes = append(selectedParentHashes, f.nodes.items[parentIdx].DataSourceHash) + } + if len(selectedParentHashes) > 0 { + return selectedParentHashes, false + } + } + return nil, false +} + // selectDuplicateNodes - selects nodes (e.g. fields) which are not unique to a single datasource, // e.g. could be resolved by multiple datasources // This method checks only nodes not already selected on the other datasource @@ -486,6 +571,27 @@ func (f *DataSourceFilter) selectDuplicateNodes(secondPass bool) { // if after all checks node was not selected, // we need a couple more checks + // Prefer a datasource that explicitly provides fields below an abstract root field. + // This keeps interface-typed @provides selections together before falling back to root order. + if f.checkNodes(itemIDs, + func(i int) bool { + return f.selectWithExternalCheck(i, ReasonStage3SelectNodeHavingPossibleChildsOnSameDataSource) + }, + func(i int) (skip bool) { + if !f.nodes.items[i].IsRootNode { + return true + } + if treeNode.GetParentID() != treeRootID { + return true + } + if !f.fieldReturnsAbstractType(f.nodes.items[i].TypeName, f.nodes.items[i].FieldName) { + return true + } + return !f.hasProvidedChildOnSameSource(i) + }) { + continue + } + // 1. Lookup in duplicates for root nodes with enabled reference resolver // in case current node suggestion is an entity root node, and it contains a key with disabled resolver // we could not select such a node, because we could not jump to the subgraph which do not have a reference resolver, @@ -648,6 +754,44 @@ func (f *DataSourceFilter) selectDuplicateNodes(secondPass bool) { } } +func (f *DataSourceFilter) fieldReturnsAbstractType(typeName, fieldName string) bool { + node, exists := f.definition.NodeByNameStr(typeName) + if !exists { + return false + } + + var fieldDefinitionRef int + var ok bool + switch node.Kind { + case ast.NodeKindObjectTypeDefinition: + fieldDefinitionRef, ok = f.definition.ObjectTypeDefinitionFieldWithName(node.Ref, []byte(fieldName)) + case ast.NodeKindInterfaceTypeDefinition: + fieldDefinitionRef, ok = f.definition.InterfaceTypeDefinitionFieldWithName(node.Ref, []byte(fieldName)) + default: + return false + } + if !ok { + return false + } + + fieldTypeName := f.definition.FieldDefinitionTypeNameBytes(fieldDefinitionRef) + fieldTypeNode, exists := f.definition.NodeByName(fieldTypeName) + if !exists { + return false + } + + return fieldTypeNode.Kind == ast.NodeKindInterfaceTypeDefinition || fieldTypeNode.Kind == ast.NodeKindUnionTypeDefinition +} + +func (f *DataSourceFilter) hasProvidedChildOnSameSource(idx int) bool { + for _, childIdx := range f.nodes.childNodesOnSameSource(idx) { + if f.nodes.items[childIdx].IsProvided { + return true + } + } + return false +} + func (f *DataSourceFilter) findPossibleParents(i int) (parentIds []int) { nodesIdsToSelect := make([]int, 0, 2) @@ -749,7 +893,7 @@ func (f *DataSourceFilter) parentNodeCouldProvideKeysForCurrentNodeWithTypename( return false } - path, exists := hasPathBetweenDs(jumpsForTypename, f.nodes.items[parentIdx].DataSourceHash, f.nodes.items[idx].DataSourceHash) + path, exists := hasPathBetweenDs(jumpsForTypename, f.nodes.items[parentIdx].DataSourceHash, f.nodes.items[idx].DataSourceHash, f.allowFallbackKeyJumps) if !exists { return false } @@ -761,6 +905,41 @@ func (f *DataSourceFilter) parentNodeCouldProvideKeysForCurrentNodeWithTypename( return true } +func sourceConnectionRequiresMissingFallbackKeyField(path *SourceConnection, node *NodeSuggestion) bool { + if path == nil || node == nil { + return false + } + + fieldPath := node.Path + + for _, jump := range path.Jumps { + if !jump.Fallback { + continue + } + + targetContainsField := false + for _, keyPath := range jump.FieldPaths { + if keyPath.Path == fieldPath { + targetContainsField = true + break + } + } + if !targetContainsField { + continue + } + + for _, keyPath := range jump.SourcePaths { + if keyPath.Path == fieldPath { + return false + } + } + + return true + } + + return false +} + type nodeJump struct { // nodeIdx is the index of the node in the nodes slice nodeIdx int diff --git a/v2/pkg/engine/plan/federation_metadata.go b/v2/pkg/engine/plan/federation_metadata.go index 748b507415..22eff0c7a8 100644 --- a/v2/pkg/engine/plan/federation_metadata.go +++ b/v2/pkg/engine/plan/federation_metadata.go @@ -1,9 +1,11 @@ package plan import ( + "bytes" "encoding/json" "fmt" "slices" + "strings" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" ) @@ -181,3 +183,90 @@ func (f *FederationFieldConfigurations) AppendIfNotPresent(config FederationFiel return true } + +func (f *FederationFieldConfigurations) HasArgumentConflictWith(configs []FederationFieldConfiguration) bool { + for i := range *f { + for j := range configs { + if requiredFieldArgumentConflict((*f)[i], configs[j]) { + return true + } + } + } + + return false +} + +func requiredFieldArgumentConflict(left, right FederationFieldConfiguration) bool { + if left.TypeName != right.TypeName { + return false + } + + leftFields, ok := requiredFieldArgumentsByPath(left) + if !ok { + return false + } + rightFields, ok := requiredFieldArgumentsByPath(right) + if !ok { + return false + } + + for path, leftArguments := range leftFields { + rightArguments, exists := rightFields[path] + if exists && leftArguments != rightArguments { + return true + } + } + + return false +} + +func requiredFieldArgumentsByPath(config FederationFieldConfiguration) (map[string]string, bool) { + if err := config.parseSelectionSet(); err != nil { + return nil, false + } + if len(config.parsedSelectionSet.FragmentDefinitions) == 0 { + return nil, false + } + + out := make(map[string]string) + collectRequiredFieldArguments(config.parsedSelectionSet, config.parsedSelectionSet.FragmentDefinitions[0].SelectionSet, nil, out) + + return out, true +} + +func collectRequiredFieldArguments(doc *ast.Document, selectionSetRef int, path []string, out map[string]string) { + for _, selectionRef := range doc.SelectionSets[selectionSetRef].SelectionRefs { + selection := doc.Selections[selectionRef] + switch selection.Kind { + case ast.SelectionKindField: + fieldRef := selection.Ref + fieldPath := append(path, doc.FieldNameString(fieldRef)) + out[strings.Join(fieldPath, ".")] = requiredFieldArgumentSignature(doc, fieldRef) + if doc.FieldHasSelections(fieldRef) { + collectRequiredFieldArguments(doc, doc.Fields[fieldRef].SelectionSet, fieldPath, out) + } + case ast.SelectionKindInlineFragment: + inlineFragmentRef := selection.Ref + fragmentPath := append(path, "... on "+doc.InlineFragmentTypeConditionNameString(inlineFragmentRef)) + if doc.InlineFragments[inlineFragmentRef].HasSelections { + collectRequiredFieldArguments(doc, doc.InlineFragments[inlineFragmentRef].SelectionSet, fragmentPath, out) + } + } + } +} + +func requiredFieldArgumentSignature(doc *ast.Document, fieldRef int) string { + if !doc.FieldHasArguments(fieldRef) { + return "" + } + + args := make([]string, 0, len(doc.FieldArguments(fieldRef))) + for _, argRef := range doc.FieldArguments(fieldRef) { + var buf bytes.Buffer + _ = doc.PrintArgument(argRef, &buf) + args = append(args, buf.String()) + } + slices.Sort(args) + + return strings.Join(args, ",") +} diff --git a/v2/pkg/engine/plan/multihop_compound_key_test.go b/v2/pkg/engine/plan/multihop_compound_key_test.go new file mode 100644 index 0000000000..8b5ebe4767 --- /dev/null +++ b/v2/pkg/engine/plan/multihop_compound_key_test.go @@ -0,0 +1,535 @@ +package plan + +import ( + "fmt" + "reflect" + "slices" + "strings" + "testing" + + "github.com/kylelemons/godebug/pretty" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astnormalization" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation" + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +func TestPlannerMultiHopCompoundKey(t *testing.T) { + definition := unsafeparser.ParseGraphqlDocumentString(multiHopCompoundKeyDefinition) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&definition)) + + operation := unsafeparser.ParseGraphqlDocumentString(`query { + topProducts { + first { id } + selected { id } + } + }`) + + report := &operationreport.Report{} + astnormalization.NewNormalizer(true, true).NormalizeOperation(&operation, &definition, report) + require.False(t, report.HasErrors(), report.Error()) + + astvalidation.DefaultOperationValidator().Validate(&operation, &definition, report) + require.False(t, report.HasErrors(), report.Error()) + + planner, err := NewPlanner(Configuration{ + DataSources: multiHopCompoundKeyDataSources(), + DisableIncludeInfo: true, + DisableIncludeFieldDependencies: true, + }) + require.NoError(t, err) + + plan := planner.Plan(&operation, &definition, "", report) + require.False(t, report.HasErrors(), report.Error()) + require.Equal(t, strings.TrimSpace(multiHopCompoundKeyExpectedPlan), strings.TrimSpace(planString(plan))) +} + +func planString(v any) string { + formatterConfig := map[reflect.Type]any{ + reflect.TypeFor[[]byte](): func(b []byte) string { return fmt.Sprintf(`"%s"`, string(b)) }, + reflect.TypeOf(map[string]struct{}{}): func(m map[string]struct{}) string { + var keys []string + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return fmt.Sprintf("%q", keys) + }, + } + + prettyCfg := &pretty.Config{ + Diffable: true, + IncludeUnexported: false, + Formatter: formatterConfig, + } + + return prettyCfg.Sprint(v) +} + +func multiHopCompoundKeyDataSources() []DataSource { + return []DataSource{ + dsb(). + WithBehavior(multiHopCompoundKeyPlanningBehavior()). + Hash(11). + Id("catalog"). + RootNode("Query", "topProducts"). + RootNode("ProductList", "products"). + RootNode("Product", "id", "category"). + RootNode("Category", "mainProduct", "id", "tag"). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "ProductList", SelectionSet: "products { id }"}, + {TypeName: "Product", SelectionSet: "id"}, + {TypeName: "Category", SelectionSet: "id"}, + {TypeName: "Category", SelectionSet: "id tag", DisableEntityResolver: true}, + }). + SchemaMergedWithBase(multiHopCatalogSubgraphSchema). + DS(), + dsb(). + WithBehavior(multiHopCompoundKeyPlanningBehavior()). + Hash(22). + Id("link"). + RootNode("Product", "id", "pid"). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "Product", SelectionSet: "id"}, + {TypeName: "Product", SelectionSet: "id pid"}, + }). + SchemaMergedWithBase(multiHopLinkSubgraphSchema). + DS(), + dsb(). + WithBehavior(multiHopCompoundKeyPlanningBehavior()). + Hash(33). + Id("collection"). + RootNode("ProductList", "products", "first", "selected"). + RootNode("Product", "id", "pid"). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "ProductList", SelectionSet: "products { id pid }"}, + {TypeName: "ProductList", SelectionSet: "products { id }", DisableEntityResolver: true}, + {TypeName: "Product", SelectionSet: "id pid"}, + {TypeName: "Product", SelectionSet: "id", DisableEntityResolver: true}, + }). + SchemaMergedWithBase(multiHopCollectionSubgraphSchema). + DS(), + dsb(). + WithBehavior(multiHopCompoundKeyPlanningBehavior()). + Hash(44). + Id("pricing"). + RootNode("ProductList", "products", "first", "selected"). + RootNode("Product", "id", "price", "pid", "category"). + RootNode("Category", "id", "tag"). + ChildNode("Price", "price"). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "ProductList", SelectionSet: "products { category { id tag } id pid } selected { id }"}, + {TypeName: "ProductList", SelectionSet: "products { id }", DisableEntityResolver: true}, + {TypeName: "ProductList", SelectionSet: "products { id pid }", DisableEntityResolver: true}, + {TypeName: "Product", SelectionSet: "category { id tag } id pid"}, + {TypeName: "Product", SelectionSet: "id", DisableEntityResolver: true}, + {TypeName: "Product", SelectionSet: "id pid", DisableEntityResolver: true}, + {TypeName: "Category", SelectionSet: "id tag"}, + {TypeName: "Category", SelectionSet: "id", DisableEntityResolver: true}, + }). + SchemaMergedWithBase(multiHopPricingSubgraphSchema). + DS(), + } +} + +func multiHopCompoundKeyPlanningBehavior() DataSourcePlanningBehavior { + return DataSourcePlanningBehavior{ + MergeAliasedRootNodes: true, + OverrideFieldPathFromAlias: true, + AllowPlanningTypeName: true, + } +} + +const multiHopCompoundKeyDefinition = ` +type Query { + topProducts: ProductList! +} + +type ProductList { + products: [Product!]! + first: Product + selected: Product +} + +type Product { + id: ID! + pid: ID + category: Category + price: Price +} + +type Category { + mainProduct: Product! + id: ID! + tag: String! +} + +type Price { + price: Float! +} +` + +const multiHopCatalogSubgraphSchema = ` +type Query { + topProducts: ProductList! +} + +type ProductList { + products: [Product!]! +} + +type Product { + id: ID! + category: Category +} + +type Category { + mainProduct: Product! + id: ID! + tag: String! +} +` + +const multiHopCollectionSubgraphSchema = ` +type ProductList { + products: [Product!]! + first: Product + selected: Product +} + +type Product { + id: ID! + pid: ID! +} +` + +const multiHopLinkSubgraphSchema = ` +type Product { + id: ID! + pid: ID! +} +` + +const multiHopPricingSubgraphSchema = ` +type ProductList { + products: [Product!]! + selected: Product +} + +type Product { + id: ID! + price: Price + pid: ID! + category: Category +} + +type Category { + id: ID! + tag: String! +} + +type Price { + price: Float! +} +` + +const multiHopCompoundKeyExpectedPlan = ` +{ + Response: { + Data: { + Nullable: false, + Path: [ + ], + Fields: [ + { + Name: "topProducts", + Value: { + Nullable: false, + Path: [ + "topProducts", + ], + Fields: [ + { + Name: "first", + Value: { + Nullable: true, + Path: [ + "first", + ], + Fields: [ + { + Name: "id", + Value: { + Path: [ + "id", + ], + Nullable: false, + Export: nil, + }, + Position: { + Line: 3, + Column: 12, + }, + Defer: nil, + Stream: nil, + OnTypeNames: [ + ], + ParentOnTypeNames: [ + ], + Info: nil, + }, + ], + PossibleTypes: ["Product"], + SourceName: "", + TypeName: "Product", + }, + Position: { + Line: 3, + Column: 4, + }, + Defer: nil, + Stream: nil, + OnTypeNames: [ + ], + ParentOnTypeNames: [ + ], + Info: nil, + }, + { + Name: "selected", + Value: { + Nullable: true, + Path: [ + "selected", + ], + Fields: [ + { + Name: "id", + Value: { + Path: [ + "id", + ], + Nullable: false, + Export: nil, + }, + Position: { + Line: 4, + Column: 15, + }, + Defer: nil, + Stream: nil, + OnTypeNames: [ + ], + ParentOnTypeNames: [ + ], + Info: nil, + }, + ], + PossibleTypes: ["Product"], + SourceName: "", + TypeName: "Product", + }, + Position: { + Line: 4, + Column: 4, + }, + Defer: nil, + Stream: nil, + OnTypeNames: [ + ], + ParentOnTypeNames: [ + ], + Info: nil, + }, + ], + PossibleTypes: ["ProductList"], + SourceName: "", + TypeName: "ProductList", + }, + Position: { + Line: 2, + Column: 3, + }, + Defer: nil, + Stream: nil, + OnTypeNames: [ + ], + ParentOnTypeNames: [ + ], + Info: nil, + }, + ], + PossibleTypes: [], + SourceName: "", + TypeName: "", + }, + RawFetches: [ + { + Fetch: { + FetchConfiguration: { + Input: "", + Variables: [ + ], + DataSource: { + }, + RequiresEntityFetch: false, + RequiresEntityBatchFetch: false, + PostProcessing: { + SelectResponseDataPath: [ + ], + SelectResponseErrorsPath: [ + ], + MergePath: [ + ], + }, + SetTemplateOutputToNullOnVariableNull: false, + QueryPlan: nil, + OperationName: "", + }, + FetchDependencies: { + FetchID: 0, + DependsOnFetchIDs: [ + ], + }, + InputTemplate: { + Segments: [ + ], + SetTemplateOutputToNullOnVariableNull: false, + }, + DataSourceIdentifier: "plan.FakeDataSource", + Trace: nil, + Info: nil, + }, + FetchPath: [ + ], + ResponsePath: "", + ResponsePathElements: [ + ], + }, + { + Fetch: { + FetchConfiguration: { + Input: "", + Variables: [ + ], + DataSource: { + }, + RequiresEntityFetch: false, + RequiresEntityBatchFetch: false, + PostProcessing: { + SelectResponseDataPath: [ + ], + SelectResponseErrorsPath: [ + ], + MergePath: [ + ], + }, + SetTemplateOutputToNullOnVariableNull: false, + QueryPlan: nil, + OperationName: "", + }, + FetchDependencies: { + FetchID: 1, + DependsOnFetchIDs: [ + 0, + ], + }, + InputTemplate: { + Segments: [ + ], + SetTemplateOutputToNullOnVariableNull: false, + }, + DataSourceIdentifier: "plan.FakeDataSource", + Trace: nil, + Info: nil, + }, + FetchPath: [ + { + Kind: "object", + Path: [ + "topProducts", + ], + TypeNames: [ + ], + }, + { + Kind: "array", + Path: [ + "products", + ], + TypeNames: [ + ], + }, + ], + ResponsePath: "topProducts.products", + ResponsePathElements: [ + "topProducts", + "products", + ], + }, + { + Fetch: { + FetchConfiguration: { + Input: "", + Variables: [ + ], + DataSource: { + }, + RequiresEntityFetch: false, + RequiresEntityBatchFetch: false, + PostProcessing: { + SelectResponseDataPath: [ + ], + SelectResponseErrorsPath: [ + ], + MergePath: [ + ], + }, + SetTemplateOutputToNullOnVariableNull: false, + QueryPlan: nil, + OperationName: "", + }, + FetchDependencies: { + FetchID: 2, + DependsOnFetchIDs: [ + 0, + 1, + ], + }, + InputTemplate: { + Segments: [ + ], + SetTemplateOutputToNullOnVariableNull: false, + }, + DataSourceIdentifier: "plan.FakeDataSource", + Trace: nil, + Info: nil, + }, + FetchPath: [ + { + Kind: "object", + Path: [ + "topProducts", + ], + TypeNames: [ + ], + }, + ], + ResponsePath: "topProducts", + ResponsePathElements: [ + "topProducts", + ], + }, + ], + Fetches: nil, + Info: nil, + DataSources: [ + ], + }, + FlushInterval: 0, + CostCalculator: nil, +} +` diff --git a/v2/pkg/engine/plan/node_selection_builder.go b/v2/pkg/engine/plan/node_selection_builder.go index d8eaa7cb20..4eccb0c9d9 100644 --- a/v2/pkg/engine/plan/node_selection_builder.go +++ b/v2/pkg/engine/plan/node_selection_builder.go @@ -18,6 +18,9 @@ type NodeSelectionBuilder struct { nodeResolvableVisitor *nodesResolvableVisitor nodeSelectionsWalker *astvisitor.Walker nodeSelectionsVisitor *nodeSelectionVisitor + + fieldMergingAliasWalker *astvisitor.Walker + fieldMergingAliasVisitor *fieldMergingAliasVisitor } type fieldDependencyKind int @@ -46,6 +49,11 @@ type NodeSelectionResult struct { // These fields should not be added to user response. skipFieldsRefs []int + // responseOnlyFieldRefs holds field refs that must appear in the response + // (resolving to null) but must NOT be sent to any subgraph in the upstream + // fetch. Used for partial-union members unique to the resolving subgraph. + responseOnlyFieldRefs map[int]struct{} + fieldRefDependsOn map[int][]int fieldDependencyKind map[fieldDependencyKey]fieldDependencyKind } @@ -70,12 +78,22 @@ func NewNodeSelectionBuilder(config *Configuration) *NodeSelectionBuilder { nodeResolvableWalker.RegisterEnterDocumentVisitor(nodeResolvableVisitor) nodeResolvableWalker.RegisterEnterFieldVisitor(nodeResolvableVisitor) + fieldMergingAliasWalker := astvisitor.NewWalkerWithID(24, "FieldMergingAliasWalker") + fieldMergingAliasVisitor := &fieldMergingAliasVisitor{ + walker: &fieldMergingAliasWalker, + dataSources: config.DataSources, + } + fieldMergingAliasWalker.RegisterEnterDocumentVisitor(fieldMergingAliasVisitor) + fieldMergingAliasWalker.RegisterEnterFieldVisitor(fieldMergingAliasVisitor) + return &NodeSelectionBuilder{ - config: config, - nodeSelectionsWalker: &nodeSelectionsWalker, - nodeSelectionsVisitor: nodeSelectionVisitor, - nodeResolvableWalker: &nodeResolvableWalker, - nodeResolvableVisitor: nodeResolvableVisitor, + config: config, + nodeSelectionsWalker: &nodeSelectionsWalker, + nodeSelectionsVisitor: nodeSelectionVisitor, + nodeResolvableWalker: &nodeResolvableWalker, + nodeResolvableVisitor: nodeResolvableVisitor, + fieldMergingAliasWalker: &fieldMergingAliasWalker, + fieldMergingAliasVisitor: fieldMergingAliasVisitor, } } @@ -85,6 +103,7 @@ func (p *NodeSelectionBuilder) SetOperationName(name string) { func (p *NodeSelectionBuilder) ResetSkipFieldRefs() { p.nodeSelectionsVisitor.skipFieldsRefs = nil + p.nodeSelectionsVisitor.responseOnlyFieldRefs = nil p.nodeSelectionsVisitor.newFieldRefs = make(map[int]struct{}) } @@ -104,6 +123,14 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, p.nodeSelectionsVisitor.debug = p.config.Debug + // Step 0. Alias fields that share a response name across concrete union/interface members but + // differ only in nullability in some subgraph schema. This must run before suggestions are + // produced so the response-name based paths stay consistent across all later phases. + p.fieldMergingAliasWalker.Walk(operation, definition, report) + if report.HasErrors() { + return + } + // Step 1. Produce initial suggestions of which datasource owns which fields. // We collect info from all subgraphs with the field, plus available keys per path. p.nodeSelectionsVisitor.dataSources, p.nodeSelectionsVisitor.nodeSuggestions = dsFilter.FilterDataSources(nil, nil) @@ -111,6 +138,25 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, return } + // Step 1.5. Resolve partial unions: when a union field can be resolved by + // multiple candidate datasources that define different members, keep only the + // members common to all candidates (see prunePartialUnionMembers). Members + // unique to the resolving subgraph are kept in the response as null but excluded + // from the upstream fetch (responseOnlyFieldRefs); foreign members are dropped. + // When this changes the operation, rebuild the suggestions from the pruned + // operation with a fresh filter so later steps never see the dropped members. + if responseOnly, changed := p.prunePartialUnionMembers(operation, definition, p.nodeSelectionsVisitor.nodeSuggestions); changed { + p.nodeSelectionsVisitor.responseOnlyFieldRefs = responseOnly + dsFilter = NewDataSourceFilter(operation, definition, report, p.config.DataSources, p.nodeSelectionsVisitor.newFieldRefs) + if p.config.Debug.PrintNodeSuggestions { + dsFilter.EnableSelectionReasons() + } + p.nodeSelectionsVisitor.dataSources, p.nodeSelectionsVisitor.nodeSuggestions = dsFilter.FilterDataSources(nil, nil) + if report.HasErrors() { + return + } + } + if p.config.Debug.PrintNodeSuggestions { p.nodeSelectionsVisitor.nodeSuggestions.printNodesWithFilter("\nInitial node suggestions:\n", p.config.Debug.PrintNodeSuggestionsFilterNotSelected) @@ -131,6 +177,18 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, i := 1 hasUnresolvedFields := false + fallbackKeyJumpsEnabled := false + refilterWithFallbackKeyJumps := false + if !p.nodeSelectionsVisitor.hasNewFields { + resolvableReport := p.isResolvable(operation, definition, p.nodeSelectionsVisitor.nodeSuggestions) + if resolvableReport.HasErrors() { + dsFilter.EnableFallbackKeyJumps() + fallbackKeyJumpsEnabled = true + refilterWithFallbackKeyJumps = true + hasUnresolvedFields = true + } + } + // Additional runs to add paths for the new required fields for p.nodeSelectionsVisitor.hasNewFields || hasUnresolvedFields { // When we have rewritten a field, the old node suggestion does not make sense anymore: @@ -141,12 +199,16 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, p.nodeSelectionsVisitor.secondaryRun = true - if p.nodeSelectionsVisitor.hasNewFields { + if p.nodeSelectionsVisitor.hasNewFields || refilterWithFallbackKeyJumps { // Repeat Step 1. Update suggestions for the new required fields. p.nodeSelectionsVisitor.dataSources, p.nodeSelectionsVisitor.nodeSuggestions = dsFilter.FilterDataSources(p.nodeSelectionsVisitor.fieldLandedTo, p.nodeSelectionsVisitor.fieldRefDependsOn) if report.HasErrors() { return } + if fallbackKeyJumpsEnabled { + p.nodeSelectionsVisitor.pruneStaleFieldRequirements() + } + refilterWithFallbackKeyJumps = false } if p.config.Debug.PrintOperationTransformations || p.config.Debug.PrintNodeSuggestions { @@ -173,6 +235,12 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, resolvableReport := p.isResolvable(operation, definition, p.nodeSelectionsVisitor.nodeSuggestions) hasUnresolvedFields = resolvableReport.HasErrors() if hasUnresolvedFields { + if !fallbackKeyJumpsEnabled { + dsFilter.EnableFallbackKeyJumps() + fallbackKeyJumpsEnabled = true + refilterWithFallbackKeyJumps = true + } + if i > 100 { report.AddInternalError(fmt.Errorf("could not resolve a field: %v", resolvableReport)) return @@ -187,20 +255,13 @@ func (p *NodeSelectionBuilder) SelectNodes(operation, definition *ast.Document, } } - if i == 1 { - // if we have not revisited the operation, we need to check if it is resolvable - if resolvableReport := p.isResolvable(operation, definition, p.nodeSelectionsVisitor.nodeSuggestions); resolvableReport.HasErrors() { - report.AddInternalError(fmt.Errorf("could not resolve a field: %v", resolvableReport)) - return - } - } - return &NodeSelectionResult{ dataSources: p.nodeSelectionsVisitor.dataSources, nodeSuggestions: p.nodeSelectionsVisitor.nodeSuggestions, fieldDependsOn: p.nodeSelectionsVisitor.fieldDependsOn, fieldRequirementsConfigs: p.nodeSelectionsVisitor.fieldRequirementsConfigs, skipFieldsRefs: p.nodeSelectionsVisitor.skipFieldsRefs, + responseOnlyFieldRefs: p.nodeSelectionsVisitor.responseOnlyFieldRefs, fieldRefDependsOn: p.nodeSelectionsVisitor.fieldRefDependsOn, fieldDependencyKind: p.nodeSelectionsVisitor.fieldDependencyKind, } diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index db8403cd3c..8acce19f88 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -28,6 +28,11 @@ type nodeSelectionVisitor struct { selectionSetRefs []int // selectionSetRefs is a stack of selection set refs - used to add required fields skipFieldsRefs []int // skipFieldsRefs holds required field refs added by planner and should not be added to user response + // responseOnlyFieldRefs holds field refs that must appear in the response + // (resolving to null) but must NOT be sent to any subgraph in the upstream + // fetch. Populated by the partial-union pass; remapped when fields are rewritten. + responseOnlyFieldRefs map[int]struct{} + pendingKeyRequirements map[int]pendingKeyRequirements // pendingKeyRequirements is a map[selectionSetRef][]keyRequirements pendingFieldRequirements map[int]pendingFieldRequirements // pendingFieldRequirements is a map[selectionSetRef]fieldRequirements @@ -65,6 +70,36 @@ func (c *nodeSelectionVisitor) addNewFieldRefs(fieldRefs ...int) { } } +func (c *nodeSelectionVisitor) pruneStaleFieldRequirements() { + if len(c.fieldDependsOn) == 0 { + return + } + + for fieldKey, deps := range c.fieldDependsOn { + if c.nodeSuggestions.hasSelectedSuggestionForFieldRefOnDataSource(fieldKey.fieldRef, fieldKey.dsHash) { + continue + } + + delete(c.fieldDependsOn, fieldKey) + delete(c.fieldRequirementsConfigs, fieldKey) + delete(c.visitedFieldsKeyChecks, fieldKey) + delete(c.visitedFieldsRequiresChecks, fieldKey) + for _, dep := range deps { + delete(c.fieldDependencyKind, fieldDependencyKey{field: fieldKey.fieldRef, dependsOn: dep}) + } + } + + c.fieldRefDependsOn = make(map[int][]int, len(c.fieldDependsOn)) + for fieldKey, deps := range c.fieldDependsOn { + for _, dep := range deps { + if slices.Contains(c.fieldRefDependsOn[fieldKey.fieldRef], dep) { + continue + } + c.fieldRefDependsOn[fieldKey.fieldRef] = append(c.fieldRefDependsOn[fieldKey.fieldRef], dep) + } + } +} + type fieldDependencyKey struct { field, dependsOn int } @@ -684,7 +719,21 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int } } - for _, requiredFieldRef := range currentFieldRefs { + sourcePathSet := keyJumpSourcePathSet(jump) + for i, requiredFieldRef := range currentFieldRefs { + if len(sourcePathSet) != 0 { + if i >= len(jump.FieldPaths) { + continue + } + if _, ok := sourcePathSet[jump.FieldPaths[i].Path]; ok { + c.fieldLandedTo[requiredFieldRef] = jump.From + continue + } + if dsHash, ok := c.nodeSuggestions.firstNonTargetSuggestionForFieldRef(requiredFieldRef, jump.To); ok { + c.fieldLandedTo[requiredFieldRef] = dsHash + } + continue + } c.fieldLandedTo[requiredFieldRef] = jump.From } @@ -694,6 +743,18 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int c.hasNewFields = true } +func keyJumpSourcePathSet(jump KeyJump) map[string]struct{} { + if len(jump.SourcePaths) == 0 { + return nil + } + + out := make(map[string]struct{}, len(jump.SourcePaths)) + for _, path := range jump.SourcePaths { + out[path.Path] = struct{}{} + } + return out +} + func (c *nodeSelectionVisitor) rewriteSelectionSetHavingAbstractFragments(fieldRef int, ds DataSource) { if _, ok := c.visitedFieldsAbstractChecks[fieldRef]; ok { return @@ -713,7 +774,6 @@ func (c *nodeSelectionVisitor) rewriteSelectionSetHavingAbstractFragments(fieldR // When newly added fields are local - rewriter will consider that rewrite is not necessary. options = append(options, withForceRewrite()) } - rewriter, err := newFieldSelectionRewriter(c.operation, c.definition, ds, options...) if err != nil { c.walker.StopWithInternalErr(fmt.Errorf("failed to create field selection rewriter for field %s at path %s: %w", c.operation.FieldNameString(fieldRef), c.walker.Path.DotDelimitedString(), err)) @@ -789,4 +849,16 @@ func (c *nodeSelectionVisitor) updateSkipFieldRefs(changedFieldRefs map[int][]in c.skipFieldsRefs = append(c.skipFieldsRefs, newRefs...) } } + + // Keep response-only markers attached when the abstract selection rewriter + // replaces a field with new refs, so the upstream fetch still excludes them. + if c.responseOnlyFieldRefs != nil { + for oldRef := range c.responseOnlyFieldRefs { + if newRefs := changedFieldRefs[oldRef]; newRefs != nil { + for _, newRef := range newRefs { + c.responseOnlyFieldRefs[newRef] = struct{}{} + } + } + } + } } diff --git a/v2/pkg/engine/plan/path_builder.go b/v2/pkg/engine/plan/path_builder.go index 5e456028a7..85bf62568e 100644 --- a/v2/pkg/engine/plan/path_builder.go +++ b/v2/pkg/engine/plan/path_builder.go @@ -2,6 +2,7 @@ package plan import ( "fmt" + "strings" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" @@ -112,6 +113,9 @@ func (p *PathBuilder) CreatePlanningPaths(operation, definition *ast.Document, r // remove unnecessary fragment paths hasRemovedPaths := p.removeUnnecessaryFragmentPaths() + if p.removeDuplicateLeafAbstractFieldPaths() { + hasRemovedPaths = true + } if hasRemovedPaths && p.config.Debug.PrintPlanningPaths { debugMessage("Final paths after removing unnecessary fragment paths:") p.printPlanningPaths(i) @@ -134,6 +138,118 @@ func (p *PathBuilder) removeUnnecessaryFragmentPaths() (hasRemovedPaths bool) { return } +func (p *PathBuilder) removeDuplicateLeafAbstractFieldPaths() (hasRemovedPaths bool) { + for plannerIdx, planner := range p.visitor.planners { + plannerPathConfig, ok := planner.(removablePlannerPathConfiguration) + if !ok { + continue + } + + pathsToRemove := make([]pathConfiguration, 0, 2) + planner.ForEachPath(func(path *pathConfiguration) (shouldBreak bool) { + if p.shouldRemoveDuplicateLeafAbstractFieldPath(plannerIdx, plannerPathConfig, path) { + pathsToRemove = append(pathsToRemove, *path) + } + return false + }) + + for _, path := range pathsToRemove { + plannerPathConfig.removePath(path) + hasRemovedPaths = true + } + } + return +} + +type removablePlannerPathConfiguration interface { + PlannerPathConfiguration + removePath(path pathConfiguration) +} + +func (p *PathBuilder) shouldRemoveDuplicateLeafAbstractFieldPath(plannerIdx int, planner removablePlannerPathConfiguration, path *pathConfiguration) bool { + if path.pathType != PathTypeField { + return false + } + if !strings.Contains(path.path, ".$") { + return false + } + if !p.visitor.operation.FieldHasSelections(path.fieldRef) { + return false + } + if p.plannerHasActivePathPrefix(planner, path.path) { + return false + } + if !p.fieldReturnsAbstractType(path) { + return false + } + if !p.hasProvidedSuggestionForPath(path) { + return false + } + return p.anotherPlannerOwnsFieldPathWithChildren(plannerIdx, path.fieldRef, path.path) +} + +func (p *PathBuilder) fieldReturnsAbstractType(path *pathConfiguration) bool { + fieldName := p.visitor.operation.FieldNameBytes(path.fieldRef) + + var fieldDefinitionRef int + var ok bool + switch path.enclosingNode.Kind { + case ast.NodeKindObjectTypeDefinition: + fieldDefinitionRef, ok = p.visitor.definition.ObjectTypeDefinitionFieldWithName(path.enclosingNode.Ref, fieldName) + case ast.NodeKindInterfaceTypeDefinition: + fieldDefinitionRef, ok = p.visitor.definition.InterfaceTypeDefinitionFieldWithName(path.enclosingNode.Ref, fieldName) + default: + return false + } + if !ok { + return false + } + + typeName := p.visitor.definition.FieldDefinitionTypeNameBytes(fieldDefinitionRef) + node, exists := p.visitor.definition.NodeByName(typeName) + if !exists { + return false + } + + return node.Kind == ast.NodeKindInterfaceTypeDefinition || node.Kind == ast.NodeKindUnionTypeDefinition +} + +func (p *PathBuilder) hasProvidedSuggestionForPath(path *pathConfiguration) bool { + for _, suggestion := range p.visitor.nodeSuggestions.items { + if suggestion.Path == path.path && suggestion.FieldRef == path.fieldRef && suggestion.IsProvided && !suggestion.IsOrphan { + return true + } + } + return false +} + +func (p *PathBuilder) anotherPlannerOwnsFieldPathWithChildren(plannerIdx int, fieldRef int, path string) bool { + for i, planner := range p.visitor.planners { + if i == plannerIdx { + continue + } + plannerPathConfig, ok := planner.(removablePlannerPathConfiguration) + if !ok { + continue + } + if plannerPathConfig.HasPathWithFieldRef(fieldRef) && plannerPathConfig.HasPath(path) && p.plannerHasActivePathPrefix(plannerPathConfig, path) { + return true + } + } + return false +} + +func (p *PathBuilder) plannerHasActivePathPrefix(planner PlannerPathConfiguration, prefix string) (hasPrefix bool) { + planner.ForEachPath(func(path *pathConfiguration) (shouldBreak bool) { + if path.path != prefix && strings.HasPrefix(path.path, prefix+".") { + hasPrefix = true + return true + } + return false + }) + return hasPrefix +} + func (p *PathBuilder) printRevisitInfo() { if p.visitor.shouldRevisit() { fmt.Println(" Will revisit") diff --git a/v2/pkg/engine/plan/path_builder_orphan_suggestion_test.go b/v2/pkg/engine/plan/path_builder_orphan_suggestion_test.go new file mode 100644 index 0000000000..e4e52ef626 --- /dev/null +++ b/v2/pkg/engine/plan/path_builder_orphan_suggestion_test.go @@ -0,0 +1,98 @@ +package plan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" +) + +func TestShouldRemoveDuplicateLeafAbstractFieldPathIgnoresOrphanedProvidedSuggestion(t *testing.T) { + definition := unsafeparser.ParseGraphqlDocumentString(` + type Query { user: User } + type User { related: SearchResult } + union SearchResult = Book | Movie + type Book { title: String } + type Movie { title: String } + `) + operation := unsafeparser.ParseGraphqlDocumentString(`query { + user { + ... on User { + related { + ... on Book { title } + } + } + } + }`) + relatedFieldRef := pathBuilderTestFieldRef(t, &operation, "related") + userNode, ok := definition.NodeByNameStr("User") + assert.True(t, ok) + + currentPlanner := newPlannerPathsConfiguration("query", PlannerPathObject, []pathConfiguration{ + { + parentPath: "query.user.$0User", + path: "query.user.$0User.related", + fieldRef: relatedFieldRef, + enclosingNode: userNode, + pathType: PathTypeField, + }, + }) + otherPlanner := newPlannerPathsConfiguration("query", PlannerPathObject, []pathConfiguration{ + { + parentPath: "query.user.$0User", + path: "query.user.$0User.related", + fieldRef: relatedFieldRef, + enclosingNode: userNode, + pathType: PathTypeField, + }, + { + parentPath: "query.user.$0User.related.$0Book", + path: "query.user.$0User.related.$0Book.title", + fieldRef: pathBuilderTestFieldRef(t, &operation, "title"), + enclosingNode: userNode, + pathType: PathTypeField, + }, + }) + + builder := &PathBuilder{ + visitor: &pathBuilderVisitor{ + operation: &operation, + definition: &definition, + nodeSuggestions: newNodeSuggestions([]NodeSuggestion{ + { + Path: "query.user.$0User.related", + FieldRef: relatedFieldRef, + IsProvided: true, + IsOrphan: true, + }, + }), + planners: []PlannerConfiguration{ + &plannerConfiguration[any]{plannerPathsConfiguration: currentPlanner}, + &plannerConfiguration[any]{plannerPathsConfiguration: otherPlanner}, + }, + }, + } + + shouldRemove := builder.shouldRemoveDuplicateLeafAbstractFieldPath(0, currentPlanner, &pathConfiguration{ + parentPath: "query.user.$0User", + path: "query.user.$0User.related", + fieldRef: relatedFieldRef, + enclosingNode: userNode, + pathType: PathTypeField, + }) + + assert.False(t, shouldRemove) +} + +func pathBuilderTestFieldRef(t *testing.T, operation *ast.Document, fieldName string) int { + t.Helper() + for i := range operation.Fields { + if operation.FieldNameString(i) == fieldName { + return i + } + } + t.Fatalf("field %q not found", fieldName) + return ast.InvalidRef +} diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 10e9e4b4da..4c475880b4 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -779,6 +779,10 @@ func (c *pathBuilderVisitor) planWithExistingPlanners(fieldRef int, typeName, fi }) if fieldHasRequiresDirective { + if plannerConfig.RequiredFields().HasArgumentConflictWith(requiresConfigurations) { + continue + } + // we should not plan fields with requires on the same planner as its dependencies, // because field with requires always will need an additional fetch before could be planned. // or the current planner provides dependencies for one of the requires dependency diff --git a/v2/pkg/engine/plan/planner.go b/v2/pkg/engine/plan/planner.go index 6be0b59488..229efbab88 100644 --- a/v2/pkg/engine/plan/planner.go +++ b/v2/pkg/engine/plan/planner.go @@ -149,6 +149,7 @@ func (p *Planner) Plan(operation, definition *ast.Document, operationName string p.planningVisitor.planners = plannersConfigurations p.planningVisitor.Config = p.config p.planningVisitor.skipFieldsRefs = selectionsConfig.skipFieldsRefs + p.planningVisitor.responseOnlyFieldRefs = selectionsConfig.responseOnlyFieldRefs p.planningVisitor.fieldRefDependsOnFieldRefs = selectionsConfig.fieldRefDependsOn p.planningVisitor.fieldDependencyKind = selectionsConfig.fieldDependencyKind p.planningVisitor.fieldRefDependants = inverseMap(selectionsConfig.fieldRefDependsOn) diff --git a/v2/pkg/engine/plan/source_connection_graph.go b/v2/pkg/engine/plan/source_connection_graph.go index c83566189d..421ef51321 100644 --- a/v2/pkg/engine/plan/source_connection_graph.go +++ b/v2/pkg/engine/plan/source_connection_graph.go @@ -7,6 +7,8 @@ type KeyJump struct { SelectionSet string FieldPaths []KeyInfoFieldPath TypeName string + Fallback bool + SourcePaths []KeyInfoFieldPath } type SourceConnectionType int @@ -25,8 +27,9 @@ type SourceConnection struct { // JumpCacheKey represents a key for the cache map type JumpCacheKey struct { - Source DSHash - Target DSHash + Source DSHash + Target DSHash + IncludeFallback bool } // DataSourceJumpsGraph represents a graph of possible jumps between each data sources @@ -37,8 +40,16 @@ type DataSourceJumpsGraph struct { } func (g *DataSourceJumpsGraph) GetPaths(source DSHash, target DSHash) ([]SourceConnection, bool) { + return g.getPaths(source, target, false) +} + +func (g *DataSourceJumpsGraph) GetPathsWithFallback(source DSHash, target DSHash) ([]SourceConnection, bool) { + return g.getPaths(source, target, true) +} + +func (g *DataSourceJumpsGraph) getPaths(source DSHash, target DSHash, includeFallback bool) ([]SourceConnection, bool) { // Create a cache key - key := JumpCacheKey{Source: source, Target: target} + key := JumpCacheKey{Source: source, Target: target, IncludeFallback: includeFallback} // Check if the path is already in the cache if path, found := g.Cache[key]; found { @@ -71,6 +82,10 @@ func (g *DataSourceJumpsGraph) GetPaths(source DSHash, target DSHash) ([]SourceC found := false for _, jump := range g.Jumps[current] { + if jump.Fallback && !includeFallback { + continue + } + if depth > 0 && jump.SelectionSet == path[len(path)-1].SelectionSet { continue // Skip jumps with the same selection set } @@ -131,16 +146,28 @@ func NewDataSourceJumpsGraph(dataSources []DSHash, keysPerPath map[DSHash][]KeyI } for _, keyInfo := range sourceKeyInfos { - if !keyInfo.Source || keyInfo.SelectionSet != targetKeyInfo.SelectionSet { + if !keyInfo.Source { continue } + fallback := false + if keyInfo.SelectionSet != targetKeyInfo.SelectionSet { + if !keyInfoFieldsCoverTargetKey(keyInfo, targetKeyInfo) { + continue + } + fallback = true + } + jump := KeyJump{ From: sourceDsHash, To: targetDSHash, - SelectionSet: keyInfo.SelectionSet, - FieldPaths: keyInfo.FieldPaths, + SelectionSet: targetKeyInfo.SelectionSet, + FieldPaths: targetKeyInfo.FieldPaths, TypeName: typeName, + Fallback: fallback, + } + if fallback { + jump.SourcePaths = keyInfo.FieldPaths } graph.Jumps[sourceDsHash] = append(graph.Jumps[sourceDsHash], jump) } @@ -150,3 +177,26 @@ func NewDataSourceJumpsGraph(dataSources []DSHash, keysPerPath map[DSHash][]KeyI return graph } + +func keyInfoFieldsCoverTargetKey(sourceKey, targetKey KeyInfo) bool { + if sourceKey.SelectionSet == targetKey.SelectionSet { + return true + } + + if len(sourceKey.FieldPaths) == 0 || len(targetKey.FieldPaths) == 0 { + return false + } + + targetPaths := make(map[string]struct{}, len(targetKey.FieldPaths)) + for _, path := range targetKey.FieldPaths { + targetPaths[path.Path] = struct{}{} + } + + for _, path := range sourceKey.FieldPaths { + if _, ok := targetPaths[path.Path]; !ok { + return false + } + } + + return true +} diff --git a/v2/pkg/engine/plan/source_connection_graph_test.go b/v2/pkg/engine/plan/source_connection_graph_test.go index e596388189..c1912652e1 100644 --- a/v2/pkg/engine/plan/source_connection_graph_test.go +++ b/v2/pkg/engine/plan/source_connection_graph_test.go @@ -213,6 +213,155 @@ func TestSourceConnectionGraph(t *testing.T) { }, path) }) + t.Run("no_regular_connection_when_source_key_is_subset_of_target_key", func(t *testing.T) { + keysPerPath := map[DSHash][]KeyInfo{ + 1: { + { + DSHash: 1, + TypeName: "ProductList", + SelectionSet: "products { id }", + FieldPaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + }, + Source: true, + Target: false, + }, + }, + 2: { + { + DSHash: 2, + TypeName: "ProductList", + SelectionSet: "products { id pid }", + FieldPaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + {Path: "query.topProducts.products.pid"}, + }, + Source: false, + Target: true, + }, + }, + } + + graph := NewDataSourceJumpsGraph([]DSHash{1, 2}, keysPerPath, "ProductList") + path, exists := graph.GetPaths(1, 2) + assert.False(t, exists, "Should not use fallback subset-key connection during regular lookup") + assert.Nil(t, path, "Path should be nil") + }) + + t.Run("fallback_connection_when_source_key_is_subset_of_target_key", func(t *testing.T) { + keysPerPath := map[DSHash][]KeyInfo{ + 1: { + { + DSHash: 1, + TypeName: "ProductList", + SelectionSet: "products { id }", + FieldPaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + }, + Source: true, + Target: false, + }, + }, + 2: { + { + DSHash: 2, + TypeName: "ProductList", + SelectionSet: "products { id pid }", + FieldPaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + {Path: "query.topProducts.products.pid"}, + }, + Source: false, + Target: true, + }, + }, + } + + graph := NewDataSourceJumpsGraph([]DSHash{1, 2}, keysPerPath, "ProductList") + path, exists := graph.GetPathsWithFallback(1, 2) + assert.True(t, exists, "Should have a connection") + + assert.Equal(t, []SourceConnection{ + { + Source: 1, + Target: 2, + Jumps: []KeyJump{ + { + From: 1, + To: 2, + SelectionSet: "products { id pid }", + FieldPaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + {Path: "query.topProducts.products.pid"}, + }, + TypeName: "ProductList", + Fallback: true, + SourcePaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + }, + }, + }, + Type: SourceConnectionTypeDirect, + }, + }, path) + }) + + }) + + t.Run("fallback connections have lower priority than regular paths", func(t *testing.T) { + keysPerPath := map[DSHash][]KeyInfo{ + 1: { + { + DSHash: 1, + TypeName: "ProductList", + SelectionSet: "products { id }", + FieldPaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + }, + Source: true, + Target: false, + }, + { + DSHash: 1, + TypeName: "ProductList", + SelectionSet: "products { id pid }", + FieldPaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + {Path: "query.topProducts.products.pid"}, + }, + Source: true, + Target: false, + }, + }, + 2: { + { + DSHash: 2, + TypeName: "ProductList", + SelectionSet: "products { id pid }", + FieldPaths: []KeyInfoFieldPath{ + {Path: "query.topProducts.products"}, + {Path: "query.topProducts.products.id"}, + {Path: "query.topProducts.products.pid"}, + }, + Source: false, + Target: true, + }, + }, + } + + graph := NewDataSourceJumpsGraph([]DSHash{1, 2}, keysPerPath, "ProductList") + path, exists := hasPathBetweenDs(graph, 1, 2, true) + assert.True(t, exists, "Should have a connection") + assert.False(t, path.Jumps[0].Fallback, "Should prefer the exact-key route over fallback") + assert.Equal(t, "products { id pid }", path.Jumps[0].SelectionSet) }) t.Run("indirect_connection_through_key_chain_with_correct_source/target_keys", func(t *testing.T) { diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index f63e0c1e5b..80500f60b4 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -45,6 +45,7 @@ type Visitor struct { currentField *resolve.Field planners []PlannerConfiguration skipFieldsRefs []int + responseOnlyFieldRefs map[int]struct{} fieldRefDependsOnFieldRefs map[int][]int fieldDependencyKind map[fieldDependencyKey]fieldDependencyKind fieldRefDependants map[int][]int // inverse of fieldRefDependsOnFieldRefs @@ -385,8 +386,16 @@ func (v *Visitor) EnterField(ref int) { onTypeNames := v.resolveOnTypeNames(ref, fieldName) + // A planner-generated alias (see abstract_selection_field_alias.go) keeps the upstream + // operation valid, but the client response name must remain the original field name. The + // upstream JSON path still follows the alias (see resolveFieldValue below). + responseName := fieldAliasOrName + if v.Operation.FieldAliasIsDefined(ref) && bytes.HasPrefix(v.Operation.FieldAliasBytes(ref), []byte(upstreamFieldMergingAliasPrefix)) { + responseName = fieldName + } + v.currentField = &resolve.Field{ - Name: fieldAliasOrName, + Name: responseName, OnTypeNames: onTypeNames, Position: v.resolveFieldPosition(ref), Info: v.resolveFieldInfo(ref, fieldDefinitionTypeRef, onTypeNames), @@ -661,6 +670,21 @@ func (v *Visitor) skipField(ref int) bool { return slices.Contains(v.skipFieldsRefs, ref) } +// IsResponseOnlyField reports whether the field must appear in the response (it +// resolves to null when the upstream omits it) but must NOT be sent to any +// subgraph in the upstream fetch. This is used for "partial union" members: when a +// union field is resolvable by multiple candidate subgraphs that define different +// members, a member unique to the resolving subgraph is kept in the response shape +// as null (matching spec-compliant routers) instead of being fetched. The upstream +// datasource planner consults this to exclude the field from the fetch. +func (v *Visitor) IsResponseOnlyField(ref int) bool { + if v.responseOnlyFieldRefs == nil { + return false + } + _, ok := v.responseOnlyFieldRefs[ref] + return ok +} + func (v *Visitor) introspectionShouldEvaluateIncludeDeprecated(fieldName string, enclosingTypeName string) bool { var introspectionEvaluateIncludeDeprecated bool