From 38dae33dd99ce8f74f6b171d3a4796a574b77829 Mon Sep 17 00:00:00 2001 From: Chris Morris Date: Mon, 12 Jan 2026 08:58:05 -0800 Subject: [PATCH 1/4] support array parsing for listSize directive (#8799) --- .../cost_calculator/directives.rs | 204 ++++++++++++++---- .../fixtures/custom_cost_schema.graphql | 13 ++ .../cost_calculator/static_cost.rs | 41 ++++ 3 files changed, 220 insertions(+), 38 deletions(-) diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs b/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs index ce4f183ff4..20b35aa14e 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs @@ -1,20 +1,72 @@ use ahash::HashMap; -use ahash::HashMapExt; use ahash::HashSet; use apollo_compiler::Schema; use apollo_compiler::ast::FieldDefinition; use apollo_compiler::ast::NamedType; +use apollo_compiler::ast::Value as AstValue; use apollo_compiler::executable::Field; use apollo_compiler::executable::SelectionSet; use apollo_compiler::parser::Parser; use apollo_compiler::validation::Valid; use apollo_federation::link::cost_spec_definition::ListSizeDirective as ParsedListSizeDirective; +use indexmap::IndexSet; +use serde_json_bytes::Value as JsonValue; use tower::BoxError; use crate::json_ext::Object; use crate::json_ext::ValueExt; use crate::plugins::demand_control::DemandControlError; +// Infers a size value from an AST argument value. +// +// Returns: +// - `Some(n)` for integer values (e.g., `first: 10` → 10) +// - `Some(len)` for array values (e.g., `ids: ["a", "b"]` → 2) +// - Resolves variable references through the provided variables map +// - `None` for null, missing, or unsupported value types +fn infer_size_from_argument(value: Option<&AstValue>, variables: &Object) -> Option { + match value? { + AstValue::Int(n) => n.try_to_i32().ok(), + AstValue::List(items) => Some(items.len() as i32), + AstValue::Variable(var_name) => infer_size_from_variable(variables.get(var_name.as_str())), + _ => None, + } +} + +// Infers a size value from a JSON variable value. +fn infer_size_from_variable(value: Option<&JsonValue>) -> Option { + match value? { + JsonValue::Array(items) => Some(items.len() as i32), + other => other.as_i32(), + } +} + +// Collects slicing argument sizes from both default values and actual query arguments. +// Actual values override defaults when both are present. +fn collect_slicing_sizes<'a>( + field: &'a Field, + slicing_argument_names: &IndexSet, + variables: &Object, +) -> HashMap<&'a str, i32> { + let is_slicing_arg = |name: &str| slicing_argument_names.contains(name); + + let defaults = field.definition.arguments.iter().filter_map(|arg| { + is_slicing_arg(arg.name.as_str()) + .then(|| infer_size_from_argument(arg.default_value.as_deref(), variables)) + .flatten() + .map(|size| (arg.name.as_str(), size)) + }); + + let actuals = field.arguments.iter().filter_map(|arg| { + is_slicing_arg(arg.name.as_str()) + .then(|| infer_size_from_argument(Some(&arg.value), variables)) + .flatten() + .map(|size| (arg.name.as_str(), size)) + }); + + defaults.chain(actuals).collect() +} + pub(in crate::plugins::demand_control) struct IncludeDirective { pub(in crate::plugins::demand_control) is_included: bool, } @@ -45,46 +97,21 @@ impl<'schema> ListSizeDirective<'schema> { field: &Field, variables: &Object, ) -> Result { - let mut slicing_arguments: HashMap<&str, i32> = HashMap::new(); - if let Some(slicing_argument_names) = parsed.slicing_argument_names.as_ref() { - // First, collect the default values for each argument - for argument in &field.definition.arguments { - if slicing_argument_names.contains(argument.name.as_str()) - && let Some(numeric_value) = - argument.default_value.as_ref().and_then(|v| v.to_i32()) - { - slicing_arguments.insert(&argument.name, numeric_value); - } - } - // Then, overwrite any default values with the actual values passed in the query - for argument in &field.arguments { - if slicing_argument_names.contains(argument.name.as_str()) { - if let Some(numeric_value) = argument.value.to_i32() { - slicing_arguments.insert(&argument.name, numeric_value); - } else if let Some(numeric_value) = argument - .value - .as_variable() - .and_then(|variable_name| variables.get(variable_name.as_str())) - .and_then(|variable| variable.as_i32()) - { - slicing_arguments.insert(&argument.name, numeric_value); - } + let expected_size = match parsed.slicing_argument_names.as_ref() { + Some(slicing_argument_names) => { + let slicing_sizes = collect_slicing_sizes(field, slicing_argument_names, variables); + + if parsed.require_one_slicing_argument && slicing_sizes.len() != 1 { + return Err(DemandControlError::QueryParseFailure(format!( + "Exactly one slicing argument is required, but found {}", + slicing_sizes.len() + ))); } - } - if parsed.require_one_slicing_argument && slicing_arguments.len() != 1 { - return Err(DemandControlError::QueryParseFailure(format!( - "Exactly one slicing argument is required, but found {}", - slicing_arguments.len() - ))); + slicing_sizes.into_values().max().or(parsed.assumed_size) } - } - - let expected_size = slicing_arguments - .values() - .max() - .cloned() - .or(parsed.assumed_size); + None => parsed.assumed_size, + }; Ok(Self { expected_size, @@ -155,3 +182,104 @@ impl SkipDirective { Ok(directive) } } + +#[cfg(test)] +mod tests { + use super::*; + + mod infer_size_from_variable_tests { + use serde_json_bytes::json; + + use super::*; + + #[rstest::rstest] + #[case::integer_value(json!(42), Some(42))] + #[case::zero(json!(0), Some(0))] + #[case::negative_integer(json!(-5), Some(-5))] + #[case::array_with_items(json!(["a", "b", "c"]), Some(3))] + #[case::empty_array(json!([]), Some(0))] + #[case::null_value(json!(null), None)] + #[case::string_value(json!("not a size"), None)] + #[case::boolean_value(json!(true), None)] + #[case::object_value(json!({"key": "value"}), None)] + #[case::float_value(json!(1.5), None)] + fn test_infer_size(#[case] input: JsonValue, #[case] expected: Option) { + assert_eq!(infer_size_from_variable(Some(&input)), expected); + } + + #[test] + fn none_input_returns_none() { + assert_eq!(infer_size_from_variable(None), None); + } + } + + mod infer_size_from_argument_tests { + use apollo_compiler::Node; + use apollo_compiler::ast::IntValue; + use serde_json_bytes::json; + + use super::*; + + // Helper to create a list with n string items + fn list_of_size(n: usize) -> AstValue { + let items = (0..n) + .map(|i| Node::new(AstValue::String(format!("item{i}")))) + .collect(); + AstValue::List(items) + } + + #[rstest::rstest] + #[case::integer_10("10", Some(10))] + #[case::integer_0("0", Some(0))] + #[case::negative("-5", Some(-5))] + fn integer_values(#[case] input: &str, #[case] expected: Option) { + let value = AstValue::Int(IntValue::new_parsed(input)); + assert_eq!( + infer_size_from_argument(Some(&value), &Object::new()), + expected + ); + } + + #[rstest::rstest] + #[case::three_items(3, Some(3))] + #[case::one_item(1, Some(1))] + #[case::empty(0, Some(0))] + fn list_values(#[case] size: usize, #[case] expected: Option) { + let value = list_of_size(size); + assert_eq!( + infer_size_from_argument(Some(&value), &Object::new()), + expected + ); + } + + #[rstest::rstest] + #[case::resolves_to_int("count", json!(5), Some(5))] + #[case::resolves_to_array("ids", json!(["x", "y", "z"]), Some(3))] + #[case::resolves_to_empty_array("empty", json!([]), Some(0))] + #[case::resolves_to_null("nullval", json!(null), None)] + fn variable_resolution( + #[case] var_name: &str, + #[case] var_value: serde_json_bytes::Value, + #[case] expected: Option, + ) { + let value = AstValue::Variable(apollo_compiler::Name::new_unchecked(var_name)); + let mut variables = Object::new(); + variables.insert(var_name, var_value); + assert_eq!(infer_size_from_argument(Some(&value), &variables), expected); + } + + #[rstest::rstest] + #[case::none_input(None)] + #[case::string_value(Some(AstValue::String("not a size".into())))] + #[case::boolean_value(Some(AstValue::Boolean(true)))] + #[case::missing_variable(Some(AstValue::Variable(apollo_compiler::Name::new_unchecked( + "missing" + ))))] + fn unsupported_values_return_none(#[case] value: Option) { + assert_eq!( + infer_size_from_argument(value.as_ref(), &Object::new()), + None + ); + } + } +} diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql index 02184164a9..08219f8fb4 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql @@ -137,6 +137,19 @@ type Query sizedFields: ["items"] requireOneSlicingArgument: true ) + itemsByIds(ids: [ID!]!): [A] + @join__field(graph: SUBGRAPHWITHLISTSIZE) + @listSize( + slicingArguments: ["ids"] + requireOneSlicingArgument: true + ) + itemsByIdsWithAssumedSize(ids: [ID!]): [A] + @join__field(graph: SUBGRAPHWITHLISTSIZE) + @listSize( + slicingArguments: ["ids"] + assumedSize: 50 + requireOneSlicingArgument: false + ) } type SizedField @join__type(graph: SUBGRAPHWITHLISTSIZE) { diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs b/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs index 5589e03b71..0bc4aec40e 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs @@ -1255,4 +1255,45 @@ mod tests { assert_eq!(planned_cost_js(schema, query, variables).await, 1.0); assert_eq!(planned_cost_rust(schema, query, variables), 1.0); } + + /// Tests for array-based slicing arguments in @listSize directive + mod array_slicing_argument_tests { + use super::estimated_cost; + + const SCHEMA: &str = include_str!("./fixtures/custom_cost_schema.graphql"); + + #[rstest::rstest] + #[case::inline_array_of_3( + r#"query { itemsByIds(ids: ["a", "b", "c"]) { id } }"#, + "{}", + 3.0 + )] + #[case::empty_inline_array(r#"query { itemsByIds(ids: []) { id } }"#, "{}", 0.0)] + #[case::variable_array_of_5( + r#"query Q($ids: [ID!]!) { itemsByIds(ids: $ids) { id } }"#, + r#"{"ids": ["a", "b", "c", "d", "e"]}"#, + 5.0 + )] + #[case::variable_empty_array( + r#"query Q($ids: [ID!]!) { itemsByIds(ids: $ids) { id } }"#, + r#"{"ids": []}"#, + 0.0 + )] + fn array_length_determines_list_size( + #[case] query: &str, + #[case] variables: &str, + #[case] expected_cost: f64, + ) { + assert_eq!(estimated_cost(SCHEMA, query, variables), expected_cost); + } + + #[rstest::rstest] + #[case::null_variable(r#"{"ids": null}"#)] + #[case::missing_variable("{}")] + fn null_or_missing_array_falls_back_to_assumed_size(#[case] variables: &str) { + let query = r#"query Q($ids: [ID!]) { itemsByIdsWithAssumedSize(ids: $ids) { id } }"#; + // assumedSize is 50 in the schema + assert_eq!(estimated_cost(SCHEMA, query, variables), 50.0); + } + } } From c4221c314ce6f9c0fb2b884655f541fd4491e167 Mon Sep 17 00:00:00 2001 From: Chris Morris Date: Fri, 30 Jan 2026 08:21:07 -0800 Subject: [PATCH 2/4] Support nested input paths in @listSize slicingArguments (#8809) --- .../cost_calculator/directives.rs | 260 ++++++++++++++++-- .../fixtures/custom_cost_schema.graphql | 41 +++ .../cost_calculator/static_cost.rs | 98 +++++++ 3 files changed, 380 insertions(+), 19 deletions(-) diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs b/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs index 20b35aa14e..d06af93091 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs @@ -17,6 +17,25 @@ use crate::json_ext::Object; use crate::json_ext::ValueExt; use crate::plugins::demand_control::DemandControlError; +// Traverses a nested AST value by path segments. +// Given path `["pagination", "count"]`, returns the value at `{pagination: {count: }}`. +fn traverse_ast_value<'a>(value: &'a AstValue, path: &[&str]) -> Option<&'a AstValue> { + path.iter() + .try_fold(value, |current, segment| match current { + AstValue::Object(fields) => fields + .iter() + .find(|(name, _)| name.as_str() == *segment) + .map(|(_, node)| node.as_ref()), + _ => None, + }) +} + +// Traverses a nested JSON value by path segments. +fn traverse_json_value<'a>(value: &'a JsonValue, path: &[&str]) -> Option<&'a JsonValue> { + path.iter() + .try_fold(value, |current, segment| current.get(segment)) +} + // Infers a size value from an AST argument value. // // Returns: @@ -41,30 +60,61 @@ fn infer_size_from_variable(value: Option<&JsonValue>) -> Option { } } +fn resolve_nested_size(value: &AstValue, path: &[&str], variables: &Object) -> Option { + match value { + AstValue::Object(_) => infer_size_from_argument(traverse_ast_value(value, path), variables), + AstValue::Variable(var_name) => infer_size_from_variable( + variables + .get(var_name.as_str()) + .and_then(|v| traverse_json_value(v, path)), + ), + _ => None, + } +} + +// Resolves a slicing argument path to its size value. +// Supports nested paths like "input.count" which traverse into input objects. +fn resolve_slicing_value( + args: &HashMap<&str, &AstValue>, + slicing_path: &str, + variables: &Object, +) -> Option { + let segments: Vec<&str> = slicing_path.split('.').collect(); + let (arg_name, nested_path) = segments.split_first()?; + let value = args.get(*arg_name)?; + + if nested_path.is_empty() { + infer_size_from_argument(Some(*value), variables) + } else { + resolve_nested_size(value, nested_path, variables) + } +} + // Collects slicing argument sizes from both default values and actual query arguments. // Actual values override defaults when both are present. fn collect_slicing_sizes<'a>( - field: &'a Field, - slicing_argument_names: &IndexSet, + field: &Field, + slicing_argument_names: &'a IndexSet, variables: &Object, ) -> HashMap<&'a str, i32> { - let is_slicing_arg = |name: &str| slicing_argument_names.contains(name); - - let defaults = field.definition.arguments.iter().filter_map(|arg| { - is_slicing_arg(arg.name.as_str()) - .then(|| infer_size_from_argument(arg.default_value.as_deref(), variables)) - .flatten() - .map(|size| (arg.name.as_str(), size)) - }); - - let actuals = field.arguments.iter().filter_map(|arg| { - is_slicing_arg(arg.name.as_str()) - .then(|| infer_size_from_argument(Some(&arg.value), variables)) - .flatten() - .map(|size| (arg.name.as_str(), size)) - }); - - defaults.chain(actuals).collect() + // Merge default and actual argument values (actuals take precedence) + let defaults = field + .definition + .arguments + .iter() + .filter_map(|arg| arg.default_value.as_deref().map(|v| (arg.name.as_str(), v))); + let actuals = field + .arguments + .iter() + .map(|arg| (arg.name.as_str(), arg.value.as_ref())); + let args: HashMap<&str, &AstValue> = defaults.chain(actuals).collect(); + + slicing_argument_names + .iter() + .filter_map(|path| { + resolve_slicing_value(&args, path, variables).map(|size| (path.as_str(), size)) + }) + .collect() } pub(in crate::plugins::demand_control) struct IncludeDirective { @@ -282,4 +332,176 @@ mod tests { ); } } + + mod traverse_ast_value_tests { + use apollo_compiler::Node; + use apollo_compiler::ast::Value as AstValue; + + use super::traverse_ast_value; + + fn make_object(fields: Vec<(&str, AstValue)>) -> AstValue { + AstValue::Object( + fields + .into_iter() + .map(|(name, value)| { + (apollo_compiler::Name::new_unchecked(name), Node::new(value)) + }) + .collect(), + ) + } + + #[test] + fn empty_path_returns_value() { + let value = AstValue::Int(apollo_compiler::ast::IntValue::new_parsed("42")); + assert!(matches!( + traverse_ast_value(&value, &[]), + Some(AstValue::Int(_)) + )); + } + + #[test] + fn single_level_traversal() { + let value = make_object(vec![( + "count", + AstValue::Int(apollo_compiler::ast::IntValue::new_parsed("10")), + )]); + let result = traverse_ast_value(&value, ["count"].as_slice()); + assert!(matches!(result, Some(AstValue::Int(_)))); + } + + #[test] + fn nested_traversal() { + let inner = make_object(vec![( + "first", + AstValue::Int(apollo_compiler::ast::IntValue::new_parsed("5")), + )]); + let outer = make_object(vec![("pagination", inner)]); + let result = traverse_ast_value(&outer, ["pagination", "first"].as_slice()); + assert!(matches!(result, Some(AstValue::Int(_)))); + } + + #[test] + fn missing_field_returns_none() { + let value = make_object(vec![( + "other", + AstValue::Int(apollo_compiler::ast::IntValue::new_parsed("10")), + )]); + assert!(traverse_ast_value(&value, &["missing"]).is_none()); + } + + #[test] + fn non_object_with_path_returns_none() { + let value = AstValue::Int(apollo_compiler::ast::IntValue::new_parsed("42")); + assert!(traverse_ast_value(&value, &["field"]).is_none()); + } + + /// Edge case: empty segment in path won't match any field + #[test] + fn empty_segment_returns_none() { + let value = make_object(vec![( + "count", + AstValue::Int(apollo_compiler::ast::IntValue::new_parsed("10")), + )]); + // An empty string segment won't match "count" + assert!(traverse_ast_value(&value, &[""]).is_none()); + } + + /// Edge case: path with empty segment in middle fails at that point + #[test] + fn empty_segment_in_middle_returns_none() { + let inner = make_object(vec![( + "first", + AstValue::Int(apollo_compiler::ast::IntValue::new_parsed("5")), + )]); + let outer = make_object(vec![("pagination", inner)]); + assert!(traverse_ast_value(&outer, &["pagination", "", "first"]).is_none()); + } + } + + mod traverse_json_value_tests { + use serde_json_bytes::json; + + use super::traverse_json_value; + + #[test] + fn empty_path_returns_value() { + let value = json!(42); + assert_eq!(traverse_json_value(&value, &[]), Some(&value)); + } + + #[test] + fn single_level_traversal() { + let value = json!({"count": 10}); + let result = traverse_json_value(&value, ["count"].as_slice()); + assert_eq!(result, Some(&json!(10))); + } + + #[test] + fn nested_traversal() { + let value = json!({"pagination": {"first": 5}}); + let result = traverse_json_value(&value, ["pagination", "first"].as_slice()); + assert_eq!(result, Some(&json!(5))); + } + + #[test] + fn deeply_nested_traversal() { + let value = json!({"level1": {"level2": {"level3": {"count": 99}}}}); + let result = + traverse_json_value(&value, ["level1", "level2", "level3", "count"].as_slice()); + assert_eq!(result, Some(&json!(99))); + } + + #[test] + fn missing_field_returns_none() { + let value = json!({"other": 10}); + assert!(traverse_json_value(&value, &["missing"]).is_none()); + } + + #[test] + fn non_object_with_path_returns_none() { + let value = json!(42); + assert!(traverse_json_value(&value, &["field"]).is_none()); + } + + #[test] + fn partial_path_missing_returns_none() { + let value = json!({"level1": {"other": 5}}); + assert!(traverse_json_value(&value, &["level1", "level2", "count"]).is_none()); + } + + /// Edge case: empty segment won't match any field + #[test] + fn empty_segment_returns_none() { + let value = json!({"count": 10}); + assert!(traverse_json_value(&value, &[""]).is_none()); + } + + /// Edge case: path with empty segment in middle fails at that point + #[test] + fn empty_segment_in_middle_returns_none() { + let value = json!({"pagination": {"first": 5}}); + assert!(traverse_json_value(&value, &["pagination", "", "first"]).is_none()); + } + + /// Edge case: whitespace in segment name won't match trimmed field names + #[test] + fn whitespace_segment_returns_none() { + let value = json!({"count": 10}); + assert!(traverse_json_value(&value, &[" count"]).is_none()); + } + + /// Edge case: null values in the path + #[test] + fn null_value_in_path_returns_none() { + let value = json!({"pagination": null}); + assert!(traverse_json_value(&value, &["pagination", "first"]).is_none()); + } + + /// Edge case: array in the path (not supported for simple traversal) + #[test] + fn array_value_in_path_returns_none() { + let value = json!({"items": [{"first": 5}]}); + assert!(traverse_json_value(&value, &["items", "first"]).is_none()); + } + } } diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql index 08219f8fb4..0346104acc 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql @@ -150,8 +150,49 @@ type Query assumedSize: 50 requireOneSlicingArgument: false ) + search(input: SearchInput!): [A] + @join__field(graph: SUBGRAPHWITHLISTSIZE) + @listSize( + slicingArguments: ["input.pagination.first"] + requireOneSlicingArgument: true + ) + searchWithAssumedSize(input: SearchInput): [A] + @join__field(graph: SUBGRAPHWITHLISTSIZE) + @listSize( + slicingArguments: ["input.pagination.first"] + assumedSize: 25 + requireOneSlicingArgument: false + ) + deeplyNested(input: DeeplyNestedInput!): [A] + @join__field(graph: SUBGRAPHWITHLISTSIZE) + @listSize( + slicingArguments: ["input.level1.level2.count"] + requireOneSlicingArgument: true + ) } type SizedField @join__type(graph: SUBGRAPHWITHLISTSIZE) { items: [A] } + +input PaginationInput @join__type(graph: SUBGRAPHWITHLISTSIZE) { + first: Int + after: String +} + +input SearchInput @join__type(graph: SUBGRAPHWITHLISTSIZE) { + pagination: PaginationInput + query: String +} + +input DeeplyNestedInput @join__type(graph: SUBGRAPHWITHLISTSIZE) { + level1: NestedLevel1Input +} + +input NestedLevel1Input @join__type(graph: SUBGRAPHWITHLISTSIZE) { + level2: NestedLevel2Input +} + +input NestedLevel2Input @join__type(graph: SUBGRAPHWITHLISTSIZE) { + count: Int +} diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs b/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs index 0bc4aec40e..3e217e36b0 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs @@ -1296,4 +1296,102 @@ mod tests { assert_eq!(estimated_cost(SCHEMA, query, variables), 50.0); } } + + /// Tests for nested input path resolution in @listSize slicingArguments + /// + /// Note: Expected costs include the cost of input objects (1 per nested object). + /// For inline objects: SearchInput (1) + PaginationInput (1) = 2 base cost + /// For variables: input objects are costed based on their nesting level + mod nested_input_path_tests { + use super::estimated_cost; + + const SCHEMA: &str = include_str!("./fixtures/custom_cost_schema.graphql"); + + // Input object costs: + // - SearchInput: 1 + // - PaginationInput: 1 + // Total input cost for search queries: 2 + + #[rstest::rstest] + #[case::inline_nested_first_10( + r#"query { search(input: {pagination: {first: 10}}) { id } }"#, + "{}", + 12.0 // 10 (list size) + 2 (input objects: SearchInput + PaginationInput) + )] + #[case::inline_nested_first_5( + r#"query { search(input: {pagination: {first: 5}, query: "test"}) { id } }"#, + "{}", + 7.0 // 5 (list size) + 2 (input objects) + )] + #[case::variable_nested_object( + r#"query Q($input: SearchInput!) { search(input: $input) { id } }"#, + r#"{"input": {"pagination": {"first": 7}, "query": "test"}}"#, + 9.0 // 7 (list size) + 2 (input objects) + )] + #[case::variable_nested_first_only( + r#"query Q($input: SearchInput!) { search(input: $input) { id } }"#, + r#"{"input": {"pagination": {"first": 3}}}"#, + 5.0 // 3 (list size) + 2 (input objects) + )] + fn nested_path_determines_list_size( + #[case] query: &str, + #[case] variables: &str, + #[case] expected_cost: f64, + ) { + assert_eq!(estimated_cost(SCHEMA, query, variables), expected_cost); + } + + // Input object costs for searchWithAssumedSize: + // - SearchInput: 1 + // - PaginationInput: 1 (if present) + // When path not found, falls back to assumedSize (25) + + #[rstest::rstest] + #[case::missing_nested_value( + r#"{"input": {"pagination": {}}}"#, + 27.0 // 25 (assumed size) + 2 (SearchInput + PaginationInput) + )] + #[case::missing_pagination( + r#"{"input": {}}"#, + 26.0 // 25 (assumed size) + 1 (SearchInput only) + )] + #[case::null_input( + r#"{"input": null}"#, + 25.0 // 25 (assumed size) + 0 (null is not scored) + )] + fn missing_nested_path_falls_back_to_assumed_size( + #[case] variables: &str, + #[case] expected_cost: f64, + ) { + let query = + r#"query Q($input: SearchInput) { searchWithAssumedSize(input: $input) { id } }"#; + assert_eq!(estimated_cost(SCHEMA, query, variables), expected_cost); + } + + // DeeplyNestedInput has 3 levels: DeeplyNestedInput(1) + NestedLevel1Input(1) + NestedLevel2Input(1) = 3 + + #[test] + fn deeply_nested_path_inline() { + let query = r#"query { deeplyNested(input: {level1: {level2: {count: 15}}}) { id } }"#; + // 15 (list size) + 3 (input objects: DeeplyNestedInput + NestedLevel1Input + NestedLevel2Input) + assert_eq!(estimated_cost(SCHEMA, query, "{}"), 18.0); + } + + #[test] + fn deeply_nested_path_variable() { + let query = + r#"query Q($input: DeeplyNestedInput!) { deeplyNested(input: $input) { id } }"#; + let variables = r#"{"input": {"level1": {"level2": {"count": 12}}}}"#; + // 12 (list size) + 3 (input objects) + assert_eq!(estimated_cost(SCHEMA, query, variables), 15.0); + } + + #[test] + fn inline_nested_object_with_other_fields() { + // Ensure other fields in the nested object don't affect the size resolution + let query = r#"query { search(input: {pagination: {first: 8, after: "cursor"}, query: "search term"}) { id } }"#; + // 8 (list size) + 2 (input objects: SearchInput + PaginationInput) + assert_eq!(estimated_cost(SCHEMA, query, "{}"), 10.0); + } + } } From 2861eaf4e773d93b18b44c8bde24378c7e81b988 Mon Sep 17 00:00:00 2001 From: Chris Morris Date: Wed, 11 Feb 2026 08:57:17 -0800 Subject: [PATCH 3/4] Support nested sizeFields for calculating cost (#8862) --- .../cost_calculator/directives.rs | 191 +++++++++++++++++- .../fixtures/custom_cost_schema.graphql | 33 +++ .../demand_control/cost_calculator/schema.rs | 20 ++ .../cost_calculator/static_cost.rs | 164 +++++++++++++-- 4 files changed, 381 insertions(+), 27 deletions(-) diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs b/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs index d06af93091..19ddf81852 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs @@ -1,10 +1,13 @@ +use std::sync::Arc; + use ahash::HashMap; -use ahash::HashSet; +use ahash::HashMapExt; use apollo_compiler::Schema; use apollo_compiler::ast::FieldDefinition; use apollo_compiler::ast::NamedType; use apollo_compiler::ast::Value as AstValue; use apollo_compiler::executable::Field; +use apollo_compiler::executable::Selection; use apollo_compiler::executable::SelectionSet; use apollo_compiler::parser::Parser; use apollo_compiler::validation::Valid; @@ -136,16 +139,176 @@ impl IncludeDirective { } } -pub(in crate::plugins::demand_control) struct ListSizeDirective<'schema> { +#[derive(Clone, Debug)] +pub(in crate::plugins::demand_control) struct SizedFields { + /// Field names we treat as the list (apply size to) at this level. + list_field_names: IndexSet, + /// Precomputed nested SizedFields per field name. Built once at schema load; descend() is a lookup. + descend_map: HashMap>, +} + +impl SizedFields { + /// Validates one path: at every level, at most one leaf (field with no sub-selections). + fn validate_one_leaf_per_path( + selection_set: &SelectionSet, + field_set_str: &str, + ) -> Result<(), DemandControlError> { + let leaf_count = selection_set + .selections + .iter() + .filter(|s| matches!(s, Selection::Field(f) if f.selection_set.selections.is_empty())) + .count(); + if leaf_count > 1 { + return Err(DemandControlError::QueryParseFailure(format!( + "sizedFields entry '{}' must specify at most one list field per path (found {}).", + field_set_str, leaf_count + ))); + } + for s in &selection_set.selections { + if let Selection::Field(f) = s + && !f.selection_set.selections.is_empty() + { + Self::validate_one_leaf_per_path(&f.selection_set, field_set_str)?; + } + } + Ok(()) + } + + pub(in crate::plugins::demand_control) fn from_strings( + schema: &Valid, + return_type: &NamedType, + field_names: &IndexSet, + ) -> Result { + let selections: Vec = field_names + .iter() + .map(|field_set_str| { + let parsed = Parser::new() + .parse_field_set(schema, return_type.clone(), field_set_str, "") + .map_err(|e| { + DemandControlError::QueryParseFailure(format!( + "Failed to parse sizedFields entry '{}': {}", + field_set_str, e + )) + })?; + let selection_set = parsed.selection_set.clone(); + Self::validate_one_leaf_per_path(&selection_set, field_set_str)?; + Ok(selection_set) + }) + .collect::, DemandControlError>>()?; + + let raw_descend = Self::build_descend_map_raw(&selections); + let list_field_names = Self::list_field_names_from_selections(&selections, &raw_descend); + let descend_map = Self::build_nested_sized_fields(raw_descend); + Ok(SizedFields { + list_field_names, + descend_map, + }) + } + + /// Build list_field_names from selections, excluding any name that is also a container. + fn list_field_names_from_selections( + selection_sets: &[SelectionSet], + raw_descend: &HashMap>, + ) -> IndexSet { + let leaf_field_names = Self::collect_leaf_names(selection_sets); + leaf_field_names + .iter() + .filter(|name| !raw_descend.contains_key(name.as_str())) + .cloned() + .collect() + } + + /// Recursively build SizedFields for each nested level so descend() is a lookup at request time. + fn build_nested_sized_fields( + raw_descend: HashMap>, + ) -> HashMap> { + raw_descend + .into_iter() + .filter_map(|(name, nested_selections)| { + if nested_selections.is_empty() { + return None; + } + let nested_raw = Self::build_descend_map_raw(&nested_selections); + let list_field_names = + Self::list_field_names_from_selections(&nested_selections, &nested_raw); + let descend_map = Self::build_nested_sized_fields(nested_raw); + Some(( + name, + Arc::new(SizedFields { + list_field_names, + descend_map, + }), + )) + }) + .collect() + } + + fn collect_leaf_names(selection_sets: &[SelectionSet]) -> IndexSet { + let mut names = IndexSet::new(); + for selection_set in selection_sets { + Self::collect_leaf_names_from_set(selection_set, &mut names); + } + names + } + + fn collect_leaf_names_from_set(selection_set: &SelectionSet, out: &mut IndexSet) { + for s in &selection_set.selections { + if let Selection::Field(f) = s { + if f.selection_set.selections.is_empty() { + out.insert(f.name.as_str().to_string()); + } else { + Self::collect_leaf_names_from_set(&f.selection_set, out); + } + } + } + } + + /// Shallow pass: field name -> nested selection sets (one level only). + fn build_descend_map_raw( + selection_sets: &[SelectionSet], + ) -> HashMap> { + let mut map = HashMap::new(); + for selection_set in selection_sets { + for s in &selection_set.selections { + if let Selection::Field(f) = s + && !f.selection_set.selections.is_empty() + { + map.entry(f.name.as_str().to_string()) + .or_insert_with(Vec::new) + .push(f.selection_set.clone()); + } + } + } + map + } + + /// True only if this field name is a leaf in our paths and not also a container at this level. + pub(in crate::plugins::demand_control) fn is_leaf(&self, field_name: &str) -> bool { + self.list_field_names.contains(field_name) + } + + /// Returns nested SizedFields for the given field (for descending into "results { page }"). + pub(in crate::plugins::demand_control) fn descend( + &self, + field_name: &str, + ) -> Option> { + self.descend_map.get(field_name).cloned() + } +} + +#[derive(Clone, Debug)] +pub(in crate::plugins::demand_control) struct ListSizeDirective { pub(in crate::plugins::demand_control) expected_size: Option, - pub(in crate::plugins::demand_control) sized_fields: Option>, + pub(in crate::plugins::demand_control) sized_fields: Option>, } -impl<'schema> ListSizeDirective<'schema> { +impl ListSizeDirective { + /// Build a ListSizeDirective at request time using pre-parsed sizedFields from schema load. pub(in crate::plugins::demand_control) fn new( - parsed: &'schema ParsedListSizeDirective, + parsed: &ParsedListSizeDirective, field: &Field, variables: &Object, + pre_parsed_sized_fields: Option>, ) -> Result { let expected_size = match parsed.slicing_argument_names.as_ref() { Some(slicing_argument_names) => { @@ -165,24 +328,30 @@ impl<'schema> ListSizeDirective<'schema> { Ok(Self { expected_size, - sized_fields: parsed - .sized_fields - .as_ref() - .map(|set| set.iter().map(|s| s.as_str()).collect()), + sized_fields: pre_parsed_sized_fields, }) } pub(in crate::plugins::demand_control) fn size_of(&self, field: &Field) -> Option { if self .sized_fields - .as_ref() - .is_some_and(|sf| sf.contains(field.name.as_str())) + .as_deref() + .is_some_and(|sf| sf.is_leaf(field.name.as_str())) { self.expected_size } else { None } } + + /// Returns a directive scoped to the given nested field (e.g. from `results { page }` to the selection under `results`). + pub(in crate::plugins::demand_control) fn descend(&self, field_name: &str) -> Option { + let nested = self.sized_fields.as_ref()?.descend(field_name)?; + Some(ListSizeDirective { + expected_size: self.expected_size, + sized_fields: Some(nested), + }) + } } pub(in crate::plugins::demand_control) struct RequiresDirective { diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql index 0346104acc..6480c84e13 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql @@ -169,6 +169,39 @@ type Query slicingArguments: ["input.level1.level2.count"] requireOneSlicingArgument: true ) + containerWithNestedList(first: Int): ResultContainer! + @join__field(graph: SUBGRAPHWITHLISTSIZE) + @listSize( + slicingArguments: ["first"] + sizedFields: ["page"] + requireOneSlicingArgument: false + ) + deepContainerWithNestedList(first: Int = 10): DeepContainer! + @join__field(graph: SUBGRAPHWITHLISTSIZE) + @listSize( + slicingArguments: ["first"] + sizedFields: ["results { page }"] + requireOneSlicingArgument: false + ) + deepContainerWithMixedSizedFields(first: Int = 10): DeepContainer! + @join__field(graph: SUBGRAPHWITHLISTSIZE) + @listSize( + slicingArguments: ["first"] + sizedFields: ["page", "results { page }"] + requireOneSlicingArgument: false + ) +} + +type ResultContainer @join__type(graph: SUBGRAPHWITHLISTSIZE) { + page: [A] + metadata: String +} + +type DeepContainer @join__type(graph: SUBGRAPHWITHLISTSIZE) { + page: [A] + results: ResultContainer + total: Int + metadata: String } type SizedField @join__type(graph: SUBGRAPHWITHLISTSIZE) { diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/schema.rs b/apollo-router/src/plugins/demand_control/cost_calculator/schema.rs index eb53b7c473..fb6551dacb 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/schema.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/schema.rs @@ -15,6 +15,7 @@ use apollo_federation::schema::ValidFederationSchema; use crate::plugins::demand_control::DemandControlError; use crate::plugins::demand_control::cost_calculator::directives::RequiresDirective; +use crate::plugins::demand_control::cost_calculator::directives::SizedFields; pub(in crate::plugins::demand_control) struct InputDefinition { name: Name, @@ -67,6 +68,8 @@ pub(in crate::plugins::demand_control) struct FieldDefinition { ty: ExtendedType, cost_directive: Option, list_size_directive: Option, + /// Parsed at schema load so invalid sizedFields fail startup, not first request. + parsed_sized_fields: Option>, requires_directive: Option, arguments: HashMap, } @@ -91,6 +94,7 @@ impl FieldDefinition { ty: field_type.clone(), cost_directive: None, list_size_directive: None, + parsed_sized_fields: None, requires_directive: None, arguments: HashMap::new(), }; @@ -102,6 +106,16 @@ impl FieldDefinition { schema, field_definition, )?; + if let Some(ref list_size) = processed_field_definition.list_size_directive + && let Some(ref field_names) = list_size.sized_fields + { + processed_field_definition.parsed_sized_fields = + Some(Arc::new(SizedFields::from_strings( + schema.schema(), + field_definition.ty.inner_named_type(), + field_names, + )?)); + } processed_field_definition.requires_directive = RequiresDirective::from_field_definition( field_definition, parent_type_name, @@ -132,6 +146,12 @@ impl FieldDefinition { self.list_size_directive.as_ref() } + pub(in crate::plugins::demand_control) fn parsed_sized_fields( + &self, + ) -> Option<&Arc> { + self.parsed_sized_fields.as_ref() + } + pub(in crate::plugins::demand_control) fn requires_directive( &self, ) -> Option<&RequiresDirective> { diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs b/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs index 3e217e36b0..6cfa7ed89b 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs @@ -190,6 +190,7 @@ impl StaticCostCalculator { field: &Field, parent_type: &NamedType, list_size_from_upstream: Option, + inherited_list_size: Option, subgraph: &str, ) -> Result { // When we pre-process the schema, __typename isn't included. So, we short-circuit here to avoid failed lookups. @@ -209,16 +210,27 @@ impl StaticCostCalculator { field.name )) })?; - let list_size_directive = match definition.list_size_directive() { - Some(dir) => ListSizeDirective::new(dir, field, ctx.variables).map(Some), - None => Ok(None), - }?; + let own_list_size_directive = match definition.list_size_directive() { + Some(dir) => Some(ListSizeDirective::new( + dir, + field, + ctx.variables, + definition.parsed_sized_fields().map(Arc::clone), + )?), + None => None, + }; + let instance_count = if !field.ty().is_list() { 1 } else if let Some(value) = list_size_from_upstream { // This is a sized field whose length is defined by the `@listSize` directive on the parent field value - } else if let Some(expected_size) = list_size_directive + } else if let Some(expected_size) = own_list_size_directive + .as_ref() + .and_then(|dir| dir.expected_size) + { + expected_size + } else if let Some(expected_size) = inherited_list_size .as_ref() .and_then(|dir| dir.expected_size) { @@ -245,7 +257,8 @@ impl StaticCostCalculator { ctx, &field.selection_set, field.ty().inner_named_type(), - list_size_directive.as_ref(), + own_list_size_directive.as_ref(), + inherited_list_size, subgraph, )?; @@ -277,7 +290,8 @@ impl StaticCostCalculator { ctx, selection_set, parent_type, - list_size_directive.as_ref(), + own_list_size_directive.as_ref(), + None, subgraph, )?; } @@ -302,6 +316,7 @@ impl StaticCostCalculator { ctx: &ScoringContext, fragment_spread: &FragmentSpread, list_size_directive: Option<&ListSizeDirective>, + inherited_list_size: Option<&ListSizeDirective>, subgraph: &str, ) -> Result { let fragment = fragment_spread.fragment_def(ctx.query).ok_or_else(|| { @@ -315,6 +330,7 @@ impl StaticCostCalculator { &fragment.selection_set, fragment.type_condition(), list_size_directive, + inherited_list_size.cloned(), subgraph, ) } @@ -325,6 +341,7 @@ impl StaticCostCalculator { inline_fragment: &InlineFragment, parent_type: &NamedType, list_size_directive: Option<&ListSizeDirective>, + inherited_list_size: Option<&ListSizeDirective>, subgraph: &str, ) -> Result { self.score_selection_set( @@ -335,6 +352,7 @@ impl StaticCostCalculator { .as_ref() .unwrap_or(parent_type), list_size_directive, + inherited_list_size.cloned(), subgraph, ) } @@ -359,6 +377,7 @@ impl StaticCostCalculator { &operation.selection_set, root_type_name, None, + None, subgraph, )?; @@ -371,22 +390,47 @@ impl StaticCostCalculator { selection: &Selection, parent_type: &NamedType, list_size_directive: Option<&ListSizeDirective>, + inherited_list_size: Option<&ListSizeDirective>, subgraph: &str, ) -> Result { match selection { - Selection::Field(f) => self.score_field( + Selection::Field(f) => { + // We need two things for scoring this field: (1) the list size to use for + // instance_count if this field is a list (list_size_from_upstream), and (2) the + // directive to pass to this field's selection set so nested lists (e.g. "page" under + // "results") get the right sizing (descended). + let size_from_parent = list_size_directive.and_then(|dir| dir.size_of(f)); + let size_from_inherited = inherited_list_size.and_then(|dir| dir.size_of(f)); + let list_size_from_upstream = size_from_parent.or(size_from_inherited); + + let descended = list_size_directive + .and_then(|dir| dir.descend(f.name.as_str())) + .or_else(|| inherited_list_size.and_then(|dir| dir.descend(f.name.as_str()))); + + self.score_field( + ctx, + f, + parent_type, + list_size_from_upstream, + descended, + subgraph, + ) + } + Selection::FragmentSpread(s) => self.score_fragment_spread( + ctx, + s, + list_size_directive, + inherited_list_size, + subgraph, + ), + Selection::InlineFragment(i) => self.score_inline_fragment( ctx, - f, + i, parent_type, - list_size_directive.and_then(|dir| dir.size_of(f)), + list_size_directive, + inherited_list_size, subgraph, ), - Selection::FragmentSpread(s) => { - self.score_fragment_spread(ctx, s, list_size_directive, subgraph) - } - Selection::InlineFragment(i) => { - self.score_inline_fragment(ctx, i, parent_type, list_size_directive, subgraph) - } } } @@ -396,6 +440,7 @@ impl StaticCostCalculator { selection_set: &SelectionSet, parent_type_name: &NamedType, list_size_directive: Option<&ListSizeDirective>, + inherited_list_size: Option, subgraph: &str, ) -> Result { let mut cost = 0.0; @@ -405,6 +450,7 @@ impl StaticCostCalculator { selection, parent_type_name, list_size_directive, + inherited_list_size.as_ref(), subgraph, )?; } @@ -1394,4 +1440,90 @@ mod tests { assert_eq!(estimated_cost(SCHEMA, query, "{}"), 10.0); } } + + /// Nested sizedFields in @listSize (e.g. "results { page }") + mod nested_sized_fields_tests { + use super::estimated_cost; + + const SCHEMA: &str = include_str!("./fixtures/custom_cost_schema.graphql"); + + #[rstest::rstest] + #[case::simple_sized_fields_on_nested_type( + r#"query { containerWithNestedList(first: 5) { page { id } metadata } }"#, + "{}", + 6.0 // ResultContainer: 1, page: 5 * 1 = 5, metadata: 0 + )] + #[case::nested_sized_fields_two_levels( + r#"query { deepContainerWithNestedList(first: 7) { results { page { id } } } }"#, + "{}", + 9.0 // DeepContainer: 1, results: 1, page: 7 * 1 = 7 + )] + #[case::nested_sized_fields_with_variable( + r#"query Q($n: Int!) { deepContainerWithNestedList(first: $n) { results { page { id } } } }"#, + r#"{"n": 3}"#, + 5.0 + )] + #[case::nested_sized_fields_with_default_value( + r#"query { deepContainerWithNestedList { results { page { id } } } }"#, + "{}", + 12.0 // default first: 10 + )] + #[case::nested_sized_fields_not_selected( + r#"query { deepContainerWithNestedList(first: 100) { total } }"#, + "{}", + 1.0 + )] + #[case::intermediate_container_without_sized_field( + r#"query { deepContainerWithNestedList(first: 100) { results { metadata } } }"#, + "{}", + 2.0 + )] + #[case::mixed_sized_fields_single_and_nested( + r#"query { + deepContainerWithMixedSizedFields(first: 5) { + page { id } + results { page { id } } + } + }"#, + "{}", + 12.0 // DeepContainer: 1, page: 5 * 1 = 5, results: 1, page: 5 * 1 = 5 + )] + fn nested_sized_fields_cases( + #[case] query: &str, + #[case] variables: &str, + #[case] expected_cost: f64, + ) { + assert_eq!(estimated_cost(SCHEMA, query, variables), expected_cost); + } + + /// Schema load fails when a sizedFields path has more than one leaf (one-leaf-per-path rule). + #[test] + fn multiple_leaves_in_one_path_fails_at_schema_load() { + use std::sync::Arc; + + use crate::plugins::demand_control::cost_calculator::schema::DemandControlledSchema; + use crate::spec; + + // Schema with sizedFields: ["results { page metadata }"] - two leaves in one path + let schema_str = include_str!("./fixtures/custom_cost_schema.graphql").replace( + r#"sizedFields: ["results { page }"]"#, + r#"sizedFields: ["results { page metadata }"]"#, + ); + // ResultContainer has page: [A] and metadata: String. So "results { page metadata }" + // has two top-level selections with no sub-selections (page is a leaf, metadata is a leaf). + let schema = spec::Schema::parse(&schema_str, &Default::default()).unwrap(); + let result = DemandControlledSchema::new(Arc::new(schema.supergraph_schema().clone())); + + match &result { + Err(e) => assert!( + e.to_string().contains("at most one list field per path"), + "expected error about one list field per path, got: {}", + e + ), + Ok(_) => { + panic!("expected schema load to fail for multiple list fields in one path") + } + } + } + } } From d7573b630d603f566cb98c0c44c90801352f2322 Mon Sep 17 00:00:00 2001 From: Chris Morris Date: Tue, 17 Feb 2026 07:57:30 -0800 Subject: [PATCH 4/4] add changeset for list size improvements --- .../feat_list_size_parsing_and_nested_paths.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changesets/feat_list_size_parsing_and_nested_paths.md diff --git a/.changesets/feat_list_size_parsing_and_nested_paths.md b/.changesets/feat_list_size_parsing_and_nested_paths.md new file mode 100644 index 0000000000..6ab28e0c51 --- /dev/null +++ b/.changesets/feat_list_size_parsing_and_nested_paths.md @@ -0,0 +1,11 @@ +### Improve `@listSize` directive parsing and nested path support + +Demand control cost calculation now supports: + +- **Array parsing for `@listSize`:** List-size directives can use array-style parsing for sizing (e.g. list arguments). +- **Nested input paths:** Nested input paths are supported when resolving list size from query arguments. +- **Nested `sizedFields`:** The `sizedFields` argument on `@listSize` supports nested field paths for more accurate cost estimation. + +These changes are backward compatible with existing schemas and directives. + +By [@cmorris](https://github.com/cmorris) in https://github.com/apollographql/router/pull/8893