diff --git a/execution/engine/execution_engine.go b/execution/engine/execution_engine.go index 27c7a46df1..178a8a5e3c 100644 --- a/execution/engine/execution_engine.go +++ b/execution/engine/execution_engine.go @@ -215,6 +215,12 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques if report.HasErrors() { return report } + if costCalculator != nil { + costCalculator.ValidateSliceArguments(e.config.plannerConfig, execContext.resolveContext.Variables, &report) + if report.HasErrors() { + return report + } + } operation.ComputeEstimatedCost(costCalculator, e.config.plannerConfig, execContext.resolveContext.Variables) if execContext.resolveContext.TracingOptions.Enable && !execContext.resolveContext.TracingOptions.ExcludePlannerStats { diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go index f4ad753359..ef270a32ed 100644 --- a/execution/engine/execution_engine_cost_test.go +++ b/execution/engine/execution_engine_cost_test.go @@ -2955,4 +2955,573 @@ func TestExecutionEngine_Cost(t *testing.T) { }) }) + + t.Run("validate requireOneSlicingArgument on concrete types", func(t *testing.T) { + listSchema := ` + type Query { + items(first: Int, last: Int): [Item!] # @listSize(assumedSize: 10, SlicingArguments: ["first", "last"], RequireOneSlicingArgument: true/false) + itemsNoSlicing: [Item!] # @listSize(assumedSize: 5, RequireOneSlicingArgument: true) + } + type Item @key(fields: "id") { + id: ID + } + ` + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"items", "itemsNoSlicing"}}, + {TypeName: "Item", FieldNames: []string{"id"}}, + } + childNodes := []plan.TypeField{} + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, listSchema), + }) + fieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "items", + Path: []string{"items"}, + Arguments: []plan.ArgumentConfiguration{ + { + Name: "first", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + { + Name: "last", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + { + TypeName: "Query", + FieldName: "itemsNoSlicing", + Path: []string{"itemsNoSlicing"}, + }, + } + + costConfigWithRequireOne := &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 10, + SlicingArguments: []string{"first", "last"}, + RequireOneSlicingArgument: true, + }, + {TypeName: "Query", FieldName: "itemsNoSlicing"}: { + AssumedSize: 5, + RequireOneSlicingArgument: true, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + } + + costConfigWithRequireOneDisabled := &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 10, + SlicingArguments: []string{"first", "last"}, + RequireOneSlicingArgument: false, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + } + + t.Run("no slicingArguments defined - requireOneSlicingArgument ignored", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ itemsNoSlicing { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"itemsNoSlicing":[{"id":"1"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: costConfigWithRequireOne, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"itemsNoSlicing":[{"id":"1"}]}}`, + expectedEstimatedCost: 15, // assumedSize(5) * (Item(2) + Item.id(1)) + }, + computeCosts(), + )) + + t.Run("exactly one slicing argument provided - valid", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ items(first: 4) { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[{"id":"1"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: costConfigWithRequireOne, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"1"}]}}`, + expectedEstimatedCost: 12, // 4 * (Item(2) + Item.id(1)) + }, + computeCosts(), + )) + + t.Run("no slicing argument provided - error", runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ items { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: costConfigWithRequireOne, + }, + customConfig, + ), + }, + fields: fieldConfig, + }, + "external: field 'Query.items' requires exactly one slicing argument, but none was provided, locations: [], path: [items]", + computeCosts(), + )) + + t.Run("multiple slicing arguments provided - error", runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ items(first: 5, last: 3) { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: costConfigWithRequireOne, + }, + customConfig, + ), + }, + fields: fieldConfig, + }, + "external: field 'Query.items' requires exactly one slicing argument, but 2 were provided, locations: [], path: [items]", + computeCosts(), + )) + + t.Run("no slicing argument but requireOneSlicingArgument disabled - valid", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ items { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[{"id":"1"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: costConfigWithRequireOneDisabled, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"1"}]}}`, + expectedEstimatedCost: 30, // assumedSize(10) * (Item(2) + Item.id(1)) + }, + computeCosts(), + )) + + t.Run("multiple slicing arguments but requireOneSlicingArgument disabled - valid", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ items(first: 5, last: 3) { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[{"id":"1"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: costConfigWithRequireOneDisabled, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"1"}]}}`, + expectedEstimatedCost: 15, // max(5,3)=5 * (Item(2) + Item.id(1)) + }, + computeCosts(), + )) + + t.Run("slicing argument provided as variable - valid", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ($n: Int!) { items(first: $n) { id } }`, + Variables: []byte(`{"n": 7}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[{"id":"1"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: costConfigWithRequireOne, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"1"}]}}`, + expectedEstimatedCost: 21, // 7 * (Item(2) + Item.id(1)) + }, + computeCosts(), + )) + + t.Run("multiple fields violating - collects all errors", func(t *testing.T) { + multiSchema := ` + type Query { + items(first: Int, last: Int): [Item!] # @listSize(assumedSize: 10, slicingArguments: ["first", "last"], requireOneSlicingArgument: true) + other(first: Int, last: Int): [Item!] # @listSize(assumedSize: 10, slicingArguments: ["first", "last"], requireOneSlicingArgument: true) + } + type Item @key(fields: "id") @cost(weight: 2) { + id: ID + } + ` + multiSchemaObj, err := graphql.NewSchemaFromString(multiSchema) + require.NoError(t, err) + multiRootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"items", "other"}}, + {TypeName: "Item", FieldNames: []string{"id"}}, + } + multiCustomConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, multiSchema), + }) + multiFieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "items", Path: []string{"items"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + {Name: "last", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Query", FieldName: "other", Path: []string{"other"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + {Name: "last", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + } + multiCostConfig := &plan.DataSourceCostConfig{ + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 10, SlicingArguments: []string{"first", "last"}, RequireOneSlicingArgument: true, + }, + {TypeName: "Query", FieldName: "other"}: { + AssumedSize: 10, SlicingArguments: []string{"first", "last"}, RequireOneSlicingArgument: true, + }, + }, + Types: map[string]int{"Item": 2}, + } + + t.Run("both fields missing slicing argument", runWithAndCompareError( + ExecutionEngineTestCase{ + schema: multiSchemaObj, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ items { id } other { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[],"other":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: multiRootNodes, + CostConfig: multiCostConfig, + }, + multiCustomConfig, + ), + }, + fields: multiFieldConfig, + }, + "external: field 'Query.items' requires exactly one slicing argument, but none was provided, locations: [], path: [items]\n"+ + "external: field 'Query.other' requires exactly one slicing argument, but none was provided, locations: [], path: [other]", + computeCosts(), + )) + }) + }) + t.Run("validate requireOneSlicingArgument on abstract types", func(t *testing.T) { + // Abstract type tests: @listSize with requireOneSlicingArgument on concrete types, + // accessed through an interface field. + abstractSchema := ` + interface Paginated { + items(first: Int, last: Int): [Item!] + } + type UserPaginated implements Paginated { + items(first: Int, last: Int): [Item!] # @listSize(assumedSize: 10, SlicingArguments: ["first", "last"], RequireOneSlicingArgument: true) + } + type PostPaginated implements Paginated { + items(first: Int, last: Int): [Item!] # @listSize(assumedSize: 10, SlicingArguments: ["first", "last"], RequireOneSlicingArgument: false) + } + type Item @key(fields: "id") { + id: ID! + } + type Query { + search: Paginated + } + ` + abstractSchemaObj, err := graphql.NewSchemaFromString(abstractSchema) + require.NoError(t, err) + abstractRootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"search"}}, + {TypeName: "UserPaginated", FieldNames: []string{"items"}}, + {TypeName: "PostPaginated", FieldNames: []string{"items"}}, + {TypeName: "Item", FieldNames: []string{"id"}}, + } + abstractChildNodes := []plan.TypeField{ + {TypeName: "Paginated", FieldNames: []string{"items"}}, + } + abstractCustomConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, abstractSchema), + }) + abstractFieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "search", Path: []string{"search"}, + }, + { + TypeName: "Paginated", FieldName: "items", Path: []string{"items"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + {Name: "last", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "UserPaginated", FieldName: "items", Path: []string{"items"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + {Name: "last", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "PostPaginated", FieldName: "items", Path: []string{"items"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + {Name: "last", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + } + abstractCostConfig := &plan.DataSourceCostConfig{ + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "UserPaginated", FieldName: "items"}: { + AssumedSize: 10, + SlicingArguments: []string{"first", "last"}, + RequireOneSlicingArgument: false, + }, + {TypeName: "PostPaginated", FieldName: "items"}: { + AssumedSize: 10, + SlicingArguments: []string{"first", "last"}, + RequireOneSlicingArgument: true, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + } + + t.Run("abstract type - exactly one slicing argument - valid", runWithoutError( + ExecutionEngineTestCase{ + schema: abstractSchemaObj, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ search { items(first: 5) { id } } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":{"__typename":"UserPaginated","items":[{"id":"1"}]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: abstractRootNodes, + ChildNodes: abstractChildNodes, + CostConfig: abstractCostConfig, + }, + abstractCustomConfig, + ), + }, + fields: abstractFieldConfig, + expectedResponse: `{"data":{"search":{"items":[{"id":"1"}]}}}`, + expectedEstimatedCost: 11, // Paginated(1) + 5 * (Item(2) + Item.id(0)) + }, + computeCosts(), + )) + + t.Run("abstract type - no slicing argument - error", runWithAndCompareError( + ExecutionEngineTestCase{ + schema: abstractSchemaObj, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ search { items { id } } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":{"__typename":"UserPaginated","items":[]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: abstractRootNodes, + ChildNodes: abstractChildNodes, + CostConfig: abstractCostConfig, + }, + abstractCustomConfig, + ), + }, + fields: abstractFieldConfig, + }, + "external: field 'Paginated.items' requires exactly one slicing argument, but none was provided, locations: [], path: [search,items]", + computeCosts(), + )) + + t.Run("abstract type - multiple slicing arguments - error", runWithAndCompareError( + ExecutionEngineTestCase{ + schema: abstractSchemaObj, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ search { items(first: 5, last: 3) { id } } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":{"__typename":"UserPaginated","items":[]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: abstractRootNodes, + ChildNodes: abstractChildNodes, + CostConfig: abstractCostConfig, + }, + abstractCustomConfig, + ), + }, + fields: abstractFieldConfig, + }, + "external: field 'Paginated.items' requires exactly one slicing argument, but 2 were provided, locations: [], path: [search,items]", + computeCosts(), + )) + + }) } diff --git a/execution/graphql/request.go b/execution/graphql/request.go index 3d27e5a1bc..282e8f8493 100644 --- a/execution/graphql/request.go +++ b/execution/graphql/request.go @@ -214,7 +214,7 @@ func (r *Request) ComputeActualCost(calc *plan.CostCalculator, config plan.Confi if calc != nil { r.actualCost = calc.ActualCost(config, actualListSizes) // Debugging of cost trees. Uncomment to debug. - // fmt.Println(calc.DebugPrint(config, variables, actualListSizes)) + // fmt.Println(calc.DebugPrint(config, nil, actualListSizes)) } else { r.actualCost = 0 } diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index 4044b22c20..6e254cb93e 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -28,9 +28,13 @@ A few things on the TBD list: import ( "fmt" "math" + "net/http" "strings" "github.com/wundergraph/astjson" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) // We don't allow configuring default weights for enums, scalars and objects. @@ -68,9 +72,8 @@ type FieldListSize struct { // For these lists we estimate the size based on the value of the slicing arguments or AssumedSize. SizedFields []string - // RequireOneSlicingArgument if true, at least one slicing argument must be provided. - // If false and no slicing argument is provided, AssumedSize is used. - // It is not used right now since it is required only for validation. + // RequireOneSlicingArgument enforces a check that exactly one slicing argument must be provided. + // When set to false or no slicing arguments are provided, the check is skipped. RequireOneSlicingArgument bool } @@ -88,7 +91,7 @@ func (ls *FieldListSize) multiplier(arguments map[string]ArgumentInfo, vars *ast } var value int - // Argument could be a variable or literal value. + // Argument could be a variable only on this stage. if arg.hasVariable { if vars == nil { continue @@ -97,8 +100,6 @@ func (ls *FieldListSize) multiplier(arguments map[string]ArgumentInfo, vars *ast continue } value = vars.GetInt(arg.varName) - } else if arg.intValue > 0 { - value = arg.intValue } if value > 0 && value > multiplier { @@ -189,7 +190,7 @@ type CostTreeNode struct { fieldRef int // Enclosing type name and field name - fieldCoord FieldCoordinate + fieldCoords FieldCoordinate // fieldTypeName contains the name of an unwrapped (named) type that is returned by this field. fieldTypeName string @@ -242,6 +243,19 @@ func (node *CostTreeNode) maxMultiplierImplementingField(config *DataSourceCostC return maxListSize } +// requiringOneArgImplementingField returns the first FieldListSize from implementing types +// that has RequireOneSlicingArgument set to true. Used for validation when the enclosing type is abstract. +func (node *CostTreeNode) requiringOneArgImplementingField(config *DataSourceCostConfig, fieldName string) *FieldListSize { + for _, implTypeName := range node.implementingTypeNames { + coords := FieldCoordinate{implTypeName, fieldName} + listSize := config.ListSizes[coords] + if listSize != nil && listSize.RequireOneSlicingArgument { + return listSize + } + } + return nil +} + // sizedFieldImplementingFields returns all listSizes from implementing types // whose SizedFields contains childFieldName. // Used when the parent field belongs to an interface but @listSize is only on concrete types. @@ -348,8 +362,8 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC configs[dsHash] = dsCostConfig } - fieldWeight := dsCostConfig.Weights[node.fieldCoord] - listSize := dsCostConfig.ListSizes[node.fieldCoord] + fieldWeight := dsCostConfig.Weights[node.fieldCoords] + listSize := dsCostConfig.ListSizes[node.fieldCoords] // The cost directive is not allowed on fields in an interface. // The cost of a field on an interface can be calculated based on the costs of // the corresponding field on each concrete type implementing that interface, @@ -364,10 +378,10 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC // This field is part of the enclosing interface/union. // We look into implementing types and find the max-weighted field. // Found fieldWeight can be used for all the calculations. - fieldWeight = parent.maxWeightImplementingField(dsCostConfig, node.fieldCoord.FieldName) + fieldWeight = parent.maxWeightImplementingField(dsCostConfig, node.fieldCoords.FieldName) // If this field has listSize defined, then do not look into implementing types. if isEstimation && listSize == nil && node.returnsListType { - listSize = parent.maxMultiplierImplementingField(dsCostConfig, node.fieldCoord.FieldName, node.arguments, variables, defaultListSize) + listSize = parent.maxMultiplierImplementingField(dsCostConfig, node.fieldCoords.FieldName, node.arguments, variables, defaultListSize) } } @@ -435,10 +449,10 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC if parent == nil { continue } - parentLS := dsCostConfig.ListSizes[parent.fieldCoord] + parentLS := dsCostConfig.ListSizes[parent.fieldCoords] if parentLS != nil { for _, sf := range parentLS.SizedFields { - if sf != node.fieldCoord.FieldName { + if sf != node.fieldCoords.FieldName { continue } m := float64(parentLS.multiplier(parent.arguments, variables, defaultListSize)) @@ -455,7 +469,7 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC grandParent := parent.parent if grandParent != nil { implementing := grandParent.sizedFieldImplementingFields( - dsCostConfig, parent.fieldCoord.FieldName, node.fieldCoord.FieldName, + dsCostConfig, parent.fieldCoords.FieldName, node.fieldCoords.FieldName, ) for _, implLS := range implementing { m := float64(implLS.multiplier(parent.arguments, variables, defaultListSize)) @@ -498,8 +512,6 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC } type ArgumentInfo struct { - intValue int - // The name of an unwrapped type. typeName string @@ -520,6 +532,7 @@ type ArgumentInfo struct { // otherwise the argument is Scalar or Enum. isInputObject bool + // isSimple is true for scalars and enums isSimple bool // When the argument points to a variable, it contains the name of the variable. @@ -577,13 +590,106 @@ func (c *CostCalculator) ActualCost(config Configuration, actualListSizes map[st return c.tree.cost(costConfigs, nil, actualCostMode, actualListSizes) } +// ValidateSliceArguments checks that all fields with slicingArguments and +// requireOneSlicingArgument are valid against the arguments passed to those fields. +// Violations are collected as external errors into the report. +func (c *CostCalculator) ValidateSliceArguments(config Configuration, variables *astjson.Value, report *operationreport.Report) { + costConfigs := make(map[DSHash]*DataSourceCostConfig) + for _, ds := range config.DataSources { + if costConfig := ds.GetCostConfig(); costConfig != nil { + costConfigs[ds.Hash()] = costConfig + } + } + c.tree.validateSliceArguments(costConfigs, variables, report) +} + +func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceCostConfig, variables *astjson.Value, report *operationreport.Report) { + if node == nil { + return + } + + for _, dsHash := range node.dataSourceHashes { + dsCostConfig := configs[dsHash] + if dsCostConfig == nil { + continue + } + + listSize := dsCostConfig.ListSizes[node.fieldCoords] + if listSize == nil && node.isEnclosingTypeAbstract && node.parent != nil && node.parent.returnsAbstractType { + // We pick the first from the list of implementing types. Composition should verify that + // all implementations are aligned on the slicingArguments within the single subgraph. + // Otherwise, we would have inconsistent expectations between implementing types. + listSize = node.parent.requiringOneArgImplementingField(dsCostConfig, node.fieldCoords.FieldName) + } + if listSize == nil || !listSize.RequireOneSlicingArgument || len(listSize.SlicingArguments) == 0 { + continue + } + + count := 0 + // The engine has all inlined literals converted to variables at this stage. + // No need to check for literals. + if variables != nil { + for _, slicingArg := range listSize.SlicingArguments { + arg, ok := node.arguments[slicingArg] + if !ok || !arg.isSimple { + continue + } + if arg.hasVariable { + v := variables.Get(arg.varName) + if v == nil || v.Type() == astjson.TypeNull { + continue + } + count++ + } + } + } + if count != 1 { + path := node.buildASTPath() + if count == 0 { + report.AddExternalError(operationreport.ExternalError{ + Message: fmt.Sprintf("field '%s' requires exactly one slicing argument, but none was provided", node.fieldCoords), + Path: path, + StatusCode: http.StatusBadRequest, + }) + } else { + report.AddExternalError(operationreport.ExternalError{ + Message: fmt.Sprintf("field '%s' requires exactly one slicing argument, but %d were provided", node.fieldCoords, count), + Path: path, + StatusCode: http.StatusBadRequest, + }) + } + } + // Only report once per field node, even if multiple data sources agree. + break + } + + for _, child := range node.children { + child.validateSliceArguments(configs, variables, report) + } +} + +// buildASTPath constructs an ast.Path from the node's jsonPath (e.g. "search.items" → [search,items]). +func (node *CostTreeNode) buildASTPath() ast.Path { + if node.jsonPath == "" { + return nil + } + segments := strings.Split(node.jsonPath, ".") + path := make(ast.Path, len(segments)) + for i, seg := range segments { + path[i] = ast.PathItem{ + Kind: ast.FieldName, + FieldName: []byte(seg), + } + } + return path +} + // DebugPrint prints the cost tree structure for debugging purposes. // It shows each node's field coordinate, costs, multipliers, and computed totals. func (c *CostCalculator) DebugPrint(config Configuration, variables *astjson.Value, actualListSizes map[string]int) string { if c.tree == nil || len(c.tree.children) == 0 { return "" } - var sb strings.Builder costConfigs := make(map[DSHash]*DataSourceCostConfig) for _, ds := range config.DataSources { if costConfig := ds.GetCostConfig(); costConfig != nil { @@ -594,6 +700,7 @@ func (c *CostCalculator) DebugPrint(config Configuration, variables *astjson.Val if defaultListSize < 1 { defaultListSize = 1 } + var sb strings.Builder if actualListSizes != nil { defaultListSize = -1 sb.WriteString("Actual Cost Tree Debug\n") @@ -616,9 +723,7 @@ func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*Da indent := strings.Repeat(" ", depth) - fieldInfo := fmt.Sprintf("%s.%s", node.fieldCoord.TypeName, node.fieldCoord.FieldName) - - fmt.Fprintf(sb, "%s* %s", indent, fieldInfo) + fmt.Fprintf(sb, "%s* %s", indent, node.fieldCoords) if node.fieldTypeName != "" { fmt.Fprintf(sb, " : %s", node.fieldTypeName) @@ -670,9 +775,14 @@ func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*Da var argStrs []string for name, arg := range node.arguments { if arg.hasVariable { - argStrs = append(argStrs, fmt.Sprintf("%s=$%s", name, arg.varName)) - } else if arg.isSimple { - argStrs = append(argStrs, fmt.Sprintf("%s=%d", name, arg.intValue)) + if variables == nil { + // actual cost + argStrs = append(argStrs, fmt.Sprintf("%s=$%s", name, arg.varName)) + } else { + // estimated cost + v := variables.Get(arg.varName) + argStrs = append(argStrs, fmt.Sprintf("%s=%s($%s)", name, v, arg.varName)) + } } else { argStrs = append(argStrs, fmt.Sprintf("%s=", name)) } diff --git a/v2/pkg/engine/plan/cost_visitor.go b/v2/pkg/engine/plan/cost_visitor.go index 6af1128296..dd9ead081b 100644 --- a/v2/pkg/engine/plan/cost_visitor.go +++ b/v2/pkg/engine/plan/cost_visitor.go @@ -36,7 +36,7 @@ type CostVisitor struct { func NewCostVisitor(walker *astvisitor.Walker, operation, definition *ast.Document) *CostVisitor { stack := make([]*CostTreeNode, 0, 16) rootNode := CostTreeNode{ - fieldCoord: FieldCoordinate{"_none", "_root"}, + fieldCoords: FieldCoordinate{"_none", "_root"}, } stack = append(stack, &rootNode) return &CostVisitor{ @@ -104,7 +104,7 @@ func (v *CostVisitor) EnterField(fieldRef int) { // Create a skeleton node. dataSourceHashes will be filled in leaveFieldCost node := CostTreeNode{ fieldRef: fieldRef, - fieldCoord: FieldCoordinate{typeName, fieldName}, + fieldCoords: FieldCoordinate{typeName, fieldName}, fieldTypeName: unwrappedTypeName, implementingTypeNames: implementingTypeNames, returnsListType: isListType, diff --git a/v2/pkg/engine/plan/federation_metadata.go b/v2/pkg/engine/plan/federation_metadata.go index a344938ed3..748b507415 100644 --- a/v2/pkg/engine/plan/federation_metadata.go +++ b/v2/pkg/engine/plan/federation_metadata.go @@ -2,6 +2,7 @@ package plan import ( "encoding/json" + "fmt" "slices" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -89,11 +90,17 @@ type KeyCondition struct { FieldPath []string `json:"field_path"` } +// FieldCoordinate contains coordinates of a field in a type +// TODO: rename to FieldCoordinates type FieldCoordinate struct { TypeName string `json:"type_name"` FieldName string `json:"field_name"` } +func (f FieldCoordinate) String() string { + return fmt.Sprintf("%s.%s", f.TypeName, f.FieldName) +} + // parseSelectionSet parses the selection set and stores the parsed AST in parsedSelectionSet. // should have pointer receiver to preserve the value func (f *FederationFieldConfiguration) parseSelectionSet() error {