diff --git a/crates/apollo-compiler/Cargo.toml b/crates/apollo-compiler/Cargo.toml index 67846c245..22114acda 100644 --- a/crates/apollo-compiler/Cargo.toml +++ b/crates/apollo-compiler/Cargo.toml @@ -36,6 +36,7 @@ anyhow = "1.0" criterion = "0.5.1" expect-test = "1.4" notify = "6.0.0" +oorandom = "11.1" pretty_assertions = "1.3.0" serde_json = "1.0" serial_test = "3.0.0" diff --git a/crates/apollo-compiler/src/arbitrary/arbitrary_executable.rs b/crates/apollo-compiler/src/arbitrary/arbitrary_executable.rs new file mode 100644 index 000000000..52e5d922d --- /dev/null +++ b/crates/apollo-compiler/src/arbitrary/arbitrary_executable.rs @@ -0,0 +1,554 @@ +use crate::arbitrary::common::abritrary_directive_list; +use crate::arbitrary::common::arbitary_name; +use crate::arbitrary::common::arbitrary_arguments; +use crate::arbitrary::common::gather_directive_definitions_by_location; +use crate::arbitrary::common::Context; +use crate::arbitrary::common::DirectiveDefinitionsByLocation; +use crate::arbitrary::entropy::Entropy; +use crate::executable; +use crate::schema; +use crate::schema::DirectiveLocation; +use crate::schema::MetaFieldDefinitions; +use crate::schema::NamedType; +use crate::validation::Valid; +use crate::Name; +use crate::Node; +use crate::Schema; +use indexmap::IndexMap; +use indexmap::IndexSet; +use std::collections::HashMap; + +/// Generate a executable document valid for `schema`, from bytes typically provided by a fuzzer. +/// +/// The “size” of the document is roughly proportional to `arbitrary_bytes.len()`. +/// +/// Ideally, enumerating all possible byte strings should yield all possible valid documents. +/// However there may be known and unknown cases that are not implemented yet. +pub fn arbitrary_valid_executable_document( + schema: &Valid, + arbitrary_bytes: &[u8], +) -> Valid { + let mut builder = Builder { + schema, + compatible_types_map: gather_compatible_types(schema), + directive_definitions_by_location: gather_directive_definitions_by_location(schema), + entropy: Entropy::new(arbitrary_bytes), + operation_variables: Default::default(), + fragment_map: Default::default(), + fragment_counter: 0, + field_counter: 0, + }; + + let (operation_type, root_type) = builder.arbitrary_root_operation(); + let is_subscription = operation_type == executable::OperationType::Subscription; + + let operation_is_named = builder.entropy.bool(); + let name = operation_is_named.then(|| arbitary_name(&mut builder.entropy)); + + let mut operation_selection_set = builder.arbitrary_selection_set_with_one_selection(root_type); + while !builder.entropy.is_empty() { + builder.expand_arbitrary_selection_set(&mut operation_selection_set, is_subscription); + } + // Make fragment ordering independent of `swap_remove` + re-`insert` operations. + builder + .fragment_map + .sort_by_cached_key(|name, _| name.strip_prefix("Frag").unwrap().parse::().unwrap()); + + let location = match operation_type { + executable::OperationType::Query => DirectiveLocation::Query, + executable::OperationType::Mutation => DirectiveLocation::Mutation, + executable::OperationType::Subscription => DirectiveLocation::Subscription, + }; + let doc = executable::ExecutableDocument { + sources: Default::default(), + operations: executable::OperationMap::from_one(executable::Operation { + operation_type, + name, + directives: builder.abritrary_directive_list(location), + variables: builder.operation_variables, + selection_set: operation_selection_set, + }), + fragments: builder.fragment_map, + }; + doc.validate(schema) + .expect("bug in arbitrary_valid_executable_document") +} + +struct Builder<'a> { + schema: &'a Valid, + compatible_types_map: CompatibleTypesMap<'a>, + directive_definitions_by_location: DirectiveDefinitionsByLocation<'a>, + entropy: Entropy<'a>, + fragment_map: executable::FragmentMap, + operation_variables: Vec>, + fragment_counter: usize, + field_counter: usize, +} + +/// For all output non-leaf types in the schema, the set of type conditions that a fragment can have: +/// +type CompatibleTypesMap<'schema> = HashMap<&'schema NamedType, IndexSet<&'schema NamedType>>; + +impl<'a> Builder<'a> { + fn arbitrary_root_operation(&mut self) -> (executable::OperationType, NamedType) { + let count = self.schema.schema_definition.iter_root_operations().count(); + let index = self.entropy.index(count).expect( + "valid schema unexpectedly lacks any root operation, should have at least a query", + ); + let (operation_type, root_type) = self + .schema + .schema_definition + .iter_root_operations() + .nth(index) + .unwrap(); + (operation_type, root_type.name.clone()) + } + + fn context(&mut self) -> Context<'_, 'a> { + Context { + schema: self.schema, + directive_definitions_by_location: &self.directive_definitions_by_location, + entropy: &mut self.entropy, + variable_definitions: Some(&mut self.operation_variables), + } + } + + fn abritrary_directive_list( + &mut self, + location: DirectiveLocation, + ) -> executable::DirectiveList { + abritrary_directive_list(&mut self.context(), location) + } + + /// Create a selection set with one arbitrary selection + fn arbitrary_selection_set_with_one_selection( + &mut self, + ty: NamedType, + ) -> executable::SelectionSet { + let mut selection_set = executable::SelectionSet::new(ty); + self.arbitrary_selection_into(&mut selection_set); + selection_set + } + + /// Add some selections to the given non-empty selection set + /// or some of its nested selection sets + fn expand_arbitrary_selection_set( + &mut self, + selection_set: &mut executable::SelectionSet, + is_subscription_top_level: bool, + ) { + // If a subscription top-level already has selections + // we can’t add sibling ones so we must go deeper + let go_deeper = is_subscription_top_level || self.entropy.bool(); + if !go_deeper { + return self.arbitrary_selection_into(selection_set); + } + + // unwrap: `expand_selection_set` is only called on non-empty selection sets + // (initially created by `arbitrary_selection_set_with_one_selection`) + // so `index()` returns `Some` + let index = self.entropy.index(selection_set.selections.len()).unwrap(); + match &mut selection_set.selections[index] { + executable::Selection::InlineFragment(inline) => { + let selection_set_to_expand = &mut inline.make_mut().selection_set; + self.expand_arbitrary_selection_set( + selection_set_to_expand, + is_subscription_top_level, + ); + } + executable::Selection::FragmentSpread(spread) => { + // Temporarily remove the fragment definition from the map + // so that we can mutably borrow its selection set independently of `&mut self` + let mut fragment_def = self + .fragment_map + .swap_remove(&spread.fragment_name) + .expect("spread of undefined named fragment"); + let selection_set_to_expand = &mut fragment_def.make_mut().selection_set; + self.expand_arbitrary_selection_set( + selection_set_to_expand, + is_subscription_top_level, + ); + self.fragment_map + .insert(spread.fragment_name.clone(), fragment_def); + } + executable::Selection::Field(field) => { + if field + .inner_type_def(self.schema) + .expect("field of undefined type") + .is_leaf() + { + // A leaf field cannot be expanded + if is_subscription_top_level { + // There is nothing else we can expand while + // keeping a single response-top-level field for a subscription, + // so end `arbitrary_executable_document`’s loop here. + self.entropy.take_all(); + } else { + // Give it a new sibling instead + self.arbitrary_selection_into(selection_set); + } + } else { + let selection_set_to_expand = &mut field.make_mut().selection_set; + let nested_is_subscription_top_level = false; + self.expand_arbitrary_selection_set( + selection_set_to_expand, + nested_is_subscription_top_level, + ); + } + } + } + } + + fn arbitrary_selection_into(&mut self, selection_set: &mut executable::SelectionSet) { + match self.entropy.u8() { + // 50% of cases + 0..=127 => self.arbitrary_field_into(selection_set), + + // 37.5% of cases + 128..=223 => self.arbitrary_inline_fragment_into(selection_set), + + // 12.5% of cases + _ => self.arbitrary_fragment_spread_into(selection_set), + } + } + + fn arbitrary_field_into(&mut self, selection_set: &mut executable::SelectionSet) { + // TODO: don’t always generate an alias, sometimes use an already-used response key while + // ensuring https://spec.graphql.org/draft/#sec-Field-Selection-Merging + let alias = Some(Name::try_from(format!("field{}", self.field_counter)).unwrap()); + self.field_counter += 1; + + let empty = IndexMap::new(); + let explicit_fields = match &self.schema.types[&selection_set.ty] { + schema::ExtendedType::Interface(def) => &def.fields, + schema::ExtendedType::Object(def) => &def.fields, + schema::ExtendedType::Scalar(_) + | schema::ExtendedType::Union(_) + | schema::ExtendedType::Enum(_) + | schema::ExtendedType::InputObject(_) => &empty, + }; + let meta_fields = [&MetaFieldDefinitions::get().__typename]; + let field_count = meta_fields.len() + explicit_fields.len(); + // unwrap: `field_count` is always at least 1 for `__typename` + let choice = self.entropy.index(field_count).unwrap(); + let definition = if let Some(index) = choice.checked_sub(meta_fields.len()) { + explicit_fields[index].node.clone() + } else { + meta_fields[choice].node.clone() + }; + let arguments = arbitrary_arguments(&mut self.context(), &definition.arguments); + let ty = definition.ty.inner_named_type().clone(); + selection_set.push(executable::Field { + alias, + name: definition.name.clone(), + arguments, + directives: self.abritrary_directive_list(DirectiveLocation::Field), + selection_set: if self.schema.types[&ty].is_leaf() { + executable::SelectionSet::new(ty) + } else { + self.arbitrary_selection_set_with_one_selection(ty) + }, + definition, + }) + } + + fn arbitrary_inline_fragment_into(&mut self, selection_set: &mut executable::SelectionSet) { + let nested_type; + let type_condition; + let use_type_condition = self.entropy.bool(); + if use_type_condition { + nested_type = self.abritrary_type_condition(&selection_set.ty); + type_condition = Some(nested_type.clone()); + } else { + nested_type = selection_set.ty.clone(); + type_condition = None; + }; + selection_set.push(executable::InlineFragment { + type_condition, + directives: self.abritrary_directive_list(DirectiveLocation::InlineFragment), + selection_set: self.arbitrary_selection_set_with_one_selection(nested_type), + }) + } + + fn arbitrary_fragment_spread_into(&mut self, selection_set: &mut executable::SelectionSet) { + // TODO: sometimes add spreads of existing named fragments? + // This needs a way to track and avoid introducing cycles + // + + let fragment_name = Name::try_from(format!("Frag{}", self.fragment_counter)).unwrap(); + self.fragment_counter += 1; + let fragment_type = self.abritrary_type_condition(&selection_set.ty); + let fragment_def = executable::Fragment { + name: fragment_name.clone(), + directives: self.abritrary_directive_list(DirectiveLocation::FragmentDefinition), + selection_set: self.arbitrary_selection_set_with_one_selection(fragment_type), + }; + self.fragment_map + .insert(fragment_name.clone(), fragment_def.into()); + selection_set.push(executable::FragmentSpread { + fragment_name, + directives: self.abritrary_directive_list(DirectiveLocation::FragmentSpread), + }); + } + + fn abritrary_type_condition(&mut self, parent_selection_set_type: &NamedType) -> NamedType { + let compatible_types = &self.compatible_types_map[parent_selection_set_type]; + // unwrap: `compatible_types` is non-empty + // since every type is at least compatible with itself + let index = self.entropy.index(compatible_types.len()).unwrap(); + compatible_types[index].clone() + } +} + +/// For all output non-leaf types in the schema, the set of type conditions that a fragment can have: +/// +// Clippy false positive: https://github.com/rust-lang/rust-clippy/issues/12908 +#[allow(clippy::needless_lifetimes)] +fn gather_compatible_types<'schema>(schema: &'schema Valid) -> CompatibleTypesMap<'schema> { + // key: interface type name, value: types that implement this interface + let implementers_map = schema.implementers_map(); + + // key: object type name, values: unions this object is a member of + let mut unions_map = HashMap::<&NamedType, Vec<&NamedType>>::new(); + for (name, type_def) in &schema.types { + if let schema::ExtendedType::Union(def) = type_def { + for member in &def.members { + unions_map.entry(member).or_default().push(name) + } + } + } + + schema + .types + .iter() + .filter(|(_name, type_def)| type_def.is_output_type() && !type_def.is_leaf()) + .map(|(name, type_def)| { + let mut compatible_types = IndexSet::new(); + // Any type is compatible with itself + compatible_types.insert(name); + + let mut add_object_type_and_its_supertypes = |object: &'schema schema::ObjectType| { + compatible_types.insert(&object.name); + if let Some(unions) = unions_map.get(&object.name) { + compatible_types.extend(unions); + } + compatible_types.extend( + object + .implements_interfaces + .iter() + .map(|interface| &interface.name), + ); + }; + match type_def { + schema::ExtendedType::Scalar(_) | schema::ExtendedType::Enum(_) => {} + schema::ExtendedType::Object(object) => add_object_type_and_its_supertypes(object), + schema::ExtendedType::Interface(_) => { + if let Some(implementers) = implementers_map.get(name) { + for object_name in &implementers.objects { + let object = schema + .get_object(object_name) + .expect("implementers_map refers to undefined object"); + add_object_type_and_its_supertypes(object) + } + } + } + schema::ExtendedType::Union(union_) => { + for object_name in &union_.members { + let object = schema + .get_object(object_name) + .expect("union has undefined object type as a member"); + add_object_type_and_its_supertypes(object) + } + } + schema::ExtendedType::InputObject(_) => unreachable!(), + } + (name, compatible_types) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::arbitrary_valid_executable_document; + use super::gather_compatible_types; + use crate::arbitrary::common::tests::arbitrary_bytes; + use crate::Schema; + use expect_test::expect; + use std::fmt::Write; + + fn format_compatible_types(schema: &str) -> String { + let schema = Schema::parse_and_validate(schema, "").unwrap(); + let mut formatted = String::new(); + for (name, others) in gather_compatible_types(&schema) + .into_iter() + // For deterministic ordering: + .map(|(k, v)| (k, v.into_iter().collect::>())) + .collect::>() + { + writeln!(&mut formatted, "{name}: {others:?}",).unwrap(); + } + formatted + } + + #[test] + fn compatible_types() { + let expected = expect![[r#" + Alien: {"Alien", "HumanOrAlien", "Sentient"} + Cat: {"Cat", "CatOrDog", "Pet"} + CatOrDog: {"Cat", "CatOrDog", "Dog", "DogOrHuman", "Pet"} + Dog: {"CatOrDog", "Dog", "DogOrHuman", "Pet"} + DogOrHuman: {"CatOrDog", "Dog", "DogOrHuman", "Human", "HumanOrAlien", "Pet", "Sentient"} + Human: {"DogOrHuman", "Human", "HumanOrAlien", "Sentient"} + HumanOrAlien: {"Alien", "DogOrHuman", "Human", "HumanOrAlien", "Sentient"} + Pet: {"Cat", "CatOrDog", "Dog", "DogOrHuman", "Pet"} + Query: {"Query"} + Sentient: {"Alien", "DogOrHuman", "Human", "HumanOrAlien", "Sentient"} + __Directive: {"__Directive"} + __EnumValue: {"__EnumValue"} + __Field: {"__Field"} + __InputValue: {"__InputValue"} + __Schema: {"__Schema"} + __Type: {"__Type"} + "#]]; + let schema = include_str!("../../examples/documents/schema.graphql"); + expected.assert_eq(&format_compatible_types(schema)); + } + + #[test] + fn executable_document() { + let schema = include_str!("../../benches/testdata/supergraph.graphql"); + let schema = Schema::parse_and_validate(schema, "").unwrap(); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(0, 0)); + expect![[r#" + { + field0: __typename + } + "#]] + .assert_eq(&doc.to_string()); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(1, 1)); + expect![[r#" + mutation { + field0: __typename + } + "#]] + .assert_eq(&doc.to_string()); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(2, 2)); + expect![[r#" + query A { + field0: __typename + } + "#]] + .assert_eq(&doc.to_string()); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(3, 4)); + expect![[r#" + { + ... on Query { + field0: __typename + } + } + "#]] + .assert_eq(&doc.to_string()); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(4, 8)); + expect![[r#" + mutation H { + field0: __typename + field1: __typename + } + "#]] + .assert_eq(&doc.to_string()); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(4, 16)); + expect![[r#" + mutation H { + field0: __typename + field1: login(username: "NIm", password: "A") { + field2: __typename + } + } + "#]] + .assert_eq(&doc.to_string()); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(5, 16)); + expect![[r#" + mutation X { + ...Frag0 + field1: updateReview(review: {id: 0}) { + field2: __typename + } + } + + fragment Frag0 on Mutation { + ... { + field0: __typename + } + } + "#]] + .assert_eq(&doc.to_string()); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(6, 16)); + expect![[r#" + { + field0: product(upc: "vTQ") @include(if: false) { + field1: __typename + } + } + "#]] + .assert_eq(&doc.to_string()); + + let doc = arbitrary_valid_executable_document(&schema, &arbitrary_bytes(6, 100)); + expect![[r#" + query($var0: String!, $var1: Int, $var2: Boolean! = false) { + field0: product(upc: "vTQ") @include(if: false) { + ... on Product { + field1: name @transform(from: $var0) + ... on Furniture { + ... { + ...Frag0 + } + } + } + field5: __typename + } + field2: topCars(first: $var1) { + ... on Thing { + field3: __typename + } + field6: retailPrice + } + field7: me { + ...Frag1 + } + } + + fragment Frag0 on Product { + ... { + ... { + ... @skip(if: $var2) { + ... { + field4: __typename + } + } + } + } + } + + fragment Frag1 on User { + ... on User @skip(if: false) { + field8: __typename + } + } + "#]] + .assert_eq(&doc.to_string()); + + // Generate a bunch more just to check generation completes + // without panic or stack overflow, and returns something valid + for seed in 1000..2000 { + arbitrary_valid_executable_document(&schema, &arbitrary_bytes(seed, 100)); + } + } +} diff --git a/crates/apollo-compiler/src/arbitrary/common.rs b/crates/apollo-compiler/src/arbitrary/common.rs new file mode 100644 index 000000000..1ff3467a0 --- /dev/null +++ b/crates/apollo-compiler/src/arbitrary/common.rs @@ -0,0 +1,326 @@ +//! Generating values that can appear both in schemas and in executable documents + +use crate::arbitrary::entropy::Entropy; +use crate::executable; +use crate::schema; +use crate::schema::Value; +use crate::validation::Valid; +use crate::Name; +use crate::Node; +use crate::Schema; +use std::collections::HashMap; + +pub(crate) fn arbitary_name(entropy: &mut Entropy<'_>) -> Name { + // unwrap: `arbitary_name_string` should always generate valid GraphQL Name syntax + Name::new(&arbitary_name_string(entropy)).unwrap() +} + +fn arbitary_name_string(entropy: &mut Entropy<'_>) -> String { + const NAME_START: &[u8; 53] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"; + const NAME_CONTINUE: &[u8; 63] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_0123456789"; + let mut name = String::with_capacity(8); + // unwrap: `NAME_START` and `NAME_CONTINUE` are not empty + name.push(*entropy.choose(NAME_START).unwrap() as char); + while entropy.bool() { + name.push(*entropy.choose(NAME_CONTINUE).unwrap() as char); + } + name +} + +/// Grab-bag of common parameters +pub(crate) struct Context<'a, 'b> { + pub(crate) schema: &'a Valid, + pub(crate) directive_definitions_by_location: &'a DirectiveDefinitionsByLocation<'b>, + pub(crate) entropy: &'a mut Entropy<'b>, + pub(crate) variable_definitions: Option<&'a mut Vec>>, +} + +/// `variables` is `None` iff generating in a "const" context. +// Clippy false positive: https://github.com/rust-lang/rust-clippy/issues/13077 +#[allow(clippy::needless_option_as_deref)] +pub(crate) fn arbitrary_arguments( + context: &mut Context<'_, '_>, + argument_definitions: &[Node], +) -> Vec> { + let mut arguments = Vec::with_capacity(argument_definitions.len()); + for def in argument_definitions { + let specified = def.is_required() || context.entropy.bool(); + if specified { + arguments.push( + executable::Argument { + name: def.name.clone(), + value: arbitrary_value(context, &def.ty).into(), + } + .into(), + ); + } + } + arguments +} + +/// `variables` is `None` iff generating a "const" value. +fn arbitrary_value(context: &mut Context<'_, '_>, expected_type: &schema::Type) -> Value { + if !expected_type.is_non_null() { + // Use null if entropy is exhausted + let non_null = context.entropy.bool(); + if !non_null { + return Value::Null; + } + } + + if let Some(variable_definitions) = &mut context.variable_definitions { + let emit_variable = context.entropy.bool(); + if emit_variable { + let new_variable = context.entropy.bool(); + if !new_variable { + for var_def in variable_definitions.iter() { + if var_def.ty.is_assignable_to(expected_type) { + return Value::Variable(var_def.name.clone()); + } + } + } + + let var_type = abritrary_type_assignable_to(context.entropy, expected_type); + let define_default_value = context.entropy.bool(); + let mut context_for_var_def = Context { + schema: context.schema, + directive_definitions_by_location: context.directive_definitions_by_location, + entropy: context.entropy, + // Both DefaultValue and Directives are const inside a VariableDefinition: + variable_definitions: None, + }; + // No default if entropy is exhausted + let default_value = if define_default_value { + Some(arbitrary_value(&mut context_for_var_def, &var_type).into()) + } else { + None + }; + let directives = abritrary_directive_list( + &mut context_for_var_def, + schema::DirectiveLocation::VariableDefinition, + ); + let name = Name::try_from(format!("var{}", variable_definitions.len())).unwrap(); + variable_definitions.push( + (executable::VariableDefinition { + name: name.clone(), + default_value, + ty: var_type.into(), + directives, + }) + .into(), + ); + return Value::Variable(name); + } + } + + match expected_type { + schema::Type::Named(name) | schema::Type::NonNullNamed(name) => { + arbitrary_value_of_named_type(context, name) + } + schema::Type::List(inner) | schema::Type::NonNullList(inner) => { + let mut list = Vec::new(); + while context.entropy.bool() { + let item = arbitrary_value(context, inner); + list.push(item.into()) + } + Value::List(list) + } + } +} + +fn abritrary_type_assignable_to( + entropy: &mut Entropy<'_>, + expected: &schema::Type, +) -> schema::Type { + let generated = match expected { + schema::Type::NonNullNamed(_) => expected.clone(), + schema::Type::NonNullList(inner) => { + schema::Type::NonNullList(Box::new(abritrary_type_assignable_to(entropy, inner))) + } + schema::Type::Named(name) => { + if entropy.bool() { + schema::Type::NonNullNamed(name.clone()) + } else { + schema::Type::Named(name.clone()) + } + } + schema::Type::List(inner) => { + let non_null = entropy.bool(); + let inner = Box::new(abritrary_type_assignable_to(entropy, inner)); + if non_null { + schema::Type::NonNullList(inner) + } else { + schema::Type::List(inner) + } + } + }; + assert!(generated.is_assignable_to(expected)); + generated +} + +fn arbitrary_value_of_named_type( + context: &mut Context<'_, '_>, + expected_type: &schema::NamedType, +) -> Value { + match &context.schema.types[expected_type] { + schema::ExtendedType::Enum(def) => { + let index = context + .entropy + .index(def.values.len()) + .expect("enum type with no values"); + Value::Enum(def.values[index].value.clone()) + } + schema::ExtendedType::InputObject(def) => { + let mut object = Vec::with_capacity(def.fields.len()); + for (name, field_def) in &def.fields { + let specified = field_def.is_required() || context.entropy.bool(); + if specified { + let item = arbitrary_value(context, &field_def.ty); + object.push((name.clone(), item.into())); + } + } + Value::Object(object) + } + schema::ExtendedType::Scalar(def) => match def.name.as_str() { + "Int" | "ID" => Value::Int(context.entropy.i32().into()), + "Float" => Value::Float(context.entropy.f64().into()), + "String" => Value::String(arbitary_name_string(context.entropy)), + "Boolean" => Value::Boolean(context.entropy.bool()), + _ => Value::String("custom scalar".into()), + }, + schema::ExtendedType::Object(_) + | schema::ExtendedType::Interface(_) + | schema::ExtendedType::Union(_) => { + unreachable!("generating a GraphQL value of non-input type") + } + } +} + +pub(crate) type DirectiveDefinitionsByLocation<'schema> = + HashMap>; + +pub(crate) fn gather_directive_definitions_by_location( + schema: &Valid, +) -> DirectiveDefinitionsByLocation<'_> { + let mut by_location = DirectiveDefinitionsByLocation::new(); + for def in schema.directive_definitions.values() { + for &location in &def.locations { + by_location.entry(location).or_default().push(def) + } + } + by_location +} + +// Clippy false positive: https://github.com/rust-lang/rust-clippy/issues/13077 +#[allow(clippy::needless_option_as_deref)] +pub(crate) fn abritrary_directive_list( + context: &mut Context<'_, '_>, + location: schema::DirectiveLocation, +) -> executable::DirectiveList { + let Some(definitions) = context.directive_definitions_by_location.get(&location) else { + // No directive definition for this location, generate an empty list + return Default::default(); + }; + let mut list = executable::DirectiveList::new(); + // 75% of directive lists are empty. expected length: 0.33 + while context.entropy.u8() >= 192 { + // unwrap: `gather_directive_definitions_by_location` only generates an entry + // for at least one definition, so `definitions` is non-empty. + let def = *context.entropy.choose(definitions).unwrap(); + if def.repeatable || !list.has(&def.name) { + list.push( + executable::Directive { + name: def.name.clone(), + arguments: arbitrary_arguments(context, &def.arguments), + } + .into(), + ); + } else { + // We already have this non-repeatable directive in this list + } + } + list +} + +#[cfg(test)] +pub(crate) mod tests { + use super::abritrary_type_assignable_to; + use super::arbitary_name; + use crate::arbitrary::entropy::Entropy; + use crate::ty; + use crate::Name; + use expect_test::expect; + use std::fmt::Write; + + pub(crate) fn arbitrary_bytes(seed: u64, len: usize) -> Vec { + let mut rng = oorandom::Rand32::new(seed); + (0..len).map(|_| rng.rand_u32() as u8).collect() + } + + pub(crate) fn with_entropy( + seed: u64, + len: usize, + f: impl FnOnce(&mut Entropy<'_>) -> R, + ) -> R { + f(&mut Entropy::new(&arbitrary_bytes(seed, len))) + } + + #[test] + fn name() { + expect!["A"].assert_eq(&with_entropy::(0, 0, arbitary_name)); + expect!["K"].assert_eq(&with_entropy::(1, 1, arbitary_name)); + expect!["mA"].assert_eq(&with_entropy::(2, 2, arbitary_name)); + expect!["t"].assert_eq(&with_entropy::(3, 3, arbitary_name)); + expect!["wo"].assert_eq(&with_entropy::(4, 4, arbitary_name)); + expect!["fD"].assert_eq(&with_entropy::(5, 4, arbitary_name)); + expect!["J"].assert_eq(&with_entropy::(6, 4, arbitary_name)); + expect!["x7A"].assert_eq(&with_entropy::(7, 4, arbitary_name)); + expect!["gLA"].assert_eq(&with_entropy::(8, 4, arbitary_name)); + } + + #[test] + fn type_assignable_to() { + let gen = + |seed, ty| with_entropy(0, seed, |e| abritrary_type_assignable_to(e, &ty)).to_string(); + expect!["Int"].assert_eq(&gen(0, ty!(Int))); + expect!["Int!"].assert_eq(&gen(1, ty!(Int))); + expect!["Int!"].assert_eq(&gen(0, ty!(Int!))); + expect!["Int!"].assert_eq(&gen(1, ty!(Int!))); + expect!["[[[Int]]!]!"].assert_eq(&gen(2, ty!([[[Int]]]))); + } + + #[test] + fn directives_by_location() { + let schema = " + type Query { field: Int } + directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + "; + let schema = crate::Schema::parse_and_validate(schema, "").unwrap(); + let mut formatted = String::new(); + for (location, definitions) in super::gather_directive_definitions_by_location(&schema) + .into_iter() + .map(|(loc, defs)| (loc.to_string(), defs)) + // For deterministic ordering: + .collect::>() + { + writeln!( + &mut formatted, + "{location}: {:?}", + definitions.into_iter().map(|d| &d.name).collect::>() + ) + .unwrap(); + } + expect![[r#" + ARGUMENT_DEFINITION: ["deprecated"] + ENUM_VALUE: ["deprecated"] + FIELD: ["skip", "include"] + FIELD_DEFINITION: ["deprecated"] + FRAGMENT_SPREAD: ["skip", "include", "defer"] + INLINE_FRAGMENT: ["skip", "include", "defer"] + INPUT_FIELD_DEFINITION: ["deprecated"] + SCALAR: ["specifiedBy"] + "#]] + .assert_eq(&formatted); + } +} diff --git a/crates/apollo-compiler/src/arbitrary/entropy.rs b/crates/apollo-compiler/src/arbitrary/entropy.rs new file mode 100644 index 000000000..e9c604565 --- /dev/null +++ b/crates/apollo-compiler/src/arbitrary/entropy.rs @@ -0,0 +1,479 @@ +//! Supporting library for generating GraphQL documents +//! in a [fuzzing target](https://rust-fuzz.github.io/book/introduction.html) +//! +//! This is based on parts of the [`arbitrary`](https://github.com/rust-fuzz/arbitrary) crate +//! (some code copied under the dual MIT or Apache-2.0 license) +//! with some key differences: +//! +//! * No `Arbitrary` trait which generates a value in isolation. +//! Parts of a GraphQL document often refer to each other so generation needs to be context aware. +//! (For example: what are the available types a new field definition can have?) +//! +//! * No `size_hint` mechanism. +//! It seems designed for types that have a finite set of possible values +//! and does not work out well for tree-like data structures. +//! Generating a single `&str` consumes up to all available entropy (half of it on average). +//! +//! * Infallible APIs. +//! `arbitrary` can return an error enum in many places +//! but each of its variants is only generated in a few places, and not consistently. +//! For example, `int_in_range` looks like it could return `arbitrary::Error::NotEnoughData` +//! but it never does. +//! Instead it defaults to `range.start()` when entropy is exhausted: +//! +//! +//! > We've had empirical results that suggest that this behavior +//! > results in better fuzzing performance and exploration of the input state space +//! +//! We apply this choice everywhere and remove the error enum entirely. + +use std::ops::RangeInclusive; + +/// Uses a byte sequence (typically provided by a fuzzer) as a source of entropy +/// to generate various arbitrary values. +pub struct Entropy<'arbitrary_bytes> { + bytes: std::slice::Iter<'arbitrary_bytes, u8>, +} + +impl<'arbitrary_bytes> Entropy<'arbitrary_bytes> { + /// Create a new source of entropy from bytes typically + /// [provided by cargo-fuzz](https://rust-fuzz.github.io/book/cargo-fuzz/tutorial.html). + /// + /// Bytes are assumed to have a mostly uniform distribution in the `0..=255` range. + pub fn new(arbitrary_bytes: &'arbitrary_bytes [u8]) -> Self { + Self { + bytes: arbitrary_bytes.iter(), + } + } + + /// Returns whether entropy has been exhausted + pub fn is_empty(&self) -> bool { + self.bytes.len() == 0 + } + + /// Take all remaining entropy. After this, [`Self::is_empty`] return true. + pub fn take_all(&mut self) { + self.bytes = Default::default() + } + + /// Generates an arbitary byte, or zero if entropy is exhausted. + pub fn u8(&mut self) -> u8 { + if let Some(&b) = self.bytes.next() { + b + } else { + 0 + } + } + + pub fn u8_array(&mut self) -> [u8; N] { + std::array::from_fn(|_| self.u8()) + } + + pub fn i32(&mut self) -> i32 { + i32::from_le_bytes(self.u8_array()) + } + + pub fn f64(&mut self) -> f64 { + f64::from_le_bytes(self.u8_array()) + } + + /// Generates an arbitrary boolean, or `false` if entropy is exhausted. + /// + /// Generally, code paths that cause more entropy to be consumed + /// should be taken when this method returns `true`. + /// If used in a loop break condition for example, + /// make sure to use this boolean as “keep going?” instead of “break?” + /// so that the loop stops when entropy is exhausted. + /// + /// A loop like `while keep_going() { … }` where `keep_going()` is true with probability `P`: + /// + /// * Has probability `(1-P)` to never run + /// * Has probability `P × (1-P) ` to run exactly one iteration + /// * Has probability `P² × (1-P)` to run exactly two iterations + /// * In general, has probability `P^k × (1-P)` to run exactly `k` iterations + /// + /// The expected (average) number of iterations is: + /// `N = sum[k = 0 to ∞] of k × P^k × (1-P) = P / (1 - P)` (thanks WolframAlpha). + /// Conversely, to get on average `N` iterations we should pick `P = N / (N + 1)`. + /// + /// For example, `while entropy.bool() { … }` has `P = 0.5` and so `N = 1`. + /// To make a similar loop that runs more than one iteration on average + /// we need “keep going” boolean conditions with higher probability. + pub fn bool(&mut self) -> bool { + (self.u8() & 1) == 1 + } + + /// Generates an arbitrary index in `0..collection_len`, or zero if entropy is exhausted. + /// + /// Retuns `None` if `collection_len == 0`. + /// + /// The returned index is biased towards lower values: + /// + /// * If `choice_count` is not a power of two, or + /// * If entropy becomes exhausted while generating the index + pub fn index(&mut self, collection_len: usize) -> Option { + let last = collection_len.checked_sub(1)?; + Some(self.int(0..=last)) + } + + /// Chooses an arbitrary item of the given slice, or the first if entropy is exhausted. + /// + /// Retuns `None` if the slice is empty. + /// + /// The returned index is biased towards earlier items: + /// + /// * If the slice length is not a power of two, or + /// * If entropy becomes exhausted while generating the index + pub fn choose<'a, T>(&mut self, slice: &'a [T]) -> Option<&'a T> { + let index = self.index(slice.len())?; + Some(&slice[index]) + } + + /// Generates an arbitrary integer in the given range, + /// or `range.start()` if entropy is exhausted. + /// + /// The returned value is biased towards lower values: + /// + /// * If `choice_count` is not a power of two, or + /// * If entropy becomes exhausted while generating the value + /// + /// # Panics + /// + /// Panics if `range.start > range.end`. + /// That is, the given range must be non-empty. + pub fn int(&mut self, range: RangeInclusive) -> T { + // Based on `arbitrary::Unstructured::int_in_range`: + // https://docs.rs/arbitrary/1.3.2/src/arbitrary/unstructured.rs.html#302 + + let start = *range.start(); + let end = *range.end(); + assert!(start <= end, "`Entropy::int` requires a non-empty range"); + + // When there is only one possible choice, + // don’t waste any entropy from the underlying data. + if start == end { + return start; + } + + // From here on out we work with the unsigned representation. + // All of the operations performed below work out just as well + // whether or not `T` is a signed or unsigned integer. + let start = start.to_unsigned(); + let end = end.to_unsigned(); + + let delta = end.wrapping_sub(start); + debug_assert_ne!(delta, T::Unsigned::ZERO); + + // Compute an arbitrary integer offset from the start of the range. + // We do this by consuming up to `size_of(T)` bytes from the input + // to create an arbitrary integer + // and then clamping that int into our range bounds with a modulo operation. + let entropy_bits_wanted = T::BITS - delta.leading_zeros(); + let entropy_bytes_wanted = entropy_bits_wanted.div_ceil(8); + let mut arbitrary_int = T::Unsigned::ZERO; + for _ in 0..entropy_bytes_wanted { + let next = match self.bytes.next() { + None => break, + Some(&byte) => T::Unsigned::from(byte), + }; + + // Combine this byte into our arbitrary integer, but avoid + // overflowing the shift for `u8` and `i8`. + arbitrary_int = if std::mem::size_of::() == 1 { + next + } else { + (arbitrary_int << 8) | next + }; + } + + let offset = if delta == T::Unsigned::MAX { + arbitrary_int + } else { + arbitrary_int % (delta.checked_add(T::Unsigned::ONE).unwrap()) + }; + + // Finally, we add `start` to our offset from `start` to get the result + // actual value within the range. + let result = start.wrapping_add(offset); + + // And convert back to our maybe-signed representation. + let result = T::from_unsigned(result); + debug_assert!(*range.start() <= result); + debug_assert!(result <= *range.end()); + + result + } +} + +mod sealed { + pub trait Sealed {} +} + +// Based on https://docs.rs/arbitrary/1.3.2/src/arbitrary/unstructured.rs.html#748 + +/// A trait that is implemented for all of the primitive integers: +/// +/// * `u8` +/// * `u16` +/// * `u32` +/// * `u64` +/// * `u128` +/// * `usize` +/// * `i8` +/// * `i16` +/// * `i32` +/// * `i64` +/// * `i128` +/// * `isize` +/// +/// Intended solely for methods of [`Entropy`]. +/// The exact bounds and associated items may change. +pub trait Int: + sealed::Sealed + + Copy + + Ord + + std::ops::BitOr + + std::ops::Rem + + std::ops::Shl + + std::ops::Shr + + std::fmt::Debug +{ + #[doc(hidden)] + type Unsigned: Int + From; + + #[doc(hidden)] + const ZERO: Self; + + #[doc(hidden)] + const ONE: Self; + + #[doc(hidden)] + const MAX: Self; + + #[doc(hidden)] + const BITS: u32; + + #[doc(hidden)] + fn leading_zeros(self) -> u32; + + #[doc(hidden)] + fn checked_add(self, rhs: Self) -> Option; + + #[doc(hidden)] + fn wrapping_add(self, rhs: Self) -> Self; + + #[doc(hidden)] + fn wrapping_sub(self, rhs: Self) -> Self; + + #[doc(hidden)] + fn to_unsigned(self) -> Self::Unsigned; + + #[doc(hidden)] + fn from_unsigned(unsigned: Self::Unsigned) -> Self; +} + +macro_rules! impl_int { + ( $( $ty:ty : $unsigned_ty: ty ; )* ) => { + $( + impl sealed::Sealed for $ty {} + + impl Int for $ty { + type Unsigned = $unsigned_ty; + + const ZERO: Self = 0; + + const ONE: Self = 1; + + const MAX: Self = Self::MAX; + + const BITS: u32 = Self::BITS; + + fn leading_zeros(self) -> u32 { + <$ty>::leading_zeros(self) + } + + fn checked_add(self, rhs: Self) -> Option { + <$ty>::checked_add(self, rhs) + } + + fn wrapping_add(self, rhs: Self) -> Self { + <$ty>::wrapping_add(self, rhs) + } + + fn wrapping_sub(self, rhs: Self) -> Self { + <$ty>::wrapping_sub(self, rhs) + } + + fn to_unsigned(self) -> Self::Unsigned { + self as $unsigned_ty + } + + fn from_unsigned(unsigned: $unsigned_ty) -> Self { + unsigned as Self + } + } + )* + } +} + +impl_int! { + u8: u8; + u16: u16; + u32: u32; + u64: u64; + u128: u128; + usize: usize; + i8: u8; + i16: u16; + i32: u32; + i64: u64; + i128: u128; + isize: usize; +} + +#[cfg(test)] +mod tests { + use super::Entropy; + + #[test] + fn exhausted() { + let mut e = Entropy::new(&[]); + assert_eq!(e.u8(), 0); + assert_eq!(e.u8_array(), [0, 0, 0]); + assert_eq!(e.i32(), 0); + assert_eq!(e.f64(), 0.0); + assert!(!e.bool()); + assert_eq!(e.int(4..=7), 4); + assert_eq!(e.index(10).unwrap(), 0); + assert_eq!(*e.choose(b"abc").unwrap(), b'a'); + } + + // Tests below based on https://docs.rs/arbitrary/1.3.2/src/arbitrary/unstructured.rs.html#888 + + #[test] + fn int_in_range_of_one() { + let mut e = Entropy::new(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 6]); + let x = e.int(0..=0); + assert_eq!(x, 0); + let choice = *e.choose(&[42]).unwrap(); + assert_eq!(choice, 42) + } + + #[test] + fn int_uses_minimal_amount_of_bytes() { + let mut e = Entropy::new(&[1, 2]); + assert_eq!(1, e.int::(0..=u8::MAX)); + assert_eq!(e.bytes.len(), 1); + + let mut e = Entropy::new(&[1, 2]); + assert_eq!(1, e.int::(0..=u8::MAX as u32)); + assert_eq!(e.bytes.len(), 1); + + let mut e = Entropy::new(&[1]); + assert_eq!(1, e.int::(0..=u8::MAX as u32 + 1)); + assert!(e.is_empty()); + } + + #[test] + fn int_in_bounds() { + for input in u8::MIN..=u8::MAX { + let input = [input]; + + let mut e = Entropy::new(&input); + let x = e.int(1..=u8::MAX); + assert_ne!(x, 0); + + let mut e = Entropy::new(&input); + let x = e.int(0..=u8::MAX - 1); + assert_ne!(x, u8::MAX); + } + } + + #[test] + fn int_covers_unsigned_range() { + // Test that we generate all values within the range given to `int`. + + let mut full = [false; u8::MAX as usize + 1]; + let mut no_zero = [false; u8::MAX as usize]; + let mut no_max = [false; u8::MAX as usize]; + let mut narrow = [false; 10]; + + for input in u8::MIN..=u8::MAX { + let input = [input]; + + let mut e = Entropy::new(&input); + let x = e.int(0..=u8::MAX); + full[x as usize] = true; + + let mut e = Entropy::new(&input); + let x = e.int(1..=u8::MAX); + no_zero[x as usize - 1] = true; + + let mut e = Entropy::new(&input); + let x = e.int(0..=u8::MAX - 1); + no_max[x as usize] = true; + + let mut e = Entropy::new(&input); + let x = e.int(100..=109); + narrow[x as usize - 100] = true; + } + + for (i, covered) in full.iter().enumerate() { + assert!(covered, "full[{}] should have been generated", i); + } + for (i, covered) in no_zero.iter().enumerate() { + assert!(covered, "no_zero[{}] should have been generated", i); + } + for (i, covered) in no_max.iter().enumerate() { + assert!(covered, "no_max[{}] should have been generated", i); + } + for (i, covered) in narrow.iter().enumerate() { + assert!(covered, "narrow[{}] should have been generated", i); + } + } + + #[test] + fn int_covers_signed_range() { + // Test that we generate all values within the range given to `int`. + + let mut full = [false; u8::MAX as usize + 1]; + let mut no_min = [false; u8::MAX as usize]; + let mut no_max = [false; u8::MAX as usize]; + let mut narrow = [false; 21]; + + let abs_i8_min: isize = 128; + + for input in 0..=u8::MAX { + let input = [input]; + + let mut e = Entropy::new(&input); + let x = e.int(i8::MIN..=i8::MAX); + full[(x as isize + abs_i8_min) as usize] = true; + + let mut e = Entropy::new(&input); + let x = e.int(i8::MIN + 1..=i8::MAX); + no_min[(x as isize + abs_i8_min - 1) as usize] = true; + + let mut e = Entropy::new(&input); + let x = e.int(i8::MIN..=i8::MAX - 1); + no_max[(x as isize + abs_i8_min) as usize] = true; + + let mut e = Entropy::new(&input); + let x = e.int(-10..=10); + narrow[(x as isize + 10) as usize] = true; + } + + for (i, covered) in full.iter().enumerate() { + assert!(covered, "full[{}] should have been generated", i); + } + for (i, covered) in no_min.iter().enumerate() { + assert!(covered, "no_min[{}] should have been generated", i); + } + for (i, covered) in no_max.iter().enumerate() { + assert!(covered, "no_max[{}] should have been generated", i); + } + for (i, covered) in narrow.iter().enumerate() { + assert!(covered, "narrow[{}] should have been generated", i); + } + } +} diff --git a/crates/apollo-compiler/src/arbitrary/mod.rs b/crates/apollo-compiler/src/arbitrary/mod.rs new file mode 100644 index 000000000..6030bc47b --- /dev/null +++ b/crates/apollo-compiler/src/arbitrary/mod.rs @@ -0,0 +1,11 @@ +//! Supporting library for generating GraphQL documents +//! in a [fuzzing target](https://rust-fuzz.github.io/book/introduction.html) + +mod arbitrary_executable; +mod common; +mod entropy; + +pub use self::arbitrary_executable::arbitrary_valid_executable_document; +// TODO: should this be public? Maybe for subgraph generation in apollo-federation crate? +// pub use self::entropy::Entropy; +// pub use self::entropy::Int; diff --git a/crates/apollo-compiler/src/executable/mod.rs b/crates/apollo-compiler/src/executable/mod.rs index 32aa31e1a..f596a8809 100644 --- a/crates/apollo-compiler/src/executable/mod.rs +++ b/crates/apollo-compiler/src/executable/mod.rs @@ -342,6 +342,13 @@ impl PartialEq for ExecutableDocument { } impl OperationMap { + /// Creates a new `OperationMap` containing one operation + pub fn from_one(operation: impl Into>) -> Self { + let mut map = Self::default(); + map.insert(operation); + map + } + /// Returns an iterator of operations, both anonymous and named pub fn iter(&self) -> impl Iterator> { self.anonymous diff --git a/crates/apollo-compiler/src/lib.rs b/crates/apollo-compiler/src/lib.rs index 1de45be9f..4fab58988 100644 --- a/crates/apollo-compiler/src/lib.rs +++ b/crates/apollo-compiler/src/lib.rs @@ -2,6 +2,7 @@ #[macro_use] mod macros; +pub mod arbitrary; pub mod ast; pub mod coordinate; pub mod diagnostic; diff --git a/crates/apollo-compiler/src/schema/mod.rs b/crates/apollo-compiler/src/schema/mod.rs index 1e7c17c32..c57d9feae 100644 --- a/crates/apollo-compiler/src/schema/mod.rs +++ b/crates/apollo-compiler/src/schema/mod.rs @@ -552,6 +552,18 @@ impl Schema { } impl SchemaDefinition { + pub fn iter_root_operations( + &self, + ) -> impl Iterator { + [ + (ast::OperationType::Query, &self.query), + (ast::OperationType::Mutation, &self.mutation), + (ast::OperationType::Subscription, &self.subscription), + ] + .into_iter() + .filter_map(|(ty, maybe_op)| maybe_op.as_ref().map(|op| (ty, op))) + } + /// Collect `schema` extensions that contribute any component /// /// The order of the returned set is unspecified but deterministic @@ -640,6 +652,14 @@ impl ExtendedType { matches!(self, Self::InputObject(_)) } + /// Returns wether this type is a leaf type: scalar or enum. + /// + /// Field selections must have sub-selections if and only if + /// their inner named type is *not* a leaf field. + pub fn is_leaf(&self) -> bool { + matches!(self, Self::Scalar(_) | Self::Enum(_)) + } + /// Returns true if a value of this type can be used as an input value. /// /// # Spec @@ -924,11 +944,11 @@ impl PartialEq for Schema { fn eq(&self, other: &Self) -> bool { let Self { sources: _, // ignored - schema_definition: root_operations, + schema_definition, directive_definitions, types, } = self; - *root_operations == other.schema_definition + *schema_definition == other.schema_definition && *directive_definitions == other.directive_definitions && *types == other.types } @@ -1067,14 +1087,14 @@ impl std::fmt::Debug for DebugTypes<'_> { } } -struct MetaFieldDefinitions { - __typename: Component, - __schema: Component, - __type: Component, +pub(crate) struct MetaFieldDefinitions { + pub(crate) __typename: Component, + pub(crate) __schema: Component, + pub(crate) __type: Component, } impl MetaFieldDefinitions { - fn get() -> &'static Self { + pub(crate) fn get() -> &'static Self { static DEFS: OnceLock = OnceLock::new(); DEFS.get_or_init(|| Self { // __typename: String! diff --git a/crates/apollo-compiler/src/schema/serialize.rs b/crates/apollo-compiler/src/schema/serialize.rs index aa1e315f5..8ee658414 100644 --- a/crates/apollo-compiler/src/schema/serialize.rs +++ b/crates/apollo-compiler/src/schema/serialize.rs @@ -73,15 +73,9 @@ impl Node { .into_iter() .any(|op| op.is_some()); let root_ops = |ext: Option<&ExtensionId>| -> Vec> { - let root_op = |op: &Option, ty| { - op.as_ref() - .filter(|name| name.origin.extension_id() == ext) - .map(|name| (ty, name.name.clone()).into()) - .into_iter() - }; - root_op(&self.query, OperationType::Query) - .chain(root_op(&self.mutation, OperationType::Mutation)) - .chain(root_op(&self.subscription, OperationType::Subscription)) + self.iter_root_operations() + .filter(|(_, op)| op.origin.extension_id() == ext) + .map(|(ty, op)| (ty, op.name.clone()).into()) .collect() }; if implict {