diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 31b67f7b5..7e24137cb 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -215,6 +215,7 @@ type _executionTestOptions struct { resolvableOptions resolve.ResolvableOptions apolloRouterCompatibilitySubrequestHTTPError bool + propagateFetchReasons bool } type executionTestOptions func(*_executionTestOptions) @@ -262,6 +263,7 @@ func TestExecutionEngine_Execute(t *testing.T) { MaxConcurrency: 1024, ResolvableOptions: opts.resolvableOptions, ApolloRouterCompatibilitySubrequestHTTPError: opts.apolloRouterCompatibilitySubrequestHTTPError, + PropagateFetchReasons: opts.propagateFetchReasons, }) require.NoError(t, err) @@ -822,6 +824,59 @@ func TestExecutionEngine_Execute(t *testing.T) { }, )) + t.Run("execute simple hero operation with propagating to subgraphs reason for fields being requested", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: graphql.LoadStarWarsQuery(starwars.FileSimpleHeroQuery, nil), + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: `{"query":"{hero {name}}","extensions":{"fetch_reasons":[{"typename":"Character","field":"name","by_user":true},{"typename":"Query","field":"hero","by_user":true}]}}`, + sendResponseBody: `{"data":{"hero":{"name":"Luke Skywalker"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"hero"}, + FetchReasonFields: []string{"hero"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Character", + FieldNames: []string{"name"}, + FetchReasonFields: []string{"name"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "POST", + }, + SchemaConfiguration: mustSchemaConfig( + t, + nil, + string(graphql.StarwarsSchema(t).RawSchema()), + ), + }), + ), + }, + fields: []plan.FieldConfiguration{}, + expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker"}}}`, + }, + func(eto *_executionTestOptions) { + eto.propagateFetchReasons = true + }, + )) + t.Run("execute simple hero operation with graphql data source and empty errors list", runWithoutError( ExecutionEngineTestCase{ schema: graphql.StarwarsSchema(t), @@ -4291,117 +4346,130 @@ func TestExecutionEngine_Execute(t *testing.T) { } ` - datasources := []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, - "id-1", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "first", - expectedPath: "/", - expectedBody: `{"query":"{accounts {__typename ... on User {some {__typename id}} ... on Admin {some {__typename id}}}}"}`, - sendResponseBody: `{"data":{"accounts":[{"__typename":"User","some":{"__typename":"User","id":"1"}},{"__typename":"Admin","some":{"__typename":"User","id":"2"}},{"__typename":"User","some":{"__typename":"User","id":"3"}}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"accounts"}, - }, - { - TypeName: "User", - FieldNames: []string{"id", "some"}, - }, - { - TypeName: "Admin", - FieldNames: []string{"id", "some"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "Node", - FieldNames: []string{"id", "title", "some"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: plan.FederationFieldConfigurations{ + makeDataSource := func(t *testing.T, expectFetchReasons bool) []plan.DataSource { + var expectedBody1 string + var expectedBody2 string + if !expectFetchReasons { + expectedBody1 = `{"query":"{accounts {__typename ... on User {some {__typename id}} ... on Admin {some {__typename id}}}}"}` + } else { + expectedBody1 = `{"query":"{accounts {__typename ... on User {some {__typename id}} ... on Admin {some {__typename id}}}}","extensions":{"fetch_reasons":[{"typename":"Admin","field":"some","by_user":true},{"typename":"User","field":"id","by_subgraphs":["id-2"],"by_user":true,"is_key":true}]}}` + } + expectedBody2 = `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[{"__typename":"User","id":"1"},{"__typename":"User","id":"3"}]}}` + + return []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, + "id-1", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "first", + expectedPath: "/", + expectedBody: expectedBody1, + sendResponseBody: `{"data":{"accounts":[{"__typename":"User","some":{"__typename":"User","id":"1"}},{"__typename":"Admin","some":{"__typename":"User","id":"2"}},{"__typename":"User","some":{"__typename":"User","id":"3"}}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"accounts"}, + }, { - TypeName: "User", - SelectionSet: "id", + TypeName: "User", + FieldNames: []string{"id", "some"}, + FetchReasonFields: []string{"id"}, }, { - TypeName: "Admin", - SelectionSet: "id", + TypeName: "Admin", + FieldNames: []string{"id", "some"}, + FetchReasonFields: []string{"some"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Node", + FieldNames: []string{"id", "title", "some"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + { + TypeName: "Admin", + SelectionSet: "id", + }, }, }, }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://first/", - Method: "POST", - }, - SchemaConfiguration: mustSchemaConfig( - t, - &graphql_datasource.FederationConfiguration{ - Enabled: true, - ServiceSDL: firstSubgraphSDL, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://first/", + Method: "POST", }, - firstSubgraphSDL, - ), - }), - ), - mustGraphqlDataSourceConfiguration(t, - "id-2", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "second", - expectedPath: "/", - expectedBody: `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename title}}}","variables":{"representations":[{"__typename":"User","id":"1"},{"__typename":"User","id":"3"}]}}`, - sendResponseBody: `{"data":{"_entities":[{"__typename":"User","title":"User1"},{"__typename":"User","title":"User3"}]}}`, - sendStatusCode: 200, + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: firstSubgraphSDL, + }, + firstSubgraphSDL, + ), }), ), - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "name", "title"}, - }, - { - TypeName: "Admin", - FieldNames: []string{"id", "adminName", "title"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: plan.FederationFieldConfigurations{ + mustGraphqlDataSourceConfiguration(t, + "id-2", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "second", + expectedPath: "/", + expectedBody: expectedBody2, + sendResponseBody: `{"data":{"_entities":[{"__typename":"User","title":"User1"},{"__typename":"User","title":"User3"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ { - TypeName: "User", - SelectionSet: "id", + TypeName: "User", + FieldNames: []string{"id", "name", "title"}, }, { - TypeName: "Admin", - SelectionSet: "id", + TypeName: "Admin", + FieldNames: []string{"id", "adminName", "title"}, + }, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + { + TypeName: "User", + SelectionSet: "id", + }, + { + TypeName: "Admin", + SelectionSet: "id", + }, }, }, }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://second/", - Method: "POST", - }, - SchemaConfiguration: mustSchemaConfig( - t, - &graphql_datasource.FederationConfiguration{ - Enabled: true, - ServiceSDL: secondSubgraphSDL, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://second/", + Method: "POST", }, - secondSubgraphSDL, - ), - }), - ), + SchemaConfiguration: mustSchemaConfig( + t, + &graphql_datasource.FederationConfiguration{ + Enabled: true, + ServiceSDL: secondSubgraphSDL, + }, + secondSubgraphSDL, + ), + }), + ), + } } t.Run("run", runWithoutError(ExecutionEngineTestCase{ @@ -4432,9 +4500,46 @@ func TestExecutionEngine_Execute(t *testing.T) { }`, } }, - dataSources: datasources, + dataSources: makeDataSource(t, false), expectedResponse: `{"data":{"accounts":[{"some":{"title":"User1"}},{"some":{"__typename":"User","id":"2"}},{"some":{"title":"User3"}}]}}`, })) + + t.Run("run with extension", runWithoutError( + ExecutionEngineTestCase{ + schema: func(t *testing.T) *graphql.Schema { + t.Helper() + parseSchema, err := graphql.NewSchemaFromString(definition) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + OperationName: "Accounts", + Query: ` + query Accounts { + accounts { + ... on User { + some { + title + } + } + ... on Admin { + some { + __typename + id + } + } + } + }`, + } + }, + dataSources: makeDataSource(t, true), + expectedResponse: `{"data":{"accounts":[{"some":{"title":"User1"}},{"some":{"__typename":"User","id":"2"}},{"some":{"title":"User3"}}]}}`, + }, + func(eto *_executionTestOptions) { + eto.propagateFetchReasons = true + }, + )) }) } @@ -4592,12 +4697,10 @@ func BenchmarkIntrospection(b *testing.B) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - type benchCase struct { engine *ExecutionEngine writer *graphql.EngineResultWriter } - newEngine := func() *ExecutionEngine { engine, err := NewExecutionEngine(ctx, abstractlogger.NoopLogger, engineConf, resolve.ResolverOptions{ MaxConcurrency: 1024, @@ -4649,12 +4752,10 @@ func BenchmarkIntrospection(b *testing.B) { func BenchmarkExecutionEngine(b *testing.B) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - type benchCase struct { engine *ExecutionEngine writer *graphql.EngineResultWriter } - newEngine := func() *ExecutionEngine { schema, err := graphql.NewSchemaFromString(`type Query { hello: String}`) require.NoError(b, err) 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 aaf10250d..2e881ca07 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 @@ -1083,8 +1083,9 @@ func TestGraphQLDataSourceFederation(t *testing.T) { ExternalFieldNames: []string{"name", "shippingInfo"}, }, { - TypeName: "Address", - FieldNames: []string{"id", "line1", "line2"}, + TypeName: "Address", + FieldNames: []string{"id", "line1", "line2"}, + FetchReasonFields: []string{"id", "line1", "line2"}, }, }, ChildNodes: []plan.TypeField{ @@ -1264,8 +1265,9 @@ func TestGraphQLDataSourceFederation(t *testing.T) { &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ { - TypeName: "Address", - FieldNames: []string{"id", "line3", "zip"}, + TypeName: "Address", + FieldNames: []string{"id", "line3", "zip"}, + FetchReasonFields: []string{"line3", "zip"}, }, }, FederationMetaData: plan.FederationMetaData{ @@ -3094,6 +3096,30 @@ func TestGraphQLDataSourceFederation(t *testing.T) { Input: `{"method":"POST","url":"http://user.service","body":{"query":"{user {account {address {line1 line2 __typename id}}}}"}}`, DataSource: &Source{}, PostProcessing: DefaultPostProcessingConfiguration, + FieldFetchReasons: []resolve.FetchReason{ + { + TypeName: "Address", + FieldName: "id", + BySubgraphs: []string{ + "account.service", + "address-enricher.service", + "address.service", + }, + IsKey: true, + }, + { + TypeName: "Address", + FieldName: "line1", + BySubgraphs: []string{"account.service"}, + IsRequires: true, + }, + { + TypeName: "Address", + FieldName: "line2", + BySubgraphs: []string{"account.service"}, + IsRequires: true, + }, + }, }, }), resolve.SingleWithPath(&resolve.SingleFetch{ @@ -3234,6 +3260,20 @@ func TestGraphQLDataSourceFederation(t *testing.T) { }, }, }, + FieldFetchReasons: []resolve.FetchReason{ + { + TypeName: "Address", + FieldName: "line3", + BySubgraphs: []string{"account.service"}, + IsRequires: true, + }, + { + TypeName: "Address", + FieldName: "zip", + BySubgraphs: []string{"account.service"}, + IsRequires: true, + }, + }, Variables: []resolve.Variable{ &resolve.ResolvableObjectVariable{ Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ diff --git a/v2/pkg/engine/plan/configuration.go b/v2/pkg/engine/plan/configuration.go index c17c869c7..a5216da62 100644 --- a/v2/pkg/engine/plan/configuration.go +++ b/v2/pkg/engine/plan/configuration.go @@ -27,7 +27,11 @@ type Configuration struct { MinifySubgraphOperations bool - DisableIncludeInfo bool + // DisableIncludeInfo controls whether the planner generates resolve.FieldInfo (useful in tests). + DisableIncludeInfo bool + + // DisableIncludeFieldDependencies controls whether the planner generates + // field dependency structures (useful in tests). DisableIncludeFieldDependencies bool } diff --git a/v2/pkg/engine/plan/datasource_configuration.go b/v2/pkg/engine/plan/datasource_configuration.go index 1a6b7fceb..2601a43cc 100644 --- a/v2/pkg/engine/plan/datasource_configuration.go +++ b/v2/pkg/engine/plan/datasource_configuration.go @@ -51,8 +51,8 @@ type DataSourceMetadata struct { Directives *DirectiveConfigurations - rootNodesIndex map[string]fieldsIndex - childNodesIndex map[string]fieldsIndex + rootNodesIndex map[string]fieldsIndex // maps TypeName to fieldsIndex + childNodesIndex map[string]fieldsIndex // maps TypeName to fieldsIndex } type DirectivesConfigurations interface { @@ -71,11 +71,13 @@ type NodesInfo interface { HasChildNode(typeName, fieldName string) bool HasExternalChildNode(typeName, fieldName string) bool HasChildNodeWithTypename(typeName string) bool + RequiresFetchReason(typeName, fieldName string) bool } type fieldsIndex struct { - fields map[string]struct{} - externalFields map[string]struct{} + fields map[string]struct{} + externalFields map[string]struct{} + fetchReasonFields map[string]struct{} } func (d *DataSourceMetadata) Init() error { @@ -102,32 +104,42 @@ func (d *DataSourceMetadata) InitNodesIndex() { d.childNodesIndex = make(map[string]fieldsIndex, len(d.ChildNodes)) for i := range d.RootNodes { - if _, ok := d.rootNodesIndex[d.RootNodes[i].TypeName]; !ok { - d.rootNodesIndex[d.RootNodes[i].TypeName] = fieldsIndex{ - make(map[string]struct{}, len(d.RootNodes[i].FieldNames)), - make(map[string]struct{}, len(d.RootNodes[i].ExternalFieldNames)), + typeName := d.RootNodes[i].TypeName + if _, ok := d.rootNodesIndex[typeName]; !ok { + d.rootNodesIndex[typeName] = fieldsIndex{ + fields: make(map[string]struct{}, len(d.RootNodes[i].FieldNames)), + externalFields: make(map[string]struct{}, len(d.RootNodes[i].ExternalFieldNames)), + fetchReasonFields: make(map[string]struct{}, len(d.RootNodes[i].FetchReasonFields)), } } - for j := range d.RootNodes[i].FieldNames { - d.rootNodesIndex[d.RootNodes[i].TypeName].fields[d.RootNodes[i].FieldNames[j]] = struct{}{} + for _, name := range d.RootNodes[i].FieldNames { + d.rootNodesIndex[typeName].fields[name] = struct{}{} } - for j := range d.RootNodes[i].ExternalFieldNames { - d.rootNodesIndex[d.RootNodes[i].TypeName].externalFields[d.RootNodes[i].ExternalFieldNames[j]] = struct{}{} + for _, name := range d.RootNodes[i].ExternalFieldNames { + d.rootNodesIndex[typeName].externalFields[name] = struct{}{} + } + for _, name := range d.RootNodes[i].FetchReasonFields { + d.rootNodesIndex[typeName].fetchReasonFields[name] = struct{}{} } } for i := range d.ChildNodes { - if _, ok := d.childNodesIndex[d.ChildNodes[i].TypeName]; !ok { - d.childNodesIndex[d.ChildNodes[i].TypeName] = fieldsIndex{ - make(map[string]struct{}), - make(map[string]struct{}), + typeName := d.ChildNodes[i].TypeName + if _, ok := d.childNodesIndex[typeName]; !ok { + d.childNodesIndex[typeName] = fieldsIndex{ + fields: make(map[string]struct{}), + externalFields: make(map[string]struct{}), + fetchReasonFields: make(map[string]struct{}), } } - for j := range d.ChildNodes[i].FieldNames { - d.childNodesIndex[d.ChildNodes[i].TypeName].fields[d.ChildNodes[i].FieldNames[j]] = struct{}{} + for _, name := range d.ChildNodes[i].FieldNames { + d.childNodesIndex[typeName].fields[name] = struct{}{} + } + for _, name := range d.ChildNodes[i].ExternalFieldNames { + d.childNodesIndex[typeName].externalFields[name] = struct{}{} } - for j := range d.ChildNodes[i].ExternalFieldNames { - d.childNodesIndex[d.ChildNodes[i].TypeName].externalFields[d.ChildNodes[i].ExternalFieldNames[j]] = struct{}{} + for _, name := range d.ChildNodes[i].FetchReasonFields { + d.childNodesIndex[typeName].fetchReasonFields[name] = struct{}{} } } } @@ -195,6 +207,34 @@ func (d *DataSourceMetadata) HasExternalChildNode(typeName, fieldName string) bo return ok } +func (d *DataSourceMetadata) RequiresFetchReason(typeName, fieldName string) bool { + return d.hasFetchReasonRootNode(typeName, fieldName) || d.hasFetchReasonChildNode(typeName, fieldName) +} + +func (d *DataSourceMetadata) hasFetchReasonRootNode(typeName, fieldName string) bool { + if d.rootNodesIndex == nil { + return false + } + index, ok := d.rootNodesIndex[typeName] + if !ok { + return false + } + _, ok = index.fetchReasonFields[fieldName] + return ok +} + +func (d *DataSourceMetadata) hasFetchReasonChildNode(typeName, fieldName string) bool { + if d.childNodesIndex == nil { + return false + } + index, ok := d.childNodesIndex[typeName] + if !ok { + return false + } + _, ok = index.fetchReasonFields[fieldName] + return ok +} + func (d *DataSourceMetadata) HasChildNodeWithTypename(typeName string) bool { if d.childNodesIndex == nil { return false diff --git a/v2/pkg/engine/plan/federation_metadata.go b/v2/pkg/engine/plan/federation_metadata.go index 42f818b29..b4479a489 100644 --- a/v2/pkg/engine/plan/federation_metadata.go +++ b/v2/pkg/engine/plan/federation_metadata.go @@ -105,7 +105,7 @@ func (f *FederationFieldConfiguration) parseSelectionSet() error { return nil } -func (f FederationFieldConfiguration) String() string { +func (f *FederationFieldConfiguration) String() string { b, _ := json.Marshal(f) return string(b) } diff --git a/v2/pkg/engine/plan/planner.go b/v2/pkg/engine/plan/planner.go index 166b7ba33..25d6e8960 100644 --- a/v2/pkg/engine/plan/planner.go +++ b/v2/pkg/engine/plan/planner.go @@ -2,6 +2,7 @@ package plan import ( "fmt" + "sort" "strings" "github.com/jensneuse/abstractlogger" @@ -153,6 +154,7 @@ func (p *Planner) Plan(operation, definition *ast.Document, operationName string p.planningVisitor.skipFieldsRefs = selectionsConfig.skipFieldsRefs p.planningVisitor.fieldRefDependsOnFieldRefs = selectionsConfig.fieldRefDependsOn p.planningVisitor.fieldDependencyKind = selectionsConfig.fieldDependencyKind + p.planningVisitor.fieldRefDependants = inverseMap(selectionsConfig.fieldRefDependsOn) p.planningWalker.ResetVisitors() p.planningWalker.SetVisitorFilter(p.planningVisitor) @@ -227,6 +229,20 @@ func (p *Planner) prepareOperation(operation, definition *ast.Document, report * p.prepareOperationWalker.Walk(operation, definition, report) } +func inverseMap(m map[int][]int) map[int][]int { + inverse := make(map[int][]int) + for k, v := range m { + for _, v2 := range v { + inverse[v2] = append(inverse[v2], k) + } + } + // Normalize ordering for deterministic plans/tests + for key := range inverse { + sort.Ints(inverse[key]) + } + return inverse +} + func debugMessage(msg string) { fmt.Printf("\n\n%s\n\n", msg) } diff --git a/v2/pkg/engine/plan/type_field.go b/v2/pkg/engine/plan/type_field.go index 9e7448800..e3dfa266d 100644 --- a/v2/pkg/engine/plan/type_field.go +++ b/v2/pkg/engine/plan/type_field.go @@ -1,35 +1,10 @@ package plan -import ( - "slices" -) - type TypeField struct { TypeName string FieldNames []string ExternalFieldNames []string + FetchReasonFields []string } type TypeFields []TypeField - -func (f TypeFields) HasNode(typeName, fieldName string) bool { - return slices.ContainsFunc(f, func(t TypeField) bool { - return typeName == t.TypeName && slices.Contains(t.FieldNames, fieldName) - }) -} - -func (f TypeFields) HasExternalNode(typeName, fieldName string) bool { - return slices.ContainsFunc(f, func(t TypeField) bool { - return typeName == t.TypeName && slices.Contains(t.ExternalFieldNames, fieldName) - }) -} - -func (f TypeFields) HasNodeWithTypename(typeName string) bool { - for i := range f { - if typeName != f[i].TypeName { - continue - } - return true - } - return false -} diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index 560cb97f0..382be2807 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -2,6 +2,7 @@ package plan import ( "bytes" + "cmp" "fmt" "reflect" "regexp" @@ -46,6 +47,7 @@ type Visitor struct { skipFieldsRefs []int fieldRefDependsOnFieldRefs map[int][]int fieldDependencyKind map[fieldDependencyKey]fieldDependencyKind + fieldRefDependants map[int][]int // inverse of fieldRefDependsOnFieldRefs fieldConfigs map[int]*FieldConfiguration exportedVariables map[string]struct{} skipIncludeOnFragments map[int]skipIncludeInfo @@ -54,13 +56,13 @@ type Visitor struct { indirectInterfaceFields map[int]indirectInterfaceField pathCache map[astvisitor.VisitorKind]map[int]string - // plannerFields tells which fields are planned on which planners - // map plannerID -> []fieldRef + // plannerFields maps plannerID to fieldRefs planned on this planner. plannerFields map[int][]int - // fieldPlanners tells which planners a field was planned on - // map fieldRef -> []plannerID + + // fieldPlanners maps fieldRef to the plannerIDs where it was planned on. fieldPlanners map[int][]int - // fieldEnclosingTypeNames stores the enclosing type names for each field ref + + // fieldEnclosingTypeNames maps fieldRef to the enclosing type name. fieldEnclosingTypeNames map[int]string } @@ -632,7 +634,11 @@ func (v *Visitor) LeaveField(ref int) { } } +// skipField returns true if the field was added by the query planner as a dependency. +// For another field and should not be included in the response. +// If it returns false, the user requests the field. func (v *Visitor) skipField(ref int) bool { + // TODO: If this grows, switch to map[int]struct{} for O(1). for _, skipRef := range v.skipFieldsRefs { if skipRef == ref { return true @@ -1330,7 +1336,8 @@ func (v *Visitor) configureFetch(internal *objectFetchConfiguration, external re } if !v.Config.DisableIncludeFieldDependencies { - singleFetch.FetchConfiguration.CoordinateDependencies = v.resolveFetchDependencies(internal.fetchID) + singleFetch.CoordinateDependencies = v.resolveFetchDependencies(internal.fetchID) + singleFetch.FieldFetchReasons = v.buildFetchReasons(internal.fetchID) } return singleFetch @@ -1343,8 +1350,6 @@ func (v *Visitor) resolveFetchDependencies(fetchID int) []resolve.FetchDependenc } dependencies := make([]resolve.FetchDependency, 0, len(fields)) for _, fieldRef := range fields { - // skipField returns true if the field is a dependency and should not be included in the response - // consequently, if we don't skip the field, the client requested it in the query userRequestedField := !v.skipField(fieldRef) deps, ok := v.fieldRefDependsOnFieldRefs[fieldRef] if !ok { @@ -1352,7 +1357,7 @@ func (v *Visitor) resolveFetchDependencies(fetchID int) []resolve.FetchDependenc } dependency := resolve.FetchDependency{ Coordinate: resolve.GraphCoordinate{ - FieldName: strings.Clone(v.Operation.FieldNameString(fieldRef)), + FieldName: v.Operation.FieldNameString(fieldRef), TypeName: v.fieldEnclosingTypeNames[fieldRef], }, IsUserRequested: userRequestedField, @@ -1371,7 +1376,7 @@ func (v *Visitor) resolveFetchDependencies(fetchID int) []resolve.FetchDependenc FetchID: fetchID, Subgraph: ofc.sourceName, Coordinate: resolve.GraphCoordinate{ - FieldName: strings.Clone(v.Operation.FieldNameString(depFieldRef)), + FieldName: v.Operation.FieldNameString(depFieldRef), TypeName: v.fieldEnclosingTypeNames[depFieldRef], }, } @@ -1392,3 +1397,103 @@ func (v *Visitor) resolveFetchDependencies(fetchID int) []resolve.FetchDependenc } return dependencies } + +func (v *Visitor) buildFetchReasons(fetchID int) []resolve.FetchReason { + fields, ok := v.plannerFields[fetchID] + if !ok { + return nil + } + dsConfig := v.planners[fetchID].DataSourceConfiguration() + + type typedField struct { + typeName string + field string + } + reasons := make([]resolve.FetchReason, 0, len(fields)) + index := make(map[typedField]int, len(fields)) + + for _, fieldRef := range fields { + fieldName := v.Operation.FieldNameString(fieldRef) + if fieldName == "__typename" { + continue + } + typeName := v.fieldEnclosingTypeNames[fieldRef] + if !dsConfig.RequiresFetchReason(typeName, fieldName) { + continue + } + + byUser := !v.skipField(fieldRef) + dependants, ok := v.fieldRefDependants[fieldRef] + + var subgraphs []string + var isKey, isRequires bool + + if ok { + subgraphs = make([]string, 0, len(dependants)) + for _, reqByRef := range dependants { + plannerIDs, ok := v.fieldPlanners[reqByRef] + if !ok { + continue + } + + // Find the subgraph's names that are responsible for reqByRef. + for _, plannerID := range plannerIDs { + ofc := v.planners[plannerID].ObjectFetchConfiguration() + if ofc == nil { + continue + } + subgraphs = append(subgraphs, ofc.sourceName) + + depKind, ok := v.fieldDependencyKind[fieldDependencyKey{field: reqByRef, dependsOn: fieldRef}] + if !ok { + continue + } + switch depKind { + case fieldDependencyKindKey: + isKey = true + case fieldDependencyKindRequires: + isRequires = true + } + } + } + } + + // Deduplicate using the index and merge with existing entries. + if byUser || len(subgraphs) > 0 { + key := typedField{typeName: typeName, field: fieldName} + var i int + if i, ok = index[key]; ok { + // True should overwrite false. + reasons[i].ByUser = reasons[i].ByUser || byUser + if len(subgraphs) > 0 { + reasons[i].BySubgraphs = append(reasons[i].BySubgraphs, subgraphs...) + reasons[i].IsKey = reasons[i].IsKey || isKey + reasons[i].IsRequires = reasons[i].IsRequires || isRequires + } + } else { + reasons = append(reasons, resolve.FetchReason{ + TypeName: typeName, + FieldName: fieldName, + BySubgraphs: subgraphs, + ByUser: byUser, + IsKey: isKey, + IsRequires: isRequires, + }) + i = len(reasons) - 1 + index[key] = i + } + if reasons[i].BySubgraphs != nil { + slices.Sort(reasons[i].BySubgraphs) + reasons[i].BySubgraphs = slices.Compact(reasons[i].BySubgraphs) + } + } + } + + slices.SortFunc(reasons, func(a, b resolve.FetchReason) int { + return cmp.Or( + cmp.Compare(a.TypeName, b.TypeName), + cmp.Compare(a.FieldName, b.FieldName), + ) + }) + return reasons +} diff --git a/v2/pkg/engine/postprocess/create_concrete_single_fetch_types.go b/v2/pkg/engine/postprocess/create_concrete_single_fetch_types.go index c8a0a17e1..3d6a00b35 100644 --- a/v2/pkg/engine/postprocess/create_concrete_single_fetch_types.go +++ b/v2/pkg/engine/postprocess/create_concrete_single_fetch_types.go @@ -77,6 +77,7 @@ func (d *createConcreteSingleFetchTypes) createEntityBatchFetch(fetch *resolve.S return &resolve.BatchEntityFetch{ FetchDependencies: fetch.FetchDependencies, CoordinateDependencies: fetch.CoordinateDependencies, + FieldFetchReasons: fetch.FieldFetchReasons, Info: fetch.Info, Input: resolve.BatchInput{ Header: resolve.InputTemplate{ @@ -123,6 +124,7 @@ func (d *createConcreteSingleFetchTypes) createEntityFetch(fetch *resolve.Single return &resolve.EntityFetch{ FetchDependencies: fetch.FetchDependencies, CoordinateDependencies: fetch.CoordinateDependencies, + FieldFetchReasons: fetch.FieldFetchReasons, Info: fetch.Info, Input: resolve.EntityInput{ Header: resolve.InputTemplate{ diff --git a/v2/pkg/engine/resolve/fetch.go b/v2/pkg/engine/resolve/fetch.go index 6c487aed6..137670bcd 100644 --- a/v2/pkg/engine/resolve/fetch.go +++ b/v2/pkg/engine/resolve/fetch.go @@ -22,6 +22,7 @@ type Fetch interface { Dependencies() *FetchDependencies DataSourceInfo() DataSourceInfo DependenciesCoordinates() []FetchDependency + FetchReasons() []FetchReason } type FetchItem struct { @@ -89,6 +90,7 @@ const ( type SingleFetch struct { FetchConfiguration FetchDependencies + InputTemplate InputTemplate DataSourceIdentifier []byte Trace *DataSourceLoadTrace @@ -100,7 +102,11 @@ func (s *SingleFetch) Dependencies() *FetchDependencies { } func (s *SingleFetch) DependenciesCoordinates() []FetchDependency { - return s.FetchConfiguration.CoordinateDependencies + return s.CoordinateDependencies +} + +func (s *SingleFetch) FetchReasons() []FetchReason { + return s.FieldFetchReasons } func (s *SingleFetch) DataSourceInfo() DataSourceInfo { @@ -120,16 +126,18 @@ type FetchDependencies struct { type PostProcessingConfiguration struct { // SelectResponseDataPath used to make a jsonparser.Get call on the response data SelectResponseDataPath []string + // SelectResponseErrorsPath is similar to SelectResponseDataPath, but for errors // If this is set, the response will be considered an error if the jsonparser.Get call returns a non-empty value // The value will be expected to be a GraphQL error object SelectResponseErrorsPath []string + // MergePath can be defined to merge the result of the post-processing into the parent object at the given path // e.g. if the parent is {"a":1}, result is {"foo":"bar"} and the MergePath is ["b"], // the result will be {"a":1,"b":{"foo":"bar"}} // If the MergePath is empty, the result will be merged into the parent object // In this case, the result would be {"a":1,"foo":"bar"} - // This is useful if you make multiple fetches, e.g. parallel fetches, that would otherwise overwrite each other + // This is useful if we make multiple fetches, e.g. parallel fetches, that would otherwise overwrite each other MergePath []string } @@ -161,6 +169,7 @@ func (*SingleFetch) FetchKind() FetchKind { // representations variable will contain multiple items according to amount of entities matching this query type BatchEntityFetch struct { FetchDependencies + Input BatchInput DataSource DataSource PostProcessing PostProcessingConfiguration @@ -168,6 +177,7 @@ type BatchEntityFetch struct { Trace *DataSourceLoadTrace Info *FetchInfo CoordinateDependencies []FetchDependency + FieldFetchReasons []FetchReason } func (b *BatchEntityFetch) Dependencies() *FetchDependencies { @@ -178,6 +188,10 @@ func (b *BatchEntityFetch) DependenciesCoordinates() []FetchDependency { return b.CoordinateDependencies } +func (b *BatchEntityFetch) FetchReasons() []FetchReason { + return b.FieldFetchReasons +} + func (b *BatchEntityFetch) DataSourceInfo() DataSourceInfo { return DataSourceInfo{ ID: b.Info.DataSourceID, @@ -208,13 +222,15 @@ func (*BatchEntityFetch) FetchKind() FetchKind { // representations variable will contain single item type EntityFetch struct { FetchDependencies - CoordinateDependencies []FetchDependency + Input EntityInput DataSource DataSource PostProcessing PostProcessingConfiguration DataSourceIdentifier []byte Trace *DataSourceLoadTrace Info *FetchInfo + CoordinateDependencies []FetchDependency + FieldFetchReasons []FetchReason } func (e *EntityFetch) Dependencies() *FetchDependencies { @@ -225,6 +241,10 @@ func (e *EntityFetch) DependenciesCoordinates() []FetchDependency { return e.CoordinateDependencies } +func (e *EntityFetch) FetchReasons() []FetchReason { + return e.FieldFetchReasons +} + func (e *EntityFetch) DataSourceInfo() DataSourceInfo { return DataSourceInfo{ ID: e.Info.DataSourceID, @@ -257,7 +277,11 @@ func (p *ParallelListItemFetch) Dependencies() *FetchDependencies { } func (p *ParallelListItemFetch) DependenciesCoordinates() []FetchDependency { - return p.Fetch.FetchConfiguration.CoordinateDependencies + return p.Fetch.CoordinateDependencies +} + +func (p *ParallelListItemFetch) FetchReasons() []FetchReason { + return p.Fetch.FieldFetchReasons } func (*ParallelListItemFetch) FetchKind() FetchKind { @@ -323,7 +347,12 @@ type FetchConfiguration struct { // and how multiple dependencies lead to a chain of fetches CoordinateDependencies []FetchDependency - // OperationName is non-empty when the operation name is propagated the downstream subgraph fetch. + // FieldFetchReasons contains provenance for fields that require fetch reason to be propagated + // to their subgraph. It is optional propagation via request extensions; + // it does not affect execution. + FieldFetchReasons []FetchReason + + // OperationName is non-empty when the operation name is propagated to the upstream subgraph fetch. OperationName string } @@ -337,9 +366,11 @@ func (fc *FetchConfiguration) Equals(other *FetchConfiguration) bool { return false } - // Note: we do not compare datasources, as they will always be a different instance + // Note: we do not compare datasources, as they will always be a different instance. // Note: we do not compare CoordinateDependencies, as they contain more detailed - // dependencies information that is already present in the FetchDependencies on the fetch itself + // dependencies information that is already present in the FetchDependencies on the fetch itself. + // Note: we do not compare FieldFetchReasons, as it is derived data for an extension + // and does not affect fetch execution semantics. if fc.RequiresParallelListItemFetch != other.RequiresParallelListItemFetch { return false @@ -387,6 +418,17 @@ type FetchDependencyOrigin struct { IsRequires bool `json:"isRequires"` } +// FetchReason explains who requested a specific (typeName, fieldName) combination. +// A field can be requested by the user and/or by one or more subgraphs, with optional reasons. +type FetchReason struct { + TypeName string `json:"typename"` + FieldName string `json:"field"` + BySubgraphs []string `json:"by_subgraphs,omitempty"` + ByUser bool `json:"by_user,omitempty"` + IsKey bool `json:"is_key,omitempty"` + IsRequires bool `json:"is_requires,omitempty"` +} + type FetchInfo struct { DataSourceID string DataSourceName string diff --git a/v2/pkg/engine/resolve/loader.go b/v2/pkg/engine/resolve/loader.go index e5faa7f5a..0c6b349b2 100644 --- a/v2/pkg/engine/resolve/loader.go +++ b/v2/pkg/engine/resolve/loader.go @@ -175,6 +175,8 @@ type Loader struct { allowedSubgraphErrorFields map[string]struct{} apolloRouterCompatibilitySubrequestHTTPError bool + + propagateFetchReasons bool } func (l *Loader) Free() { @@ -1596,6 +1598,23 @@ func (l *Loader) executeSourceLoad(ctx context.Context, fetchItem *FetchItem, so return } } + if l.propagateFetchReasons && !IsIntrospectionDataSource(res.ds.ID) { + fetchReasons := fetchItem.Fetch.FetchReasons() + if len(fetchReasons) > 0 { + var encoded []byte + encoded, res.err = json.Marshal(fetchReasons) + if res.err != nil { + res.err = errors.WithStack(res.err) + return + } + // We expect that body.extensions is an object + input, res.err = jsonparser.Set(input, encoded, "body", "extensions", "fetch_reasons") + if res.err != nil { + res.err = errors.WithStack(res.err) + return + } + } + } if l.ctx.TracingOptions.Enable { ctx = setSingleFlightStats(ctx, &SingleFlightStats{}) trace.Path = fetchItem.ResponsePath diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index 20a606fce..32e33c0ae 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -110,44 +110,64 @@ type ResolverOptions struct { // PropagateSubgraphErrors adds Subgraph Errors to the response PropagateSubgraphErrors bool + // PropagateSubgraphStatusCodes adds the status code of the Subgraph to the extensions field of a Subgraph Error PropagateSubgraphStatusCodes bool + // SubgraphErrorPropagationMode defines how Subgraph Errors are propagated // SubgraphErrorPropagationModeWrapped wraps Subgraph Errors in a Subgraph Error to prevent leaking internal information // SubgraphErrorPropagationModePassThrough passes Subgraph Errors through without modification SubgraphErrorPropagationMode SubgraphErrorPropagationMode + // RewriteSubgraphErrorPaths rewrites the paths of Subgraph Errors to match the path of the field from the perspective of the client // This means that nested entity requests will have their paths rewritten from e.g. "_entities.foo.bar" to "person.foo.bar" if the root field above is "person" RewriteSubgraphErrorPaths bool + // OmitSubgraphErrorLocations omits the locations field of Subgraph Errors OmitSubgraphErrorLocations bool + // OmitSubgraphErrorExtensions omits the extensions field of Subgraph Errors OmitSubgraphErrorExtensions bool + // AllowAllErrorExtensionFields allows all fields in the extensions field of a root subgraph error AllowAllErrorExtensionFields bool + // AllowedErrorExtensionFields defines which fields are allowed in the extensions field of a root subgraph error AllowedErrorExtensionFields []string + // AttachServiceNameToErrorExtensions attaches the service name to the extensions field of a root subgraph error AttachServiceNameToErrorExtensions bool + // DefaultErrorExtensionCode is the default error code to use for subgraph errors if no code is provided DefaultErrorExtensionCode string + // MaxRecyclableParserSize limits the size of the Parser that can be recycled back into the Pool. // If set to 0, no limit is applied // This helps keep the Heap size more maintainable if you regularly perform large queries. MaxRecyclableParserSize int + // ResolvableOptions are configuration options for the Resolvable struct ResolvableOptions ResolvableOptions + // AllowedCustomSubgraphErrorFields defines which fields are allowed in the subgraph error when in passthrough mode AllowedSubgraphErrorFields []string + // SubscriptionHeartbeatInterval defines the interval in which a heartbeat is sent to all subscriptions (whether or not this does anything is determined by the subscription response writer) SubscriptionHeartbeatInterval time.Duration + // MaxSubscriptionFetchTimeout defines the maximum time a subscription fetch can take before it is considered timed out MaxSubscriptionFetchTimeout time.Duration + // ApolloRouterCompatibilitySubrequestHTTPError is a compatibility flag for Apollo Router, it is used to handle HTTP errors in subrequests differently ApolloRouterCompatibilitySubrequestHTTPError bool + + // PropagateFetchReasons enables adding the "fetch_reasons" extension to + // upstream subgraph requests. This extension explains why each field was requested. + // This flag does not expose the data to clients. + PropagateFetchReasons bool } -// New returns a new Resolver, ctx.Done() is used to cancel all active subscriptions & streams +// New returns a new Resolver. ctx.Done() is used to cancel all active subscriptions and streams. func New(ctx context.Context, options ResolverOptions) *Resolver { // options.Debug = true if options.MaxConcurrency <= 0 { @@ -227,6 +247,7 @@ func newTools(options ResolverOptions, allowedExtensionFields map[string]struct{ allowedSubgraphErrorFields: allowedErrorFields, allowAllErrorExtensionFields: options.AllowAllErrorExtensionFields, apolloRouterCompatibilitySubrequestHTTPError: options.ApolloRouterCompatibilitySubrequestHTTPError, + propagateFetchReasons: options.PropagateFetchReasons, }, } }