From 8084a2d2831f7261e2a5f2eb11d1bef661100b8b Mon Sep 17 00:00:00 2001 From: Emily Goodwin Date: Mon, 28 Jul 2025 08:49:47 -0400 Subject: [PATCH 1/4] feat: Advanced breaking change detection for inputs and arguments --- .changeset/breezy-forks-press.md | 5 + packages/libraries/router/src/graphql.rs | 764 ++++++++++++++++++++--- 2 files changed, 671 insertions(+), 98 deletions(-) create mode 100644 .changeset/breezy-forks-press.md diff --git a/.changeset/breezy-forks-press.md b/.changeset/breezy-forks-press.md new file mode 100644 index 0000000000..1744d44f14 --- /dev/null +++ b/.changeset/breezy-forks-press.md @@ -0,0 +1,5 @@ +--- +'hive-apollo-router-plugin': major +--- + +Advanced breaking change detection for inputs and arguments diff --git a/packages/libraries/router/src/graphql.rs b/packages/libraries/router/src/graphql.rs index 84b6d0e6c4..def7483d62 100644 --- a/packages/libraries/router/src/graphql.rs +++ b/packages/libraries/router/src/graphql.rs @@ -1,5 +1,6 @@ use anyhow::anyhow; use anyhow::Error; +use graphql_parser::schema::InputObjectType; use graphql_tools::ast::ext::SchemaDocumentExtension; use graphql_tools::ast::FieldByNameExtension; use graphql_tools::ast::TypeDefinitionExtension; @@ -25,8 +26,12 @@ use graphql_tools::ast::{ struct SchemaCoordinatesContext { pub schema_coordinates: HashSet, - pub input_types_to_collect: HashSet, - error: Option, + pub used_input_fields: HashSet, + pub input_values_provided: HashMap, + pub used_variables: HashSet, + pub non_null_variables: HashSet, + pub variables_with_defaults: HashSet, + error: Option } impl SchemaCoordinatesContext { @@ -41,29 +46,37 @@ pub fn collect_schema_coordinates( ) -> Result, Error> { let mut ctx = SchemaCoordinatesContext { schema_coordinates: HashSet::new(), - input_types_to_collect: HashSet::new(), + used_input_fields: HashSet::new(), + input_values_provided: HashMap::new(), + used_variables: HashSet::new(), + non_null_variables: HashSet::new(), + variables_with_defaults : HashSet::new(), error: None, }; let mut visit_context = OperationVisitorContext::new(document, schema); let mut visitor = SchemaCoordinatesVisitor {}; visit_document(&mut visitor, document, &mut visit_context, &mut ctx); - + if let Some(error) = ctx.error { Err(error) - } else { - for input_type_name in ctx.input_types_to_collect { - let named_type = schema.type_by_name(&input_type_name); - - match named_type { - Some(named_type) => match named_type { + } else { + for type_name in ctx.used_input_fields { + if is_builtin_scalar(&type_name) { + ctx.schema_coordinates.insert(type_name); + } else if let Some(type_def) = schema.type_by_name(&type_name) { + match type_def { + TypeDefinition::Scalar(scalar_def) => { + // Always collect custom scalars when referenced in variables + ctx.schema_coordinates.insert(scalar_def.name.clone()); + } TypeDefinition::InputObject(input_type) => { - for field in &input_type.fields { - ctx.schema_coordinates - .insert(format!("{}.{}", input_type_name, field.name)); - } + // Collect all fieldcollect_input_object_fieldss of input objects referenced in variable definitions + // and recursively process field types + collect_input_object_fields(schema, input_type, &mut ctx.schema_coordinates); } TypeDefinition::Enum(enum_type) => { + // Collect all values of enums referenced in variable definitions for value in &enum_type.values { ctx.schema_coordinates.insert(format!( "{}.{}", @@ -73,9 +86,6 @@ pub fn collect_schema_coordinates( } } _ => {} - }, - None => { - ctx.schema_coordinates.insert(input_type_name); } } } @@ -84,9 +94,113 @@ pub fn collect_schema_coordinates( } } +fn collect_input_object_fields( + schema: &SchemaDocument<'static, String>, + input_type: &InputObjectType<'static, String>, + coordinates: &mut HashSet, +) { + for field in &input_type.fields { + let field_coordinate = format!("{}.{}", input_type.name, field.name); + coordinates.insert(field_coordinate); + + let field_type_name = field.value_type.inner_type(); + + // Process the field's type but DON'T add the type name itself for enums/input objects + if let Some(field_type_def) = schema.type_by_name(field_type_name) { + match field_type_def { + TypeDefinition::Scalar(scalar_def) => { + // Only collect scalar types + coordinates.insert(scalar_def.name.clone()); + } + TypeDefinition::InputObject(nested_input_type) => { + // Recursively collect nested input object fields (but not the type name) + collect_input_object_fields(schema, nested_input_type, coordinates); + } + TypeDefinition::Enum(enum_type) => { + // Collect enum values (but not the enum type name) + for value in &enum_type.values { + coordinates.insert(format!("{}.{}", enum_type.name, value.name)); + } + } + _ => {} + } + } else if is_builtin_scalar(field_type_name) { + // Handle built-in scalars + coordinates.insert(field_type_name.to_string()); + } + } +} + +fn is_builtin_scalar(type_name: &str) -> bool { + matches!(type_name, "String" | "Int" | "Float" | "Boolean" | "ID") +} + +fn is_non_null_type(t: &Type) -> bool { + matches!(t, Type::NonNullType(_)) +} + +fn mark_as_used(ctx: &mut SchemaCoordinatesContext, id: &str) { + if let Some(count) = ctx.input_values_provided.get_mut(id) { + if *count > 0 { + *count -= 1; + ctx.schema_coordinates.insert(format!("{}!", id)); + } + } + ctx.schema_coordinates.insert(id.to_string()); +} + +fn count_input_value_provided(ctx: &mut SchemaCoordinatesContext, id: &str) { + let counter = ctx.input_values_provided.entry(id.to_string()).or_insert(0); + *counter += 1; +} + +fn value_exists(v: &Value) -> bool { + !matches!(v, Value::Null) +} + struct SchemaCoordinatesVisitor {} impl SchemaCoordinatesVisitor { + fn process_default_value( + &self, + info: &OperationVisitorContext, + ctx: &mut SchemaCoordinatesContext, + type_name: &str, + value: &Value, + ) { + match value { + Value::Object(obj) => { + if let Some(TypeDefinition::InputObject(input_obj)) = info.schema.type_by_name(type_name) { + for (field_name, field_value) in obj { + if let Some(field_def) = input_obj.fields.iter().find(|f| &f.name == field_name) { + let coordinate = format!("{}.{}", type_name, field_name); + + // Since a value is provided in the default, mark it with ! + ctx.schema_coordinates.insert(format!("{}!", coordinate)); + ctx.schema_coordinates.insert(coordinate); + + // Recursively process nested objects + let field_type_name = self.resolve_type_name(field_def.value_type.clone()); + self.process_default_value(info, ctx, &field_type_name, field_value); + } + } + } + } + Value::List(values) => { + for val in values { + self.process_default_value(info, ctx, type_name, val); + } + } + Value::Enum(enum_value) => { + let enum_coordinate = format!("{}.{}", type_name, enum_value); + ctx.schema_coordinates.insert(enum_coordinate); + } + _ => { + // For scalar values, the type is already collected in variable definition + } + } + } + fn resolve_type_name(&self, t: Type) -> String { match t { Type::NamedType(value) => value, @@ -126,10 +240,54 @@ impl SchemaCoordinatesVisitor { } } } + + fn collect_nested_input_coordinates( + &self, + schema: &SchemaDocument<'static, String>, + input_type: &InputObjectType<'static, String>, + ctx: &mut SchemaCoordinatesContext, + ) { + for field in &input_type.fields { + let field_coordinate = format!("{}.{}", input_type.name, field.name); + ctx.schema_coordinates.insert(field_coordinate); + + let field_type_name = field.value_type.inner_type(); + + if let Some(field_type_def) = schema.type_by_name(field_type_name) { + match field_type_def { + TypeDefinition::Scalar(scalar_def) => { + ctx.schema_coordinates.insert(scalar_def.name.clone()); + } + TypeDefinition::InputObject(nested_input_type) => { + // Recursively collect nested input object fields + self.collect_nested_input_coordinates(schema, nested_input_type, ctx); + } + TypeDefinition::Enum(enum_type) => { + // Collect enum values + for value in &enum_type.values { + ctx.schema_coordinates.insert(format!("{}.{}", enum_type.name, value.name)); + } + } + _ => {} + } + } else if is_builtin_scalar(field_type_name) { + ctx.schema_coordinates.insert(field_type_name.to_string()); + } + } + } } impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVisitor { - fn enter_field( + fn enter_variable_value( + &mut self, + info: &mut OperationVisitorContext<'a>, + ctx: &mut SchemaCoordinatesContext, + name: &str, + ) { + ctx.used_variables.insert(name.to_string()); + } + + fn enter_field( &mut self, info: &mut OperationVisitorContext<'a>, ctx: &mut SchemaCoordinatesContext, @@ -147,8 +305,8 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis ctx.schema_coordinates .insert(format!("{}.{}", parent_name, field_name)); + // If field's return type is an enum, collect all possible values if let Some(field_def) = parent_type.field_by_name(&field_name) { - // if field's type is an enum, we need to collect all possible values let field_output_type = info.schema.type_by_name(field_def.field_type.inner_type()); if let Some(TypeDefinition::Enum(enum_type)) = field_output_type { for value in &enum_type.values { @@ -168,7 +326,7 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis } } - fn enter_variable_definition( + fn enter_variable_definition( &mut self, info: &mut OperationVisitorContext<'a>, ctx: &mut SchemaCoordinatesContext, @@ -178,25 +336,30 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis return; } - let type_name = self.resolve_type_name(var.var_type.clone()); - let type_def = info.schema.type_by_name(&type_name); + if (is_non_null_type(&var.var_type) && var.default_value.is_some()) || + (var.default_value.is_some() && !matches!(var.default_value.as_ref().unwrap(), Value::Null)) { + ctx.non_null_variables.insert(var.name.clone()); + } - if let Some(TypeDefinition::Scalar(scalar_def)) = type_def { - ctx.schema_coordinates - .insert(scalar_def.name.as_str().to_string()); - return; + if var.default_value.is_some() { + ctx.variables_with_defaults.insert(var.name.clone()); } + let type_name = self.resolve_type_name(var.var_type.clone()); + + // Always collect the variable's type for reference resolution if let Some(inner_types) = self.resolve_references(info.schema, &type_name) { for inner_type in inner_types { - ctx.input_types_to_collect.insert(inner_type); + ctx.used_input_fields.insert(inner_type); } } + ctx.used_input_fields.insert(type_name.clone()); - ctx.input_types_to_collect.insert(type_name); + if let Some(default_value) = &var.default_value { + self.process_default_value(info, ctx, &type_name, default_value); + } } - - fn enter_argument( + fn enter_argument( &mut self, info: &mut OperationVisitorContext<'a>, ctx: &mut SchemaCoordinatesContext, @@ -214,60 +377,68 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis return; } - let type_name = info.current_parent_type().unwrap().name(); - + let parent_type = info.current_parent_type().unwrap(); + let type_name = parent_type.name(); let field = info.current_field(); if let Some(field) = field { let field_name = field.name.clone(); - let arg_name = arg.0.clone(); - - ctx.schema_coordinates - .insert(format!("{type_name}.{field_name}.{arg_name}").to_string()); - - let arg_value = arg.1.clone(); + let (arg_name, arg_value) = arg; + + let coordinate = format!("{type_name}.{field_name}.{arg_name}"); + + let has_value = match arg_value { + Value::Null => false, + Value::Variable(var_name) => { + ctx.variables_with_defaults.contains(var_name) + } + _ => true, + }; + + if has_value { + count_input_value_provided(ctx, &coordinate); + } + mark_as_used(ctx, &coordinate); - if let Some(input_type) = info.current_input_type() { - match input_type { - TypeDefinition::Scalar(scalar_def) => { - ctx.schema_coordinates.insert(scalar_def.name.clone()); - } - _ => { - let input_type_name = input_type.name(); - match arg_value { - Value::Enum(value) => { - let value_str = value.to_string(); - ctx.schema_coordinates - .insert(format!("{input_type_name}.{value_str}").to_string()); - } - Value::List(_) => { - // handled by enter_list_value - } - Value::Object(_a) => { - // handled by enter_object_field + if let Some(field_def) = parent_type.field_by_name(&field_name) { + if let Some(arg_def) = field_def.arguments.iter().find(|a| &a.name == arg_name) { + let arg_type_name = self.resolve_type_name(arg_def.value_type.clone()); + + match arg_value { + Value::Enum(value) => { + let value_str: String = value.to_string(); + ctx.schema_coordinates + .insert(format!("{arg_type_name}.{value_str}").to_string()); + } + Value::List(_) => { + // handled by enter_list_value + } + Value::Object(_) => { + // CRITICAL FIX: Only collect scalar type if it's actually a custom scalar + // receiving an object value (like JSON) + if let Some(TypeDefinition::Scalar(_)) = info.schema.type_by_name(&arg_type_name) { + ctx.schema_coordinates.insert(arg_type_name.clone()); } - Value::Variable(_) => { - // handled by enter_variable_definition + // Otherwise handled by enter_object_value + } + Value::Variable(_) => { + // Variables are handled by enter_variable_definition + } + _ => { + // For literal scalar values, collect the scalar type + // But only for actual scalars, not enum/input types + if is_builtin_scalar(&arg_type_name) { + ctx.schema_coordinates.insert(arg_type_name.clone()); + } else if let Some(TypeDefinition::Scalar(_)) = info.schema.type_by_name(&arg_type_name) { + ctx.schema_coordinates.insert(arg_type_name.clone()); } - _ => {} } } } } } } - - fn enter_object_field( - &mut self, - info: &mut OperationVisitorContext<'a>, - ctx: &mut SchemaCoordinatesContext, - _object_field: &(String, graphql_tools::static_graphql::query::Value), - ) { - if let Some(TypeDefinition::Scalar(scalar_def)) = info.current_input_type() { - ctx.schema_coordinates.insert(scalar_def.name.clone()); - } - } - + fn enter_list_value( &mut self, info: &mut OperationVisitorContext<'a>, @@ -279,55 +450,78 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis } if let Some(input_type) = info.current_input_type() { + let coordinate = input_type.name().to_string(); for value in values { match value { + Value::Enum(value) => { + let value_str = value.to_string(); + ctx.schema_coordinates + .insert(format!("{}.{}", coordinate, value_str)); + } Value::Object(_) => { - // object fields are handled by enter_object_value + // handled by enter_object_value } Value::List(_) => { - // handled by enter_list_value + // handled by enter_list_value recursively } Value::Variable(_) => { // handled by enter_variable_definition } - Value::Enum(value) => { - let value_str = value.to_string(); - ctx.schema_coordinates - .insert(format!("{}.{}", input_type.name(), value_str).to_string()); - } _ => { - ctx.input_types_to_collect - .insert(input_type.name().to_string()); + // For scalar literals in lists, collect the scalar type + if is_builtin_scalar(&coordinate) { + ctx.schema_coordinates.insert(coordinate.clone()); + } else if let Some(TypeDefinition::Scalar(_)) = info.schema.type_by_name(&coordinate) { + ctx.schema_coordinates.insert(coordinate.clone()); + } } } } } } - fn enter_object_value( +fn enter_object_value( &mut self, info: &mut OperationVisitorContext<'a>, ctx: &mut SchemaCoordinatesContext, object_value: &BTreeMap, ) { if let Some(TypeDefinition::InputObject(input_object_def)) = info.current_input_type() { + // First, collect all fields that are explicitly provided in the object object_value.iter().for_each(|(name, value)| { if let Some(field) = input_object_def .fields .iter() .find(|field| field.name.eq(name)) { - ctx.schema_coordinates.insert(format!( - "{}.{}", - input_object_def.name.as_str(), - field.name.as_str() - )); + let coordinate = format!("{}.{}", input_object_def.name, field.name); + + let has_value = match value { + Value::Variable(var_name) => { + ctx.variables_with_defaults.contains(var_name) || + ctx.non_null_variables.contains(var_name) + } + _ => value_exists(value) + }; + + let should_mark_non_null = has_value && ( + is_non_null_type(&field.value_type) || + match value { + Value::Variable(var_name) => ctx.non_null_variables.contains(var_name), + _ => true + } + ); + + if should_mark_non_null { + ctx.schema_coordinates.insert(format!("{coordinate}!")); + } + + mark_as_used(ctx, &coordinate); let field_type_name = field.value_type.inner_type(); match value { Value::Enum(value) => { - // Collect only a specific enum value let value_str = value.to_string(); ctx.schema_coordinates .insert(format!("{field_type_name}.{value_str}").to_string()); @@ -336,14 +530,35 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis // handled by enter_list_value } Value::Object(_) => { - // handled by enter_object_value + // CRITICAL FIX: Only collect scalar type if it's a custom scalar receiving object + if let Some(TypeDefinition::Scalar(_)) = info.schema.type_by_name(&field_type_name) { + ctx.schema_coordinates.insert(field_type_name.to_string()); + } + // Otherwise handled by enter_object_value recursively } Value::Variable(_) => { - // handled by enter_variable_definition + // Variables handled by enter_variable_definition + // Only collect scalar types for variables, not enum/input types + if is_builtin_scalar(&field_type_name) { + ctx.schema_coordinates.insert(field_type_name.to_string()); + } else if let Some(TypeDefinition::Scalar(_)) = info.schema.type_by_name(&field_type_name) { + ctx.schema_coordinates.insert(field_type_name.to_string()); + } + } + Value::Null => { + // CRITICAL FIX: When a field has a null value, we should still collect + // all nested coordinates for input object types + if let Some(TypeDefinition::InputObject(nested_input_obj)) = info.schema.type_by_name(&field_type_name) { + self.collect_nested_input_coordinates(info.schema, nested_input_obj, ctx); + } } _ => { - ctx.input_types_to_collect - .insert(field_type_name.to_string()); + // For literal scalar values, only collect actual scalar types + if is_builtin_scalar(&field_type_name) { + ctx.schema_coordinates.insert(field_type_name.to_string()); + } else if let Some(TypeDefinition::Scalar(_)) = info.schema.type_by_name(&field_type_name) { + ctx.schema_coordinates.insert(field_type_name.to_string()); + } } } } @@ -860,6 +1075,7 @@ mod tests { "ProjectOrderByInput.direction", "OrderDirection.ASC", "OrderDirection.DESC", + "JSON" ] .into_iter() .map(|s| s.to_string()) @@ -907,6 +1123,7 @@ mod tests { "ProjectOrderByInput.direction", "OrderDirection.ASC", "OrderDirection.DESC", + "JSON" ] .into_iter() .map(|s| s.to_string()) @@ -938,12 +1155,14 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.and", + "Query.projects.and!", "Project.name", "PaginationInput.limit", "Int", "PaginationInput.offset", "FilterInput.pagination", "FilterInput.type", + "FilterInput.type!", "ProjectType.FEDERATION", ] .into_iter() @@ -976,6 +1195,7 @@ mod tests { let expected = vec![ "Query.projectsByTypes", "Query.projectsByTypes.types", + "Query.projectsByTypes.types!", "Project.name", "ProjectType.FEDERATION", "ProjectType.STITCHING", @@ -995,12 +1215,12 @@ mod tests { fn enums_and_scalars_input() { let schema = parse_schema::(SCHEMA_SDL).unwrap(); let document = parse_query::( - " - query getProjects($limit: Int!, $type: ProjectType!) { - projects(filter: { pagination: { limit: $limit }, type: $type }) { - id + " + query getProjects($limit: Int!, $type: ProjectType!) { + projects(filter: { pagination: { limit: $limit }, type: $type }) { + id + } } - } ", ) .unwrap(); @@ -1010,12 +1230,14 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "Int", "ProjectType.FEDERATION", "ProjectType.STITCHING", "ProjectType.SINGLE", "FilterInput.pagination", + "FilterInput.pagination!", "FilterInput.type", "PaginationInput.limit", ] @@ -1049,10 +1271,13 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "FilterInput.pagination", + "FilterInput.pagination!", "Int", "PaginationInput.limit", + "PaginationInput.limit!", ] .into_iter() .map(|s| s.to_string()) @@ -1084,10 +1309,13 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "Int", "FilterInput.pagination", + "FilterInput.pagination!", "FilterInput.type", + "FilterInput.type!", "PaginationInput.limit", "ProjectType.FEDERATION", ] @@ -1121,6 +1349,7 @@ mod tests { let expected = vec![ "Query.projectsByTypes", "Query.projectsByTypes.types", + "Query.projectsByTypes.types!", "Project.id", "ProjectType.FEDERATION", ] @@ -1189,6 +1418,7 @@ mod tests { let expected = vec![ "Query.projectsByType", "Query.projectsByType.type", + "Query.projectsByType.type!", "Project.id", "ProjectType.FEDERATION", ] @@ -1222,12 +1452,14 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "Int", "ProjectType.FEDERATION", "ProjectType.STITCHING", "ProjectType.SINGLE", "FilterInput.pagination", + "FilterInput.pagination!", "FilterInput.type", "PaginationInput.limit", ] @@ -1270,6 +1502,7 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "Project.name", "Int", @@ -1278,6 +1511,7 @@ mod tests { "ProjectType.SINGLE", "Boolean", "FilterInput.pagination", + "FilterInput.pagination!", "FilterInput.type", "PaginationInput.limit", ] @@ -1314,12 +1548,14 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "Int", "ProjectType.FEDERATION", "ProjectType.STITCHING", "ProjectType.SINGLE", "FilterInput.pagination", + "FilterInput.pagination!", "FilterInput.type", "PaginationInput.limit", ] @@ -1353,6 +1589,7 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "PaginationInput.limit", "Int", @@ -1393,6 +1630,7 @@ mod tests { let expected = vec![ "Query.projectsByMetadata", "Query.projectsByMetadata.metadata", + "Query.projectsByMetadata.metadata!", "Project.name", "JSON", ] @@ -1459,6 +1697,7 @@ mod tests { let expected = vec![ "Query.projectsByMetadata", "Query.projectsByMetadata.metadata", + "Query.projectsByMetadata.metadata!", "Project.name", "JSON", ] @@ -1473,8 +1712,6 @@ mod tests { assert_eq!(missing.len(), 0, "Missing: {:?}", missing); } - // - #[test] fn custom_scalar_as_input_field_inlined() { let schema = parse_schema::(SCHEMA_SDL).unwrap(); @@ -1494,7 +1731,9 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "FilterInput.metadata", + "FilterInput.metadata!", "Project.name", "JSON", ] @@ -1528,6 +1767,7 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "FilterInput.metadata", "Project.name", "JSON", @@ -1562,7 +1802,9 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "FilterInput.metadata", + "FilterInput.metadata!", "Project.name", "JSON", ] @@ -1576,4 +1818,330 @@ mod tests { assert_eq!(extra.len(), 0, "Extra: {:?}", extra); assert_eq!(missing.len(), 0, "Missing: {:?}", missing); } + + #[test] + fn primitive_field_with_arg_schema_coor() { + let schema = parse_schema::("type Query { + hello(message: String): String + }").unwrap(); + let document = parse_query::( + " + query { + hello(message: \"world\") + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "Query.hello", + "Query.hello.message!", + "Query.hello.message", + "String", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn unused_variable_as_nullable_argument(){ + let schema = parse_schema::( + " + type Query { + random(a: String): String + } + ") + .unwrap(); + let document = parse_query::( + " + query Foo($a: String) { + random(a: $a) + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "Query.random", + "Query.random.a", + "String" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn unused_nullable_input_field(){ + let schema = parse_schema::( + " + type Query { + random(a: A): String + } + input A { + b: B + } + input B { + c: C + } + input C { + d: String + } + ") + .unwrap(); + let document = parse_query::( + " + query Foo { + random(a: { b: null }) + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "Query.random", + "Query.random.a", + "Query.random.a!", + "A.b", + "B.c", + "C.d", + "String" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn required_variable_as_input_field(){ + let schema = parse_schema::( + " + type Query { + random(a: A): String + } + input A { + b: String + } + ") + .unwrap(); + let document = parse_query::( + " + query Foo($b:String! = \"b\") { + random(a: { b: $b }) + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "Query.random", + "Query.random.a", + "Query.random.a!", + "A.b", + "A.b!", + "String" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn undefined_variable_as_input_field(){ + let schema = parse_schema::( + " + type Query { + random(a: A): String + } + input A { + b: String + } + ") + .unwrap(); + let document = parse_query::( + " + query Foo($b: String!) { + random(a: { b: $b }) + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "Query.random", + "Query.random.a", + "Query.random.a!", + "A.b", + "String" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn deeply_nested_variables(){ +let schema = parse_schema::( + " + type Query { + random(a: A): String + } + input A { + b: B + } + input B { + c: C + } + input C { + d: String + } + ") + .unwrap(); + let document = parse_query::( + " + query Random($a: A = { b: { c: { d: \"D\" } } }) { + random(a: $a) + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "Query.random", + "Query.random.a", + "Query.random.a!", + "A.b", + "A.b!", + "B.c", + "B.c!", + "C.d", + "C.d!", + "String", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } +#[test] + fn aliased_field() { + let schema = parse_schema::( + " + type Query { + random(a: String): String + } + input C { + d: String + } + ") + .unwrap(); + let document = parse_query::( + " + query Random($a: String= \"B\" ) { + foo: random(a: $a ) + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "Query.random", + "Query.random.a", + "Query.random.a!", + "String" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn multiple_fields_with_mixed_nullability(){ + let schema = parse_schema::( + " + type Query { + random(a: String): String + } + input C { + d: String + } + ") + .unwrap(); + let document = parse_query::( + " + query Random($a: String = null) { + nullable: random(a: $a) + nonnullable: random(a: \"B\") + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "Query.random", + "Query.random.a", + "Query.random.a!", + "String" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + } From 5b9b102d133cb0a557028a13e01726b283915684 Mon Sep 17 00:00:00 2001 From: Emily Goodwin Date: Mon, 28 Jul 2025 08:57:15 -0400 Subject: [PATCH 2/4] fix comments --- packages/libraries/router/src/graphql.rs | 31 +++++++++--------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/libraries/router/src/graphql.rs b/packages/libraries/router/src/graphql.rs index def7483d62..6df23abec2 100644 --- a/packages/libraries/router/src/graphql.rs +++ b/packages/libraries/router/src/graphql.rs @@ -31,7 +31,7 @@ struct SchemaCoordinatesContext { pub used_variables: HashSet, pub non_null_variables: HashSet, pub variables_with_defaults: HashSet, - error: Option + error: Option, } impl SchemaCoordinatesContext { @@ -67,12 +67,9 @@ pub fn collect_schema_coordinates( } else if let Some(type_def) = schema.type_by_name(&type_name) { match type_def { TypeDefinition::Scalar(scalar_def) => { - // Always collect custom scalars when referenced in variables ctx.schema_coordinates.insert(scalar_def.name.clone()); } TypeDefinition::InputObject(input_type) => { - // Collect all fieldcollect_input_object_fieldss of input objects referenced in variable definitions - // and recursively process field types collect_input_object_fields(schema, input_type, &mut ctx.schema_coordinates); } TypeDefinition::Enum(enum_type) => { @@ -105,19 +102,15 @@ fn collect_input_object_fields( let field_type_name = field.value_type.inner_type(); - // Process the field's type but DON'T add the type name itself for enums/input objects if let Some(field_type_def) = schema.type_by_name(field_type_name) { match field_type_def { TypeDefinition::Scalar(scalar_def) => { - // Only collect scalar types coordinates.insert(scalar_def.name.clone()); } TypeDefinition::InputObject(nested_input_type) => { - // Recursively collect nested input object fields (but not the type name) collect_input_object_fields(schema, nested_input_type, coordinates); } TypeDefinition::Enum(enum_type) => { - // Collect enum values (but not the enum type name) for value in &enum_type.values { coordinates.insert(format!("{}.{}", enum_type.name, value.name)); } @@ -287,7 +280,7 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis ctx.used_variables.insert(name.to_string()); } - fn enter_field( + fn enter_field( &mut self, info: &mut OperationVisitorContext<'a>, ctx: &mut SchemaCoordinatesContext, @@ -305,8 +298,8 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis ctx.schema_coordinates .insert(format!("{}.{}", parent_name, field_name)); - // If field's return type is an enum, collect all possible values if let Some(field_def) = parent_type.field_by_name(&field_name) { + // if field's type is an enum, we need to collect all possible values let field_output_type = info.schema.type_by_name(field_def.field_type.inner_type()); if let Some(TypeDefinition::Enum(enum_type)) = field_output_type { for value in &enum_type.values { @@ -326,7 +319,7 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis } } - fn enter_variable_definition( + fn enter_variable_definition( &mut self, info: &mut OperationVisitorContext<'a>, ctx: &mut SchemaCoordinatesContext, @@ -347,7 +340,6 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis let type_name = self.resolve_type_name(var.var_type.clone()); - // Always collect the variable's type for reference resolution if let Some(inner_types) = self.resolve_references(info.schema, &type_name) { for inner_type in inner_types { ctx.used_input_fields.insert(inner_type); @@ -414,8 +406,8 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis // handled by enter_list_value } Value::Object(_) => { - // CRITICAL FIX: Only collect scalar type if it's actually a custom scalar - // receiving an object value (like JSON) + // Only collect scalar type if it's actually a custom scalar + // receiving an object value if let Some(TypeDefinition::Scalar(_)) = info.schema.type_by_name(&arg_type_name) { ctx.schema_coordinates.insert(arg_type_name.clone()); } @@ -459,10 +451,10 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis .insert(format!("{}.{}", coordinate, value_str)); } Value::Object(_) => { - // handled by enter_object_value + // object fields are handled by enter_object_value } Value::List(_) => { - // handled by enter_list_value recursively + // handled by enter_list_value } Value::Variable(_) => { // handled by enter_variable_definition @@ -480,14 +472,13 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis } } -fn enter_object_value( + fn enter_object_value( &mut self, info: &mut OperationVisitorContext<'a>, ctx: &mut SchemaCoordinatesContext, object_value: &BTreeMap, ) { if let Some(TypeDefinition::InputObject(input_object_def)) = info.current_input_type() { - // First, collect all fields that are explicitly provided in the object object_value.iter().for_each(|(name, value)| { if let Some(field) = input_object_def .fields @@ -530,7 +521,7 @@ fn enter_object_value( // handled by enter_list_value } Value::Object(_) => { - // CRITICAL FIX: Only collect scalar type if it's a custom scalar receiving object + // Only collect scalar type if it's a custom scalar receiving object if let Some(TypeDefinition::Scalar(_)) = info.schema.type_by_name(&field_type_name) { ctx.schema_coordinates.insert(field_type_name.to_string()); } @@ -546,7 +537,7 @@ fn enter_object_value( } } Value::Null => { - // CRITICAL FIX: When a field has a null value, we should still collect + // When a field has a null value, we should still collect // all nested coordinates for input object types if let Some(TypeDefinition::InputObject(nested_input_obj)) = info.schema.type_by_name(&field_type_name) { self.collect_nested_input_coordinates(info.schema, nested_input_obj, ctx); From 4cc290755d7021517751451b58755b3097b7ee18 Mon Sep 17 00:00:00 2001 From: Emily Goodwin Date: Mon, 4 Aug 2025 13:40:25 -0400 Subject: [PATCH 3/4] CR comment changes --- packages/libraries/router/src/graphql.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/libraries/router/src/graphql.rs b/packages/libraries/router/src/graphql.rs index 6df23abec2..d28467b9cb 100644 --- a/packages/libraries/router/src/graphql.rs +++ b/packages/libraries/router/src/graphql.rs @@ -351,7 +351,8 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis self.process_default_value(info, ctx, &type_name, default_value); } } - fn enter_argument( + + fn enter_argument( &mut self, info: &mut OperationVisitorContext<'a>, ctx: &mut SchemaCoordinatesContext, @@ -382,7 +383,7 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis let has_value = match arg_value { Value::Null => false, Value::Variable(var_name) => { - ctx.variables_with_defaults.contains(var_name) + ctx.variables_with_defaults.contains(var_name) && ctx.non_null_variables.contains(var_name) } _ => true, }; From f52b55e9cd6190b90fc29909a527480602fca23c Mon Sep 17 00:00:00 2001 From: Emily Goodwin Date: Wed, 13 Aug 2025 20:21:23 -0400 Subject: [PATCH 4/4] update code and tests to count required parameters as non nullable --- packages/libraries/router/src/graphql.rs | 287 +++++++++++++++++++---- 1 file changed, 236 insertions(+), 51 deletions(-) diff --git a/packages/libraries/router/src/graphql.rs b/packages/libraries/router/src/graphql.rs index d28467b9cb..467b3e1b50 100644 --- a/packages/libraries/router/src/graphql.rs +++ b/packages/libraries/router/src/graphql.rs @@ -329,8 +329,7 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis return; } - if (is_non_null_type(&var.var_type) && var.default_value.is_some()) || - (var.default_value.is_some() && !matches!(var.default_value.as_ref().unwrap(), Value::Null)) { + if is_non_null_type(&var.var_type) { ctx.non_null_variables.insert(var.name.clone()); } @@ -383,7 +382,7 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis let has_value = match arg_value { Value::Null => false, Value::Variable(var_name) => { - ctx.variables_with_defaults.contains(var_name) && ctx.non_null_variables.contains(var_name) + ctx.variables_with_defaults.contains(var_name) || ctx.non_null_variables.contains(var_name) } _ => true, }; @@ -910,7 +909,7 @@ mod tests { type Query { project(selector: ProjectSelectorInput!): Project projectsByType(type: ProjectType!): [Project!]! - projectsByTypes(types: [ProjectType!]!): [Project!]! + projectsByTypes(types: [ ProjectType!]!): [Project!]! projects(filter: FilterInput, and: [FilterInput!]): [Project!]! projectsByMetadata(metadata: JSON): [Project!]! } @@ -1006,6 +1005,7 @@ mod tests { let expected = vec![ "Mutation.deleteProject", "Mutation.deleteProject.selector", + "Mutation.deleteProject.selector!", "DeleteProjectPayload.selector", "ProjectSelector.organization", "ProjectSelector.project", @@ -1231,7 +1231,9 @@ mod tests { "FilterInput.pagination", "FilterInput.pagination!", "FilterInput.type", + "FilterInput.type!", "PaginationInput.limit", + "PaginationInput.limit!", ] .into_iter() .map(|s| s.to_string()) @@ -1309,6 +1311,7 @@ mod tests { "FilterInput.type", "FilterInput.type!", "PaginationInput.limit", + "PaginationInput.limit!", "ProjectType.FEDERATION", ] .into_iter() @@ -1375,6 +1378,7 @@ mod tests { let expected = vec![ "Query.projectsByTypes", "Query.projectsByTypes.types", + "Query.projectsByTypes.types!", "Project.id", "ProjectType.FEDERATION", "ProjectType.STITCHING", @@ -1453,8 +1457,10 @@ mod tests { "FilterInput.pagination", "FilterInput.pagination!", "FilterInput.type", + "FilterInput.type!", "PaginationInput.limit", - ] + "PaginationInput.limit!", + ] .into_iter() .map(|s| s.to_string()) .collect::>(); @@ -1505,7 +1511,9 @@ mod tests { "FilterInput.pagination", "FilterInput.pagination!", "FilterInput.type", + "FilterInput.type!", "PaginationInput.limit", + "PaginationInput.limit!", ] .into_iter() .map(|s| s.to_string()) @@ -1549,7 +1557,9 @@ mod tests { "FilterInput.pagination", "FilterInput.pagination!", "FilterInput.type", + "FilterInput.type!", "PaginationInput.limit", + "PaginationInput.limit!", ] .into_iter() .map(|s| s.to_string()) @@ -1590,7 +1600,9 @@ mod tests { "ProjectType.STITCHING", "ProjectType.SINGLE", "FilterInput.pagination", + "FilterInput.pagination!", "FilterInput.type", + "FilterInput.type!", ] .into_iter() .map(|s| s.to_string()) @@ -1796,7 +1808,6 @@ mod tests { "Query.projects.filter", "Query.projects.filter!", "FilterInput.metadata", - "FilterInput.metadata!", "Project.name", "JSON", ] @@ -1967,7 +1978,7 @@ mod tests { assert_eq!(missing.len(), 0, "Missing: {:?}", missing); } - #[test] + #[test] fn undefined_variable_as_input_field(){ let schema = parse_schema::( " @@ -1994,6 +2005,7 @@ mod tests { "Query.random.a", "Query.random.a!", "A.b", + "A.b!", "String" ] .into_iter() @@ -2009,20 +2021,20 @@ mod tests { #[test] fn deeply_nested_variables(){ -let schema = parse_schema::( - " - type Query { - random(a: A): String - } - input A { - b: B - } - input B { - c: C - } - input C { - d: String - } + let schema = parse_schema::( + " + type Query { + random(a: A): String + } + input A { + b: B + } + input B { + c: C + } + input C { + d: String + } ") .unwrap(); let document = parse_query::( @@ -2055,20 +2067,21 @@ let schema = parse_schema::( let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } -#[test] - fn aliased_field() { - let schema = parse_schema::( - " - type Query { - random(a: String): String - } - input C { - d: String - } - ") - .unwrap(); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn aliased_field() { + let schema = parse_schema::( + " + type Query { + random(a: String): String + } + input C { + d: String + } + ") + .unwrap(); let document = parse_query::( " query Random($a: String= \"B\" ) { @@ -2096,29 +2109,29 @@ let schema = parse_schema::( assert_eq!(missing.len(), 0, "Missing: {:?}", missing); } - #[test] + #[test] fn multiple_fields_with_mixed_nullability(){ - let schema = parse_schema::( - " - type Query { - random(a: String): String - } - input C { - d: String - } - ") - .unwrap(); + let schema = parse_schema::( + " + type Query { + random(a: String): String + } + input C { + d: String + } + " + ).unwrap(); let document = parse_query::( - " - query Random($a: String = null) { + " + query Random($a: String = null) { nullable: random(a: $a) nonnullable: random(a: \"B\") } - ", + ", ) .unwrap(); - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); let expected = vec![ "Query.random", "Query.random.a", @@ -2133,7 +2146,179 @@ let schema = parse_schema::( let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn nonnull_and_default_arguments(){ + let schema = parse_schema::( + " + type Query { + user(id: ID!, name: String): User + } + + type User { + id: ID! + name: String + } + " + ).unwrap(); + let document = parse_query::( + " + query($id: ID! = \"123\") { + user(id: $id) { name } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "User.name", + "Query.user", + "ID", + "Query.user.id!", + "Query.user.id" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); } + #[test] + fn default_nullable_arguments(){ + let schema = parse_schema::( + " + type Query { + user(id: ID!, name: String): User + } + + type User { + id: ID! + name: String + } + " + ).unwrap(); + let document = parse_query::( + " + query($name: String = \"John\") { + user(id: \"fixed\", name: $name) { id } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "User.id", + "Query.user", + "ID", + "Query.user.id!", + "Query.user.id", + "Query.user.name!", + "Query.user.name", + "String" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn non_null_no_default_arguments(){ + let schema = parse_schema::( + " + type Query { + user(id: ID!, name: String): User + } + + type User { + id: ID! + name: String + } + " + ).unwrap(); + let document = parse_query::( + " + query($id: ID!) { + user(id: $id) { name } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "User.name", + "Query.user", + "ID", + "Query.user.id!", + "Query.user.id", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn fixed_arguments(){ + let schema = parse_schema::( + " + type Query { + user(id: ID!, name: String): User + } + + type User { + id: ID! + name: String + } + " + ).unwrap(); + let document = parse_query::( + " + query($name: String) { + user(id: \"fixed\", name: $name) { id } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + let expected = vec![ + "User.id", + "Query.user", + "ID", + "Query.user.id!", + "Query.user.id", + "Query.user.name", + "String" + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } }