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 new file mode 100644 index 0000000000..1b4947969f --- /dev/null +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_provides_test.go @@ -0,0 +1,445 @@ +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_NestedRequiresProvides(t *testing.T) { + t.Run("ignore requires due to implicit provide by parent", func(t *testing.T) { + definition := ` + type Query { + order: Order + } + + type Order { + id: ID! + shippingInfo: ShippingInfo + } + + type ShippingInfo { + id: ID! + details: DeliveryDetails + status: String! + fullLog: String! + } + + type DeliveryDetails { + trackingNumber: String! + carrier: String! + estimatedDelivery: String! + } + ` + + service1SDL := ` + type Query { + order: Order + } + + type Order @key(fields: "id") { + id: ID! + shippingInfo: ShippingInfo @provides(fields: "details { trackingNumber carrier }") + } + + type ShippingInfo @key(fields: "id") { + id: ID! + details: DeliveryDetails @external + status: String! @requires(fields: "details { trackingNumber carrier }") + fullLog: String! @requires(fields: "details { trackingNumber carrier estimatedDelivery }") + } + + type DeliveryDetails { + trackingNumber: String! + carrier: String! + estimatedDelivery: String! + } + ` + + service1DataSourceConfig := mustDataSourceConfiguration( + t, + "service1", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"order"}, + }, + { + TypeName: "Order", + FieldNames: []string{"id", "shippingInfo"}, + }, + { + TypeName: "ShippingInfo", + FieldNames: []string{"id", "status", "fullLog"}, + ExternalFieldNames: []string{"details"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "DeliveryDetails", + FieldNames: []string{"trackingNumber", "carrier", "estimatedDelivery"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "Order", + FieldName: "", + SelectionSet: "id", + }, + { + TypeName: "ShippingInfo", + FieldName: "", + SelectionSet: "id", + }, + }, + Requires: plan.FederationFieldConfigurations{ + { + TypeName: "ShippingInfo", + FieldName: "status", + SelectionSet: "details { trackingNumber carrier }", + }, + { + TypeName: "ShippingInfo", + FieldName: "fullLog", + SelectionSet: "details { trackingNumber carrier estimatedDelivery }", + }, + }, + Provides: plan.FederationFieldConfigurations{ + { + TypeName: "Order", + FieldName: "shippingInfo", + SelectionSet: "details { trackingNumber carrier }", + }, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://service1", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: service1SDL, + }, + service1SDL, + ), + }, + ), + ) + + service2SDL := ` + type ShippingInfo @key(fields: "id") { + id: ID! + details: DeliveryDetails + } + + type DeliveryDetails { + trackingNumber: String! + carrier: String! + estimatedDelivery: String! + } + ` + + service2DataSourceConfig := mustDataSourceConfiguration( + t, + "service2", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "ShippingInfo", + FieldNames: []string{"id", "details"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "DeliveryDetails", + FieldNames: []string{"trackingNumber", "carrier", "estimatedDelivery"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "ShippingInfo", + FieldName: "", + SelectionSet: "id", + }, + }, + }, + }, + mustCustomConfiguration(t, + ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://service2", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: service2SDL, + }, + service2SDL, + ), + }, + ), + ) + + planConfiguration := plan.Configuration{ + DisableResolveFieldPositions: true, + Debug: plan.DebugConfiguration{ + PrintQueryPlans: false, + PrintPlanningPaths: false, + }, + DataSources: []plan.DataSource{ + service1DataSourceConfig, + service2DataSourceConfig, + }, + } + + t.Run("query nested fields with required fields which provided", func(t *testing.T) { + t.Run("run", RunTest( + definition, + ` + query NestedRequires { + order { + shippingInfo { + status + fullLog + } + } + }`, + "NestedRequires", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://service1","body":{"query":"{order {shippingInfo {status fullLog}}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + }), + /* + // Nested fetch won't happen, because we provide sibling fields, so parent shippingInfo - is provided and could be selected. + // "fullLog" is a sibling of provided fields, and it is not marked as external, so basically we should be able to query it. + + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://service1","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on ShippingInfo {__typename fullLog}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + PostProcessing: SingleEntityPostProcessingConfiguration, + RequiresEntityFetch: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("ShippingInfo")}, + }, + { + Name: []byte("details"), + Value: &resolve.Object{ + Path: []string{"details"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("trackingNumber"), + Value: &resolve.String{ + Path: []string{"trackingNumber"}, + }, + }, + { + Name: []byte("carrier"), + Value: &resolve.String{ + Path: []string{"carrier"}, + }, + }, + { + Name: []byte("estimatedDelivery"), + Value: &resolve.String{ + Path: []string{"estimatedDelivery"}, + }, + }, + }, + }, + OnTypeNames: [][]byte{[]byte("ShippingInfo")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("ShippingInfo")}, + }, + }, + }), + }, + }, + SetTemplateOutputToNullOnVariableNull: true, + }, + }, "order.shippingInfo", resolve.ObjectPath("order"), resolve.ObjectPath("shippingInfo")), + */ + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("order"), + Value: &resolve.Object{ + Path: []string{"order"}, + Nullable: true, + TypeName: "Order", + PossibleTypes: map[string]struct{}{"Order": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("shippingInfo"), + Value: &resolve.Object{ + Path: []string{"shippingInfo"}, + Nullable: true, + TypeName: "ShippingInfo", + PossibleTypes: map[string]struct{}{"ShippingInfo": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("status"), + Value: &resolve.String{ + Path: []string{"status"}, + }, + }, + { + Name: []byte("fullLog"), + Value: &resolve.String{ + Path: []string{"fullLog"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + )) + }) + + t.Run("query nested fields which should be provided", func(t *testing.T) { + // technically estimated delivery is not provided + // but as it is not marked external on the DeliveryDetails type + // it could be selected by planner as it's external parent ShippingInfo.details selectable + // due to provides directive on the parent Order.shippingInfo + + t.Run("run", RunTest( + definition, + ` + query NestedRequires { + order { + shippingInfo { + details { + trackingNumber + carrier + estimatedDelivery + } + } + } + }`, + "NestedRequires", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://service1","body":{"query":"{order {shippingInfo {details {trackingNumber carrier estimatedDelivery}}}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + }), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("order"), + Value: &resolve.Object{ + Path: []string{"order"}, + Nullable: true, + TypeName: "Order", + PossibleTypes: map[string]struct{}{"Order": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("shippingInfo"), + Value: &resolve.Object{ + Path: []string{"shippingInfo"}, + Nullable: true, + TypeName: "ShippingInfo", + PossibleTypes: map[string]struct{}{"ShippingInfo": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("details"), + Value: &resolve.Object{ + Path: []string{"details"}, + Nullable: true, + TypeName: "DeliveryDetails", + PossibleTypes: map[string]struct{}{"DeliveryDetails": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("trackingNumber"), + Value: &resolve.String{ + Path: []string{"trackingNumber"}, + }, + }, + { + Name: []byte("carrier"), + Value: &resolve.String{ + Path: []string{"carrier"}, + }, + }, + { + Name: []byte("estimatedDelivery"), + Value: &resolve.String{ + Path: []string{"estimatedDelivery"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + planConfiguration, + WithDefaultPostProcessor(), + )) + }) + + }) +} diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go index 3c9713918d..7b90e6c778 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go @@ -987,6 +987,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { address: Address deliveryAddress: Address secretAddress: Address + providedAddress: Address shippingInfo: ShippingInfo } type Address { @@ -1149,6 +1150,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { info: Info shippingInfo: ShippingInfo secretAddress: Address + providedAddress: Address @provides(fields: "line1 line2 line3(test:\"BOOM\") zip") } type Info { @@ -1182,7 +1184,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { }, { TypeName: "Account", - FieldNames: []string{"id", "name", "info", "shippingInfo", "secretAddress"}, + FieldNames: []string{"id", "name", "info", "shippingInfo", "secretAddress", "providedAddress"}, }, { TypeName: "Address", @@ -1224,6 +1226,13 @@ func TestGraphQLDataSourceFederation(t *testing.T) { SelectionSet: "zip", }, }, + Provides: plan.FederationFieldConfigurations{ + { + TypeName: "Account", + FieldName: "providedAddress", + SelectionSet: "zip line1 line2 line3(test:\"BOOM\")", + }, + }, }, }, mustCustomConfiguration(t, @@ -1320,7 +1329,6 @@ func TestGraphQLDataSourceFederation(t *testing.T) { Keys: plan.FederationFieldConfigurations{ { TypeName: "Address", - FieldName: "", SelectionSet: "id", }, }, @@ -4303,7 +4311,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { }, DataSourceIdentifier: []byte("graphql_datasource.Source"), FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://user.service","body":{"query":"{user {oldAccount {deliveryAddress {line1} shippingInfo {zip} __typename id info {a b}}}}"}}`, + Input: `{"method":"POST","url":"http://user.service","body":{"query":"{user {oldAccount {deliveryAddress {line1}}}}"}}`, DataSource: &Source{}, PostProcessing: DefaultPostProcessingConfiguration, }, @@ -4394,6 +4402,187 @@ func TestGraphQLDataSourceFederation(t *testing.T) { ) }) + t.Run("nested selection set - but requirements are provided with entity query", func(t *testing.T) { + operation := ` + query Requires { + user { + account { + providedAddress { + secretLine + fullAddress + } + } + } + }` + + operationName := "Requires" + + expectedPlan := func() *plan.SynchronousResponsePlan { + return &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://user.service","body":{"query":"{user {account {__typename id info {a b}}}}"}}`, + DataSource: &Source{}, + PostProcessing: DefaultPostProcessingConfiguration, + }, + }), + resolve.SingleWithPath(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + DependsOnFetchIDs: []int{0}, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://account.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Account {__typename providedAddress {secretLine fullAddress}}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + PostProcessing: SingleEntityPostProcessingConfiguration, + RequiresEntityFetch: true, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("Account")}, + }, + { + Name: []byte("id"), + Value: &resolve.Scalar{ + Path: []string{"id"}, + }, + OnTypeNames: [][]byte{[]byte("Account")}, + }, + { + Name: []byte("info"), + Value: &resolve.Object{ + Path: []string{"info"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("a"), + Value: &resolve.Scalar{ + Path: []string{"a"}, + }, + }, + { + Name: []byte("b"), + Value: &resolve.Scalar{ + Path: []string{"b"}, + }, + }, + }, + }, + OnTypeNames: [][]byte{[]byte("Account")}, + }, + }, + }), + }, + }, + SetTemplateOutputToNullOnVariableNull: true, + }, + }, "user.account", resolve.ObjectPath("user"), resolve.ObjectPath("account")), + ), + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: true, + PossibleTypes: map[string]struct{}{ + "User": {}, + }, + TypeName: "User", + Fields: []*resolve.Field{ + { + Name: []byte("account"), + Value: &resolve.Object{ + Path: []string{"account"}, + Nullable: true, + PossibleTypes: map[string]struct{}{ + "Account": {}, + }, + TypeName: "Account", + Fields: []*resolve.Field{ + { + Name: []byte("providedAddress"), + Value: &resolve.Object{ + Path: []string{"providedAddress"}, + Nullable: true, + PossibleTypes: map[string]struct{}{ + "Address": {}, + }, + TypeName: "Address", + Fields: []*resolve.Field{ + { + Name: []byte("secretLine"), + Value: &resolve.String{ + Path: []string{"secretLine"}, + }, + }, + { + Name: []byte("fullAddress"), + Value: &resolve.String{ + Path: []string{"fullAddress"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + } + + RunWithPermutations( + t, + definition, + operation, + operationName, + expectedPlan(), + plan.Configuration{ + Debug: plan.DebugConfiguration{}, + DataSources: []plan.DataSource{ + usersDatasourceConfiguration, + accountsDatasourceConfiguration, + addressesDatasourceConfiguration, + addressesEnricherDatasourceConfiguration, + }, + DisableResolveFieldPositions: true, + Fields: plan.FieldConfigurations{ + { + TypeName: "Address", + FieldName: "line3", + Arguments: plan.ArgumentsConfigurations{ + { + Name: "test", + SourceType: plan.FieldArgumentSource, + }, + }, + }, + }, + }, + WithDefaultPostProcessor(), + ) + }) + t.Run("requires fields from the root query subgraph", func(t *testing.T) { definition := ` type User { 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 b9d47ad3ba..82f05fddea 100644 --- a/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go +++ b/v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go @@ -84,10 +84,11 @@ func (c *nodesCollector) initVisitors() { keys: make([]DSKeyInfo, 0, 2), localSeenKeys: make(map[SeenKeyPath]struct{}), localSuggestionLookup: make(map[int]struct{}), + providesEntries: make(map[string]struct{}), globalSeenKeys: c.seenKeys, + dataSource: dataSource, + notExternalKeyPaths: make(map[string]struct{}), } - visitor.dataSource = dataSource - visitor.notExternalKeyPaths = make(map[string]struct{}) c.dsVisitors = append(c.dsVisitors, visitor) c.dsVisitorsReports = append(c.dsVisitorsReports, operationreport.NewReport()) } @@ -252,22 +253,37 @@ type collectNodesDSVisitor struct { definition *ast.Document dataSource DataSource + // local suggestions stores suggestions for the current run of collecting fields + // they are reset after each run, because each time we collect suggestion only for new field refs localSuggestions []*NodeSuggestion localSuggestionLookup map[int]struct{} - providesEntries []*NodeSuggestion + // local provides entries, they should survive reset + // because unique fields refs are collected only once + providesEntries map[string]struct{} + // global node suggestion, we append to them after each run nodes *NodeSuggestions + // notExternalKeyPaths - stores paths of fields used in keys, which marked external + // but semantically are not true external notExternalKeyPaths map[string]struct{} - info map[int]fieldInfo + // reference to a global cache of field info shared between all collector instances + info map[int]fieldInfo + + // information about keys available for a given path collected during the current run keys []DSKeyInfo + // globalSeenKeys - stores key information which is shared globally between collectors, needed to avoid collecting keys on the same path + // between different runs, as evaluating keys are very expensive + // as it is shared between goroutines of collector we read it during the run, and write after the run finished globalSeenKeys map[SeenKeyPath]struct{} - localSeenKeys map[SeenKeyPath]struct{} + // seen keys local to the current run + localSeenKeys map[SeenKeyPath]struct{} } +// reset - cleanups only data which should not be persisted between runs func (f *collectNodesDSVisitor) reset() { f.localSuggestions = f.localSuggestions[:0] f.keys = f.keys[:0] @@ -346,31 +362,26 @@ func (f *collectNodesDSVisitor) handleProvidesSuggestions(fieldRef int, typeName } fieldTypeName := f.definition.FieldDefinitionTypeNameString(fieldDefRef) - providesFieldSet, report := providesFragment(fieldTypeName, providesSelectionSet, f.definition) - if report.HasErrors() { - return fmt.Errorf("failed to parse provides fields for %s.%s at path %s: %v", typeName, fieldName, currentPath, report) - } - - selectionSetRef, ok := f.operation.FieldSelectionSet(fieldRef) + _, ok = f.operation.FieldSelectionSet(fieldRef) if !ok { return fmt.Errorf("failed to get selection set ref for %s.%s at path %s. Field with provides directive should have a selections", typeName, fieldName, currentPath) } input := &providesInput{ - providesFieldSet: providesFieldSet, - operation: f.operation, - definition: f.definition, - operationSelectionSet: selectionSetRef, - report: report, - parentPath: currentPath, - dataSource: f.dataSource, - } - providesSuggestions := providesSuggestions(input) + parentTypeName: fieldTypeName, + providesSelectionSet: providesSelectionSet, + definition: f.definition, + parentPath: currentPath, + } + providesSuggestions, report := providesSuggestions(input) if report.HasErrors() { return fmt.Errorf("failed to get provides suggestions for %s.%s at path %s: %v", typeName, fieldName, currentPath, report) } - f.providesEntries = append(f.providesEntries, providesSuggestions...) + for providedKey := range providesSuggestions { + f.providesEntries[providedKey] = struct{}{} + } + return nil } @@ -449,9 +460,7 @@ func (f *collectNodesDSVisitor) EnterField(fieldRef int, itemIds []int, treeNode return nil } - isProvided := slices.ContainsFunc(f.providesEntries, func(suggestion *NodeSuggestion) bool { - return suggestion.TypeName == info.typeName && suggestion.FieldName == info.fieldName && suggestion.Path == info.currentPath - }) + _, 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 @@ -522,6 +531,7 @@ func (f *collectNodesDSVisitor) EnterField(fieldRef int, itemIds []int, treeNode } func (f *collectNodesDSVisitor) applySuggestions() { + // copy local suggestions to the global nodes suggestions for _, suggestion := range f.localSuggestions { f.nodes.addSuggestion(suggestion) itemId := len(f.nodes.items) - 1 @@ -531,6 +541,11 @@ func (f *collectNodesDSVisitor) applySuggestions() { itemIds = append(itemIds, itemId) treeNode.SetData(itemIds) } + + // apply provides entries + for entry := range f.providesEntries { + f.nodes.addProvidedField(entry, f.dataSource.Hash()) + } } func TreeNodeID(fieldRef int) uint { diff --git a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go index 77b444188a..71ea3ebfc8 100644 --- a/v2/pkg/engine/plan/datasource_filter_node_suggestions.go +++ b/v2/pkg/engine/plan/datasource_filter_node_suggestions.go @@ -96,6 +96,7 @@ type NodeSuggestions struct { pathSuggestions map[string][]*NodeSuggestion seenFields map[int]struct{} responseTree tree.Tree[[]int] + providedFields map[DSHash]map[string]struct{} } func TraverseBFS(data tree.Tree[[]int]) iter.Seq2[uint, tree.Node[[]int]] { @@ -133,6 +134,7 @@ func NewNodeSuggestionsWithSize(size int) *NodeSuggestions { seenFields: make(map[int]struct{}, size), pathSuggestions: make(map[string][]*NodeSuggestion), responseTree: *responseTree, + providedFields: make(map[DSHash]map[string]struct{}), } } @@ -203,6 +205,14 @@ func (f *NodeSuggestions) SuggestionsForPath(typeName, fieldName, path string) ( return suggestions } +// addProvidedField stores globally provided fields paths for a datasource +func (f *NodeSuggestions) addProvidedField(key string, dsHash DSHash) { + if _, ok := f.providedFields[dsHash]; !ok { + f.providedFields[dsHash] = make(map[string]struct{}) + } + f.providedFields[dsHash][key] = struct{}{} +} + func (f *NodeSuggestions) HasSuggestionForPath(typeName, fieldName, path string) (dsHash DSHash, ok bool) { items, ok := f.pathSuggestions[path] if !ok { diff --git a/v2/pkg/engine/plan/key_fields_visitor.go b/v2/pkg/engine/plan/key_fields_visitor.go index 1e018d56dd..0707dcb9dd 100644 --- a/v2/pkg/engine/plan/key_fields_visitor.go +++ b/v2/pkg/engine/plan/key_fields_visitor.go @@ -1,7 +1,6 @@ package plan import ( - "slices" "strings" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -16,7 +15,7 @@ type keyVisitorInput struct { report *operationreport.Report dataSource DataSource - providesEntries []*NodeSuggestion + providesEntries map[string]struct{} keyIsConditional bool } @@ -217,9 +216,7 @@ func (v *keyInfoVisitor) EnterField(ref int) { isExternal := hasExternalRootNode || hasExternalChildNode if isExternal { - isProvided := slices.ContainsFunc(v.input.providesEntries, func(suggestion *NodeSuggestion) bool { - return suggestion.TypeName == typeName && suggestion.FieldName == fieldName && suggestion.Path == currentPath - }) + _, isProvided := v.input.providesEntries[providedFieldKey(typeName, fieldName, currentPath)] if isProvided { // if the field is provided, it should not be marked as external diff --git a/v2/pkg/engine/plan/key_fields_visitor_test.go b/v2/pkg/engine/plan/key_fields_visitor_test.go index 7cb91112ce..8454685224 100644 --- a/v2/pkg/engine/plan/key_fields_visitor_test.go +++ b/v2/pkg/engine/plan/key_fields_visitor_test.go @@ -18,7 +18,7 @@ func TestKeyInfo(t *testing.T) { typeName string dataSource DataSource - providesEntries []*NodeSuggestion + providesEntries map[string]struct{} expectPaths []KeyInfoFieldPath expectExternalFields bool @@ -166,17 +166,9 @@ func TestKeyInfo(t *testing.T) { SelectionSet: "id name", }, }).DS(), - providesEntries: []*NodeSuggestion{ - { - TypeName: "User", - FieldName: "id", - Path: "query.me.id", - }, - { - TypeName: "User", - FieldName: "name", - Path: "query.me.name", - }, + providesEntries: map[string]struct{}{ + "User|id|query.me.id": {}, + "User|name|query.me.name": {}, }, expectPaths: []KeyInfoFieldPath{ {Path: "query.me.id"}, @@ -221,7 +213,7 @@ func TestCollectKeysForPath(t *testing.T) { typeName string dataSource DataSource - providesEntries []*NodeSuggestion + providesEntries map[string]struct{} expectKeys []DSKeyInfo }{ @@ -326,17 +318,9 @@ func TestCollectKeysForPath(t *testing.T) { }, }). DS(), - providesEntries: []*NodeSuggestion{ - { - TypeName: "User", - FieldName: "id", - Path: "query.me.id", - }, - { - TypeName: "User", - FieldName: "name", - Path: "query.me.name", - }, + providesEntries: map[string]struct{}{ + "User|id|query.me.id": {}, + "User|name|query.me.name": {}, }, expectKeys: []DSKeyInfo{ { diff --git a/v2/pkg/engine/plan/node_selection_visitor.go b/v2/pkg/engine/plan/node_selection_visitor.go index bbcffd3c43..db8403cd3c 100644 --- a/v2/pkg/engine/plan/node_selection_visitor.go +++ b/v2/pkg/engine/plan/node_selection_visitor.go @@ -1,7 +1,6 @@ package plan import ( - "errors" "fmt" "slices" @@ -232,7 +231,7 @@ func (c *nodeSelectionVisitor) handleEnterField(fieldRef int, handleRequires boo return d.Hash() == suggestion.DataSourceHash }) if dsIdx == -1 { - c.walker.StopWithInternalErr(errors.New("we should always have a datasource for a suggestion")) + c.walker.StopWithInternalErr(fmt.Errorf("do not have a datasource for a field suggestion for field %s at path %s", fieldName, currentPath)) return } ds := c.dataSources[dsIdx] @@ -290,9 +289,30 @@ func (c *nodeSelectionVisitor) handleFieldRequiredByRequires(fieldRef int, paren return } - // we should plan adding required fields for the field - // they will be added in the on LeaveSelectionSet callback for the current selection set - // and current field ref will be added to fieldDependsOn map + // check if the required fields are already provided + input := areRequiredFieldsProvidedInput{ + typeName: typeName, + requiredFields: requiresConfiguration.SelectionSet, + definition: c.definition, + dataSource: dsConfig, + providedFields: c.nodeSuggestions.providedFields[dsConfig.Hash()], + parentPath: parentPath, + } + + provided, report := areRequiredFieldsProvided(input) + if report.HasErrors() { + c.walker.StopWithInternalErr(fmt.Errorf("failed to check if required fields are provided for field %s at path %s: %w", fieldName, currentPath, report)) + return + } + + if provided { + // if all fields from requires configuration are provided, we do not need to add them to the operation + return + } + + // we should plan to add required fields for the field + // they will be added in the on LeaveSelectionSet callback for the current selection set, + // and the current field ref will be added to the fieldDependsOn map c.addPendingFieldRequirements(fieldRef, dsConfig.Hash(), requiresConfiguration, currentPath, false) c.handleKeyRequirementsForBackJumpOnSameDataSource(fieldRef, dsConfig, typeName, parentPath) } @@ -515,7 +535,7 @@ func (c *nodeSelectionVisitor) addFieldRequirementsToOperation(selectionSetRef i addFieldsResult, report := addRequiredFields(input) if report.HasErrors() { - c.walker.StopWithInternalErr(fmt.Errorf("failed to add required fields %s for %s at path %s", requirements.selectionSet, typeName, requirements.path)) + c.walker.StopWithInternalErr(fmt.Errorf("failed to add required fields %s for %s at path %s: %w", requirements.selectionSet, typeName, requirements.path, report)) return } c.resetVisitedAbstractChecksForModifiedFields(addFieldsResult.modifiedFieldRefs) @@ -602,7 +622,7 @@ func (c *nodeSelectionVisitor) addKeyRequirementsToOperation(selectionSetRef int addFieldsResult, report := addRequiredFields(input) if report.HasErrors() { - c.walker.StopWithInternalErr(fmt.Errorf("failed to add required key fields %s for %s", jump.SelectionSet, jump.TypeName)) + c.walker.StopWithInternalErr(fmt.Errorf("failed to add required key fields %s for %s: %w", jump.SelectionSet, jump.TypeName, report)) return } c.resetVisitedAbstractChecksForModifiedFields(addFieldsResult.modifiedFieldRefs) @@ -696,13 +716,13 @@ func (c *nodeSelectionVisitor) rewriteSelectionSetHavingAbstractFragments(fieldR rewriter, err := newFieldSelectionRewriter(c.operation, c.definition, ds, options...) if err != nil { - c.walker.StopWithInternalErr(err) + 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)) return } result, err := rewriter.RewriteFieldSelection(fieldRef, c.walker.EnclosingTypeDefinition) if err != nil { - c.walker.StopWithInternalErr(err) + c.walker.StopWithInternalErr(fmt.Errorf("failed to rewrite field selection for field %s at path %s: %w", c.operation.FieldNameString(fieldRef), c.walker.Path.DotDelimitedString(), err)) return } diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 7ccb90bb60..b66b41375a 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -721,40 +721,24 @@ func (c *pathBuilderVisitor) addFieldDependencies(fieldRef int, typeName, fieldN func (c *pathBuilderVisitor) isPlannerDependenciesAllowsToPlanField(fieldRef int, currentPlannerIdx int) bool { fieldKey := fieldIndexKey{fieldRef, c.planners[currentPlannerIdx].DataSourceConfiguration().Hash()} - // we have a field which have `requires` directive and depends on some fields, - // so we need to check is current planner already involved in this requires chain + // we have a field that has `requires` directive and depends on some fields, + // so we need to check is the current planner already involved in this requires chain waitingFor := c.fieldDependsOn[fieldKey] - // iterate over fields we depends on + // iterate over fields we depend on for _, waitingForFieldRef := range waitingFor { - // get all planners which planned the field we depend on + // get all planners that planned the field we depend on plannedOnPlannerIds := c.fieldsPlannedOn[waitingForFieldRef] - // for each planner which has planned the field we depend on + // for each planner that has planned the field we depend on for _, plannedOnPlannerId := range plannedOnPlannerIds { - // check if it has a dependency on the current planner id - if slices.Contains(c.planners[plannedOnPlannerId].ObjectFetchConfiguration().dependsOnFetchIDs, currentPlannerIdx) { + // check if any dependency field planned on the current planner + if plannedOnPlannerId == currentPlannerIdx { return false } - } - } - - return true -} - -func (c *pathBuilderVisitor) isAllFieldDependenciesOnSameDataSource(fieldRef int, currentPlannerIdx int) bool { - fieldKey := fieldIndexKey{fieldRef, c.planners[currentPlannerIdx].DataSourceConfiguration().Hash()} - - // we have a field which have `requires` directive and depends on some fields, - waitingFor := c.fieldDependsOn[fieldKey] - // iterate over fields we depends on - for _, waitingForFieldRef := range waitingFor { - // get all planners which planned the field we depend on - plannedOnPlannerIds := c.fieldsPlannedOn[waitingForFieldRef] - - for _, plannedOnPlannerId := range plannedOnPlannerIds { - if plannedOnPlannerId != currentPlannerIdx { + // check if it has a dependency on the current planner id + if slices.Contains(c.planners[plannedOnPlannerId].ObjectFetchConfiguration().dependsOnFetchIDs, currentPlannerIdx) { return false } } @@ -795,12 +779,9 @@ func (c *pathBuilderVisitor) planWithExistingPlanners(fieldRef int, typeName, fi }) if fieldHasRequiresDirective { - // we should not plan fields with requires on a root level planner - // because field with requires always will need an additional fetch before could be planned - if !plannerConfig.IsNestedPlanner() && !c.isAllFieldDependenciesOnSameDataSource(fieldRef, plannerIdx) { - 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 if !c.isPlannerDependenciesAllowsToPlanField(fieldRef, plannerIdx) { continue } diff --git a/v2/pkg/engine/plan/provides_fields_visitor.go b/v2/pkg/engine/plan/provides_fields_visitor.go index 50cf19eed2..060e54817f 100644 --- a/v2/pkg/engine/plan/provides_fields_visitor.go +++ b/v2/pkg/engine/plan/provides_fields_visitor.go @@ -9,11 +9,10 @@ import ( ) type providesInput struct { - providesFieldSet, operation, definition *ast.Document - report *operationreport.Report - operationSelectionSet int - parentPath string - dataSource DataSource + parentTypeName string + providesSelectionSet string + definition *ast.Document + parentPath string } type addTypenamesVisitor struct { @@ -65,60 +64,53 @@ func providesFragment(fieldTypeName string, providesSelectionSet string, definit return providesFieldSet, report } -func providesSuggestions(input *providesInput) []*NodeSuggestion { +func providesSuggestions(input *providesInput) (map[string]struct{}, *operationreport.Report) { + providesFieldSet, report := providesFragment(input.parentTypeName, input.providesSelectionSet, input.definition) + if report.HasErrors() { + return nil, report + } + walker := astvisitor.NewWalkerWithID(48, "ProvidesVisitor") visitor := &providesVisitor{ - walker: &walker, - input: input, + walker: &walker, + suggestions: make(map[string]struct{}), + providesFieldSet: providesFieldSet, + definition: input.definition, + parentTypeName: input.parentTypeName, + parentPath: input.parentPath, } - walker.RegisterEnterDocumentVisitor(visitor) - walker.RegisterEnterFragmentDefinitionVisitor(visitor) walker.RegisterEnterFieldVisitor(visitor) - walker.Walk(input.providesFieldSet, input.definition, input.report) + walker.Walk(providesFieldSet, input.definition, report) - return visitor.suggestions + return visitor.suggestions, report } type providesVisitor struct { - walker *astvisitor.Walker - input *providesInput - - suggestions []*NodeSuggestion - pathPrefix string -} - -func (v *providesVisitor) EnterFragmentDefinition(ref int) { - v.pathPrefix = v.input.providesFieldSet.FragmentDefinitionTypeNameString(ref) -} - -func (v *providesVisitor) EnterDocument(_, _ *ast.Document) { + walker *astvisitor.Walker + suggestions map[string]struct{} + providesFieldSet *ast.Document + definition *ast.Document + parentTypeName string + parentPath string } func (v *providesVisitor) EnterField(ref int) { - fieldName := v.input.providesFieldSet.FieldNameUnsafeString(ref) - typeName := v.walker.EnclosingTypeDefinition.NameString(v.input.definition) + fieldName := v.providesFieldSet.FieldNameUnsafeString(ref) + typeName := v.walker.EnclosingTypeDefinition.NameString(v.definition) currentPathWithoutFragments := v.walker.Path.WithoutInlineFragmentNames().DotDelimitedString() - parentPath := v.input.parentPath + strings.TrimPrefix(currentPathWithoutFragments, v.pathPrefix) + // remove the parent type name from the path because we are walking a fragment with the provided fields + parentPath := v.parentPath + strings.TrimPrefix(currentPathWithoutFragments, v.parentTypeName) currentPath := parentPath + "." + fieldName - suggestion := &NodeSuggestion{ - FieldRef: ast.InvalidRef, - TypeName: typeName, - FieldName: fieldName, - DataSourceHash: v.input.dataSource.Hash(), - DataSourceID: v.input.dataSource.Id(), - DataSourceName: v.input.dataSource.Name(), - Path: currentPath, - ParentPath: parentPath, - } - - v.suggestions = append(v.suggestions, suggestion) + v.suggestions[providedFieldKey(typeName, fieldName, currentPath)] = struct{}{} } -func (v *providesVisitor) LeaveField(ref int) { - +// providedFieldKey returns a unique key for a provided field +// it consists of the type name, field name and dot delimited path from a query +func providedFieldKey(typeName, fieldName, path string) string { + return typeName + "|" + fieldName + "|" + path } diff --git a/v2/pkg/engine/plan/provides_fields_visitor_test.go b/v2/pkg/engine/plan/provides_fields_visitor_test.go index a553c88741..f8486b685d 100644 --- a/v2/pkg/engine/plan/provides_fields_visitor_test.go +++ b/v2/pkg/engine/plan/provides_fields_visitor_test.go @@ -5,9 +5,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" - "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) func TestProvidesSuggestions(t *testing.T) { @@ -36,96 +34,27 @@ func TestProvidesSuggestions(t *testing.T) { }` definition := unsafeparser.ParseGraphqlDocumentStringWithBaseSchema(definitionSDL) - fieldSet, report := providesFragment("User", keySDL, &definition) - assert.False(t, report.HasErrors()) cases := []struct { - selectionSetRef int - expected []*NodeSuggestion + expected map[string]struct{} }{ { - selectionSetRef: 2, - expected: []*NodeSuggestion{ - { - FieldRef: ast.InvalidRef, - TypeName: "User", - FieldName: "name", - DataSourceHash: 2023, - Path: "query.me.name", - ParentPath: "query.me", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "User", - FieldName: "info", - DataSourceHash: 2023, - Path: "query.me.info", - ParentPath: "query.me", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "Info", - FieldName: "age", - DataSourceHash: 2023, - Path: "query.me.info.age", - ParentPath: "query.me.info", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "Info", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.me.info.__typename", - ParentPath: "query.me.info", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "User", - FieldName: "address", - DataSourceHash: 2023, - Path: "query.me.address", - ParentPath: "query.me", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "Address", - FieldName: "street", - DataSourceHash: 2023, - Path: "query.me.address.street", - ParentPath: "query.me.address", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "Address", - FieldName: "zip", - DataSourceHash: 2023, - Path: "query.me.address.zip", - ParentPath: "query.me.address", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "Address", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.me.address.__typename", - ParentPath: "query.me.address", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "User", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.me.__typename", - ParentPath: "query.me", - }, + expected: map[string]struct{}{ + "User|name|query.me.name": {}, + "User|info|query.me.info": {}, + "Info|age|query.me.info.age": {}, + "Info|__typename|query.me.info.__typename": {}, + "User|address|query.me.address": {}, + "Address|street|query.me.address.street": {}, + "Address|zip|query.me.address.zip": {}, + "Address|__typename|query.me.address.__typename": {}, + "User|__typename|query.me.__typename": {}, }, }, } for _, c := range cases { t.Run(keySDL, func(t *testing.T) { - report := &operationreport.Report{} - meta := &DataSourceMetadata{ RootNodes: []TypeField{ { @@ -152,21 +81,14 @@ func TestProvidesSuggestions(t *testing.T) { } meta.InitNodesIndex() - ds := &dataSourceConfiguration[string]{ - hash: 2023, - DataSourceMetadata: meta, - } - input := &providesInput{ - operationSelectionSet: c.selectionSetRef, - providesFieldSet: fieldSet, - definition: &definition, - report: report, - parentPath: "query.me", - dataSource: ds, + parentTypeName: "User", + providesSelectionSet: keySDL, + definition: &definition, + parentPath: "query.me", } - suggestions := providesSuggestions(input) + suggestions, report := providesSuggestions(input) assert.False(t, report.HasErrors()) assert.Equal(t, c.expected, suggestions) }) @@ -200,122 +122,47 @@ func TestProvidesSuggestionsWithFragments(t *testing.T) { definition := unsafeparser.ParseGraphqlDocumentStringWithBaseSchema(definitionSDL) cases := []struct { - selectionSetRef int parentPath string fieldTypeName string providesSelectionSet string - expected []*NodeSuggestion + expected map[string]struct{} }{ { - selectionSetRef: 1, parentPath: "query.ab", fieldTypeName: "AB", providesSelectionSet: `... on A {a} ... on B {b}`, - expected: []*NodeSuggestion{ - { - FieldRef: ast.InvalidRef, - TypeName: "A", - FieldName: "a", - DataSourceHash: 2023, - Path: "query.ab.a", - ParentPath: "query.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "A", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.ab.__typename", - ParentPath: "query.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "B", - FieldName: "b", - DataSourceHash: 2023, - Path: "query.ab.b", - ParentPath: "query.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "B", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.ab.__typename", - ParentPath: "query.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "AB", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.ab.__typename", - ParentPath: "query.ab", - }, + expected: map[string]struct{}{ + "A|a|query.ab.a": {}, + "A|__typename|query.ab.__typename": {}, + "B|b|query.ab.b": {}, + "B|__typename|query.ab.__typename": {}, + "AB|__typename|query.ab.__typename": {}, + }, + }, + { + parentPath: "query.nestedAB.ab", + fieldTypeName: "AB", + providesSelectionSet: `... on A {a} ... on B {b}`, + expected: map[string]struct{}{ + "A|a|query.nestedAB.ab.a": {}, + "A|__typename|query.nestedAB.ab.__typename": {}, + "B|b|query.nestedAB.ab.b": {}, + "B|__typename|query.nestedAB.ab.__typename": {}, + "AB|__typename|query.nestedAB.ab.__typename": {}, }, }, { - selectionSetRef: 1, parentPath: "query.nestedAB", fieldTypeName: "NestedAB", providesSelectionSet: `ab { ... on A {a} ... on B {b} }`, - expected: []*NodeSuggestion{ - { - FieldRef: ast.InvalidRef, - TypeName: "NestedAB", - FieldName: "ab", - DataSourceHash: 2023, - Path: "query.nestedAB.ab", - ParentPath: "query.nestedAB", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "A", - FieldName: "a", - DataSourceHash: 2023, - Path: "query.nestedAB.ab.a", - ParentPath: "query.nestedAB.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "A", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.nestedAB.ab.__typename", - ParentPath: "query.nestedAB.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "B", - FieldName: "b", - DataSourceHash: 2023, - Path: "query.nestedAB.ab.b", - ParentPath: "query.nestedAB.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "B", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.nestedAB.ab.__typename", - ParentPath: "query.nestedAB.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "AB", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.nestedAB.ab.__typename", - ParentPath: "query.nestedAB.ab", - }, - { - FieldRef: ast.InvalidRef, - TypeName: "NestedAB", - FieldName: "__typename", - DataSourceHash: 2023, - Path: "query.nestedAB.__typename", - ParentPath: "query.nestedAB", - }, + expected: map[string]struct{}{ + "NestedAB|ab|query.nestedAB.ab": {}, + "A|a|query.nestedAB.ab.a": {}, + "A|__typename|query.nestedAB.ab.__typename": {}, + "B|b|query.nestedAB.ab.b": {}, + "B|__typename|query.nestedAB.ab.__typename": {}, + "AB|__typename|query.nestedAB.ab.__typename": {}, + "NestedAB|__typename|query.nestedAB.__typename": {}, }, }, } @@ -346,24 +193,14 @@ func TestProvidesSuggestionsWithFragments(t *testing.T) { } meta.InitNodesIndex() - ds := &dataSourceConfiguration[string]{ - hash: 2023, - DataSourceMetadata: meta, - } - - fieldSet, report := providesFragment(c.fieldTypeName, c.providesSelectionSet, &definition) - assert.False(t, report.HasErrors()) - input := &providesInput{ - operationSelectionSet: c.selectionSetRef, - providesFieldSet: fieldSet, - definition: &definition, - report: report, - parentPath: c.parentPath, - dataSource: ds, + parentTypeName: c.fieldTypeName, + providesSelectionSet: c.providesSelectionSet, + definition: &definition, + parentPath: c.parentPath, } - suggestions := providesSuggestions(input) + suggestions, report := providesSuggestions(input) assert.False(t, report.HasErrors()) assert.Equal(t, c.expected, suggestions) }) diff --git a/v2/pkg/engine/plan/required_fields_provided_visitor.go b/v2/pkg/engine/plan/required_fields_provided_visitor.go new file mode 100644 index 0000000000..557e099526 --- /dev/null +++ b/v2/pkg/engine/plan/required_fields_provided_visitor.go @@ -0,0 +1,111 @@ +package plan + +import ( + "strings" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +type areRequiredFieldsProvidedInput struct { + typeName string + requiredFields string + definition *ast.Document + dataSource DataSource + providedFields map[string]struct{} + parentPath string +} + +// areRequiredFieldsProvided checks if all required fields are provided on the given path in a query +// Example subgraph schema: +// type Address @key(fields: "zip") { +// id: ID! +// street: String @requires(fields: "zip") +// zip: String @external +// } + +// type User @key(fields: "id") { +// id: ID! +// address: Address @external +// } + +// type Query { +// me: User @provides(fields: "address { street zip }") +// } +// +// Example query: +// +// query { +// me { +// address { +// street +// } +// } +// } +// +// When one of the parent nodes provides fields, which are mentioned in requires. +// We can skip fetching these requirements, because fields are already available under the given path. +func areRequiredFieldsProvided(input areRequiredFieldsProvidedInput) (bool, *operationreport.Report) { + if len(input.providedFields) == 0 { + return false, operationreport.NewReport() + } + + key, report := RequiredFieldsFragment(input.typeName, input.requiredFields, false) + if report.HasErrors() { + return false, report + } + + walker := astvisitor.NewWalkerWithID(4, "RequiredFieldsProvidedVisitor") + + visitor := &requiredFieldsProvidedVisitor{ + walker: &walker, + input: input, + key: key, + allProvided: true, + } + + walker.RegisterEnterFieldVisitor(visitor) + walker.Walk(key, input.definition, report) + + return visitor.allProvided, report +} + +type requiredFieldsProvidedVisitor struct { + walker *astvisitor.Walker + input areRequiredFieldsProvidedInput + key *ast.Document + allProvided bool +} + +func (v *requiredFieldsProvidedVisitor) EnterField(ref int) { + typeName := v.walker.EnclosingTypeDefinition.NameString(v.input.definition) + currentFieldName := v.key.FieldNameUnsafeString(ref) + + currentPathWithoutFragments := v.walker.Path.WithoutInlineFragmentNames().DotDelimitedString() + // remove the parent type name from the path because we are walking a fragment with the required fields + parentPath := v.input.parentPath + strings.TrimPrefix(currentPathWithoutFragments, v.input.typeName) + currentPath := parentPath + "." + currentFieldName + + key := providedFieldKey(typeName, currentFieldName, currentPath) + + _, provided := v.input.providedFields[key] + + if !provided { + // if we are on a nested path - it means that parent was provided as we reach this + if parentPath != "" { + hasRootNode := v.input.dataSource.HasRootNode(typeName, currentFieldName) + hasChildNode := v.input.dataSource.HasChildNode(typeName, currentFieldName) + + // if the field is not external under the parent + if hasRootNode || hasChildNode { + // we consider it accessible. + // e.g., implicitly provided + return + } + } + + v.allProvided = false + v.walker.Stop() + } +} diff --git a/v2/pkg/engine/plan/required_fields_provided_visitor_test.go b/v2/pkg/engine/plan/required_fields_provided_visitor_test.go new file mode 100644 index 0000000000..b83d5c2eda --- /dev/null +++ b/v2/pkg/engine/plan/required_fields_provided_visitor_test.go @@ -0,0 +1,190 @@ +package plan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" +) + +func TestAreRequiredFieldsProvided(t *testing.T) { + definitionSDL := ` + type User { + id: ID! + name: String! + username: String! + address: Address + thing: Thing + } + + type Address { + street: String! + zip: String! + } + + type A { + a: String! + } + + type B { + b: String! + } + + union Thing = A | B + + type Query { + me: User + } + ` + definition := unsafeparser.ParseGraphqlDocumentStringWithBaseSchema(definitionSDL) + + cases := []struct { + name string + typeName string + requiredFields string + parentPath string + providedFields map[string]struct{} + expected bool + datasource DataSource + }{ + { + name: "all fields provided", + typeName: "User", + requiredFields: "id name", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|id|query.me.id": {}, + "User|name|query.me.name": {}, + }, + expected: true, + }, + { + name: "one field missing", + typeName: "User", + requiredFields: "id name", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|id|query.me.id": {}, + }, + expected: false, + }, + { + name: "nested fields provided", + typeName: "User", + requiredFields: "address { street }", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|address|query.me.address": {}, + "Address|street|query.me.address.street": {}, + }, + expected: true, + }, + { + name: "one nested field missing - missing field is external", + typeName: "User", + requiredFields: "address { street zip }", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|address|query.me.address": {}, + "Address|street|query.me.address.street": {}, + }, + expected: false, + datasource: dsb(). + ChildNode("User", "address"). + ChildNode("Address", "street"). + AddChildNodeExternalFieldNames("Address", "zip"). + DS(), + }, + { + // case of implicitly provided field, due to provided parent + name: "one nested field missing - missing field is not external", + typeName: "User", + requiredFields: "address { street zip }", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|address|query.me.address": {}, + "Address|street|query.me.address.street": {}, + }, + expected: true, + datasource: dsb(). + ChildNode("User", "address"). + ChildNode("Address", "street", "zip"). + DS(), + }, + { + name: "deeply nested fields provided", + typeName: "User", + requiredFields: "address { street zip }", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|address|query.me.address": {}, + "Address|street|query.me.address.street": {}, + "Address|zip|query.me.address.zip": {}, + }, + expected: true, + }, + { + name: "requires with field name", + typeName: "User", + requiredFields: "name", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|name|query.me.name": {}, + }, + expected: true, + }, + { + name: "no provided fields", + typeName: "User", + requiredFields: "id", + parentPath: "query.me", + providedFields: map[string]struct{}{}, + expected: false, + }, + { + name: "nested fragments (union)", + typeName: "User", + requiredFields: "thing { ... on A { a } ... on B { b } }", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|thing|query.me.thing": {}, + "A|a|query.me.thing.a": {}, + "B|b|query.me.thing.b": {}, + }, + expected: true, + }, + { + name: "nested fragments (union) - missing B", + typeName: "User", + requiredFields: "thing { ... on A { a } ... on B { b } }", + parentPath: "query.me", + providedFields: map[string]struct{}{ + "User|thing|query.me.thing": {}, + "A|a|query.me.thing.a": {}, + }, + expected: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + input := areRequiredFieldsProvidedInput{ + typeName: c.typeName, + requiredFields: c.requiredFields, + definition: &definition, + providedFields: c.providedFields, + parentPath: c.parentPath, + dataSource: dsb().DS(), + } + if c.datasource != nil { + input.dataSource = c.datasource + } + + result, report := areRequiredFieldsProvided(input) + require.False(t, report.HasErrors()) + assert.Equal(t, c.expected, result) + }) + } +}