diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go index b34afe3f37..3617263c7d 100644 --- a/execution/engine/execution_engine_cost_test.go +++ b/execution/engine/execution_engine_cost_test.go @@ -1047,16 +1047,16 @@ func TestExecutionEngine_Cost(t *testing.T) { t.Run("listSize", func(t *testing.T) { listSchema := ` - input SInput { - pagination: PInput + input Search { + pagination: Page query: String } - input PInput { + input Page { first: Int } type Query { items(first: Int, last: Int): [Item!] - search(input: SInput): [Item!] + search(input: Search): [Item!] } type Item @key(fields: "id") { id: ID @@ -1388,7 +1388,7 @@ func TestExecutionEngine_Cost(t *testing.T) { schema: schemaSlicing, operation: func(t *testing.T) graphql.Request { return graphql.Request{ - Query: `query NestedInput($input: SInput) { + Query: `query NestedInput($input: Search) { search(input: $input) { id } }`, Variables: []byte(`{"input":{"pagination":{"first":12},"query":"abc"}}`), @@ -1433,7 +1433,7 @@ func TestExecutionEngine_Cost(t *testing.T) { schema: schemaSlicing, operation: func(t *testing.T) graphql.Request { return graphql.Request{ - Query: `query NestedInput($input: SInput) { + Query: `query NestedInput($input: Search) { search(input: $input) { id } }`, Variables: []byte(`{"input":{"pagination":{"first":7},"query":"abc"}}`), @@ -1479,7 +1479,7 @@ func TestExecutionEngine_Cost(t *testing.T) { schema: schemaSlicing, operation: func(t *testing.T) graphql.Request { return graphql.Request{ - Query: `query NestedInput($input: SInput) { + Query: `query NestedInput($input: Search) { search(input: $input) { id } }`, Variables: []byte(`{"input":{"query":"abc"}}`), @@ -1524,7 +1524,7 @@ func TestExecutionEngine_Cost(t *testing.T) { schema: schemaSlicing, operation: func(t *testing.T) graphql.Request { return graphql.Request{ - Query: `query NestedInput($input: SInput) { + Query: `query NestedInput($input: Search) { search(input: $input) { id } }`, Variables: []byte(`{"input":{"pagination":{"first":null},"query":"abc"}}`), @@ -1569,7 +1569,7 @@ func TestExecutionEngine_Cost(t *testing.T) { schema: schemaSlicing, operation: func(t *testing.T) graphql.Request { return graphql.Request{ - Query: `query NestedInput($input: SInput) { + Query: `query NestedInput($input: Search) { search(input: $input) { id } }`, Variables: []byte(`{}`), @@ -1749,6 +1749,260 @@ func TestExecutionEngine_Cost(t *testing.T) { computeCosts(), )) + t.Run("sliceArguments with defaulted arguments", func(t *testing.T) { + // When a slicing argument is omitted from the operation, + // the engine must fall back to the upstream SDL default. + listSchemaWithDefaults := ` + input Search { + pagination: Page + query: String + } + input Page { + first: Int = 10 + } + type Query { + items(first: Int = 25, last: Int = 10): [Item!] + search(input: Search = { pagination: { first: 8 } }): [Item!] + } + type Item @key(fields: "id") { + id: ID + } + ` + schemaSlicingDefaults, err := graphql.NewSchemaFromString(listSchemaWithDefaults) + require.NoError(t, err) + customConfigDefaults := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, listSchemaWithDefaults), + }) + + t.Run("flat slicing arg omitted - uses schema Int default", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicingDefaults, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query FlatDefault { items { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[ {"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: "items"}: { + AssumedSize: 8, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfigDefaults, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: intPtr(100), // max(first=25, last=10) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("when dot-path arg omitted, outer object-literal default supplies leaf", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicingDefaults, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query OuterObjectDefault { search { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"} ]}}`, + 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}, + }, + }, + customConfigDefaults, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"}]}}`, + expectedEstimatedCost: intPtr(32), // outer default { pagination: { first: 8 } } * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("when dot-path with partially provided input, inner field default supplies leaf", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicingDefaults, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + // `pagination` is provided as an empty object — `first` is absent + // and must resolve to the Page.first schema default (= 10). + Query: `query InnerFieldDefault { search(input: { pagination: {}, query: "q" }) { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"} ]}}`, + 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}, + }, + }, + customConfigDefaults, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"}]}}`, + expectedEstimatedCost: intPtr(40), // inner Page.first default (10) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + + t.Run("explicit null at dot-path leaf must not use schema default", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicingDefaults, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ExplicitNullLeaf { search(input: { pagination: { first: null }, query: "q" }) { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"} ]}}`, + 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"}: { + AssumedSize: 5, + SlicingArguments: []string{"input.pagination.first"}, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfigDefaults, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"}]}}`, + // AssumedSize (5) * (Item(3)+Item.id(1)) + expectedEstimatedCost: intPtr(20), + // 1 * (Item(3)+Item.id(1)) + expectedActualCost: intPtr(4), + }, + computeCosts(), + )) + + t.Run("variable-nulled dot-path leaf must not use schema default", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicingDefaults, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query VarNullLeaf($input: Search) { search(input: $input) { id } }`, + Variables: []byte(`{"input":{"pagination":{"first":null},"query":"q"}}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"search":[ {"id":"2"} ]}}`, + 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"}: { + AssumedSize: 5, + SlicingArguments: []string{"input.pagination.first"}, + }, + }, + Types: map[string]int{"Item": 3}, + }, + }, + customConfigDefaults, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"id":"2"}]}}`, + // AssumedSize (5) * (Item(3)+Item.id(1)) + expectedEstimatedCost: intPtr(20), + // 1 * (Item(3)+Item.id(1)) + expectedActualCost: intPtr(4), + }, + computeCosts(), + )) + }) + }) t.Run("nested lists with compounding multipliers", func(t *testing.T) { @@ -4064,6 +4318,325 @@ func TestExecutionEngine_Cost(t *testing.T) { )) }) }) + + t.Run("validate requireOneSlicingArgument with schema defaults", func(t *testing.T) { + listSchema := ` + input Page { + first: Int = 8 + } + type Query { + search(input: Page): [Item!] + items1(first: Int = 5, last: Int): [Item!] + items2(first: Int = 5, last: Int = 3): [Item!] + } + type Item @key(fields: "id") { + id: ID + } + ` + + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"items1", "items2", "search"}}, + {TypeName: "Item", FieldNames: []string{"id"}}, + {TypeName: "Page", FieldNames: []string{"first"}}, + } + childNodes := []plan.TypeField{} + + fieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "items1", + Path: []string{"items1"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + {Name: "last", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Query", + FieldName: "items2", + Path: []string{"items2"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + {Name: "last", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Query", + FieldName: "search", + Path: []string{"search"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "input", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + } + + 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: "items1"}: { + AssumedSize: 10, + SlicingArguments: []string{"first", "last"}, + RequireOneSlicingArgument: true, + }, + {TypeName: "Query", FieldName: "items2"}: { + AssumedSize: 10, + SlicingArguments: []string{"first", "last"}, + RequireOneSlicingArgument: true, + }, + {TypeName: "Query", FieldName: "search"}: { + SlicingArguments: []string{"input.first"}, + RequireOneSlicingArgument: true, + }, + }, + Types: map[string]int{"Item": 2}, + } + items1Body := `{"data":{"items1":[{"id":"1"}]}}` + items2Body := `{"data":{"items2":[{"id":"1"}]}}` + searchBody := `{"data":{"search":[{"id":"1"}]}}` + makeDS := func(t *testing.T, body string, schema string) []plan.DataSource { + t.Helper() + return []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: body, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: costConfig, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, schema), + }), + ), + } + } + + t.Run("single slicing arg supplied entirely by schema default is valid", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items1 { id } }`} + }, + dataSources: makeDS(t, items1Body, listSchema), + fields: fieldConfig, + expectedResponse: items1Body, + expectedEstimatedCost: intPtr(15), // first default (5) * (Item(2)+Item.id(1)) + expectedActualCost: intPtr(3), + }, + computeCosts(), + )(t) + }) + + t.Run("flat slicing arg with omitted variables falls back to schema default", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ($limit: Int) { items1(first: $limit) { id } }`, + } + }, + dataSources: makeDS(t, items1Body, listSchema), + fields: fieldConfig, + expectedResponse: items1Body, + expectedEstimatedCost: intPtr(15), // first default (5) * (Item(2)+Item.id(1)) + expectedActualCost: intPtr(3), // 1 * (Item(2)+Item.id(1)) + }, + computeCosts(), + )(t) + }) + + t.Run("flat slicing arg with empty variable falls back to schema default", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ($limit: Int) { items1(first: $limit) { id } }`, + Variables: []byte(`{}`), + // An absent variable is treated as omitted, schema default applies. + } + }, + dataSources: makeDS(t, items1Body, listSchema), + fields: fieldConfig, + expectedResponse: items1Body, + expectedEstimatedCost: intPtr(15), // first default (5) * (Item(2)+Item.id(1)) + expectedActualCost: intPtr(3), // 1 * (Item(2)+Item.id(1)) + }, + computeCosts(), + )(t) + }) + + t.Run("two slicing args, both supplied by schema defaults, are not valid", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items2 { id } }`} + }, + dataSources: makeDS(t, items2Body, listSchema), + fields: fieldConfig, + }, + "external: field 'Query.items2' requires exactly one slicing argument, but 2 were provided, locations: [], path: [items2]", + computeCosts(), + )(t) + }) + + t.Run("one explicit slicing arg and defaulted arg are invalid", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items2(first: 7) { id } }`} + }, + dataSources: makeDS(t, items2Body, listSchema), + fields: fieldConfig, + }, + "external: field 'Query.items2' requires exactly one slicing argument, but 2 were provided, locations: [], path: [items2]", + computeCosts(), + )(t) + }) + + t.Run("one explicit slicing arg and variable-nulled arg are valid", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ($n: Int) { items2(first: 7, last: $n) { id } }`, + Variables: []byte(`{"n": null}`), + } + }, + dataSources: makeDS(t, items2Body, listSchema), + fields: fieldConfig, + expectedResponse: items2Body, + expectedEstimatedCost: intPtr(21), // first (7) * (Item(2)+Item.id(1)) + expectedActualCost: intPtr(3), + }, + computeCosts(), + )(t) + }) + + t.Run("one explicit slicing arg and nulled arg are valid", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ items2(first: 7, last: null) { id } }`} + }, + dataSources: makeDS(t, items2Body, listSchema), + fields: fieldConfig, + expectedResponse: items2Body, + expectedEstimatedCost: intPtr(21), // first default (7) * (Item(2)+Item.id(1)) + expectedActualCost: intPtr(3), + }, + computeCosts(), + )(t) + }) + + t.Run("dot-path slicing arg supplied by input field default is valid", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ search(input: {}) { id } }`} + }, + dataSources: makeDS(t, searchBody, listSchema), + fields: fieldConfig, + expectedResponse: searchBody, + expectedEstimatedCost: intPtr(24), // Page.first default (8) * (Item(2)+Item.id(1)) + expectedActualCost: intPtr(3), + }, + computeCosts(), + )(t) + }) + + t.Run("explicit null at dot-path leaf must not satisfy RequireOneSlicingArgument", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ search(input: { first: null }) { id } }`} + }, + dataSources: makeDS(t, searchBody, listSchema), + fields: fieldConfig, + }, + "external: field 'Query.search' requires exactly one slicing argument, but none was provided, locations: [], path: [search]", + computeCosts(), + )(t) + }) + + t.Run("explicit null at dot-path leaf variable must not satisfy RequireOneSlicingArgument", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ($n: Int) { search(input: { first: $n }) { id } }`, + Variables: []byte(`{"n": null}`), + } + }, + dataSources: makeDS(t, searchBody, listSchema), + fields: fieldConfig, + }, + "external: field 'Query.search' requires exactly one slicing argument, but none was provided, locations: [], path: [search]", + computeCosts(), + )(t) + }) + + t.Run("explicit null at dot-path variable must not satisfy RequireOneSlicingArgument", func(t *testing.T) { + schema, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + runWithAndCompareError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ($n: Page) { search(input: $n) { id } }`, + Variables: []byte(`{"n": null}`), + } + }, + dataSources: makeDS(t, searchBody, listSchema), + fields: fieldConfig, + }, + "external: field 'Query.search' requires exactly one slicing argument, but none was provided, locations: [], path: [search]", + computeCosts(), + )(t) + }) + }) + 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. diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index 770396c074..49cb7825e0 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -78,41 +78,23 @@ type FieldListSize struct { // 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 + + // SlicingArgumentDefaults holds the leaf Int default value declared in + // the schema for each slicing argument path. Per GraphQL, an omitted + // slicing argument with a default here is treated as effectively provided with that value, + // both for `RequireOneSlicingArgument` validation and as a cost multiplier for lists. + SlicingArgumentDefaults map[string]int } // 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. -func (ls *FieldListSize) multiplier(arguments map[string]ArgumentInfo, vars *astjson.Value, defaultListSize int) int { +func (ls *FieldListSize) multiplier(args map[string]ArgumentInfo, vars *astjson.Value, defaultListSize int) int { multiplier := -1 - 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 - } - // Otherwise, try the simple arg: - arg, ok := arguments[slicingArg] - if !ok || !arg.isSimple { - continue - } - - 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 - } + for _, slicingArg := range ls.SlicingArguments { + value, found := ls.resolveSlicingArg(slicingArg, args, vars) + if found && value > 0 && value > multiplier { + multiplier = value } } @@ -126,30 +108,62 @@ func (ls *FieldListSize) multiplier(arguments map[string]ArgumentInfo, vars *ast 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) +// resolveSlicingArg resolves the value of a slicing argument from arguments/variables. +// It falls back to SlicingArgumentDefaults when no value is provided. +// The slicingArg may be a simple argument name or a dot-path into an input object argument. +// An explicitly provided [null] value in variables overrides the default value in schema. +func (ls *FieldListSize) resolveSlicingArg(slicingArg string, args map[string]ArgumentInfo, vars *astjson.Value) (int, bool) { + defaultValue, hasDefault := ls.SlicingArgumentDefaults[slicingArg] + if strings.Contains(slicingArg, ".") { + value := extractSlicingArgValue(slicingArg, args, vars) if value == nil { - return 0, false + return defaultValue, hasDefault } - for _, key := range path[1:] { - value = value.Get(key) - if value == nil { - return 0, false - } - } - if value.Type() != astjson.TypeNumber { - return 0, false + if value.Type() == astjson.TypeNumber { + return value.GetInt(), true } + // TypeNull value should not lead to the defaults being used. + return 0, false + } + arg, found := args[slicingArg] + if !found { + return defaultValue, hasDefault + } + if !arg.hasVariable { + return 0, false + } + value := vars.Get(arg.varName) + if value == nil { + return defaultValue, hasDefault + } + if value.Type() == astjson.TypeNumber { return value.GetInt(), true } return 0, false } +// extractSlicingArgValue extracts a value from variables using slicingArg that contains +// a string in the format: "....." +func extractSlicingArgValue(slicingArg string, args map[string]ArgumentInfo, vars *astjson.Value) *astjson.Value { + if vars == nil { + return nil + } + path := strings.Split(slicingArg, ".") + inputArg := path[0] + arg, found := args[inputArg] + if !found || !arg.hasVariable || !arg.isInputObject { + return nil + } + value := vars.Get(arg.varName) + for _, key := range path[1:] { + if value == nil || value.Type() == astjson.TypeNull { + return value + } + value = value.Get(key) + } + return value +} + // DataSourceCostConfig holds all cost configurations for a data source. // This data is passed from the composition. type DataSourceCostConfig struct { @@ -759,27 +773,9 @@ func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceC 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 { - // 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.TypeNumber { - count++ - } - } + for _, slicingArg := range listSize.SlicingArguments { + if _, found := listSize.resolveSlicingArg(slicingArg, node.arguments, variables); found { + count++ } } if count != 1 { diff --git a/v2/pkg/engine/plan/cost_defaults.go b/v2/pkg/engine/plan/cost_defaults.go new file mode 100644 index 0000000000..5e753e988f --- /dev/null +++ b/v2/pkg/engine/plan/cost_defaults.go @@ -0,0 +1,144 @@ +package plan + +import ( + "strings" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" +) + +// fillListSizeDefaults fills FieldListSize.SlicingArgumentDefaults by +// walking the data source's parsed upstream SDL. +// +// Composition has already validated the @listSize directive; the engine acts as a +// pure extractor here. Anything that does not resolve to an Int default is silently +// skipped — composition is the validator. +func fillListSizeDefaults(listSizes map[FieldCoordinate]*FieldListSize, schema *ast.Document) { + if schema == nil || len(listSizes) == 0 { + return + } + for coords, listSize := range listSizes { + if listSize == nil || len(listSize.SlicingArguments) == 0 { + continue + } + + typeNode, exists := schema.Index.FirstNodeByNameStr(coords.TypeName) + if !exists { + continue + } + fieldDefRef, exists := schema.NodeFieldDefinitionByName(typeNode, []byte(coords.FieldName)) + if !exists { + continue + } + if !schema.FieldDefinitionHasArgumentsDefinitions(fieldDefRef) { + continue + } + + for _, slicingArgPath := range listSize.SlicingArguments { + segments := strings.Split(slicingArgPath, ".") + value, ok := getEffectiveSlicingArgLeafDefault(schema, fieldDefRef, segments) + if !ok { + continue + } + if listSize.SlicingArgumentDefaults == nil { + listSize.SlicingArgumentDefaults = make(map[string]int) + } + listSize.SlicingArgumentDefaults[slicingArgPath] = value + } + } +} + +// getEffectiveSlicingArgLeafDefault returns the effective leaf Int default for a +// slicing argument path declared on the given field definition, or (0, false) if +// no default along the chain resolves to a defined Int leaf. +func getEffectiveSlicingArgLeafDefault(schema *ast.Document, fieldDefRef int, segments []string) (int, bool) { + if len(segments) == 0 { + return 0, false + } + + // Build the chain of InputValueDefinition refs. One per path segment, + // starting with the field's argument matching segments[0] and traversing through + // nested input-object field definitions for the rest. + // If any segment fails to resolve, we bail. + chain := make([]int, 0, len(segments)) + + // Segment 0: the field's argument. + argRefs := schema.FieldDefinitionArgumentsDefinitions(fieldDefRef) + firstArgRef := -1 + for _, ref := range argRefs { + if schema.InputValueDefinitionNameString(ref) == segments[0] { + firstArgRef = ref + break + } + } + if firstArgRef == -1 { + return 0, false + } + chain = append(chain, firstArgRef) + + // Segments 1...n-1. Descend through nested input-object field definitions. + currentTypeRef := schema.InputValueDefinitionType(firstArgRef) + for i := 1; i < len(segments); i++ { + typeName := schema.ResolveTypeNameString(currentTypeRef) + typeNode, exists := schema.Index.FirstNodeByNameStr(typeName) + if !exists || typeNode.Kind != ast.NodeKindInputObjectTypeDefinition { + return 0, false + } + nextRef := schema.InputObjectTypeDefinitionInputValueDefinitionByName(typeNode.Ref, []byte(segments[i])) + if nextRef == -1 { + return 0, false + } + chain = append(chain, nextRef) + currentTypeRef = schema.InputValueDefinitionType(nextRef) + } + + // Walk the chain outermost-to-leaf: for each position i, take that + // InputValueDefinition's declared default and step through segments[i+1...n-1] + // inside the default's object-literal AST. The first chain position whose + // default resolves to a defined ValueKindInteger leaf wins. + // A defined-but-non-Int outer value shadows inner defaults. + for i := 0; i < len(chain); i++ { + if !schema.InputValueDefinitionHasDefaultValue(chain[i]) { + continue + } + value := schema.InputValueDefinitionDefaultValue(chain[i]) + + // Scan segments[i+1...n-1] for the default's object literal. + resolved := value + ok := true + for j := i + 1; j < len(segments); j++ { + if resolved.Kind != ast.ValueKindObject { + ok = false + break + } + fieldValue, found := findObjectFieldValue(schema, resolved.Ref, segments[j]) + if !found { + ok = false + break + } + resolved = fieldValue + } + if !ok { + continue // This position's default doesn't cover the rest of the path. + } + + // resolved is the candidate leaf. + // If it is not an Int, this outer default shadows inner defaults. + if resolved.Kind == ast.ValueKindInteger { + return int(schema.IntValueAsInt(resolved.Ref)), true + } + return 0, false + } + return 0, false +} + +// findObjectFieldValue looks up a named field inside an object-literal Value (the +// default value of an InputValueDefinition that has Kind == ValueKindObject) and +// returns its Value plus true if found. +func findObjectFieldValue(schema *ast.Document, objectValueRef int, fieldName string) (ast.Value, bool) { + for _, fieldRef := range schema.ObjectValues[objectValueRef].Refs { + if schema.ObjectFieldNameString(fieldRef) == fieldName { + return schema.ObjectFieldValue(fieldRef), true + } + } + return ast.Value{}, false +} diff --git a/v2/pkg/engine/plan/cost_defaults_test.go b/v2/pkg/engine/plan/cost_defaults_test.go new file mode 100644 index 0000000000..e667cb310a --- /dev/null +++ b/v2/pkg/engine/plan/cost_defaults_test.go @@ -0,0 +1,309 @@ +package plan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" +) + +// runTestFillDefaults parses the given SDL (merging the base schema so built-in scalars +// resolve) and runs fillListSizeDefaults against the supplied map. +// The map is mutated in place; the test owns it and asserts directly on its entries. +// +// Pass sdl == "" to exercise the nil-schema no-op path. +func runTestFillDefaults(t *testing.T, sdl string, listSizes map[FieldCoordinate]*FieldListSize) { + t.Helper() + var schema *ast.Document + if sdl != "" { + doc := unsafeparser.ParseGraphqlDocumentString(sdl) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&doc)) + schema = &doc + } + fillListSizeDefaults(listSizes, schema) +} + +func TestFillListSizeDefaultsFromSchema(t *testing.T) { + t.Run("extract flat slicing arg with Int default", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"limit"}} + runTestFillDefaults(t, ` + type Query { + boards(limit: Int = 25): [Board] + } + type Board { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "boards"}: ls, + }) + assert.Equal(t, map[string]int{"limit": 25}, ls.SlicingArgumentDefaults) + }) + + t.Run("map is empty after flat slicing arg without default", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"limit"}} + runTestFillDefaults(t, ` + type Query { + boards(limit: Int): [Board] + } + type Board { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "boards"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("multiple slicing args, only some defaulted", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"first", "last"}} + runTestFillDefaults(t, ` + type Query { + users(first: Int = 20, last: Int): [User] + } + type User { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "users"}: ls, + }) + assert.Equal(t, map[string]int{"first": 20}, ls.SlicingArgumentDefaults) + }) + + t.Run("two fields in the same schema are enriched independently", func(t *testing.T) { + boards := &FieldListSize{SlicingArguments: []string{"limit"}} + users := &FieldListSize{SlicingArguments: []string{"first", "last"}} + runTestFillDefaults(t, ` + type Query { + boards(limit: Int = 25): [Board] + users(first: Int = 20, last: Int): [User] + } + type Board { id: ID } + type User { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "boards"}: boards, + {TypeName: "Query", FieldName: "users"}: users, + }) + assert.Equal(t, map[string]int{"limit": 25}, boards.SlicingArgumentDefaults) + assert.Equal(t, map[string]int{"first": 20}, users.SlicingArgumentDefaults) + }) + + t.Run("fill dot-path with leaf input-field default", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.first"}} + runTestFillDefaults(t, ` + type Query { + search(input: PaginationInput!): [Book] + } + input PaginationInput { first: Int = 10 } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Equal(t, map[string]int{"input.first": 10}, ls.SlicingArgumentDefaults) + }) + + t.Run("fill dot-path with outer-arg default supplying leaf", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.first"}} + runTestFillDefaults(t, ` + type Query { + search(input: PaginationInput = { first: 15 }): [Book] + } + input PaginationInput { first: Int } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Equal(t, map[string]int{"input.first": 15}, ls.SlicingArgumentDefaults) + }) + + t.Run("dot-path with outer-overrides-inner default", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.first"}} + runTestFillDefaults(t, ` + type Query { + search(input: PaginationInput = { first: 15 }): [Book] + } + input PaginationInput { first: Int = 10 } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Equal(t, map[string]int{"input.first": 15}, ls.SlicingArgumentDefaults) + }) + + t.Run("explicit null in outer default shadows inner Int default", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.first"}} + runTestFillDefaults(t, ` + type Query { + search(input: PaginationInput = { first: null }): [Book] + } + input PaginationInput { first: Int = 10 } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("non-Int default is skipped", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"filter"}} + runTestFillDefaults(t, ` + type Query { + items(filter: String = "all"): [Item] + } + type Item { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "items"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("unresolved leading segment is silently skipped", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"wrong.missing"}} + runTestFillDefaults(t, ` + type Query { + search(input: PaginationInput!): [Book] + } + input PaginationInput { first: Int = 10 } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("unresolved path segment is silently skipped", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.missing"}} + runTestFillDefaults(t, ` + type Query { + search(input: PaginationInput!): [Book] + } + input PaginationInput { first: Int = 10 } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("missing field on the schema - skipped without panic", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"limit"}} + runTestFillDefaults(t, ` + type Query { + other(x: Int): [Item] + } + type Item { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "boards"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("nil schema is a no-op", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"limit"}} + runTestFillDefaults(t, "", map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "boards"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("outer default missing nested field falls through to inner Int default", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.first"}} + runTestFillDefaults(t, ` + type Query { + search(input: PaginationInput = { other: 99 }): [Book] + } + input PaginationInput { first: Int = 10, other: Int } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Equal(t, map[string]int{"input.first": 10}, ls.SlicingArgumentDefaults) + }) + + t.Run("outer with non-Int leaf shadows inner Int default", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.first"}} + runTestFillDefaults(t, ` + type Query { + search(input: PaginationInput = { first: "many" }): [Book] + } + input PaginationInput { first: Int = 10 } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("three-segment path resolves through nested input objects", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.page.first"}} + runTestFillDefaults(t, ` + type Query { + search(input: SearchInput!): [Book] + } + input SearchInput { page: PageInput } + input PageInput { first: Int = 7 } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Equal(t, map[string]int{"input.page.first": 7}, ls.SlicingArgumentDefaults) + }) + + t.Run("three-segment path with outer object provides the full chain", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"input.page.first"}} + runTestFillDefaults(t, ` + type Query { + search(input: SearchInput = { page: { first: 42 } }): [Book] + } + input SearchInput { page: PageInput } + input PageInput { first: Int = 7 } + type Book { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "search"}: ls, + }) + assert.Equal(t, map[string]int{"input.page.first": 42}, ls.SlicingArgumentDefaults) + }) + + t.Run("path descends into a non-input-object type is skipped", func(t *testing.T) { + // `limit` is a scalar Int — a dotted path beneath it has no input-object + // to descend into and must be silently skipped. + ls := &FieldListSize{SlicingArguments: []string{"limit.first"}} + runTestFillDefaults(t, ` + type Query { + boards(limit: Int = 25): [Board] + } + type Board { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "boards"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("nil FieldListSize entry is skipped without panic", func(t *testing.T) { + runTestFillDefaults(t, ` + type Query { boards(limit: Int = 25): [Board] } + type Board { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "boards"}: nil, + }) + }) + + t.Run("empty SlicingArguments leaves defaults nil", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{}} + runTestFillDefaults(t, ` + type Query { boards(limit: Int = 25): [Board] } + type Board { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "Query", FieldName: "boards"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) + + t.Run("missing parent type is skipped without panic", func(t *testing.T) { + ls := &FieldListSize{SlicingArguments: []string{"limit"}} + runTestFillDefaults(t, ` + type Query { boards(limit: Int = 25): [Board] } + type Board { id: ID } + `, map[FieldCoordinate]*FieldListSize{ + {TypeName: "DoesNotExist", FieldName: "boards"}: ls, + }) + assert.Nil(t, ls.SlicingArgumentDefaults) + }) +} diff --git a/v2/pkg/engine/plan/datasource_configuration.go b/v2/pkg/engine/plan/datasource_configuration.go index 0601e3abfb..0a54d79173 100644 --- a/v2/pkg/engine/plan/datasource_configuration.go +++ b/v2/pkg/engine/plan/datasource_configuration.go @@ -262,14 +262,21 @@ func NewDataSourceConfigurationWithName[T any](id string, name string, factory P } } - return &dataSourceConfiguration[T]{ + dsc := &dataSourceConfiguration[T]{ DataSourceMetadata: metadata, id: id, name: name, factory: factory, custom: customConfig, hash: DSHash(xxhash.Sum64([]byte(id))), - }, nil + } + if metadata != nil && metadata.CostConfig != nil && len(metadata.CostConfig.ListSizes) > 0 { + if schema, ok := dsc.UpstreamSchema(); ok && schema != nil { + // Backfill slicing-argument defaults from the data source's parsed upstream SDL. + fillListSizeDefaults(metadata.CostConfig.ListSizes, schema) + } + } + return dsc, nil } type DataSourceConfiguration[T any] interface {