diff --git a/Cargo.toml b/Cargo.toml index 2c9752b..76b5ec1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "bluejay-parser", "bluejay-printer", "bluejay-schema-comparator", + "bluejay-operation-normalizer", "bluejay-typegen", "bluejay-typegen-codegen", "bluejay-typegen-macro", @@ -27,6 +28,7 @@ bluejay-typegen = { path = "./bluejay-typegen", version = "=0.3.1" } bluejay-typegen-codegen = { path = "./bluejay-typegen-codegen", version = "=0.3.1" } bluejay-typegen-macro = { path = "./bluejay-typegen-macro", version = "=0.3.1" } bluejay-validator = { path = "./bluejay-validator", version = "=0.3.1" } +bluejay-operation-normalizer = { path = "./bluejay-operation-normalizer", version = "=0.3.1" } bluejay-visibility = { path = "./bluejay-visibility", version = "=0.3.1" } [profile.shopify-function] diff --git a/bluejay-operation-normalizer/Cargo.toml b/bluejay-operation-normalizer/Cargo.toml new file mode 100644 index 0000000..a2e9f8d --- /dev/null +++ b/bluejay-operation-normalizer/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bluejay-operation-normalizer" +version.workspace = true +edition = "2021" +license = "MIT" +repository = "https://github.com/Shopify/bluejay" +homepage = "https://github.com/Shopify/bluejay" +keywords = ["graphql"] +description = "GraphQL operation normalization and signature hashing" + +[dependencies] +bluejay-core = { workspace = true } +blake3 = "1" + +[dev-dependencies] +bluejay-parser = { workspace = true } +criterion = "0.5" + +[[bench]] +name = "normalize" +harness = false + +[lints] +workspace = true diff --git a/bluejay-operation-normalizer/benches/normalize.rs b/bluejay-operation-normalizer/benches/normalize.rs new file mode 100644 index 0000000..851bd20 --- /dev/null +++ b/bluejay-operation-normalizer/benches/normalize.rs @@ -0,0 +1,324 @@ +use bluejay_parser::ast::{executable::ExecutableDocument, Parse}; +use criterion::{criterion_group, criterion_main, Criterion}; + +fn parse(input: &str) -> ExecutableDocument { + ExecutableDocument::parse(input) + .result + .expect("parse error") +} + +fn bench_small(c: &mut Criterion) { + let doc = parse("query { user { name email } }"); + c.bench_function("normalize_small", |b| { + b.iter(|| bluejay_operation_normalizer::normalize(&doc, None).unwrap()) + }); + c.bench_function("signature_small", |b| { + b.iter(|| bluejay_operation_normalizer::signature(&doc, None).unwrap()) + }); +} + +fn bench_medium(c: &mut Criterion) { + let doc = parse( + r#" + query GetUser($id: ID!, $first: Int = 10, $after: String) { + user(id: $id) { + name + email + avatar + role + posts(first: $first, after: $after, orderBy: "created_at") { + edges { + cursor + node { + title + body + createdAt + tags + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + "#, + ); + c.bench_function("normalize_medium", |b| { + b.iter(|| bluejay_operation_normalizer::normalize(&doc, Some("GetUser")).unwrap()) + }); + c.bench_function("signature_medium", |b| { + b.iter(|| bluejay_operation_normalizer::signature(&doc, Some("GetUser")).unwrap()) + }); +} + +fn bench_complex(c: &mut Criterion) { + let doc = parse( + r#" + query ComplexQuery($userId: ID!, $includeEmail: Boolean = true, $limit: Int = 20, $offset: Int = 0) @cacheControl(maxAge: 300) { + user(id: $userId) { + ...UserBasic + ...UserPosts + followers(limit: $limit, offset: $offset) { + ...UserBasic + mutualFriends { + ...UserBasic + } + } + } + systemStatus { + healthy + version + uptime + } + } + + fragment UserBasic on User { + id + name + email @include(if: $includeEmail) + avatar + role + createdAt + } + + fragment UserPosts on User { + posts(first: 10) { + edges { + cursor + node { + ...PostDetails + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + + fragment PostDetails on Post { + id + title + body + createdAt + updatedAt + author { + ...UserBasic + } + comments(first: 5) { + edges { + node { + id + body + author { + name + } + } + } + } + tags + likes + } + "#, + ); + c.bench_function("normalize_complex", |b| { + b.iter(|| bluejay_operation_normalizer::normalize(&doc, Some("ComplexQuery")).unwrap()) + }); + c.bench_function("signature_complex", |b| { + b.iter(|| bluejay_operation_normalizer::signature(&doc, Some("ComplexQuery")).unwrap()) + }); +} + +/// Simulates a Relay/Apollo Client app where each component defines a small +/// fragment and the page query composes them. 10 fragments, transitive deps, +/// plus an unused fragment that should be stripped. +fn bench_fragment_colocation(c: &mut Criterion) { + let doc = parse( + r#" + query ProductPage($handle: String!, $first: Int = 10, $after: String) { + product(handle: $handle) { + ...ProductHeader + ...ProductPricing + ...ProductMedia + ...ProductVariants + ...ProductMetafields + ...ProductSeo + } + shop { + ...ShopInfo + } + cart { + ...CartSummary + } + } + + fragment ProductHeader on Product { + id + title + handle + description + vendor + productType + tags + createdAt + updatedAt + } + + fragment ProductPricing on Product { + priceRange { + ...MoneyRange + } + compareAtPriceRange { + ...MoneyRange + } + } + + fragment MoneyRange on PriceRange { + minVariantPrice { ...MoneyFields } + maxVariantPrice { ...MoneyFields } + } + + fragment MoneyFields on Money { + amount + currencyCode + } + + fragment ProductMedia on Product { + images(first: 10) { + edges { + node { + id + url + altText + width + height + } + } + } + } + + fragment ProductVariants on Product { + variants(first: $first, after: $after) { + edges { + cursor + node { + id + title + sku + availableForSale + price { ...MoneyFields } + compareAtPrice { ...MoneyFields } + selectedOptions { + name + value + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + + fragment ProductMetafields on Product { + metafield1: metafield(namespace: "custom", key: "care_instructions") { value type } + metafield2: metafield(namespace: "custom", key: "material") { value type } + metafield3: metafield(namespace: "custom", key: "sizing_guide") { value type } + } + + fragment ProductSeo on Product { + seo { + title + description + } + } + + fragment ShopInfo on Shop { + name + primaryDomain { url } + shipsToCountries + } + + fragment CartSummary on Cart { + id + totalQuantity + estimatedCost { + totalAmount { ...MoneyFields } + subtotalAmount { ...MoneyFields } + totalTaxAmount { ...MoneyFields } + } + } + + fragment UnusedAnalytics on Product { + id + title + vendor + } + "#, + ); + c.bench_function("normalize_fragment_colocation", |b| { + b.iter(|| bluejay_operation_normalizer::normalize(&doc, Some("ProductPage")).unwrap()) + }); + c.bench_function("signature_fragment_colocation", |b| { + b.iter(|| bluejay_operation_normalizer::signature(&doc, Some("ProductPage")).unwrap()) + }); +} + +/// 30 fields in reverse alphabetical order at root level. Worst case for sort. +fn bench_wide_reverse_sorted(c: &mut Criterion) { + let doc = parse( + r#" + query DashboardQuery { + zones { id } + yields { id } + xrefs { id } + webhooks { id } + variants { id } + users { id } + transactions { id } + subscriptions { id } + returns { id } + quotas { id } + products { id } + payments { id } + orders { id } + notifications { id } + metafields { id } + locations { id } + inventoryLevels { id } + images { id } + hooks { id } + giftCards { id } + fulfillments { id } + events { id } + discounts { id } + customers { id } + collections { id } + blogs { id } + articles { id } + } + "#, + ); + c.bench_function("normalize_wide_reverse", |b| { + b.iter(|| bluejay_operation_normalizer::normalize(&doc, Some("DashboardQuery")).unwrap()) + }); + c.bench_function("signature_wide_reverse", |b| { + b.iter(|| bluejay_operation_normalizer::signature(&doc, Some("DashboardQuery")).unwrap()) + }); +} + +criterion_group!( + benches, + bench_small, + bench_medium, + bench_complex, + bench_fragment_colocation, + bench_wide_reverse_sorted, +); +criterion_main!(benches); diff --git a/bluejay-operation-normalizer/src/fragments.rs b/bluejay-operation-normalizer/src/fragments.rs new file mode 100644 index 0000000..61d2368 --- /dev/null +++ b/bluejay-operation-normalizer/src/fragments.rs @@ -0,0 +1,41 @@ +use bluejay_core::executable::{ + ExecutableDocument, Field, FragmentDefinition, FragmentSpread, InlineFragment, Selection, + SelectionReference, +}; +use bluejay_core::AsIter; +use std::collections::{HashMap, HashSet}; + +pub(crate) fn collect_used_fragments<'a, E: ExecutableDocument + 'a>( + selection_set: &'a E::SelectionSet, + fragment_defs: &HashMap<&'a str, &'a E::FragmentDefinition>, +) -> Vec<&'a str> { + let mut seen = HashSet::new(); + let mut stack: Vec<&'a E::SelectionSet> = vec![selection_set]; + + while let Some(ss) = stack.pop() { + for selection in ss.iter() { + match selection.as_ref() { + SelectionReference::Field(field) => { + if let Some(sub_ss) = field.selection_set() { + stack.push(sub_ss); + } + } + SelectionReference::FragmentSpread(spread) => { + let name = spread.name(); + if seen.insert(name) { + if let Some(frag_def) = fragment_defs.get(name) { + stack.push(frag_def.selection_set()); + } + } + } + SelectionReference::InlineFragment(inline) => { + stack.push(inline.selection_set()); + } + } + } + } + + let mut used: Vec<_> = seen.into_iter().collect(); + used.sort_unstable(); + used +} diff --git a/bluejay-operation-normalizer/src/lib.rs b/bluejay-operation-normalizer/src/lib.rs new file mode 100644 index 0000000..7ce433d --- /dev/null +++ b/bluejay-operation-normalizer/src/lib.rs @@ -0,0 +1,368 @@ +mod fragments; +mod normalize; +mod sort; + +use bluejay_core::executable::ExecutableDocument; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignatureError { + OperationNotFound(String), + AmbiguousOperation, + NoOperations, +} + +impl std::fmt::Display for SignatureError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::OperationNotFound(name) => write!(f, "operation not found: {name}"), + Self::AmbiguousOperation => { + write!(f, "multiple operations found; specify operation name") + } + Self::NoOperations => write!(f, "no operations in document"), + } + } +} + +impl std::error::Error for SignatureError {} + +pub fn normalize( + doc: &E, + op_name: Option<&str>, +) -> Result { + normalize::normalize_doc::(doc, op_name) +} + +pub fn signature( + doc: &E, + op_name: Option<&str>, +) -> Result { + let normalized = normalize::(doc, op_name)?; + Ok(blake3::hash(normalized.as_bytes()).to_hex().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bluejay_parser::ast::{executable::ExecutableDocument as ParserDoc, Parse}; + + fn parse(input: &str) -> ParserDoc { + ParserDoc::parse(input).result.expect("parse error") + } + + #[test] + fn fields_sorted() { + let doc = parse("{ z a m }"); + assert_eq!(normalize(&doc, None).unwrap(), "query{a m z}"); + } + + #[test] + fn arguments_sorted() { + let doc = parse("{ field(z: 1, a: 2, m: 3) }"); + assert_eq!(normalize(&doc, None).unwrap(), "query{field(a:0,m:0,z:0)}"); + } + + #[test] + fn variables_sorted() { + let doc = parse("query Foo($z: String, $a: Int, $m: Boolean) { field }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query Foo($a:Int,$m:Boolean,$z:String){field}" + ); + } + + #[test] + fn string_value_normalized() { + let doc = parse(r#"{ field(arg: "hello world") }"#); + assert_eq!(normalize(&doc, None).unwrap(), r#"query{field(arg:"")}"#); + } + + #[test] + fn numeric_values_normalized() { + let doc = parse("{ field(a: 42, b: 3.14) }"); + assert_eq!(normalize(&doc, None).unwrap(), "query{field(a:0,b:0)}"); + } + + #[test] + fn object_value_normalized() { + let doc = parse(r#"{ field(arg: { z: "hello", a: 42, m: true }) }"#); + assert_eq!( + normalize(&doc, None).unwrap(), + r#"query{field(arg:{a:0,m:true,z:""})}"# + ); + } + + #[test] + fn list_value_normalized() { + let doc = parse("{ field(arg: [1, 2, 3]) }"); + assert_eq!(normalize(&doc, None).unwrap(), "query{field(arg:[])}"); + } + + #[test] + fn preserved_values() { + let doc = parse("{ field(a: true, b: false, c: null, d: SOME_ENUM) }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query{field(a:true,b:false,c:null,d:SOME_ENUM)}" + ); + } + + #[test] + fn variable_reference_preserved() { + let doc = parse("query($x: String) { field(arg: $x) }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query($x:String){field(arg:$x)}" + ); + } + + #[test] + fn fragment_ordering() { + let doc = parse( + "query { ...Z ...A } + fragment Z on Query { z } + fragment A on Query { a }", + ); + assert_eq!( + normalize(&doc, None).unwrap(), + "fragment A on Query{a}fragment Z on Query{z}query{...A ...Z}" + ); + } + + #[test] + fn unused_fragments_excluded() { + let doc = parse( + "query { ...Used } + fragment Used on Query { a } + fragment Unused on Query { b }", + ); + assert_eq!( + normalize(&doc, None).unwrap(), + "fragment Used on Query{a}query{...Used}" + ); + } + + #[test] + fn selection_sort_order() { + let doc = parse( + "query { + ... on Query { inlined } + ...Frag + aliased: field + regular + } + fragment Frag on Query { x }", + ); + assert_eq!( + normalize(&doc, None).unwrap(), + "fragment Frag on Query{x}query{aliased:field regular ...Frag ... on Query{inlined}}" + ); + } + + #[test] + fn directive_sorting() { + let doc = parse("query @z @a @m { field }"); + assert_eq!(normalize(&doc, None).unwrap(), "query@a@m@z{field}"); + } + + #[test] + fn alias_format() { + let doc = parse("{ myAlias: someField }"); + assert_eq!(normalize(&doc, None).unwrap(), "query{myAlias:someField}"); + } + + #[test] + fn error_operation_not_found() { + let doc = parse("query Foo { a }"); + assert_eq!( + normalize(&doc, Some("Bar")), + Err(SignatureError::OperationNotFound("Bar".to_string())) + ); + } + + #[test] + fn error_ambiguous_operation() { + let doc = parse("query A { a } query B { b }"); + assert_eq!( + normalize(&doc, None), + Err(SignatureError::AmbiguousOperation) + ); + } + + #[test] + fn named_operation_selection() { + let doc = parse("query A { a } query B { b }"); + assert_eq!(normalize(&doc, Some("B")).unwrap(), "query B{b}"); + } + + #[test] + fn idempotency() { + let input = "query Foo($a: Int, $b: String) @dir { b a field(x: 1) }"; + let doc1 = parse(input); + let normalized1 = normalize(&doc1, None).unwrap(); + let doc2 = parse(&normalized1); + let normalized2 = normalize(&doc2, None).unwrap(); + assert_eq!(normalized1, normalized2); + } + + #[test] + fn signature_hash() { + let doc = parse("{ field }"); + let normalized = normalize(&doc, None).unwrap(); + assert_eq!(normalized, "query{field}"); + let sig = signature(&doc, None).unwrap(); + let expected = blake3::hash(b"query{field}").to_hex().to_string(); + assert_eq!(sig, expected); + } + + #[test] + fn nested_selection_sorting() { + let doc = parse("{ parent { z a m } }"); + assert_eq!(normalize(&doc, None).unwrap(), "query{parent{a m z}}"); + } + + #[test] + fn default_value_normalization() { + let doc = parse(r#"query($x: String = "hello", $y: Int = 42) { field }"#); + assert_eq!( + normalize(&doc, None).unwrap(), + r#"query($x:String="",$y:Int=0){field}"# + ); + } + + #[test] + fn mutation() { + let doc = parse("mutation { doThing }"); + assert_eq!(normalize(&doc, None).unwrap(), "mutation{doThing}"); + } + + #[test] + fn subscription() { + let doc = parse("subscription { onEvent }"); + assert_eq!(normalize(&doc, None).unwrap(), "subscription{onEvent}"); + } + + #[test] + fn transitive_fragments() { + let doc = parse( + "query { ...A } + fragment A on Query { ...B a } + fragment B on Query { ...C b } + fragment C on Query { c } + fragment Unused on Query { unused }", + ); + let result = normalize(&doc, None).unwrap(); + assert!(result.contains("fragment A on Query")); + assert!(result.contains("fragment B on Query")); + assert!(result.contains("fragment C on Query")); + assert!(!result.contains("Unused")); + } + + #[test] + fn inline_fragment_no_type_condition() { + let doc = parse("{ ... @include(if: true) { field } }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query{...@include(if:true){field}}" + ); + } + + #[test] + fn complex_variable_types() { + let doc = parse("query($a: [String!]!, $b: [[Int]]) { field }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query($a:[String!]!,$b:[[Int]]){field}" + ); + } + + #[test] + fn directive_with_arguments() { + let doc = parse("{ field @custom(z: 1, a: 2) }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query{field@custom(a:0,z:0)}" + ); + } + + #[test] + fn multiple_aliased_fields_sorted_by_alias() { + let doc = parse("{ z: field1 a: field2 m: field3 }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query{a:field2 m:field3 z:field1}" + ); + } + + #[test] + fn mixed_aliased_and_non_aliased() { + let doc = parse("{ z: field1 b a: field2 c }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query{a:field2 b c z:field1}" + ); + } + + #[test] + fn inline_fragment_tie_breaker_is_canonical() { + let a_then_b = parse("query { ... on Query { a } ... on Query { b } }"); + let b_then_a = parse("query { ... on Query { b } ... on Query { a } }"); + + assert_eq!(normalize(&a_then_b, None), normalize(&b_then_a, None)); + } + + #[test] + fn aliased_field_tie_breaker_is_canonical() { + let first = parse("query { x: b x: a }"); + let second = parse("query { x: a x: b }"); + + assert_eq!(normalize(&first, None), normalize(&second, None)); + } + + #[test] + fn error_no_operations() { + let doc = parse("fragment F on Query { a }"); + assert_eq!(normalize(&doc, None), Err(SignatureError::NoOperations)); + } + + #[test] + fn float_normalized_to_zero() { + let doc = parse("{ field(arg: 3.14) }"); + assert_eq!(normalize(&doc, None).unwrap(), "query{field(arg:0)}"); + } + + #[test] + fn fragment_with_directives() { + let doc = parse( + "query { ...F } + fragment F on Query @deprecated { a }", + ); + assert_eq!( + normalize(&doc, None).unwrap(), + "fragment F on Query@deprecated{a}query{...F}" + ); + } + + #[test] + fn variable_definition_directives() { + let doc = parse("query($x: String @deprecated) { field }"); + assert_eq!( + normalize(&doc, None).unwrap(), + "query($x:String@deprecated){field}" + ); + } + + #[test] + fn duplicate_fields_preserved() { + let doc = parse("{ a a a }"); + assert_eq!(normalize(&doc, None).unwrap(), "query{a a a}"); + } + + #[test] + fn nested_object_default_value() { + let doc = parse(r#"query($x: Input = { nested: { key: "val" } }) { field }"#); + assert_eq!( + normalize(&doc, None).unwrap(), + r#"query($x:Input={nested:{key:""}}){field}"# + ); + } +} diff --git a/bluejay-operation-normalizer/src/normalize.rs b/bluejay-operation-normalizer/src/normalize.rs new file mode 100644 index 0000000..f739a69 --- /dev/null +++ b/bluejay-operation-normalizer/src/normalize.rs @@ -0,0 +1,332 @@ +use bluejay_core::executable::{ + ExecutableDocument, Field, FragmentDefinition, FragmentSpread, InlineFragment, + OperationDefinition, OperationDefinitionReference, Selection, SelectionReference, + VariableDefinition, VariableType, VariableTypeReference, +}; +use bluejay_core::{ + Argument, AsIter, Directive, ObjectValue, OperationType, Value, ValueReference, Variable, +}; +use std::collections::HashMap; + +use crate::fragments::collect_used_fragments; +use crate::sort::SelectionSortKey; +use crate::SignatureError; + +pub(crate) fn normalize_doc( + doc: &E, + op_name: Option<&str>, +) -> Result { + let fragment_defs: HashMap<&str, &E::FragmentDefinition> = + doc.fragment_definitions().map(|f| (f.name(), f)).collect(); + + let operation = resolve_operation::(doc, op_name)?; + let op_ref = operation.as_ref(); + let used_fragments = collect_used_fragments::(op_ref.selection_set(), &fragment_defs); + + let mut output = String::with_capacity(256); + + for frag_name in &used_fragments { + if let Some(frag_def) = fragment_defs.get(frag_name) { + write_fragment_definition::(&mut output, frag_def); + } + } + + write_operation::(&mut output, &op_ref); + + Ok(output) +} + +fn resolve_operation<'a, E: ExecutableDocument>( + doc: &'a E, + op_name: Option<&str>, +) -> Result<&'a E::OperationDefinition, SignatureError> { + match op_name { + Some(name) => doc + .operation_definitions() + .find(|op| op.as_ref().name() == Some(name)) + .ok_or_else(|| SignatureError::OperationNotFound(name.to_string())), + None => { + let mut iter = doc.operation_definitions(); + let first = iter.next().ok_or(SignatureError::NoOperations)?; + if iter.next().is_some() { + return Err(SignatureError::AmbiguousOperation); + } + Ok(first) + } + } +} + +// ===================================================================== +// Writing functions +// ===================================================================== + +fn write_operation( + out: &mut String, + op_ref: &OperationDefinitionReference<'_, E::OperationDefinition>, +) { + out.push_str(match op_ref.operation_type() { + OperationType::Query => "query", + OperationType::Mutation => "mutation", + OperationType::Subscription => "subscription", + }); + + if let Some(name) = op_ref.name() { + out.push(' '); + out.push_str(name); + } + + if let Some(var_defs) = op_ref.variable_definitions() { + write_variable_definitions::(out, var_defs); + } + + if let Some(directives) = op_ref.directives() { + write_directives::(out, directives); + } + + write_selection_set::(out, op_ref.selection_set()); +} + +fn write_fragment_definition( + out: &mut String, + frag: &E::FragmentDefinition, +) { + out.push_str("fragment "); + out.push_str(frag.name()); + out.push_str(" on "); + out.push_str(frag.type_condition()); + + if let Some(directives) = frag.directives() { + write_directives::(out, directives); + } + + write_selection_set::(out, frag.selection_set()); +} + +fn write_selection_set(out: &mut String, ss: &E::SelectionSet) { + let mut selections: Vec<&E::Selection> = ss.iter().collect(); + + if selections.len() > 1 { + selections + .sort_unstable_by(|a, b| sort_key::(&a.as_ref()).cmp(&sort_key::(&b.as_ref()))); + + // Tie-break groups with equal sort keys via compact rendering + let mut group_start = 0; + while group_start < selections.len() { + let start_key = sort_key::(&selections[group_start].as_ref()); + let mut group_end = group_start + 1; + while group_end < selections.len() + && sort_key::(&selections[group_end].as_ref()) == start_key + { + group_end += 1; + } + if group_end - group_start > 1 { + selections[group_start..group_end] + .sort_by_cached_key(|sel| render_selection_compact::(sel)); + } + group_start = group_end; + } + } + + out.push('{'); + for (i, sel) in selections.iter().enumerate() { + if i > 0 { + out.push(' '); + } + write_selection::(out, &sel.as_ref()); + } + out.push('}'); +} + +fn sort_key<'a, E: ExecutableDocument>( + sel: &SelectionReference<'a, E::Selection>, +) -> SelectionSortKey<'a> { + match sel { + SelectionReference::Field(f) => SelectionSortKey::Field(f.alias().unwrap_or(f.name())), + SelectionReference::FragmentSpread(s) => SelectionSortKey::FragmentSpread(s.name()), + SelectionReference::InlineFragment(i) => { + SelectionSortKey::InlineFragment(i.type_condition()) + } + } +} + +fn render_selection_compact(sel: &E::Selection) -> String { + let mut out = String::with_capacity(64); + write_selection::(&mut out, &sel.as_ref()); + out +} + +fn write_selection( + out: &mut String, + sel: &SelectionReference<'_, E::Selection>, +) { + match sel { + SelectionReference::Field(f) => write_field::(out, f), + SelectionReference::FragmentSpread(s) => write_fragment_spread::(out, s), + SelectionReference::InlineFragment(i) => write_inline_fragment::(out, i), + } +} + +fn write_field(out: &mut String, field: &E::Field) { + if let Some(alias) = field.alias() { + out.push_str(alias); + out.push(':'); + } + out.push_str(field.name()); + + if let Some(args) = field.arguments() { + write_arguments::(out, args); + } + + if let Some(directives) = field.directives() { + write_directives::(out, directives); + } + + if let Some(ss) = field.selection_set() { + write_selection_set::(out, ss); + } +} + +fn write_fragment_spread(out: &mut String, spread: &E::FragmentSpread) { + out.push_str("..."); + out.push_str(spread.name()); + + if let Some(directives) = spread.directives() { + write_directives::(out, directives); + } +} + +fn write_inline_fragment(out: &mut String, inline: &E::InlineFragment) { + out.push_str("..."); + if let Some(tc) = inline.type_condition() { + out.push_str(" on "); + out.push_str(tc); + } + + if let Some(directives) = inline.directives() { + write_directives::(out, directives); + } + + write_selection_set::(out, inline.selection_set()); +} + +fn write_variable_definitions( + out: &mut String, + var_defs: &E::VariableDefinitions, +) { + let mut vars: Vec<_> = var_defs.iter().collect(); + if vars.is_empty() { + return; + } + vars.sort_unstable_by(|a, b| a.variable().cmp(b.variable())); + + out.push('('); + for (i, var) in vars.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push('$'); + out.push_str(var.variable()); + out.push(':'); + write_variable_type::(out, &var.r#type().as_ref()); + if let Some(default) = var.default_value() { + out.push('='); + write_value::(out, default); + } + if let Some(directives) = var.directives() { + write_directives::(out, directives); + } + } + out.push(')'); +} + +fn write_variable_type( + out: &mut String, + vt: &VariableTypeReference<'_, E::VariableType>, +) { + match vt { + VariableTypeReference::Named(name, required) => { + out.push_str(name); + if *required { + out.push('!'); + } + } + VariableTypeReference::List(inner, required) => { + out.push('['); + write_variable_type::(out, &inner.as_ref()); + out.push(']'); + if *required { + out.push('!'); + } + } + } +} + +fn write_directives( + out: &mut String, + directives: &E::Directives, +) { + let mut dirs: Vec<_> = directives.iter().collect(); + dirs.sort_unstable_by(|a, b| a.name().cmp(b.name())); + + for dir in &dirs { + out.push('@'); + out.push_str(dir.name()); + if let Some(args) = dir.arguments() { + write_arguments::(out, args); + } + } +} + +fn write_arguments( + out: &mut String, + args: &E::Arguments, +) { + let mut arg_list: Vec<_> = args.iter().collect(); + if arg_list.is_empty() { + return; + } + arg_list.sort_unstable_by(|a, b| a.name().cmp(b.name())); + + out.push('('); + for (i, arg) in arg_list.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(arg.name()); + out.push(':'); + write_value::(out, arg.value()); + } + out.push(')'); +} + +fn write_value( + out: &mut String, + value: &E::Value, +) { + match value.as_ref() { + ValueReference::Variable(var) => { + out.push('$'); + out.push_str(var.name()); + } + ValueReference::Integer(_) | ValueReference::Float(_) => out.push('0'), + ValueReference::String(_) => out.push_str("\"\""), + ValueReference::Boolean(b) => out.push_str(if b { "true" } else { "false" }), + ValueReference::Null => out.push_str("null"), + ValueReference::Enum(e) => out.push_str(e), + ValueReference::List(_) => out.push_str("[]"), + ValueReference::Object(obj) => { + let mut entries: Vec<_> = obj.iter().collect(); + entries.sort_unstable_by(|a, b| a.0.as_ref().cmp(b.0.as_ref())); + out.push('{'); + for (i, (key, val)) in entries.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(key.as_ref()); + out.push(':'); + write_value::(out, val); + } + out.push('}'); + } + } +} diff --git a/bluejay-operation-normalizer/src/sort.rs b/bluejay-operation-normalizer/src/sort.rs new file mode 100644 index 0000000..aa66e41 --- /dev/null +++ b/bluejay-operation-normalizer/src/sort.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum SelectionSortKey<'a> { + Field(&'a str), + FragmentSpread(&'a str), + InlineFragment(Option<&'a str>), +} diff --git a/bluejay-parser/src/ast/executable/executable_document.rs b/bluejay-parser/src/ast/executable/executable_document.rs index 892fc65..fc18598 100644 --- a/bluejay-parser/src/ast/executable/executable_document.rs +++ b/bluejay-parser/src/ast/executable/executable_document.rs @@ -190,7 +190,7 @@ mod tests { .result .unwrap_err(); - assert_eq!(1, errors.len(), "{:?}", errors); + assert_eq!(1, errors.len(), "{errors:?}"); assert_eq!("Max depth exceeded", errors[0].message()); diff --git a/bluejay-typegen-codegen/src/executable_definition/intermediate_representation.rs b/bluejay-typegen-codegen/src/executable_definition/intermediate_representation.rs index 6c7f1a9..725a282 100644 --- a/bluejay-typegen-codegen/src/executable_definition/intermediate_representation.rs +++ b/bluejay-typegen-codegen/src/executable_definition/intermediate_representation.rs @@ -219,11 +219,11 @@ impl ExecutableStruct<'_> { fn prefix_for_schema_definition_module(&self) -> impl Iterator { // root is one level higher than the executable/query module - std::iter::repeat(Default::default()).take(self.depth + 1) + std::iter::repeat_n(Default::default(), self.depth + 1) } fn prefix_for_executable_document_module(&self) -> impl Iterator { - std::iter::repeat(Default::default()).take(self.depth) + std::iter::repeat_n(Default::default(), self.depth) } } @@ -257,7 +257,7 @@ impl ExecutableEnum<'_> { pub enum WrappedExecutableType<'a> { /// a required type, unless wrapped in an `Optional` - Base(ExecutableType<'a>), + Base(Box>), /// an optional type Optional(Box>), /// a list type, required unless wrapped in an `Optional` @@ -533,13 +533,13 @@ impl<'a, E: ExecutableDocument, S: SchemaDefinition, C: CodeGenerator> } } OutputTypeReference::Base(inner, required) => { - let base_type = WrappedExecutableType::Base(self.build_base_type( + let base_type = WrappedExecutableType::Base(Box::new(self.build_base_type( field.response_name(), field.selection_set(), inner, depth, path, - )); + ))); if required { base_type } else { diff --git a/bluejay-typegen-codegen/src/input.rs b/bluejay-typegen-codegen/src/input.rs index 373f725..d368cb7 100644 --- a/bluejay-typegen-codegen/src/input.rs +++ b/bluejay-typegen-codegen/src/input.rs @@ -57,7 +57,7 @@ impl DocumentInput { let file_path = base_path.join(filename.value()); std::fs::read_to_string(file_path) - .map_err(|err| syn::Error::new(filename.span(), format!("{}", err))) + .map_err(|err| syn::Error::new(filename.span(), format!("{err}"))) } } @@ -117,7 +117,7 @@ pub(crate) fn parse_key_value_with( if value.is_some() { return Err(syn::Error::new( key.span(), - format!("Duplicate entry for `{}`", key), + format!("Duplicate entry for `{key}`"), )); } diff --git a/bluejay-validator/src/executable/operation/analyzers/input_size.rs b/bluejay-validator/src/executable/operation/analyzers/input_size.rs index 8468bc1..ae85185 100644 --- a/bluejay-validator/src/executable/operation/analyzers/input_size.rs +++ b/bluejay-validator/src/executable/operation/analyzers/input_size.rs @@ -96,7 +96,7 @@ fn find_input_size_offenders_arguments< offenders, variable_values, variable_definitions, - format!("{}.{}", argument_name, index), + format!("{argument_name}.{index}"), item, ); }) @@ -170,7 +170,7 @@ fn find_input_size_offenders_variables( max_length, offenders, - format!("{}.{}", argument_name, index), + format!("{argument_name}.{index}"), item, ); }) diff --git a/bluejay-validator/src/executable/operation/analyzers/variable_values_are_valid.rs b/bluejay-validator/src/executable/operation/analyzers/variable_values_are_valid.rs index 014c6df..c89dbdd 100644 --- a/bluejay-validator/src/executable/operation/analyzers/variable_values_are_valid.rs +++ b/bluejay-validator/src/executable/operation/analyzers/variable_values_are_valid.rs @@ -209,13 +209,7 @@ mod tests { "#, None, &serde_json::json!({}), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" @@ -244,13 +238,7 @@ mod tests { "#, None, &serde_json::json!({}), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" @@ -260,13 +248,7 @@ mod tests { "#, None, &serde_json::json!({ "arg": "value" }), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" @@ -276,13 +258,7 @@ mod tests { "#, None, &serde_json::json!({ "arg": null }), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" @@ -311,13 +287,7 @@ mod tests { "#, None, &serde_json::json!({ "arg": "value" }), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" @@ -356,13 +326,7 @@ mod tests { "#, None, &serde_json::json!({}), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" @@ -372,13 +336,7 @@ mod tests { "#, None, &serde_json::json!({}), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" @@ -388,13 +346,7 @@ mod tests { "#, None, &serde_json::json!({ "arg": "value" }), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" @@ -404,13 +356,7 @@ mod tests { "#, None, &serde_json::json!({ "arg": null }), - |errors| { - assert!( - errors.is_empty(), - "Expected errors to be empty: {:?}", - errors - ) - }, + |errors| assert!(errors.is_empty(), "Expected errors to be empty: {errors:?}",), ); validate_variable_values( r#" diff --git a/bluejay-validator/src/executable/operation/orchestrator.rs b/bluejay-validator/src/executable/operation/orchestrator.rs index bb9b41f..d54d369 100644 --- a/bluejay-validator/src/executable/operation/orchestrator.rs +++ b/bluejay-validator/src/executable/operation/orchestrator.rs @@ -384,7 +384,9 @@ pub enum OperationResolutionError<'a> { impl OperationResolutionError<'_> { pub fn message(&self) -> Cow<'static, str> { match self { - Self::NoOperationWithName { name } => format!("No operation defined with name {}", name).into(), + Self::NoOperationWithName { name } => { + format!("No operation defined with name {name}").into() + } Self::AnonymousNotEligible => "Anonymous operation can only be used when the document contains exactly one operation definition".into(), } } diff --git a/bluejay-validator/src/path.rs b/bluejay-validator/src/path.rs index a877cba..21b95c4 100644 --- a/bluejay-validator/src/path.rs +++ b/bluejay-validator/src/path.rs @@ -21,8 +21,8 @@ impl From for PathElement<'_> { impl std::fmt::Display for PathElement<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PathElement::Key(s) => write!(f, "{}", s), - PathElement::Index(i) => write!(f, "{}", i), + PathElement::Key(s) => write!(f, "{s}"), + PathElement::Index(i) => write!(f, "{i}"), } } }