diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_entity_interfaces_typename_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_entity_interfaces_typename_test.go new file mode 100644 index 0000000000..7b7d048de9 --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_entity_interfaces_typename_test.go @@ -0,0 +1,676 @@ +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 TestGraphQLDataSourceFederationEntityInterfaces_TypenamePlanning(t *testing.T) { + definition, planConfiguration := interfaceObjectTypenamePlanConfiguration(t) + + t.Run("concrete typename from interface object", func(t *testing.T) { + t.Run("run", RunTest( + definition, + ` + query TestQuery { + c { + a { + id + ... on A1 { + fieldFromA + } + } + } + } + `, + "TestQuery", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://c.service","body":{"query":"{c {a {__typename id}}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://a.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on A1 {__typename fieldFromA}}}","variables":{"representations":[$$0$$]}}}`, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("A1"), []byte("A")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A1"), []byte("A")}, + }, + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("A")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A")}, + }, + }, + }), + }, + }, + DataSource: &Source{}, + PostProcessing: SingleEntityPostProcessingConfiguration, + RequiresEntityFetch: true, + SetTemplateOutputToNullOnVariableNull: true, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "c.a", resolve.ObjectPath("c"), resolve.ObjectPath("a")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("c"), + Value: &resolve.Object{ + Path: []string{"c"}, + Nullable: true, + PossibleTypes: map[string]struct{}{"C": {}}, + TypeName: "C", + Fields: []*resolve.Field{ + { + Name: []byte("a"), + Value: &resolve.Object{ + Path: []string{"a"}, + Nullable: true, + PossibleTypes: map[string]struct{}{"A": {}, "A1": {}, "A2": {}}, + TypeName: "A", + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A1"), []byte("A")}, + }, + { + Name: []byte("fieldFromA"), + Value: &resolve.String{ + Path: []string{"fieldFromA"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("A1")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A2"), []byte("A")}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + )) + }) + + t.Run("mixed root and entity selections", func(t *testing.T) { + t.Run("run", RunTest( + definition, + ` + query TestQuery { + a { + id + fieldFromA + ... on A1 { + fieldFromA1 + fieldFromB + } + ... on A2 { + fieldFromA2 + } + } + b { + id + fieldFromB + } + c { + id + fieldFromC + a { + id + ... on A1 { + fieldFromA1 + } + } + } + } + `, + "TestQuery", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://a.service","body":{"query":"{a {id fieldFromA __typename ... on A1 {fieldFromA1 __typename id} ... on A2 {fieldFromA2}}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://b.service","body":{"query":"{b {id fieldFromB}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://c.service","body":{"query":"{c {id fieldFromC a {__typename id}}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 3, + DependsOnFetchIDs: []int{2}, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://a.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on A1 {__typename fieldFromA1}}}","variables":{"representations":[$$0$$]}}}`, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("A1"), []byte("A")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A1"), []byte("A")}, + }, + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("A")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A")}, + }, + }, + }), + }, + }, + DataSource: &Source{}, + PostProcessing: SingleEntityPostProcessingConfiguration, + RequiresEntityFetch: true, + SetTemplateOutputToNullOnVariableNull: true, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "c.a", resolve.ObjectPath("c"), resolve.ObjectPath("a")), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 4, + DependsOnFetchIDs: []int{0}, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://b.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on A1 {__typename fieldFromB}}}","variables":{"representations":[$$0$$]}}}`, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("A1")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A1")}, + }, + }, + }), + }, + }, + DataSource: &Source{}, + PostProcessing: SingleEntityPostProcessingConfiguration, + RequiresEntityFetch: true, + SetTemplateOutputToNullOnVariableNull: true, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "a", resolve.ObjectPath("a")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("a"), + Value: &resolve.Object{ + Path: []string{"a"}, + Nullable: true, + PossibleTypes: map[string]struct{}{"A": {}, "A1": {}, "A2": {}}, + TypeName: "A", + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("fieldFromA"), + Value: &resolve.String{ + Path: []string{"fieldFromA"}, + Nullable: true, + }, + }, + { + Name: []byte("fieldFromA1"), + Value: &resolve.String{ + Path: []string{"fieldFromA1"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("A1")}, + }, + { + Name: []byte("fieldFromB"), + Value: &resolve.String{ + Path: []string{"fieldFromB"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("A1")}, + }, + { + Name: []byte("fieldFromA2"), + Value: &resolve.String{ + Path: []string{"fieldFromA2"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("A2")}, + }, + }, + }, + }, + { + Name: []byte("b"), + Value: &resolve.Object{ + Path: []string{"b"}, + Nullable: true, + PossibleTypes: map[string]struct{}{"B": {}}, + TypeName: "B", + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("fieldFromB"), + Value: &resolve.String{ + Path: []string{"fieldFromB"}, + Nullable: true, + }, + }, + }, + }, + }, + { + Name: []byte("c"), + Value: &resolve.Object{ + Path: []string{"c"}, + Nullable: true, + PossibleTypes: map[string]struct{}{"C": {}}, + TypeName: "C", + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("fieldFromC"), + Value: &resolve.String{ + Path: []string{"fieldFromC"}, + Nullable: true, + }, + }, + { + Name: []byte("a"), + Value: &resolve.Object{ + Path: []string{"a"}, + Nullable: true, + PossibleTypes: map[string]struct{}{"A": {}, "A1": {}, "A2": {}}, + TypeName: "A", + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A1"), []byte("A")}, + }, + { + Name: []byte("fieldFromA1"), + Value: &resolve.String{ + Path: []string{"fieldFromA1"}, + Nullable: true, + }, + OnTypeNames: [][]byte{[]byte("A1")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("A2"), []byte("A")}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + )) + }) +} + +func interfaceObjectTypenamePlanConfiguration(t *testing.T) (string, plan.Configuration) { + t.Helper() + + definition := ` + type Query { + c: C + b: B + a: A + } + + type C { + id: ID! + fieldFromC: String + a: A + } + + interface A { + id: ID! + fieldFromA: String + } + + type B { + id: ID! + fieldFromB: String + } + + type A1 implements A { + id: ID! + fieldFromB: String + fieldFromA: String + fieldFromA1: String + } + + type A2 implements A { + id: ID! + fieldFromA: String + fieldFromA2: String + } + ` + + subgraphCSDL := ` + type Query { + c: C + } + + type C @key(fields: "id") { + id: ID! + fieldFromC: String + a: A + } + + type A @key(fields: "id") @interfaceObject { + id: ID! + } + + extend schema @link( + url: "https://specs.apollo.dev/federation/v2.3", + import: ["@key", "@tag", "@shareable", "@inaccessible", "@override", "@external", "@provides", "@requires", "@composeDirective", "@interfaceObject"] + ) + ` + + subgraphBSDL := ` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable", "@requires", "@external", "@interfaceObject"]) + + type Query { + b: B + } + + type B @key(fields: "id") { + id: ID! + fieldFromB: String + } + + extend type A1 @key(fields: "id") { + id: ID! + fieldFromB: String + } + ` + + subgraphBSchema := ` + type Query { + b: B + } + + type B @key(fields: "id") { + id: ID! + fieldFromB: String + } + + type A1 @key(fields: "id") { + id: ID! + fieldFromB: String + } + + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable", "@requires", "@external", "@interfaceObject"]) + ` + + subgraphASDL := ` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: [ + "@key" + "@tag" + "@shareable" + "@inaccessible" + "@override" + "@external" + "@provides" + "@requires" + "@interfaceObject" + ]) + + type Query { + a: A + } + + interface A @key(fields: "id") { + id: ID! + fieldFromA: String + } + + type A1 implements A @key(fields: "id") { + id: ID! + fieldFromA: String + fieldFromA1: String + } + + type A2 implements A @key(fields: "id") { + id: ID! + fieldFromA: String + fieldFromA2: String + } + ` + + cDataSource := mustDataSourceConfiguration( + t, + "c-service", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"c"}}, + {TypeName: "C", FieldNames: []string{"id", "fieldFromC", "a"}}, + {TypeName: "A", FieldNames: []string{"id"}}, + {TypeName: "A1", FieldNames: []string{"id"}}, + {TypeName: "A2", FieldNames: []string{"id"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "C", SelectionSet: "id"}, + {TypeName: "A", SelectionSet: "id"}, + {TypeName: "A1", SelectionSet: "id"}, + {TypeName: "A2", SelectionSet: "id"}, + }, + InterfaceObjects: []plan.EntityInterfaceConfiguration{ + {InterfaceTypeName: "A", ConcreteTypeNames: []string{"A1", "A2"}}, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://c.service"}, + SchemaConfiguration: mustSchema(t, &FederationConfiguration{ + Enabled: true, + ServiceSDL: subgraphCSDL, + }, subgraphCSDL), + }), + ) + + bDataSource := mustDataSourceConfiguration( + t, + "b-service", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"b"}}, + {TypeName: "B", FieldNames: []string{"id", "fieldFromB"}}, + {TypeName: "A1", FieldNames: []string{"id", "fieldFromB"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "B", SelectionSet: "id"}, + {TypeName: "A1", SelectionSet: "id"}, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://b.service"}, + SchemaConfiguration: mustSchema(t, &FederationConfiguration{ + Enabled: true, + ServiceSDL: subgraphBSDL, + }, subgraphBSchema), + }), + ) + + aDataSource := mustDataSourceConfiguration( + t, + "a-service", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"a"}}, + {TypeName: "A", FieldNames: []string{"id", "fieldFromA"}}, + {TypeName: "A1", FieldNames: []string{"id", "fieldFromA", "fieldFromA1"}}, + {TypeName: "A2", FieldNames: []string{"id", "fieldFromA", "fieldFromA2"}}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "A", SelectionSet: "id"}, + {TypeName: "A1", SelectionSet: "id"}, + {TypeName: "A2", SelectionSet: "id"}, + }, + EntityInterfaces: []plan.EntityInterfaceConfiguration{ + {InterfaceTypeName: "A", ConcreteTypeNames: []string{"A1", "A2"}}, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{URL: "http://a.service"}, + SchemaConfiguration: mustSchema(t, &FederationConfiguration{ + Enabled: true, + ServiceSDL: subgraphASDL, + }, subgraphASDL), + }), + ) + + return definition, plan.Configuration{ + DataSources: []plan.DataSource{cDataSource, bDataSource, aDataSource}, + DisableResolveFieldPositions: true, + } +} diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index b66b41375a..c0dc48ceac 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -522,6 +522,17 @@ func (c *pathBuilderVisitor) EnterField(fieldRef int) { c.handlePlanningField(fieldRef, typeName, fieldName, currentPath, parentPath, precedingParentPath, suggestion, ds, shareable) } + if _, pathPlanned := c.addedPathDSHash(currentPath); !pathPlanned { + // Concrete __typename selections under interface objects can miss regular suggestions even though + // we already have exactly one planner anchored on the same parent path that can legally satisfy them. + // Reusing that planner here is narrower than relaxing suggestion collection for every __typename field. + if plannerIdx, planned := c.planTypenameOnExistingPlanner(fieldRef, typeName, fieldName, currentPath, parentPath, precedingParentPath); planned { + c.recordFieldPlannedOn(fieldRef, plannerIdx) + c.addFieldDependencies(fieldRef, typeName, fieldName, plannerIdx) + c.addRootField(fieldRef, plannerIdx) + } + } + c.addArrayField(fieldRef, currentPath) // pushResponsePath uses array fields so it should be called after addArrayField c.pushResponsePath(fieldRef, fieldAliasOrName) @@ -584,6 +595,58 @@ func (c *pathBuilderVisitor) handlePlanningField(fieldRef int, typeName, fieldNa c.handleMissingPath(planned, typeName, fieldName, currentPath, shareable) } +// planTypenameOnExistingPlanner recovers __typename paths that belong on an already-selected planner. +// We first try planners rooted on the non-fragment parent path to preserve the existing fetch shape. Only when +// none exist do we fall back to a planner rooted on the concrete-fragment parent path itself, which covers +// entity fetches created directly inside the fragment without reopening broader planners. +func (c *pathBuilderVisitor) planTypenameOnExistingPlanner(fieldRef int, typeName, fieldName, currentPath, parentPath, precedingParentPath string) (plannerIdx int, planned bool) { + if fieldName != typeNameField { + return -1, false + } + + var anchoredCandidates []int + var fragmentCandidates []int + for i, planner := range c.planners { + dsConfiguration := planner.DataSourceConfiguration() + if !dsConfiguration.HasRootNodeWithTypename(typeName) && !dsConfiguration.HasChildNodeWithTypename(typeName) { + continue + } + + if planner.ParentPath() == precedingParentPath { + if planner.HasPath(parentPath) || planner.HasPath(precedingParentPath) { + anchoredCandidates = append(anchoredCandidates, i) + } + continue + } + + if planner.ParentPath() != parentPath { + continue + } + + if !planner.HasPath(parentPath) { + continue + } + + fragmentCandidates = append(fragmentCandidates, i) + } + + candidates := anchoredCandidates + if len(candidates) == 0 { + candidates = fragmentCandidates + } + + if len(candidates) != 1 { + return -1, false + } + + plannerIdx = candidates[0] + if pathAdded := c.addPlannerPathForTypename(plannerIdx, currentPath, parentPath, fieldRef, fieldName, typeName, c.planners[plannerIdx].DataSourceConfiguration().PlanningBehavior()); !pathAdded { + return -1, false + } + + return plannerIdx, true +} + func (c *pathBuilderVisitor) couldPlanField(fieldRef int, dsHash DSHash) (ok bool) { fieldKey := fieldIndexKey{fieldRef, dsHash} fieldRefs, ok := c.fieldDependsOn[fieldKey] diff --git a/v2/pkg/engine/plan/path_builder_visitor_test.go b/v2/pkg/engine/plan/path_builder_visitor_test.go new file mode 100644 index 0000000000..fd3e49b387 --- /dev/null +++ b/v2/pkg/engine/plan/path_builder_visitor_test.go @@ -0,0 +1,284 @@ +package plan + +import ( + "testing" + + "github.com/jensneuse/abstractlogger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +func TestPathBuilderVisitor_EnterField_PlansTypenameOnExistingPlannerFallback(t *testing.T) { + definition := unsafeparser.ParseGraphqlDocumentStringWithBaseSchema(` + type Query { + a: A + } + + type A { + id: ID! + } + `) + operation := unsafeparser.ParseGraphqlDocumentString(` + query TestQuery { + a { + __typename + } + } + `) + + rootSelectionSet := operation.OperationDefinitions[0].SelectionSet + rootFieldRef := operation.Selections[operation.SelectionSets[rootSelectionSet].SelectionRefs[0]].Ref + typenameSelectionSet := operation.Fields[rootFieldRef].SelectionSet + typenameFieldRef := operation.Selections[operation.SelectionSets[typenameSelectionSet].SelectionRefs[0]].Ref + + planner := testPlannerConfiguration( + t, + "existing-planner", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: "A", FieldNames: []string{"id"}}}, + "query.a", + "query.a", + ) + + walker := astvisitor.NewWalker(8) + report := &operationreport.Report{} + + visitor := newTestPathBuilderVisitor(planner) + visitor.operation = &operation + visitor.definition = &definition + visitor.walker = &walker + visitor.nodeSuggestions = NewNodeSuggestions() + visitor.fieldsPlannedOn = make(map[int][]int) + + walker.RegisterEnterFieldVisitor(visitor) + walker.RegisterLeaveFieldVisitor(visitor) + walker.Walk(&operation, &definition, report) + require.False(t, report.HasErrors(), report.Error()) + + assert.True(t, planner.HasPath("query.a.__typename")) + assert.Equal(t, []int{0}, visitor.fieldsPlannedOn[typenameFieldRef]) + assert.Equal(t, []resolve.GraphCoordinate{{ + TypeName: "A", + FieldName: "__typename", + }}, planner.ObjectFetchConfiguration().rootFields) +} + +func TestPathBuilderVisitor_PlanTypenameOnExistingPlanner_SkipsIneligiblePlanners(t *testing.T) { + const ( + precedingParentPath = "query.c.a" + parentPath = "query.c.a.A1" + currentPath = "query.c.a.A1.__typename" + typeName = "A1" + fieldRef = 11 + ) + + broaderOwningPlanner := testPlannerConfiguration( + t, + "broader-owning-planner", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: typeName, FieldNames: []string{"id"}}}, + "query", + "query.c", + precedingParentPath, + parentPath, + ) + plannerWithoutParentPath := testPlannerConfiguration( + t, + "missing-parent-path", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: typeName, FieldNames: []string{"id"}}}, + parentPath, + precedingParentPath, + ) + plannerWithoutTypenameSupport := testPlannerConfiguration( + t, + "missing-typename-support", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: "Other", FieldNames: []string{"id"}}}, + parentPath, + parentPath, + ) + validPlanner := testPlannerConfiguration( + t, + "valid", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: typeName, FieldNames: []string{"id"}}}, + parentPath, + parentPath, + ) + + visitor := newTestPathBuilderVisitor(broaderOwningPlanner, plannerWithoutParentPath, plannerWithoutTypenameSupport, validPlanner) + + plannerIdx, planned := visitor.planTypenameOnExistingPlanner(fieldRef, typeName, typeNameField, currentPath, parentPath, precedingParentPath) + + require.True(t, planned) + assert.Equal(t, 3, plannerIdx) + assert.False(t, broaderOwningPlanner.HasPath(currentPath)) + assert.False(t, plannerWithoutParentPath.HasPath(currentPath)) + assert.False(t, plannerWithoutTypenameSupport.HasPath(currentPath)) + assert.True(t, validPlanner.HasPath(currentPath)) + assert.Len(t, visitor.addedPathTracker, 1) + assert.Equal(t, currentPath, visitor.addedPathTracker[0].path) +} + +func TestPathBuilderVisitor_PlanTypenameOnExistingPlanner_PrefersAnchoredPlanner(t *testing.T) { + const ( + precedingParentPath = "query.c.a" + parentPath = "query.c.a.A1" + currentPath = "query.c.a.A1.__typename" + typeName = "A1" + fieldRef = 12 + ) + + fragmentPlanner := testPlannerConfiguration( + t, + "fragment-planner", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: typeName, FieldNames: []string{"id"}}}, + parentPath, + parentPath, + ) + anchoredPlanner := testPlannerConfiguration( + t, + "anchored-planner", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: typeName, FieldNames: []string{"id"}}}, + precedingParentPath, + precedingParentPath, + ) + + visitor := newTestPathBuilderVisitor(fragmentPlanner, anchoredPlanner) + + plannerIdx, planned := visitor.planTypenameOnExistingPlanner(fieldRef, typeName, typeNameField, currentPath, parentPath, precedingParentPath) + + require.True(t, planned) + assert.Equal(t, 1, plannerIdx) + assert.False(t, fragmentPlanner.HasPath(currentPath)) + assert.True(t, anchoredPlanner.HasPath(currentPath)) +} + +func TestPathBuilderVisitor_PlanTypenameOnExistingPlanner_ReturnsFalseWhenTypenamePathCannotBeAdded(t *testing.T) { + const ( + precedingParentPath = "query.c" + parentPath = "query.c.a" + currentPath = "query.c.a.__typename" + typeName = "A" + fieldRef = 13 + ) + + planner := testPlannerConfiguration( + t, + "disabled-typename-planning", + DataSourcePlanningBehavior{AllowPlanningTypeName: false}, + []TypeField{{TypeName: typeName, FieldNames: []string{"id"}}}, + precedingParentPath, + precedingParentPath, + parentPath, + ) + + visitor := newTestPathBuilderVisitor(planner) + + plannerIdx, planned := visitor.planTypenameOnExistingPlanner(fieldRef, typeName, typeNameField, currentPath, parentPath, precedingParentPath) + + assert.False(t, planned) + assert.Equal(t, -1, plannerIdx) + assert.False(t, planner.HasPath(currentPath)) + assert.Empty(t, visitor.addedPathTracker) +} + +func TestPathBuilderVisitor_PlanTypenameOnExistingPlanner_ReturnsFalseWhenMultipleCandidatesMatch(t *testing.T) { + const ( + precedingParentPath = "query.c" + parentPath = "query.c.a" + currentPath = "query.c.a.__typename" + typeName = "A" + fieldRef = 14 + ) + + firstPlanner := testPlannerConfiguration( + t, + "first-candidate", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: typeName, FieldNames: []string{"id"}}}, + precedingParentPath, + precedingParentPath, + parentPath, + ) + secondPlanner := testPlannerConfiguration( + t, + "second-candidate", + DataSourcePlanningBehavior{AllowPlanningTypeName: true}, + []TypeField{{TypeName: typeName, FieldNames: []string{"id"}}}, + precedingParentPath, + precedingParentPath, + parentPath, + ) + + visitor := newTestPathBuilderVisitor(firstPlanner, secondPlanner) + + plannerIdx, planned := visitor.planTypenameOnExistingPlanner(fieldRef, typeName, typeNameField, currentPath, parentPath, precedingParentPath) + + assert.False(t, planned) + assert.Equal(t, -1, plannerIdx) + assert.False(t, firstPlanner.HasPath(currentPath)) + assert.False(t, secondPlanner.HasPath(currentPath)) + assert.Empty(t, visitor.addedPathTracker) +} + +func newTestPathBuilderVisitor(planners ...PlannerConfiguration) *pathBuilderVisitor { + return &pathBuilderVisitor{ + plannerConfiguration: &Configuration{}, + planners: planners, + addedPathTrackerIndex: make(map[string][]int), + missingPathTracker: make(map[string]struct{}), + } +} + +func testPlannerConfiguration( + t *testing.T, + id string, + behavior DataSourcePlanningBehavior, + rootNodes []TypeField, + parentPath string, + paths ...string, +) PlannerConfiguration { + t.Helper() + + ds, err := NewDataSourceConfiguration( + id, + &FakeFactory[struct{}]{ + behavior: &behavior, + }, + &DataSourceMetadata{ + RootNodes: rootNodes, + }, + struct{}{}, + ) + require.NoError(t, err) + + pathConfigs := make([]pathConfiguration, 0, len(paths)) + for _, path := range paths { + pathConfigs = append(pathConfigs, pathConfiguration{ + parentPath: parentPath, + path: path, + shouldWalkFields: true, + typeName: "A", + fieldRef: 1, + dsHash: ds.Hash(), + fragmentRef: -1, + pathType: PathTypeField, + }) + } + + return ds.CreatePlannerConfiguration( + abstractlogger.NoopLogger, + &objectFetchConfiguration{}, + newPlannerPathsConfiguration(parentPath, PlannerPathObject, pathConfigs), + &Configuration{}, + ) +}