diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go index cc6b720024..b34afe3f37 100644 --- a/execution/engine/execution_engine_cost_test.go +++ b/execution/engine/execution_engine_cost_test.go @@ -1047,17 +1047,26 @@ func TestExecutionEngine_Cost(t *testing.T) { t.Run("listSize", func(t *testing.T) { listSchema := ` + input SInput { + pagination: PInput + query: String + } + input PInput { + first: Int + } type Query { - items(first: Int, last: Int): [Item!] + items(first: Int, last: Int): [Item!] + search(input: SInput): [Item!] } type Item @key(fields: "id") { - id: ID - } + id: ID + } ` schemaSlicing, err := graphql.NewSchemaFromString(listSchema) require.NoError(t, err) rootNodes := []plan.TypeField{ {TypeName: "Query", FieldNames: []string{"items"}}, + {TypeName: "Query", FieldNames: []string{"search"}}, {TypeName: "Item", FieldNames: []string{"id"}}, } childNodes := []plan.TypeField{} @@ -1086,6 +1095,18 @@ func TestExecutionEngine_Cost(t *testing.T) { }, }, }, + { + TypeName: "Query", + FieldName: "search", + Path: []string{"search"}, + Arguments: []plan.ArgumentConfiguration{ + { + Name: "input", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, } t.Run("multiple slicing arguments as literals", runWithoutError( ExecutionEngineTestCase{ @@ -1180,6 +1201,414 @@ func TestExecutionEngine_Cost(t *testing.T) { }, computeCosts(), )) + + t.Run("dot-path slicing argument passed as literal is valid", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput{ + search(input: { pagination: { first: 8 }, query: "abc" }) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + RequireOneSlicingArgument: true, + SlicingArguments: []string{"input.pagination.first"}, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: intPtr(32), // slicingArgument(8) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("slicing argument as nested input literal missing leaf fallbacks to defaultListSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput{ + search(input: { pagination: { first: null }, query: "abc" }) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + RequireOneSlicingArgument: false, + SlicingArguments: []string{"input.pagination.first"}, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: intPtr(40), // defaultListSize(10) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("slicing argument as nested input literal with null at the middle fallbacks to AssumedSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput{ + search(input: { pagination: null, query: "abc" }) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + RequireOneSlicingArgument: false, + AssumedSize: 15, + SlicingArguments: []string{"input.pagination.first"}, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: intPtr(60), // AssumedSize(15) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("slicing argument as nested input literal starting with null fallbacks to defaultListSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput{ + search(input: null) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + RequireOneSlicingArgument: false, + SlicingArguments: []string{"input.pagination.first"}, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: intPtr(40), // defaultListSize(10) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("slicing argument as nested input variable is valid", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput($input: SInput) { + search(input: $input) { id } + }`, + Variables: []byte(`{"input":{"pagination":{"first":12},"query":"abc"}}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + SlicingArguments: []string{"input.pagination.first"}, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: intPtr(48), // slicingArgument($input.pagination.first=12) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("required dot-path slicing argument passed as var is valid", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput($input: SInput) { + search(input: $input) { id } + }`, + Variables: []byte(`{"input":{"pagination":{"first":7},"query":"abc"}}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + SlicingArguments: []string{"input.pagination.first"}, + RequireOneSlicingArgument: true, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: intPtr(28), // slicingArgument(7) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("required dot-path slicing argument missing intermediate is invalid", runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput($input: SInput) { + search(input: $input) { id } + }`, + Variables: []byte(`{"input":{"query":"abc"}}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + SlicingArguments: []string{"input.pagination.first"}, + RequireOneSlicingArgument: true, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + }, + "external: field 'Query.search' requires exactly one slicing argument, but none was provided, locations: [], path: [search]", + computeCosts(), + )) + + t.Run("required dot-path slicing argument missing leaf is invalid", runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput($input: SInput) { + search(input: $input) { id } + }`, + Variables: []byte(`{"input":{"pagination":{"first":null},"query":"abc"}}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + SlicingArguments: []string{"input.pagination.first"}, + RequireOneSlicingArgument: true, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + }, + "external: field 'Query.search' requires exactly one slicing argument, but none was provided, locations: [], path: [search]", + computeCosts(), + )) + + t.Run("required dot-path slicing argument with empty variables is invalid", runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NestedInput($input: SInput) { + search(input: $input) { id } + }`, + Variables: []byte(`{}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: { + SlicingArguments: []string{"input.pagination.first"}, + RequireOneSlicingArgument: true, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + }, + "external: field 'Query.search' requires exactly one slicing argument, but none was provided, locations: [], path: [search]", + computeCosts(), + )) + t.Run("slicing argument not provided falls back to assumedSize", runWithoutError( ExecutionEngineTestCase{ schema: schemaSlicing, diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index e73ac1366c..770396c074 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -66,7 +66,8 @@ type FieldListSize struct { // If 0, the global default list cost is used. AssumedSize int - // SlicingArguments are argument names that control list size (e.g., "first", "last", "limit") + // SlicingArguments are argument names that control list size + // (e.g., "first", "last", "pagination.limit.first"). // The value of these arguments will be used as the multiplier. SlicingArguments []string @@ -82,42 +83,73 @@ type FieldListSize struct { // multiplier returns the multiplier based on arguments and variables. // It picks the maximum value among slicing arguments, otherwise it tries to use AssumedSize. // If neither is available, it falls back to defaultListSize. -// -// Does not take into account the SizedFields; TBD later. func (ls *FieldListSize) multiplier(arguments map[string]ArgumentInfo, vars *astjson.Value, defaultListSize int) int { multiplier := -1 - for _, slicingArg := range ls.SlicingArguments { - arg, ok := arguments[slicingArg] - if !ok || !arg.isSimple { - continue - } - - var value int - // Argument could be a variable only on this stage. - if arg.hasVariable { - if vars == nil { + if vars != nil { + for _, slicingArg := range ls.SlicingArguments { + // First, try arg as the dot-path: + if strings.Contains(slicingArg, ".") { + value, found := resolveSlicingArgIntValue(slicingArg, arguments, vars) + if found && value > 0 && value > multiplier { + multiplier = value + } continue } - if v := vars.Get(arg.varName); v == nil || v.Type() != astjson.TypeNumber { + // Otherwise, try the simple arg: + arg, ok := arguments[slicingArg] + if !ok || !arg.isSimple { continue } - value = vars.GetInt(arg.varName) - } - if value > 0 && value > multiplier { - multiplier = value + var value int + // At this stage the argument is a variable. + if arg.hasVariable { + v := vars.Get(arg.varName) + if v != nil && v.Type() == astjson.TypeNumber { + value = vars.GetInt(arg.varName) + } + } + + if value > 0 && value > multiplier { + multiplier = value + } } } - if multiplier == -1 && ls.AssumedSize > 0 { - multiplier = ls.AssumedSize - } if multiplier == -1 { - multiplier = defaultListSize + if ls.AssumedSize > 0 { + multiplier = ls.AssumedSize + } else { + multiplier = defaultListSize + } } return multiplier } +// resolveSlicingArgIntValue extracts the integer value from variables using slicingArg as the path +func resolveSlicingArgIntValue(slicingArg string, arguments map[string]ArgumentInfo, vars *astjson.Value) (int, bool) { + path := strings.Split(slicingArg, ".") + inputArg := path[0] + arg, ok := arguments[inputArg] + if ok && arg.hasVariable && arg.isInputObject { + value := vars.Get(arg.varName) + if value == nil { + return 0, false + } + for _, key := range path[1:] { + value = value.Get(key) + if value == nil { + return 0, false + } + } + if value.Type() != astjson.TypeNumber { + return 0, false + } + return value.GetInt(), true + } + return 0, false +} + // DataSourceCostConfig holds all cost configurations for a data source. // This data is passed from the composition. type DataSourceCostConfig struct { @@ -729,16 +761,24 @@ func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceC // No need to check for literals. if variables != nil { for _, slicingArg := range listSize.SlicingArguments { + // First, try arg as the dot-path: + if strings.Contains(slicingArg, ".") { + _, found := resolveSlicingArgIntValue(slicingArg, node.arguments, variables) + if found { + count++ + } + continue + } + // Otherwise, try the simple arg: 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 + if v != nil && v.Type() == astjson.TypeNumber { + count++ } - count++ } } }