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..467b3e1b50 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,7 +26,11 @@ use graphql_tools::ast::{ struct SchemaCoordinatesContext { pub schema_coordinates: HashSet, - pub input_types_to_collect: HashSet, + 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, } @@ -41,29 +46,34 @@ 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) => { + 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_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 +83,6 @@ pub fn collect_schema_coordinates( } } _ => {} - }, - None => { - ctx.schema_coordinates.insert(input_type_name); } } } @@ -84,9 +91,109 @@ 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(); + + if let Some(field_type_def) = schema.type_by_name(field_type_name) { + match field_type_def { + TypeDefinition::Scalar(scalar_def) => { + coordinates.insert(scalar_def.name.clone()); + } + TypeDefinition::InputObject(nested_input_type) => { + collect_input_object_fields(schema, nested_input_type, coordinates); + } + TypeDefinition::Enum(enum_type) => { + 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,9 +233,53 @@ 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_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>, @@ -178,24 +329,28 @@ 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) { + 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()); + 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( &mut self, info: &mut OperationVisitorContext<'a>, @@ -214,60 +369,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) || ctx.non_null_variables.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(_) => { + // 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()); } - 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,8 +442,14 @@ 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 } @@ -290,14 +459,13 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis 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()); + } } } } @@ -317,17 +485,34 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis .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 +521,35 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis // handled by enter_list_value } Value::Object(_) => { - // handled by enter_object_value + // 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 => { + // 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()); + } } } } @@ -703,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!]! } @@ -799,6 +1005,7 @@ mod tests { let expected = vec![ "Mutation.deleteProject", "Mutation.deleteProject.selector", + "Mutation.deleteProject.selector!", "DeleteProjectPayload.selector", "ProjectSelector.organization", "ProjectSelector.project", @@ -860,6 +1067,7 @@ mod tests { "ProjectOrderByInput.direction", "OrderDirection.ASC", "OrderDirection.DESC", + "JSON" ] .into_iter() .map(|s| s.to_string()) @@ -907,6 +1115,7 @@ mod tests { "ProjectOrderByInput.direction", "OrderDirection.ASC", "OrderDirection.DESC", + "JSON" ] .into_iter() .map(|s| s.to_string()) @@ -938,12 +1147,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 +1187,7 @@ mod tests { let expected = vec![ "Query.projectsByTypes", "Query.projectsByTypes.types", + "Query.projectsByTypes.types!", "Project.name", "ProjectType.FEDERATION", "ProjectType.STITCHING", @@ -995,12 +1207,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,14 +1222,18 @@ 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", + "FilterInput.type!", "PaginationInput.limit", + "PaginationInput.limit!", ] .into_iter() .map(|s| s.to_string()) @@ -1049,10 +1265,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,11 +1303,15 @@ 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", + "PaginationInput.limit!", "ProjectType.FEDERATION", ] .into_iter() @@ -1121,6 +1344,7 @@ mod tests { let expected = vec![ "Query.projectsByTypes", "Query.projectsByTypes.types", + "Query.projectsByTypes.types!", "Project.id", "ProjectType.FEDERATION", ] @@ -1154,6 +1378,7 @@ mod tests { let expected = vec![ "Query.projectsByTypes", "Query.projectsByTypes.types", + "Query.projectsByTypes.types!", "Project.id", "ProjectType.FEDERATION", "ProjectType.STITCHING", @@ -1189,6 +1414,7 @@ mod tests { let expected = vec![ "Query.projectsByType", "Query.projectsByType.type", + "Query.projectsByType.type!", "Project.id", "ProjectType.FEDERATION", ] @@ -1222,15 +1448,19 @@ 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", + "FilterInput.type!", "PaginationInput.limit", - ] + "PaginationInput.limit!", + ] .into_iter() .map(|s| s.to_string()) .collect::>(); @@ -1270,6 +1500,7 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "Project.name", "Int", @@ -1278,8 +1509,11 @@ mod tests { "ProjectType.SINGLE", "Boolean", "FilterInput.pagination", + "FilterInput.pagination!", "FilterInput.type", + "FilterInput.type!", "PaginationInput.limit", + "PaginationInput.limit!", ] .into_iter() .map(|s| s.to_string()) @@ -1314,14 +1548,18 @@ 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", + "FilterInput.type!", "PaginationInput.limit", + "PaginationInput.limit!", ] .into_iter() .map(|s| s.to_string()) @@ -1353,6 +1591,7 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "Project.id", "PaginationInput.limit", "Int", @@ -1361,7 +1600,9 @@ mod tests { "ProjectType.STITCHING", "ProjectType.SINGLE", "FilterInput.pagination", + "FilterInput.pagination!", "FilterInput.type", + "FilterInput.type!", ] .into_iter() .map(|s| s.to_string()) @@ -1393,6 +1634,7 @@ mod tests { let expected = vec![ "Query.projectsByMetadata", "Query.projectsByMetadata.metadata", + "Query.projectsByMetadata.metadata!", "Project.name", "JSON", ] @@ -1459,6 +1701,7 @@ mod tests { let expected = vec![ "Query.projectsByMetadata", "Query.projectsByMetadata.metadata", + "Query.projectsByMetadata.metadata!", "Project.name", "JSON", ] @@ -1473,8 +1716,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 +1735,9 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "FilterInput.metadata", + "FilterInput.metadata!", "Project.name", "JSON", ] @@ -1528,6 +1771,7 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "FilterInput.metadata", "Project.name", "JSON", @@ -1562,6 +1806,7 @@ mod tests { let expected = vec![ "Query.projects", "Query.projects.filter", + "Query.projects.filter!", "FilterInput.metadata", "Project.name", "JSON", @@ -1576,4 +1821,504 @@ 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", + "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); + } + + #[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); + } }