diff --git a/README.md b/README.md index be79ff35f..9e605b077 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,8 @@ supported: - Interfaces & union types - Introspection via [`cynic-cli`][6] or [`cynic-introspection`][7] - GraphQL Subscriptions via [`graphql-ws-client`][8]. - -The following features are not currently supported, but may be one day. - -- Directives -- Potentially other things (please open an issue if you find anything obviously - missing) +- Field directives (`@skip`, `@include` and any custom directives that don't + require client support) ### Documentation diff --git a/cynic-codegen/src/error.rs b/cynic-codegen/src/error.rs index a559b07f2..16c169506 100644 --- a/cynic-codegen/src/error.rs +++ b/cynic-codegen/src/error.rs @@ -61,6 +61,16 @@ impl std::iter::FromIterator for Errors { } } +impl IntoIterator for Errors { + type Item = syn::Error; + + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.errors.into_iter() + } +} + impl From for Errors { fn from(err: syn::Error) -> Errors { Errors { errors: vec![err] } diff --git a/cynic-codegen/src/fragment_derive/arguments/analyse.rs b/cynic-codegen/src/fragment_derive/arguments/analyse.rs index 109f3a4be..fe30a2493 100644 --- a/cynic-codegen/src/fragment_derive/arguments/analyse.rs +++ b/cynic-codegen/src/fragment_derive/arguments/analyse.rs @@ -15,12 +15,18 @@ use { }; #[derive(Debug, PartialEq)] -pub struct AnalysedArguments<'a> { +pub struct AnalysedFieldArguments<'a> { pub schema_field: schema::Field<'a>, pub arguments: Vec>, pub variants: Vec>>, } +#[derive(Debug, PartialEq)] +pub struct AnalysedDirectiveArguments<'a> { + pub arguments: Vec>, + pub variants: Vec>>, +} + #[derive(Debug, PartialEq)] pub struct Object<'a> { pub schema_obj: InputObjectType<'a>, @@ -58,13 +64,13 @@ pub struct VariantDetails<'a> { pub(super) variant: String, } -pub fn analyse<'a>( +pub fn analyse_field_arguments<'a>( schema: &'a Schema<'a, Unvalidated>, literals: Vec, field: &schema::Field<'a>, variables_fields: Option<&syn::Path>, span: Span, -) -> Result, Errors> { +) -> Result, Errors> { let mut analysis = Analysis { variables_fields, variants: HashSet::new(), @@ -75,13 +81,36 @@ pub fn analyse<'a>( let mut variants = analysis.variants.into_iter().collect::>(); variants.sort_by_key(|v| (v.en.name.clone(), v.variant.clone())); - Ok(AnalysedArguments { + Ok(AnalysedFieldArguments { schema_field: field.clone(), arguments, variants, }) } +pub fn analyse_directive_arguments<'a>( + schema: &'a Schema<'a, Unvalidated>, + literals: Vec, + directive: &schema::Directive<'a>, + variables_fields: Option<&syn::Path>, + span: Span, +) -> Result, Errors> { + let mut analysis = Analysis { + variables_fields, + variants: HashSet::new(), + }; + + let arguments = analyse_fields(&mut analysis, literals, &directive.arguments, span, schema)?; + + let mut variants = analysis.variants.into_iter().collect::>(); + variants.sort_by_key(|v| (v.en.name.clone(), v.variant.clone())); + + Ok(AnalysedDirectiveArguments { + arguments, + variants, + }) +} + struct Analysis<'schema, 'a> { variables_fields: Option<&'a syn::Path>, variants: HashSet>>, diff --git a/cynic-codegen/src/fragment_derive/arguments/mod.rs b/cynic-codegen/src/fragment_derive/arguments/mod.rs index df2b595cd..69722a218 100644 --- a/cynic-codegen/src/fragment_derive/arguments/mod.rs +++ b/cynic-codegen/src/fragment_derive/arguments/mod.rs @@ -1,5 +1,5 @@ -mod analyse; -mod output; +pub(super) mod analyse; +pub(super) mod output; mod parsing; use proc_macro2::Span; @@ -9,7 +9,12 @@ use crate::{ schema::{Schema, Unvalidated}, }; -pub use self::{output::Output, parsing::arguments_from_field_attrs}; +pub use self::{ + output::Output, + parsing::{arguments_from_field_attrs, CynicArguments, FieldArgument, FieldArgumentValue}, +}; + +pub(super) use self::parsing::ArgumentLiteral; pub fn process_arguments<'a>( schema: &'a Schema<'a, Unvalidated>, @@ -19,7 +24,8 @@ pub fn process_arguments<'a>( variables_fields: Option<&syn::Path>, span: Span, ) -> Result, Errors> { - let analysed = analyse::analyse(schema, literals, field, variables_fields, span)?; + let analysed = + analyse::analyse_field_arguments(schema, literals, field, variables_fields, span)?; Ok(Output { analysed, diff --git a/cynic-codegen/src/fragment_derive/arguments/output.rs b/cynic-codegen/src/fragment_derive/arguments/output.rs index b54517baf..4d138ca27 100644 --- a/cynic-codegen/src/fragment_derive/arguments/output.rs +++ b/cynic-codegen/src/fragment_derive/arguments/output.rs @@ -2,10 +2,10 @@ use quote::{format_ident, quote, ToTokens, TokenStreamExt}; use crate::idents::to_pascal_case; -use super::analyse::{AnalysedArguments, ArgumentValue, VariantDetails}; +use super::analyse::{AnalysedFieldArguments, ArgumentValue, VariantDetails}; pub struct Output<'a> { - pub(super) analysed: AnalysedArguments<'a>, + pub(super) analysed: AnalysedFieldArguments<'a>, pub(super) schema_module: syn::Path, } @@ -59,9 +59,9 @@ impl ToTokens for Output<'_> { } } -struct ArgumentValueTokens<'a> { - value: &'a ArgumentValue<'a>, - schema_module: &'a syn::Path, +pub struct ArgumentValueTokens<'a> { + pub value: &'a ArgumentValue<'a>, + pub schema_module: &'a syn::Path, } impl<'a> ArgumentValueTokens<'a> { @@ -138,9 +138,13 @@ impl<'a> VariantDetails<'a> { } } -struct VariantDetailsTokens<'a> { - details: &'a VariantDetails<'a>, - schema_module: &'a syn::Path, +/// Tokens for serializing an enum variant literal. +/// +/// We can't rely on any types outside of our derive for these so we need to construct +/// individual structs for each variant that we need to serialize. +pub struct VariantDetailsTokens<'a> { + pub details: &'a VariantDetails<'a>, + pub schema_module: &'a syn::Path, } impl<'a> quote::ToTokens for VariantDetailsTokens<'a> { diff --git a/cynic-codegen/src/fragment_derive/arguments/parsing.rs b/cynic-codegen/src/fragment_derive/arguments/parsing.rs index d3c361862..c6ffffebf 100644 --- a/cynic-codegen/src/fragment_derive/arguments/parsing.rs +++ b/cynic-codegen/src/fragment_derive/arguments/parsing.rs @@ -34,6 +34,12 @@ impl Parse for CynicArguments { } } +impl CynicArguments { + pub fn into_inner(self) -> Vec { + self.arguments.into_iter().collect() + } +} + #[derive(Debug, Clone)] pub struct FieldArgument { pub argument_name: Ident, @@ -79,7 +85,7 @@ pub enum ArgumentLiteral { } impl ArgumentLiteral { - pub(super) fn span(&self) -> Span { + pub fn span(&self) -> Span { match self { ArgumentLiteral::Literal(lit) => lit.span(), ArgumentLiteral::Enum(ident) => ident.span(), diff --git a/cynic-codegen/src/fragment_derive/arguments/tests.rs b/cynic-codegen/src/fragment_derive/arguments/tests.rs index 9c1e13687..c4de10a60 100644 --- a/cynic-codegen/src/fragment_derive/arguments/tests.rs +++ b/cynic-codegen/src/fragment_derive/arguments/tests.rs @@ -4,7 +4,7 @@ use syn::parse_quote; use crate::schema::{types::Type, Schema, SchemaInput}; -use super::{analyse::analyse, parsing::CynicArguments}; +use super::{analyse::analyse_field_arguments, parsing::CynicArguments}; #[rstest] #[case::scalars("scalars", "someScalarParams", parse_quote! { anInt: 1, aFloat: 3, anId: "hello" })] @@ -34,7 +34,7 @@ fn test_analyse( insta::assert_debug_snapshot!( snapshot_name, - analyse( + analyse_field_arguments( &schema, literals, field, @@ -55,9 +55,14 @@ fn test_analyse_errors_without_argument_struct() { parse_quote! { filters: $aVaraible, optionalFilters: $anotherVar }; let literals = literals.arguments.into_iter().collect::>(); - insta::assert_debug_snapshot!( - analyse(&schema, literals, field, None, Span::call_site()).map(|o| o.arguments) + insta::assert_debug_snapshot!(analyse_field_arguments( + &schema, + literals, + field, + None, + Span::call_site() ) + .map(|o| o.arguments)) } const SCHEMA: &str = r#" diff --git a/cynic-codegen/src/fragment_derive/deserialize_impl.rs b/cynic-codegen/src/fragment_derive/deserialize_impl.rs index 486726fe2..e3afb35be 100644 --- a/cynic-codegen/src/fragment_derive/deserialize_impl.rs +++ b/cynic-codegen/src/fragment_derive/deserialize_impl.rs @@ -32,6 +32,7 @@ struct Field { is_flattened: bool, is_recurse: bool, is_feature_flagged: bool, + is_skippable: bool, } impl<'a> DeserializeImpl<'a> { @@ -40,7 +41,7 @@ impl<'a> DeserializeImpl<'a> { name: &'a syn::Ident, generics: &'a syn::Generics, ) -> Self { - let spreading = fields.iter().any(|f| *f.0.spread); + let spreading = fields.iter().any(|f| f.0.spread()); let target_struct = name; let fields = fields @@ -123,6 +124,10 @@ impl quote::ToTokens for StandardDeserializeImpl<'_> { quote_spanned!{ span => let #rust_name = #rust_name.unwrap_or_default(); } + } else if field.is_skippable { + quote! { + let #rust_name = #rust_name.unwrap_or_default(); + } } else { quote! { let #rust_name = #rust_name.ok_or_else(|| cynic::serde::de::Error::missing_field(#serialized_name))?; @@ -269,7 +274,7 @@ impl quote::ToTokens for SpreadingDeserializeImpl<'_> { fn process_field(field: &FragmentDeriveField, schema_field: Option<&schema::Field<'_>>) -> Field { // Should be ok to unwrap since we only accept struct style input - let rust_name = field.ident.as_ref().unwrap(); + let rust_name = field.ident().unwrap(); let field_variant_name = rust_name.clone(); Field { @@ -278,10 +283,11 @@ fn process_field(field: &FragmentDeriveField, schema_field: Option<&schema::Fiel .alias() .or_else(|| schema_field.map(|f| f.name.as_str().to_string())), rust_name: rust_name.clone(), - ty: field.ty.clone(), - is_spread: *field.spread, - is_flattened: *field.flatten, - is_recurse: field.recurse.is_some(), - is_feature_flagged: field.feature.is_some(), + ty: field.raw_field.ty.clone(), + is_spread: field.spread(), + is_flattened: *field.raw_field.flatten, + is_recurse: field.raw_field.recurse.is_some(), + is_feature_flagged: field.raw_field.feature.is_some(), + is_skippable: field.is_skippable(), } } diff --git a/cynic-codegen/src/fragment_derive/directives/mod.rs b/cynic-codegen/src/fragment_derive/directives/mod.rs new file mode 100644 index 000000000..22ef0509c --- /dev/null +++ b/cynic-codegen/src/fragment_derive/directives/mod.rs @@ -0,0 +1,108 @@ +mod output; +mod parsing; + +use proc_macro2::Span; +use syn::Ident; + +use crate::{ + error::Errors, + schema::{types::Directive, Schema, Unvalidated}, +}; + +pub use self::{ + output::Output, + parsing::{directives_from_field_attrs, FieldDirective}, +}; + +use super::arguments::{analyse::AnalysedDirectiveArguments, ArgumentLiteral, FieldArgument}; + +pub struct AnalysedFieldDirective<'a> { + directive: Directive<'a>, + arguments: AnalysedDirectiveArguments<'a>, +} + +pub fn process_directive<'a>( + schema: &'a Schema<'a, Unvalidated>, + directive: parsing::FieldDirective, + variables_fields: Option<&syn::Path>, + span: Span, +) -> Result, Errors> { + match directive { + FieldDirective::Skip(inner) => { + let Some(directive) = schema.lookup_directive("skip")? else { + return Err(syn::Error::new(span, "Unknown directive: skip").into()); + }; + + let arguments = vec![FieldArgument { + argument_name: Ident::new("if", span), + value: super::arguments::FieldArgumentValue::Literal(match inner { + parsing::BooleanLiteral::Boolean(value) => { + ArgumentLiteral::Literal(syn::Lit::Bool(syn::LitBool { value, span })) + } + parsing::BooleanLiteral::Variable(var, span) => { + ArgumentLiteral::Variable(var, span) + } + }), + }]; + + let arguments = super::arguments::analyse::analyse_directive_arguments( + schema, + arguments, + &directive, + variables_fields, + span, + )?; + + Ok(AnalysedFieldDirective { + directive, + arguments, + }) + } + FieldDirective::Include(inner) => { + let Some(directive) = schema.lookup_directive("include")? else { + return Err(syn::Error::new(span, "Unknown directive: include").into()); + }; + let arguments = vec![FieldArgument { + argument_name: Ident::new("if", span), + value: super::arguments::FieldArgumentValue::Literal(match inner { + parsing::BooleanLiteral::Boolean(value) => { + ArgumentLiteral::Literal(syn::Lit::Bool(syn::LitBool { value, span })) + } + parsing::BooleanLiteral::Variable(var, span) => { + ArgumentLiteral::Variable(var, span) + } + }), + }]; + + let arguments = super::arguments::analyse::analyse_directive_arguments( + schema, + arguments, + &directive, + variables_fields, + span, + )?; + + Ok(AnalysedFieldDirective { + directive, + arguments, + }) + } + FieldDirective::Other { name, arguments } => { + let Some(directive) = schema.lookup_directive(&name.to_string())? else { + return Err(syn::Error::new(span, format!("Unknown directive: {name}")).into()); + }; + let arguments = super::arguments::analyse::analyse_directive_arguments( + schema, + arguments, + &directive, + variables_fields, + span, + )?; + + Ok(AnalysedFieldDirective { + directive, + arguments, + }) + } + } +} diff --git a/cynic-codegen/src/fragment_derive/directives/output.rs b/cynic-codegen/src/fragment_derive/directives/output.rs new file mode 100644 index 000000000..275701c20 --- /dev/null +++ b/cynic-codegen/src/fragment_derive/directives/output.rs @@ -0,0 +1,66 @@ +use quote::{quote, ToTokens, TokenStreamExt}; + +use crate::fragment_derive::arguments::output::{ArgumentValueTokens, VariantDetailsTokens}; + +use super::AnalysedFieldDirective; + +pub struct Output<'a> { + pub analysed: &'a AnalysedFieldDirective<'a>, + pub schema_module: &'a syn::Path, +} + +impl ToTokens for Output<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let schema_module = &self.schema_module; + + let directive_marker = self + .analysed + .directive + .marker_ident() + .to_path(schema_module); + + let argument_module = &self + .analysed + .directive + .argument_module() + .to_path(schema_module); + + let variant_structs = + self.analysed + .arguments + .variants + .iter() + .map(|details| VariantDetailsTokens { + details, + schema_module, + }); + + let arg_markers = self + .analysed + .arguments + .arguments + .iter() + .map(|arg| arg.schema_field.marker_ident().to_rust_ident()); + + let arg_values = self + .analysed + .arguments + .arguments + .iter() + .map(|arg| ArgumentValueTokens { + value: &arg.value, + schema_module, + }); + + tokens.append_all(quote! { + { + #(#variant_structs)* + let mut directive_builder = field_builder.directive::<#directive_marker>(); + #( + directive_builder.argument::<#argument_module::#arg_markers>() + #arg_values; + )* + } + }) + } +} diff --git a/cynic-codegen/src/fragment_derive/directives/parsing.rs b/cynic-codegen/src/fragment_derive/directives/parsing.rs new file mode 100644 index 000000000..9f14e0119 --- /dev/null +++ b/cynic-codegen/src/fragment_derive/directives/parsing.rs @@ -0,0 +1,229 @@ +use std::collections::HashSet; + +use proc_macro2::{Ident, Span}; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, + Meta, Token, +}; + +use crate::fragment_derive::arguments::FieldArgumentValue; + +use super::super::arguments::{ArgumentLiteral, CynicArguments, FieldArgument}; + +#[derive(Debug, Clone)] +pub enum FieldDirective { + Skip(BooleanLiteral), + Include(BooleanLiteral), + Other { + name: Ident, + arguments: Vec, + }, +} + +#[derive(Debug, Clone)] +pub enum BooleanLiteral { + Boolean(bool), + Variable(proc_macro2::Ident, Span), +} + +pub fn directives_from_field_attrs(attrs: &[syn::Attribute]) -> syn::Result> { + let mut directives = vec![]; + for attr in attrs { + if attr.path().is_ident("directives") { + let parsed: FieldDirectiveAttribute = attr.parse_args()?; + directives.extend(parsed.directives); + } + } + + Ok(directives) +} + +/// Implements syn::Parse to parse out arguments from the arguments +/// attribute. +#[derive(Debug)] +struct FieldDirectiveAttribute { + pub directives: Vec, +} + +impl Parse for FieldDirectiveAttribute { + fn parse(input: ParseStream<'_>) -> syn::Result { + let metas = Punctuated::::parse_terminated(input)?; + let mut directives = vec![]; + for meta in metas { + let span = meta.span(); + + let (path, arguments) = match meta { + Meta::Path(path) => (path, vec![]), + Meta::List(list) => ( + list.path, + syn::parse2::(list.tokens)?.into_inner(), + ), + Meta::NameValue(_) => { + return Err(syn::Error::new_spanned( + meta, + "directives cannot use name = value syntax", + )) + } + }; + + if path.is_ident("skip") { + let value = validate_if_or_skip(&arguments, span)?; + directives.push(FieldDirective::Skip(value)); + } else if path.is_ident("include") { + let value = validate_if_or_skip(&arguments, span)?; + directives.push(FieldDirective::Include(value)); + } else { + let Some(name) = path.get_ident().cloned() else { + return Err(syn::Error::new_spanned( + path, + "malformed directive - could not determine its name", + )); + }; + directives.push(FieldDirective::Other { name, arguments }) + } + } + + Ok(FieldDirectiveAttribute { directives }) + } +} + +fn validate_if_or_skip( + arguments: &[FieldArgument], + parent_span: Span, +) -> Result { + let mut already_seen = HashSet::new(); + let mut return_value = None; + for argument in arguments { + if argument.argument_name != "if" { + return Err(syn::Error::new( + argument.argument_name.span(), + format!("unknown argument: {}", argument.argument_name), + )); + } + if already_seen.contains(&argument.argument_name) { + return Err(syn::Error::new( + argument.argument_name.span(), + "duplicate argument", + )); + } + already_seen.insert(argument.argument_name.clone()); + match &argument.value { + FieldArgumentValue::Expression(expr) => { + return Err(syn::Error::new( + expr.span(), + "unsuppported syntax - use graphql argument syntax here", + )) + } + FieldArgumentValue::Literal(ArgumentLiteral::Literal(syn::Lit::Bool(bool))) => { + return_value = Some(BooleanLiteral::Boolean(bool.value)); + } + FieldArgumentValue::Literal(ArgumentLiteral::Variable(ident, span)) => { + return_value = Some(BooleanLiteral::Variable(ident.clone(), *span)); + } + FieldArgumentValue::Literal(other) => { + return Err(syn::Error::new( + other.span(), + "invalid argument for if: expected a booolean or variable", + )); + } + } + } + + let Some(return_value) = return_value else { + return Err(syn::Error::new(parent_span, "missing argument: if")); + }; + + Ok(return_value) +} + +#[cfg(test)] +mod test { + use assert_matches::assert_matches; + use quote::quote; + use syn::{parse2, parse_quote}; + + use super::*; + + #[test] + fn test_parsing_skip() { + let parsed: FieldDirectiveAttribute = parse_quote! { skip(if: true) }; + + let directives = parsed.directives; + + assert_eq!(directives.len(), 1); + assert_matches!( + directives[0], + FieldDirective::Skip(BooleanLiteral::Boolean(true)) + ); + } + + #[test] + fn test_parsing_include() { + let parsed: FieldDirectiveAttribute = parse_quote! { include(if: false) }; + + let directives = parsed.directives; + + assert_eq!(directives.len(), 1); + assert_matches!( + directives[0], + FieldDirective::Include(BooleanLiteral::Boolean(false)) + ); + } + + #[test] + fn test_parsing_boolean_literal_with_variables() { + let parsed: FieldDirectiveAttribute = parse_quote! { include(if: $someVariable) }; + + let directives = parsed.directives; + + assert_eq!(directives.len(), 1); + assert_matches!( + &directives[0], + FieldDirective::Include(BooleanLiteral::Variable(ident, _)) => { + assert_eq!(ident, "someVariable"); + } + ); + } + + #[test] + fn test_missing_argument() { + let err = parse2::(quote! { include() }).unwrap_err(); + insta::assert_display_snapshot!(err, @"missing argument: if"); + } + + #[test] + fn test_unknown_argument() { + let err = parse2::(quote! { include(if: true, other: false) }) + .unwrap_err(); + insta::assert_display_snapshot!(err, @"unknown argument: other"); + } + + #[test] + fn test_duplicate_argument() { + let err = + parse2::(quote! { include(if: true, if: false) }).unwrap_err(); + insta::assert_display_snapshot!(err, @"duplicate argument"); + } + + #[test] + fn test_non_boolean_argument() { + let err = parse2::(quote! { include(if: [true]) }).unwrap_err(); + insta::assert_display_snapshot!(err, @"invalid argument for if: expected a booolean or variable"); + } + + #[test] + fn test_other_directives() { + let parsed: FieldDirectiveAttribute = + parse_quote! { other(obj: {name: true}, list: ["hello"]) }; + + let directives = parsed.directives; + + assert_eq!(directives.len(), 1); + assert_matches!(&directives[0], FieldDirective::Other { name, arguments } => { + assert_eq!(name, "other"); + assert_eq!(arguments.len(), 2); + }); + } +} diff --git a/cynic-codegen/src/fragment_derive/fragment_impl.rs b/cynic-codegen/src/fragment_derive/fragment_impl.rs index ad399e43f..6da177583 100644 --- a/cynic-codegen/src/fragment_derive/fragment_impl.rs +++ b/cynic-codegen/src/fragment_derive/fragment_impl.rs @@ -16,6 +16,7 @@ use crate::{ use super::{ arguments::{arguments_from_field_attrs, process_arguments}, + directives::{process_directive, AnalysedFieldDirective}, fragment_derive_type::FragmentDeriveType, }; @@ -38,6 +39,7 @@ enum Selection<'a> { struct FieldSelection<'a> { rust_field_type: syn::Type, + schema_module_path: &'a syn::Path, field_marker_type_path: syn::Path, graphql_field_kind: FieldKind, graphql_field: &'a Field<'a>, @@ -47,6 +49,7 @@ struct FieldSelection<'a> { recurse_limit: Option, span: proc_macro2::Span, requires_feature: Option, + directives: Vec>, } struct SpreadSelection { @@ -70,7 +73,7 @@ impl<'schema, 'a: 'schema> FragmentImpl<'schema, 'a> { name: &'a syn::Ident, generics: &'a syn::Generics, schema_type: &FragmentDeriveType<'schema>, - schema_module_path: &syn::Path, + schema_module_path: &'a syn::Path, graphql_type_name: &str, variables: Option<&syn::Path>, ) -> Result { @@ -120,22 +123,23 @@ fn process_field<'a>( field: &FragmentDeriveField, schema_field: Option<&'a Field<'a>>, field_module_path: &syn::Path, - schema_module_path: &syn::Path, + schema_module_path: &'a syn::Path, variables_fields: Option<&syn::Path>, ) -> Result, Errors> { + let ty = &field.raw_field.ty; if field.type_check_mode() == CheckMode::Spreading { - check_spread_type(&field.ty)?; + check_spread_type(ty)?; return Ok(Selection::Spread(SpreadSelection { - rust_field_type: field.ty.clone(), - span: field.ty.span(), + rust_field_type: ty.clone(), + span: ty.span(), })); } let schema_field = schema_field.expect("only spread fields should have schema_field == None"); - let (arguments, argument_span) = - arguments_from_field_attrs(&field.attrs)?.unwrap_or_else(|| (vec![], Span::call_site())); + let (arguments, argument_span) = arguments_from_field_attrs(&field.raw_field.attrs)? + .unwrap_or_else(|| (vec![], Span::call_site())); let arguments = process_arguments( schema, @@ -146,24 +150,41 @@ fn process_field<'a>( argument_span, )?; - check_types_are_compatible(&schema_field.field_type, &field.ty, field.type_check_mode())?; + let mut directives = vec![]; + for directive in &field.directives { + directives.push(process_directive( + schema, + directive.clone(), + variables_fields, + argument_span, + )?); + } + + check_types_are_compatible( + &schema_field.field_type, + &field.raw_field.ty, + field.type_check_mode(), + )?; let field_marker_type_path = schema_field.marker_ident().to_path(field_module_path); Ok(Selection::Field(FieldSelection { - rust_field_type: field.ty.clone(), + rust_field_type: field.raw_field.ty.clone(), arguments, + schema_module_path, field_marker_type_path, graphql_field: schema_field, - recurse_limit: field.recurse.as_ref().map(|f| **f), - span: field.ty.span(), + recurse_limit: field.raw_field.recurse.as_ref().map(|f| **f), + span: ty.span(), alias: field.alias(), graphql_field_kind: schema_field.field_type.inner_type(schema).as_kind(), - flatten: *field.flatten, + flatten: *field.raw_field.flatten, requires_feature: field + .raw_field .feature .as_ref() .map(|feature| feature.as_ref().clone()), + directives, })) } @@ -235,6 +256,15 @@ impl quote::ToTokens for FieldSelection<'_> { } }); + let directives = self + .directives + .iter() + .map(|analysed| super::directives::Output { + analysed, + schema_module: self.schema_module_path, + }) + .collect::>(); + let selection_mode = match (&self.graphql_field_kind, self.flatten, self.recurse_limit) { (FieldKind::Enum | FieldKind::Scalar, true, _) => SelectionMode::FlattenLeaf, (FieldKind::Enum | FieldKind::Scalar, false, _) => SelectionMode::Leaf, @@ -286,6 +316,7 @@ impl quote::ToTokens for FieldSelection<'_> { #alias #arguments + #(#directives)* <#aligned_type as cynic::QueryFragment>::query( field_builder.select_children() @@ -303,6 +334,7 @@ impl quote::ToTokens for FieldSelection<'_> { #alias #arguments + #(#directives)* <#aligned_type as cynic::QueryFragment>::query( field_builder.select_children() @@ -320,6 +352,7 @@ impl quote::ToTokens for FieldSelection<'_> { #alias #arguments + #(#directives)* } } SelectionMode::Recurse(limit) => { @@ -332,6 +365,7 @@ impl quote::ToTokens for FieldSelection<'_> { { #alias #arguments + #(#directives)* <#aligned_type as cynic::QueryFragment>::query( field_builder.select_children() @@ -349,6 +383,7 @@ impl quote::ToTokens for FieldSelection<'_> { #alias #arguments + #(#directives)* } } }; diff --git a/cynic-codegen/src/fragment_derive/input.rs b/cynic-codegen/src/fragment_derive/input.rs index a71e2b6dc..f8ddfb145 100644 --- a/cynic-codegen/src/fragment_derive/input.rs +++ b/cynic-codegen/src/fragment_derive/input.rs @@ -1,3 +1,5 @@ +use super::directives::FieldDirective; + use {darling::util::SpannedValue, proc_macro2::Span, std::collections::HashSet}; use crate::{idents::RenamableFieldIdent, schema::SchemaInput, types::CheckMode, Errors}; @@ -6,7 +8,7 @@ use crate::{idents::RenamableFieldIdent, schema::SchemaInput, types::CheckMode, #[darling(attributes(cynic), supports(struct_named))] pub struct FragmentDeriveInput { pub(super) ident: proc_macro2::Ident, - pub(super) data: darling::ast::Data<(), FragmentDeriveField>, + pub(super) data: darling::ast::Data<(), RawFragmentDeriveField>, pub(super) generics: syn::Generics, #[darling(default)] @@ -46,7 +48,7 @@ impl FragmentDeriveInput { .unwrap_or_else(|| self.ident.span()) } - pub fn validate(&self) -> Result<(), Errors> { + pub fn validate(&self) -> Result, Errors> { let data_field_is_empty = matches!(self.data.clone(), darling::ast::Data::Struct(fields) if fields.fields.is_empty()); if data_field_is_empty { return Err(syn::Error::new( @@ -59,21 +61,29 @@ impl FragmentDeriveInput { .into()); } - let errors = self + let mut fields = vec![]; + let mut errors = Errors::default(); + + let results = self .data .clone() - .map_struct_fields(|field| field.validate().err()) + .map_struct_fields(|field| field.validate()) .take_struct() .unwrap() - .into_iter() - .flatten() - .collect::(); + .into_iter(); + + for result in results { + match result { + Ok(field) => fields.push(field), + Err(error) => errors.extend(error), + } + } if !errors.is_empty() { return Err(errors); } - Ok(()) + Ok(fields) } pub fn detect_aliases(&mut self) { @@ -112,8 +122,8 @@ impl FragmentDeriveInput { } #[derive(darling::FromField, Clone)] -#[darling(attributes(cynic), forward_attrs(arguments))] -pub struct FragmentDeriveField { +#[darling(attributes(cynic), forward_attrs(arguments, directives))] +pub struct RawFragmentDeriveField { pub(super) ident: Option, pub(super) ty: syn::Type, @@ -138,8 +148,14 @@ pub struct FragmentDeriveField { pub(super) feature: Option>, } -impl FragmentDeriveField { - pub fn validate(&self) -> Result<(), Errors> { +pub struct FragmentDeriveField { + pub(super) raw_field: RawFragmentDeriveField, + + pub(super) directives: Vec, +} + +impl RawFragmentDeriveField { + pub fn validate(self) -> Result { if *self.flatten && self.recurse.is_some() { return Err(syn::Error::new( self.recurse.as_ref().unwrap().span(), @@ -172,28 +188,83 @@ impl FragmentDeriveField { .into()); } - Ok(()) + let directives = super::directives::directives_from_field_attrs(&self.attrs)?; + let skippable = directives.iter().any(|directive| { + matches!( + directive, + FieldDirective::Include(_) | FieldDirective::Skip(_) + ) + }); + + if skippable { + if *self.spread { + return Err(syn::Error::new( + self.spread.span(), + "spread can't currently be used on fields with skip or include directives", + ) + .into()); + } else if *self.flatten { + return Err(syn::Error::new( + self.flatten.span(), + "flatten can't currently be used on fields with skip or include directives", + ) + .into()); + } else if let Some(recurse) = self.recurse { + return Err(syn::Error::new( + recurse.span(), + "recurse can't currently be used on fields with skip or include directives", + ) + .into()); + } + } + + Ok(FragmentDeriveField { + directives, + raw_field: self, + }) } +} +impl FragmentDeriveField { pub(super) fn type_check_mode(&self) -> CheckMode { - if *self.flatten { + if *self.raw_field.flatten { CheckMode::Flattening - } else if self.recurse.is_some() { + } else if self.raw_field.recurse.is_some() { CheckMode::Recursing - } else if *self.spread { + } else if *self.raw_field.spread { CheckMode::Spreading + } else if self.is_skippable() { + CheckMode::Skippable } else { CheckMode::OutputTypes } } + pub(super) fn is_skippable(&self) -> bool { + self.directives.iter().any(|directive| { + matches!( + directive, + FieldDirective::Include(_) | FieldDirective::Skip(_) + ) + }) + } + + pub(super) fn spread(&self) -> bool { + *self.raw_field.spread + } + + pub(super) fn ident(&self) -> Option<&proc_macro2::Ident> { + self.raw_field.ident.as_ref() + } + pub(super) fn graphql_ident(&self) -> RenamableFieldIdent { let mut ident = RenamableFieldIdent::from( - self.ident + self.raw_field + .ident .clone() .expect("FragmentDerive only supports named structs"), ); - if let Some(rename) = &self.rename { + if let Some(rename) = &self.raw_field.rename { let span = rename.span(); let rename = (**rename).clone(); ident.set_rename(rename, span) @@ -202,8 +273,13 @@ impl FragmentDeriveField { } pub(super) fn alias(&self) -> Option { - self.alias - .then(|| self.ident.as_ref().expect("ident is required").to_string()) + self.raw_field.alias.then(|| { + self.raw_field + .ident + .as_ref() + .expect("ident is required") + .to_string() + }) } } @@ -211,7 +287,7 @@ impl FragmentDeriveField { mod tests { use super::*; - use {assert_matches::assert_matches, quote::format_ident}; + use quote::format_ident; #[test] fn test_fragment_derive_validate_pass() { @@ -220,7 +296,7 @@ mod tests { data: darling::ast::Data::Struct(darling::ast::Fields::new( darling::ast::Style::Struct, vec![ - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_one")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -231,7 +307,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_two")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -242,7 +318,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_three")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -253,7 +329,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("some_spread")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -274,7 +350,7 @@ mod tests { variables: None, }; - assert_matches!(input.validate(), Ok(())); + assert!(input.validate().is_ok()); } #[test] @@ -284,7 +360,7 @@ mod tests { data: darling::ast::Data::Struct(darling::ast::Fields::new( darling::ast::Style::Struct, vec![ - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_one")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -295,7 +371,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_two")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -306,7 +382,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_three")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -317,7 +393,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("some_spread")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -328,7 +404,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("some_other_spread")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -339,7 +415,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("some_other_spread")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -360,7 +436,7 @@ mod tests { variables: None, }; - let errors = input.validate().unwrap_err(); + let errors = input.validate().map(|_| ()).unwrap_err(); assert_eq!(errors.len(), 5); } @@ -379,7 +455,7 @@ mod tests { graphql_type: Some("abcd".to_string().into()), variables: None, }; - let errors = input.validate().unwrap_err(); + let errors = input.validate().map(|_| ()).unwrap_err(); insta::assert_snapshot!(errors.to_compile_errors().to_string(), @r###":: core :: compile_error ! { "At least one field should be selected for `TestInput`." }"###); } @@ -390,7 +466,7 @@ mod tests { data: darling::ast::Data::Struct(darling::ast::Fields::new( darling::ast::Style::Struct, vec![ - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_one")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -401,7 +477,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_two")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -412,7 +488,7 @@ mod tests { alias: false.into(), feature: None, }, - FragmentDeriveField { + RawFragmentDeriveField { ident: Some(format_ident!("field_three")), ty: syn::parse_quote! { String }, attrs: vec![], @@ -433,6 +509,6 @@ mod tests { variables: None, }; - assert_matches!(input.validate(), Ok(())); + assert!(input.validate().is_ok()) } } diff --git a/cynic-codegen/src/fragment_derive/mod.rs b/cynic-codegen/src/fragment_derive/mod.rs index 3de64bdba..731864b6a 100644 --- a/cynic-codegen/src/fragment_derive/mod.rs +++ b/cynic-codegen/src/fragment_derive/mod.rs @@ -1,4 +1,4 @@ -use proc_macro2::{Span, TokenStream}; +use proc_macro2::TokenStream; use crate::{ schema::{ @@ -11,6 +11,7 @@ use crate::{ mod arguments; mod deserialize_impl; +mod directives; mod fragment_derive_type; mod fragment_impl; mod type_ext; @@ -37,8 +38,9 @@ pub fn fragment_derive(ast: &syn::DeriveInput) -> Result Result { let mut input = input; - input.validate()?; + input.detect_aliases(); + let fields = input.validate()?; let schema = Schema::new(input.schema_input()?); @@ -49,33 +51,25 @@ pub fn fragment_derive_impl(input: FragmentDeriveInput) -> Result( @@ -86,7 +80,7 @@ fn pair_fields<'a>( let mut unknown_fields = Vec::new(); for field in rust_fields { let ident = field.graphql_ident(); - match (schema_type.field(&ident), *field.spread) { + match (schema_type.field(&ident), field.spread()) { (Some(schema_field), _) => result.push((field, Some(schema_field.clone()))), (None, false) => unknown_fields.push(ident), (None, true) => result.push((field, None)), diff --git a/cynic-codegen/src/schema/markers.rs b/cynic-codegen/src/schema/markers.rs index cc0891fba..e48eac259 100644 --- a/cynic-codegen/src/schema/markers.rs +++ b/cynic-codegen/src/schema/markers.rs @@ -36,6 +36,18 @@ pub struct ArgumentMarkerModule<'a> { field_name: Cow<'a, str>, } +/// Ident for a directive marker type +#[derive(Clone, Debug)] +pub struct DirectiveMarkerIdent<'a> { + graphql_name: &'a str, +} + +/// A module that contains everything associated with an argument to a directive +#[derive(Debug)] +pub struct DirectiveArgumentMarkerModule<'a> { + directive_name: &'a str, +} + /// Marker to the type of a field - handles options & vecs and whatever the inner /// type is #[derive(Clone)] @@ -169,6 +181,30 @@ impl ArgumentMarkerModule<'_> { } } +impl<'a> DirectiveMarkerIdent<'a> { + pub fn to_path(&self, schema_module_path: &syn::Path) -> syn::Path { + let mut path = schema_module_path.clone(); + path.push(self.to_rust_ident()); + path + } + + pub fn to_rust_ident(&self) -> proc_macro2::Ident { + format_ident!("{}", transform_keywords(self.graphql_name)) + } +} + +impl DirectiveArgumentMarkerModule<'_> { + pub fn ident(&self) -> proc_macro2::Ident { + format_ident!("_{}_arguments", self.directive_name) + } + + pub fn to_path(&self, schema_module_path: &syn::Path) -> syn::Path { + let mut path = schema_module_path.clone(); + path.push(self.ident()); + path + } +} + macro_rules! marker_ident_for_named { () => {}; ($kind:ident) => { @@ -260,6 +296,20 @@ impl<'a> InterfaceRef<'a> { } } +impl<'a> Directive<'a> { + pub fn argument_module(&self) -> DirectiveArgumentMarkerModule<'_> { + DirectiveArgumentMarkerModule { + directive_name: self.name.borrow(), + } + } + + pub fn marker_ident(&self) -> DirectiveMarkerIdent<'_> { + DirectiveMarkerIdent { + graphql_name: self.name.borrow(), + } + } +} + impl<'a, T> TypeRef<'a, T> { pub fn marker_type(&'a self) -> TypeRefMarker<'a, T> { TypeRefMarker { type_ref: self } diff --git a/cynic-codegen/src/schema/mod.rs b/cynic-codegen/src/schema/mod.rs index bb0dd96bf..125c651be 100644 --- a/cynic-codegen/src/schema/mod.rs +++ b/cynic-codegen/src/schema/mod.rs @@ -12,7 +12,10 @@ use std::convert::Infallible; use std::marker::PhantomData; pub use self::{input::SchemaInput, names::FieldName, parser::load_schema}; -use self::{type_index::TypeIndex, types::SchemaRoots}; +use self::{ + type_index::TypeIndex, + types::{Directive, SchemaRoots}, +}; // TODO: Uncomment this // pub use self::{types::*}, @@ -55,6 +58,13 @@ impl<'a> Schema<'a, Unvalidated> { }) } + pub fn lookup_directive<'b>( + &'b self, + name: &str, + ) -> Result>, SchemaError> { + self.type_index.lookup_directive(name) + } + pub fn lookup<'b, Kind>(&'b self, name: &str) -> Result where Kind: TryFrom> + 'b, @@ -89,13 +99,19 @@ impl<'a> Schema<'a, Validated> { Kind: TryFrom>, Kind::Error: Into, { - // unsafe_lookup is safe because we're validated - Kind::try_from(self.type_index.unsafe_lookup(name).ok_or_else(|| { - SchemaError::CouldNotFindType { - name: name.to_string(), - } - })?) - .map_err(Into::into) + // unsafe_lookup is safe because we're validated and this function + // should only be called if we're sure the type is in the schema + Kind::try_from(self.type_index.unsafe_lookup(name)).map_err(Into::into) + } + + pub fn lookup_directive<'b>(&'b self, name: &str) -> Option> { + // unsafe_directive_lookup is safe because we're validated and this function + // should only be called if we're sure the type is in the schema + self.type_index.unsafe_directive_lookup(name) + } + + pub fn directives(&self) -> impl Iterator> { + self.type_index.unsafe_directive_iter() } } @@ -106,6 +122,12 @@ pub enum SchemaError { expected: types::Kind, found: types::Kind, }, + InvalidDirectiveArgument { + directive_name: String, + argument_name: String, + expected: types::Kind, + found: types::Kind, + }, InvalidTypeInSchema { name: String, details: String, @@ -133,6 +155,9 @@ impl std::fmt::Display for SchemaError { Self::CouldNotFindType { name } => { write!(f, "Could not find a type named `{}` in the schema", name) } + Self::InvalidDirectiveArgument { directive_name, argument_name, expected, found } => { + write!(f, "argument {argument_name} on @{directive_name} is a {found} but needs to be a {expected}") + } } } } diff --git a/cynic-codegen/src/schema/type_index/mod.rs b/cynic-codegen/src/schema/type_index/mod.rs index 237ff10bd..ec968dea8 100644 --- a/cynic-codegen/src/schema/type_index/mod.rs +++ b/cynic-codegen/src/schema/type_index/mod.rs @@ -1,5 +1,5 @@ use super::{ - types::{SchemaRoots, Type}, + types::{Directive, SchemaRoots, Type}, SchemaError, }; @@ -13,10 +13,13 @@ pub use self::schema_backed::SchemaBackedTypeIndex; pub trait TypeIndex { fn validate_all(&self) -> Result<(), SchemaError>; fn lookup_valid_type<'a>(&'a self, name: &str) -> Result, SchemaError>; + fn lookup_directive<'b>(&'b self, name: &str) -> Result>, SchemaError>; fn root_types(&self) -> Result, SchemaError>; // These are only safe to call if the TypeIndex has been validated. // The Schema should make sure that's the case... - fn unsafe_lookup<'a>(&'a self, name: &str) -> Option>; + fn unsafe_lookup<'a>(&'a self, name: &str) -> Type<'a>; fn unsafe_iter<'a>(&'a self) -> Box> + 'a>; + fn unsafe_directive_lookup<'b>(&'b self, name: &str) -> Option>; + fn unsafe_directive_iter<'a>(&'a self) -> Box> + 'a>; } diff --git a/cynic-codegen/src/schema/type_index/optimised.rs b/cynic-codegen/src/schema/type_index/optimised.rs index 1e68fca57..72280343f 100644 --- a/cynic-codegen/src/schema/type_index/optimised.rs +++ b/cynic-codegen/src/schema/type_index/optimised.rs @@ -5,7 +5,7 @@ use rkyv::Deserialize; use crate::schema::{ self, - types::{SchemaRoots, Type}, + types::{Directive, SchemaRoots, Type}, Schema, SchemaError, }; @@ -15,6 +15,7 @@ use crate::schema::{ /// for quicker re-loading. pub struct OptimisedTypes<'a> { types: HashMap>, + directives: HashMap>, schema_roots: SchemaRoots<'a>, } @@ -26,6 +27,11 @@ impl Schema<'_, schema::Validated> { .unsafe_iter() .map(|ty| (ty.name().to_string(), ty)) .collect(), + directives: self + .type_index + .unsafe_directive_iter() + .map(|directive| (directive.name.to_string(), directive)) + .collect(), schema_roots: self.type_index.root_types().expect("valid root types"), } } @@ -65,6 +71,18 @@ impl super::TypeIndex for ArchiveBacked { .expect("infalliable")) } + fn lookup_directive<'b>(&'b self, name: &str) -> Result>, SchemaError> { + let Some(directive) = self.borrow_archived().directives.get(name) else { + return Ok(None); + }; + + Ok(Some( + directive + .deserialize(&mut rkyv::Infallible) + .expect("infalliable"), + )) + } + fn root_types(&self) -> Result, SchemaError> { Ok(self .borrow_archived() @@ -73,15 +91,13 @@ impl super::TypeIndex for ArchiveBacked { .expect("infallible")) } - fn unsafe_lookup<'a>(&'a self, name: &str) -> Option> { - Some( - self.borrow_archived() - .types - .get(name) - .unwrap() - .deserialize(&mut rkyv::Infallible) - .expect("infallible"), - ) + fn unsafe_lookup<'a>(&'a self, name: &str) -> Type<'a> { + self.borrow_archived() + .types + .get(name) + .unwrap() + .deserialize(&mut rkyv::Infallible) + .expect("infallible") } fn unsafe_iter<'a>(&'a self) -> Box> + 'a> { @@ -91,6 +107,29 @@ impl super::TypeIndex for ArchiveBacked { .expect("infallible") })) } + + fn unsafe_directive_lookup<'b>(&'b self, name: &str) -> Option> { + Some( + self.borrow_archived() + .directives + .get(name)? + .deserialize(&mut rkyv::Infallible) + .expect("infallible"), + ) + } + + fn unsafe_directive_iter<'a>(&'a self) -> Box> + 'a> { + Box::new( + self.borrow_archived() + .directives + .values() + .map(|archived_type| { + archived_type + .deserialize(&mut rkyv::Infallible) + .expect("infallible") + }), + ) + } } #[cfg(test)] @@ -147,7 +186,7 @@ mod tests { for ty in schema_backed.unsafe_iter() { assert_eq!(archive_backed.lookup_valid_type(ty.name()).unwrap(), ty); - assert_eq!(archive_backed.unsafe_lookup(ty.name()).unwrap(), ty); + assert_eq!(archive_backed.unsafe_lookup(ty.name()), ty); } let all_schema_backed = schema_backed.unsafe_iter().collect::>(); diff --git a/cynic-codegen/src/schema/type_index/schema_backed.rs b/cynic-codegen/src/schema/type_index/schema_backed.rs index 0e023c0c3..d32ba8742 100644 --- a/cynic-codegen/src/schema/type_index/schema_backed.rs +++ b/cynic-codegen/src/schema/type_index/schema_backed.rs @@ -7,7 +7,10 @@ use std::{ use cynic_parser::{ common::{TypeWrappers, WrappingType}, - type_system::{self as parser, Definition, TypeDefinition}, + type_system::{ + self as parser, ids::FieldDefinitionId, storage::InputValueDefinitionRecord, Definition, + DirectiveDefinition, TypeDefinition, + }, }; use crate::schema::{names::FieldName, types::*, SchemaError}; @@ -18,10 +21,13 @@ pub struct SchemaBackedTypeIndex { query_root: String, mutation_root: Option, subscription_root: Option, - typename_field: cynic_parser::type_system::ids::FieldDefinitionId, + typename_field: FieldDefinitionId, #[borrows(ast)] #[covariant] types: HashMap<&'this str, TypeDefinition<'this>>, + #[borrows(ast)] + #[covariant] + directives: HashMap<&'this str, DirectiveDefinition<'this>>, } impl SchemaBackedTypeIndex { @@ -41,32 +47,7 @@ impl SchemaBackedTypeIndex { } } - let mut writer = cynic_parser::type_system::writer::TypeSystemAstWriter::update(ast); - for builtin in BUILTIN_SCALARS { - let name = writer.ident(builtin); - writer.scalar_definition(cynic_parser::type_system::storage::ScalarDefinitionRecord { - name, - description: None, - directives: Default::default(), - span: cynic_parser::Span::new(0, 0), - }); - } - let typename_string = writer.ident("__typename"); - let string_ident = writer.ident("String"); - let typename_type = writer.type_reference(cynic_parser::type_system::storage::TypeRecord { - name: string_ident, - wrappers: TypeWrappers::none().wrap_non_null(), - }); - let typename_field = - writer.field_definition(cynic_parser::type_system::storage::FieldDefinitionRecord { - name: typename_string, - ty: typename_type, - arguments: Default::default(), - description: None, - directives: Default::default(), - span: cynic_parser::Span::new(0, 0), - }); - let ast = writer.finish(); + let (typename_field, ast) = add_builtins_to_document(ast); SchemaBackedTypeIndex::new( ast, @@ -78,15 +59,120 @@ impl SchemaBackedTypeIndex { let mut types = HashMap::new(); for definition in ast.definitions() { if let Definition::Type(type_def) = definition { - types.insert(name_for_type(type_def), type_def); + types.insert(type_def.name(), type_def); } } types }, + |ast| { + let mut directives = HashMap::new(); + for definition in ast.definitions() { + if let Definition::Directive(directive) = definition { + directives.insert(directive.name(), directive); + } + } + directives + }, ) } } +/// Adds the various builtins that might be omitted from the document +fn add_builtins_to_document( + ast: parser::TypeSystemDocument, +) -> (FieldDefinitionId, parser::TypeSystemDocument) { + let mut has_skip = false; + let mut has_include = false; + for directive in ast.directive_definitions() { + if directive.name() == "skip" { + has_skip = true; + } + if directive.name() == "include" { + has_include = true; + } + } + + let mut writer = parser::writer::TypeSystemAstWriter::update(ast); + let span = cynic_parser::Span::new(0, 0); + for builtin in BUILTIN_SCALARS { + let name = writer.ident(builtin); + writer.scalar_definition(parser::storage::ScalarDefinitionRecord { + name, + description: None, + directives: Default::default(), + span, + }); + } + let typename_string = writer.ident("__typename"); + let string_ident = writer.ident("String"); + let typename_type = writer.type_reference(parser::storage::TypeRecord { + name: string_ident, + wrappers: TypeWrappers::none().wrap_non_null(), + }); + let typename_field = writer.field_definition(parser::storage::FieldDefinitionRecord { + name: typename_string, + ty: typename_type, + arguments: Default::default(), + description: None, + directives: Default::default(), + span, + }); + + if !has_skip || !has_include { + let if_argument_type = parser::storage::TypeRecord { + name: writer.ident("Boolean"), + wrappers: [WrappingType::NonNull].into_iter().collect(), + }; + let if_argument_type = writer.type_reference(if_argument_type); + let if_argument = InputValueDefinitionRecord { + name: writer.ident("if"), + ty: if_argument_type, + description: None, + default_value: None, + directives: Default::default(), + span, + }; + + writer.input_value_definition(if_argument); + let arguments = writer.input_value_definition_range(Some(1)); + + if !has_skip { + let skip = parser::storage::DirectiveDefinitionRecord { + name: writer.ident("skip"), + description: None, + arguments, + is_repeatable: false, + locations: vec![ + parser::DirectiveLocation::Field, + parser::DirectiveLocation::FragmentSpread, + parser::DirectiveLocation::InlineFragment, + ], + span, + }; + writer.directive_definition(skip); + } + + if !has_include { + let include = parser::storage::DirectiveDefinitionRecord { + name: writer.ident("include"), + description: None, + arguments, + is_repeatable: false, + locations: vec![ + parser::DirectiveLocation::Field, + parser::DirectiveLocation::FragmentSpread, + parser::DirectiveLocation::InlineFragment, + ], + span, + }; + writer.directive_definition(include); + } + } + + let ast = writer.finish(); + (typename_field, ast) +} + impl super::TypeIndex for SchemaBackedTypeIndex { fn lookup_valid_type<'b>(&'b self, name: &str) -> Result, SchemaError> { let type_def = self.borrow_types().get(name).copied().ok_or_else(|| { @@ -98,11 +184,28 @@ impl super::TypeIndex for SchemaBackedTypeIndex { self.validate(vec![type_def])?; // Safe because we validated - Ok(self.unsafe_lookup(name).unwrap()) + Ok(self.unsafe_lookup(name)) + } + + fn lookup_directive<'b>(&'b self, name: &str) -> Result>, SchemaError> { + let Some(directive) = self.borrow_directives().get(name) else { + return Ok(None); + }; + + self.validate_directive(directive)?; + + // Safe because we validated + Ok(self.unsafe_directive_lookup(name)) } fn validate_all(&self) -> Result<(), SchemaError> { - self.validate(self.borrow_types().values().copied().collect()) + self.validate(self.borrow_types().values().copied().collect())?; + + for directive in self.borrow_directives().values() { + self.validate_directive(directive)?; + } + + Ok(()) } fn root_types(&self) -> Result, SchemaError> { @@ -127,7 +230,7 @@ impl super::TypeIndex for SchemaBackedTypeIndex { }) } - fn unsafe_lookup<'b>(&'b self, name: &str) -> Option> { + fn unsafe_lookup<'b>(&'b self, name: &str) -> Type<'b> { // Note: This function should absolutely only be called after the hierarchy has // been validated. The current module privacy settings enforce this, but don't make this // private or call it without being careful. @@ -137,7 +240,7 @@ impl super::TypeIndex for SchemaBackedTypeIndex { .copied() .expect("Couldn't find a type - this should be impossible"); - Some(match type_def { + match type_def { TypeDefinition::Scalar(def) => Type::Scalar(ScalarType { name: Cow::Borrowed(def.name()), builtin: scalar_is_builtin(def.name()), @@ -198,15 +301,34 @@ impl super::TypeIndex for SchemaBackedTypeIndex { name: Cow::Borrowed(def.name()), fields: def.fields().map(convert_input_value).collect(), }), - }) + } } fn unsafe_iter<'b>(&'b self) -> Box> + 'b> { let keys = self.borrow_types().keys().collect::>(); + Box::new(keys.into_iter().map(|name| self.unsafe_lookup(name))) + } + + fn unsafe_directive_lookup<'b>(&'b self, name: &str) -> Option> { + let parser_directive = self.borrow_directives().get(name)?; + + Some(Directive { + name: Cow::Borrowed(parser_directive.name()), + arguments: parser_directive + .arguments() + .map(convert_input_value) + .collect(), + locations: parser_directive.locations().map(Into::into).collect(), + }) + } + + fn unsafe_directive_iter<'a>(&'a self) -> Box> + 'a> { + let keys = self.borrow_directives().keys().collect::>(); + Box::new( keys.into_iter() - .map(|name| self.unsafe_lookup(name).unwrap()), + .map(|name| self.unsafe_directive_lookup(name).unwrap()), ) } } @@ -316,6 +438,36 @@ impl SchemaBackedTypeIndex { Ok(()) } + + fn validate_directive(&self, directive: &DirectiveDefinition<'_>) -> Result<(), SchemaError> { + let mut definitions = vec![]; + for argument in directive.arguments() { + let named_type = argument.ty().name(); + let def = self.lookup_type(named_type); + let Some(ty) = def else { + return Err(SchemaError::CouldNotFindType { + name: named_type.to_string(), + }); + }; + definitions.push(ty); + + if !matches!( + ty, + TypeDefinition::InputObject(_) + | TypeDefinition::Enum(_) + | TypeDefinition::Scalar(_) + ) { + return Err(SchemaError::InvalidDirectiveArgument { + directive_name: directive.name().to_string(), + argument_name: argument.name().to_string(), + expected: Kind::InputType, + found: Kind::of_definition(ty), + }); + } + } + self.validate(definitions)?; + Ok(()) + } } static BUILTIN_SCALARS: [&str; 5] = ["String", "ID", "Int", "Float", "Boolean"]; @@ -324,17 +476,6 @@ fn scalar_is_builtin(name: &str) -> bool { BUILTIN_SCALARS.iter().any(|builtin| name == *builtin) } -fn name_for_type(type_def: TypeDefinition<'_>) -> &str { - match type_def { - TypeDefinition::Scalar(inner) => inner.name(), - TypeDefinition::Object(inner) => inner.name(), - TypeDefinition::Interface(inner) => inner.name(), - TypeDefinition::Union(inner) => inner.name(), - TypeDefinition::Enum(inner) => inner.name(), - TypeDefinition::InputObject(inner) => inner.name(), - } -} - fn convert_input_value(val: cynic_parser::type_system::InputValueDefinition<'_>) -> InputValue<'_> { InputValue { name: FieldName { diff --git a/cynic-codegen/src/schema/types.rs b/cynic-codegen/src/schema/types.rs index b59546b58..2c3c891cc 100644 --- a/cynic-codegen/src/schema/types.rs +++ b/cynic-codegen/src/schema/types.rs @@ -1,6 +1,7 @@ use std::{borrow::Cow, marker::PhantomData}; use super::{names::FieldName, SchemaError}; +use cynic_parser::type_system as parser; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr( @@ -174,6 +175,47 @@ pub struct InputObjectType<'a> { pub fields: Vec>, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize), + archive(check_bytes) +)] +pub struct Directive<'a> { + #[cfg_attr(feature = "rkyv", with(rkyv::with::AsOwned))] + pub name: Cow<'a, str>, + pub arguments: Vec>, + pub locations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize), + archive(check_bytes) +)] +pub enum DirectiveLocation { + Query, + Mutation, + Subscription, + Field, + FragmentDefinition, + FragmentSpread, + InlineFragment, + Schema, + Scalar, + Object, + FieldDefinition, + ArgumentDefinition, + Interface, + Union, + Enum, + EnumValue, + InputObject, + InputFieldDefinition, + VariableDefinition, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr( feature = "rkyv", @@ -193,6 +235,19 @@ pub enum Kind { UnionOrInterface, } +impl Kind { + pub fn of_definition(definition: parser::TypeDefinition<'_>) -> Self { + match definition { + parser::TypeDefinition::Scalar(_) => Kind::Scalar, + parser::TypeDefinition::Object(_) => Kind::Object, + parser::TypeDefinition::Interface(_) => Kind::Interface, + parser::TypeDefinition::Union(_) => Kind::Union, + parser::TypeDefinition::Enum(_) => Kind::Enum, + parser::TypeDefinition::InputObject(_) => Kind::InputObject, + } + } +} + impl<'a> Type<'a> { pub fn object(&self) -> Option<&ObjectType<'a>> { match self { @@ -289,12 +344,7 @@ where // Note: We validate types prior to constructing a TypeRef // for them so the unsafe_lookup and unwrap here should // be safe. - schema - .type_index - .unsafe_lookup(name) - .unwrap() - .try_into() - .unwrap() + schema.type_index.unsafe_lookup(name).try_into().unwrap() } TypeRef::List(inner) => inner.inner_type(schema), TypeRef::Nullable(inner) => inner.inner_type(schema), @@ -433,3 +483,31 @@ impl std::fmt::Display for Kind { write!(f, "{}", s) } } + +impl From for DirectiveLocation { + fn from(value: parser::DirectiveLocation) -> Self { + match value { + parser::DirectiveLocation::Query => DirectiveLocation::Query, + parser::DirectiveLocation::Mutation => DirectiveLocation::Mutation, + parser::DirectiveLocation::Subscription => DirectiveLocation::Subscription, + parser::DirectiveLocation::Field => DirectiveLocation::Field, + parser::DirectiveLocation::FragmentDefinition => DirectiveLocation::FragmentDefinition, + parser::DirectiveLocation::FragmentSpread => DirectiveLocation::FragmentSpread, + parser::DirectiveLocation::InlineFragment => DirectiveLocation::InlineFragment, + parser::DirectiveLocation::Schema => DirectiveLocation::Schema, + parser::DirectiveLocation::Scalar => DirectiveLocation::Scalar, + parser::DirectiveLocation::Object => DirectiveLocation::Object, + parser::DirectiveLocation::FieldDefinition => DirectiveLocation::FieldDefinition, + parser::DirectiveLocation::ArgumentDefinition => DirectiveLocation::ArgumentDefinition, + parser::DirectiveLocation::Interface => DirectiveLocation::Interface, + parser::DirectiveLocation::Union => DirectiveLocation::Union, + parser::DirectiveLocation::Enum => DirectiveLocation::Enum, + parser::DirectiveLocation::EnumValue => DirectiveLocation::EnumValue, + parser::DirectiveLocation::InputObject => DirectiveLocation::InputObject, + parser::DirectiveLocation::InputFieldDefinition => { + DirectiveLocation::InputFieldDefinition + } + parser::DirectiveLocation::VariableDefinition => DirectiveLocation::VariableDefinition, + } + } +} diff --git a/cynic-codegen/src/types/validation.rs b/cynic-codegen/src/types/validation.rs index eaf8ac403..e7eafac84 100644 --- a/cynic-codegen/src/types/validation.rs +++ b/cynic-codegen/src/types/validation.rs @@ -1,4 +1,5 @@ use proc_macro2::Span; +use syn::spanned::Spanned; use { super::parsing::{parse_rust_type, RustType}, @@ -11,6 +12,7 @@ pub enum CheckMode { Flattening, Recursing, Spreading, + Skippable, } pub fn check_types_are_compatible( @@ -18,11 +20,21 @@ pub fn check_types_are_compatible( rust_type: &syn::Type, mode: CheckMode, ) -> Result<(), syn::Error> { - let rust_type = parse_rust_type(rust_type); + let parsed_rust_type = parse_rust_type(rust_type); match mode { - CheckMode::Flattening => output_type_check(gql_type, &rust_type, true)?, - CheckMode::OutputTypes => output_type_check(gql_type, &rust_type, false)?, - CheckMode::Recursing => recursing_check(gql_type, &rust_type)?, + CheckMode::Flattening => output_type_check(gql_type, &parsed_rust_type, true)?, + CheckMode::OutputTypes => output_type_check(gql_type, &parsed_rust_type, false)?, + CheckMode::Recursing => recursing_check(gql_type, &parsed_rust_type)?, + CheckMode::Skippable => { + if !outer_type_is_option(rust_type) { + return Err(TypeValidationError::SkippableFieldWithoutError { + provided_type: rust_type.to_string(), + span: rust_type.span(), + } + .into()); + } + output_type_check(gql_type, &parsed_rust_type, false)?; + } CheckMode::Spreading => { panic!("check_types_are_compatible shouldn't be called with CheckMode::Spreading") } @@ -231,6 +243,7 @@ enum TypeValidationError { RecursiveFieldWithoutOption { provided_type: String, span: Span }, SpreadOnOption { span: Span }, SpreadOnVec { span: Span }, + SkippableFieldWithoutError { provided_type: String, span: Span }, } impl From for syn::Error { @@ -255,6 +268,7 @@ impl From for syn::Error { } TypeValidationError::SpreadOnOption { .. } => "You can't spread on an optional field".to_string(), TypeValidationError::SpreadOnVec { .. } => "You can't spread on a list field".to_string(), + TypeValidationError::SkippableFieldWithoutError { provided_type,.. } => format!("This field has @skip or @include on it so it must be optional. Did you mean Option<{provided_type}>"), }; syn::Error::new(span, message) @@ -278,6 +292,7 @@ impl TypeValidationError { TypeValidationError::RecursiveFieldWithoutOption { span, .. } => *span, TypeValidationError::SpreadOnOption { span } => *span, TypeValidationError::SpreadOnVec { span } => *span, + TypeValidationError::SkippableFieldWithoutError { span, .. } => *span, } } } diff --git a/cynic-codegen/src/use_schema/argument.rs b/cynic-codegen/src/use_schema/argument.rs new file mode 100644 index 000000000..8fa820d91 --- /dev/null +++ b/cynic-codegen/src/use_schema/argument.rs @@ -0,0 +1,69 @@ +use { + quote::{quote, ToTokens, TokenStreamExt}, + syn::parse_quote, +}; + +use crate::schema::types::InputValue; + +pub struct ArgumentOutput<'a> { + argument: &'a InputValue<'a>, + + // Marker for the field or directive this is contained within + container_marker: &'a proc_macro2::Ident, + + kind: ArgumentKind, +} + +enum ArgumentKind { + Directive, + Field, +} + +impl<'a> ArgumentOutput<'a> { + pub fn field_argument( + argument: &'a InputValue<'a>, + container_marker: &'a proc_macro2::Ident, + ) -> Self { + ArgumentOutput { + argument, + container_marker, + kind: ArgumentKind::Field, + } + } + + pub fn directive_argument( + argument: &'a InputValue<'a>, + container_marker: &'a proc_macro2::Ident, + ) -> Self { + ArgumentOutput { + argument, + container_marker, + kind: ArgumentKind::Directive, + } + } +} + +impl ToTokens for ArgumentOutput<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let name = proc_macro2::Literal::string(self.argument.name.as_str()); + let argument_ident = self.argument.marker_ident().to_rust_ident(); + let field_marker = self.container_marker; + + let prefix = match self.kind { + ArgumentKind::Directive => parse_quote! { super }, + ArgumentKind::Field => parse_quote! { super::super::super }, + }; + + let schema_type = self.argument.value_type.marker_type().to_path(&prefix); + + tokens.append_all(quote! { + pub struct #argument_ident; + + impl cynic::schema::HasArgument<#argument_ident> for super::#field_marker { + type ArgumentType = #schema_type; + + const NAME: &'static str = #name; + } + }) + } +} diff --git a/cynic-codegen/src/use_schema/directive.rs b/cynic-codegen/src/use_schema/directive.rs new file mode 100644 index 000000000..0a59f2ee8 --- /dev/null +++ b/cynic-codegen/src/use_schema/directive.rs @@ -0,0 +1,39 @@ +use quote::{quote, ToTokens, TokenStreamExt}; + +use crate::schema::types::Directive; + +use super::argument::ArgumentOutput; + +pub struct FieldDirectiveOutput<'a> { + pub(super) directive: &'a Directive<'a>, +} + +impl ToTokens for FieldDirectiveOutput<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let directive_marker = self.directive.marker_ident().to_rust_ident(); + let directive_name_literal = proc_macro2::Literal::string(&self.directive.name); + tokens.append_all(quote! { + #[allow(non_camel_case_types)] + pub struct #directive_marker; + + impl cynic::schema::FieldDirective for #directive_marker { + const NAME: &'static str = #directive_name_literal; + } + }); + + if !self.directive.arguments.is_empty() { + let argument_module = self.directive.argument_module().ident(); + let arguments = + self.directive.arguments.iter().map(|argument| { + ArgumentOutput::directive_argument(argument, &directive_marker) + }); + + tokens.append_all(quote! { + #[allow(non_camel_case_types)] + pub mod #argument_module { + #(#arguments)* + } + }); + } + } +} diff --git a/cynic-codegen/src/use_schema/fields.rs b/cynic-codegen/src/use_schema/fields.rs index 0b827881f..6e554bd68 100644 --- a/cynic-codegen/src/use_schema/fields.rs +++ b/cynic-codegen/src/use_schema/fields.rs @@ -1,20 +1,17 @@ +use super::argument::ArgumentOutput; + use { quote::{quote, ToTokens, TokenStreamExt}, syn::parse_quote, }; -use crate::schema::types::{Field, InputValue}; +use crate::schema::types::Field; pub struct FieldOutput<'a> { pub(super) field: &'a Field<'a>, pub(super) parent_marker: &'a proc_macro2::Ident, } -struct ArgumentOutput<'a> { - argument: &'a InputValue<'a>, - field_marker: &'a proc_macro2::Ident, -} - impl ToTokens for FieldOutput<'_> { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { let parent_marker = self.parent_marker; @@ -43,10 +40,11 @@ impl ToTokens for FieldOutput<'_> { if !self.field.arguments.is_empty() { let argument_module = self.field.argument_module().ident(); - let arguments = self.field.arguments.iter().map(|argument| ArgumentOutput { - argument, - field_marker, - }); + let arguments = self + .field + .arguments + .iter() + .map(|argument| ArgumentOutput::field_argument(argument, field_marker)); tokens.append_all(quote! { pub mod #argument_module { @@ -56,27 +54,3 @@ impl ToTokens for FieldOutput<'_> { } } } - -impl ToTokens for ArgumentOutput<'_> { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - let name = proc_macro2::Literal::string(self.argument.name.as_str()); - let argument_ident = self.argument.marker_ident().to_rust_ident(); - let field_marker = self.field_marker; - - let schema_type = self - .argument - .value_type - .marker_type() - .to_path(&parse_quote! { super::super::super }); - - tokens.append_all(quote! { - pub struct #argument_ident; - - impl cynic::schema::HasArgument<#argument_ident> for super::#field_marker { - type ArgumentType = #schema_type; - - const NAME: &'static str = #name; - } - }) - } -} diff --git a/cynic-codegen/src/use_schema/mod.rs b/cynic-codegen/src/use_schema/mod.rs index 97595afff..202347cef 100644 --- a/cynic-codegen/src/use_schema/mod.rs +++ b/cynic-codegen/src/use_schema/mod.rs @@ -1,3 +1,5 @@ +mod argument; +mod directive; mod fields; mod input_object; mod interface; @@ -16,12 +18,15 @@ use { use crate::{ error::Errors, - schema::{types::Type, Schema, SchemaInput, Validated}, + schema::{ + types::{DirectiveLocation, Type}, + Schema, SchemaInput, Validated, + }, }; use self::{ - input_object::InputObjectOutput, interface::InterfaceOutput, named_type::NamedType, - object::ObjectOutput, subtype_markers::SubtypeMarkers, + directive::FieldDirectiveOutput, input_object::InputObjectOutput, interface::InterfaceOutput, + named_type::NamedType, object::ObjectOutput, subtype_markers::SubtypeMarkers, }; pub fn use_schema(input: UseSchemaParams) -> Result { @@ -97,6 +102,17 @@ pub(crate) fn use_schema_impl(schema: &Schema<'_, Validated>) -> Result for super::include { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} +#[allow(non_camel_case_types)] +pub struct skip; +impl cynic::schema::FieldDirective for skip { + const NAME: &'static str = "skip"; +} +#[allow(non_camel_case_types)] +pub mod _skip_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::skip { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} impl cynic::schema::NamedType for Book { const NAME: &'static str = "Book"; } diff --git a/cynic-codegen/tests/snapshots/use_schema__graphql.jobs.graphql.snap b/cynic-codegen/tests/snapshots/use_schema__graphql.jobs.graphql.snap index 3378cebcb..df9fca217 100644 --- a/cynic-codegen/tests/snapshots/use_schema__graphql.jobs.graphql.snap +++ b/cynic-codegen/tests/snapshots/use_schema__graphql.jobs.graphql.snap @@ -54,6 +54,32 @@ impl cynic::schema::InputObjectMarker for UpdateCompanyInput {} pub struct UpdateJobInput; impl cynic::schema::InputObjectMarker for UpdateJobInput {} pub struct User; +#[allow(non_camel_case_types)] +pub struct include; +impl cynic::schema::FieldDirective for include { + const NAME: &'static str = "include"; +} +#[allow(non_camel_case_types)] +pub mod _include_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::include { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} +#[allow(non_camel_case_types)] +pub struct skip; +impl cynic::schema::FieldDirective for skip { + const NAME: &'static str = "skip"; +} +#[allow(non_camel_case_types)] +pub mod _skip_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::skip { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} impl cynic::schema::NamedType for City { const NAME: &'static str = "City"; } diff --git a/cynic-codegen/tests/snapshots/use_schema__simple.graphql.snap b/cynic-codegen/tests/snapshots/use_schema__simple.graphql.snap index 003da2cfb..e46c9cbf6 100644 --- a/cynic-codegen/tests/snapshots/use_schema__simple.graphql.snap +++ b/cynic-codegen/tests/snapshots/use_schema__simple.graphql.snap @@ -14,6 +14,32 @@ pub struct MyUnionType {} pub struct Nested; pub struct Query; pub struct TestStruct; +#[allow(non_camel_case_types)] +pub struct include; +impl cynic::schema::FieldDirective for include { + const NAME: &'static str = "include"; +} +#[allow(non_camel_case_types)] +pub mod _include_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::include { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} +#[allow(non_camel_case_types)] +pub struct skip; +impl cynic::schema::FieldDirective for skip { + const NAME: &'static str = "skip"; +} +#[allow(non_camel_case_types)] +pub mod _skip_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::skip { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} impl cynic::schema::HasSubtype for MyUnionType {} impl cynic::schema::HasSubtype for MyUnionType {} impl cynic::schema::NamedType for MyUnionType { diff --git a/cynic-codegen/tests/snapshots/use_schema__starwars.schema.graphql.snap b/cynic-codegen/tests/snapshots/use_schema__starwars.schema.graphql.snap index 152416008..068645d9e 100644 --- a/cynic-codegen/tests/snapshots/use_schema__starwars.schema.graphql.snap +++ b/cynic-codegen/tests/snapshots/use_schema__starwars.schema.graphql.snap @@ -56,6 +56,32 @@ pub struct VehiclePilotsConnection; pub struct VehiclePilotsEdge; pub struct VehiclesConnection; pub struct VehiclesEdge; +#[allow(non_camel_case_types)] +pub struct include; +impl cynic::schema::FieldDirective for include { + const NAME: &'static str = "include"; +} +#[allow(non_camel_case_types)] +pub mod _include_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::include { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} +#[allow(non_camel_case_types)] +pub struct skip; +impl cynic::schema::FieldDirective for skip { + const NAME: &'static str = "skip"; +} +#[allow(non_camel_case_types)] +pub mod _skip_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::skip { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} impl cynic::schema::HasSubtype for Node {} impl cynic::schema::HasSubtype for Node {} impl cynic::schema::HasSubtype for Node {} diff --git a/cynic-codegen/tests/snapshots/use_schema__test_cases.graphql.snap b/cynic-codegen/tests/snapshots/use_schema__test_cases.graphql.snap index e832a32de..e2ce34298 100644 --- a/cynic-codegen/tests/snapshots/use_schema__test_cases.graphql.snap +++ b/cynic-codegen/tests/snapshots/use_schema__test_cases.graphql.snap @@ -17,6 +17,32 @@ pub struct UUID {} impl cynic::schema::NamedType for UUID { const NAME: &'static str = "UUID"; } +#[allow(non_camel_case_types)] +pub struct include; +impl cynic::schema::FieldDirective for include { + const NAME: &'static str = "include"; +} +#[allow(non_camel_case_types)] +pub mod _include_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::include { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} +#[allow(non_camel_case_types)] +pub struct skip; +impl cynic::schema::FieldDirective for skip { + const NAME: &'static str = "skip"; +} +#[allow(non_camel_case_types)] +pub mod _skip_arguments { + pub struct r#if; + impl cynic::schema::HasArgument for super::skip { + type ArgumentType = super::Boolean; + const NAME: &'static str = "if"; + } +} impl cynic::schema::NamedType for Bar { const NAME: &'static str = "Bar"; } diff --git a/cynic-parser/benches/schema.rs b/cynic-parser/benches/schema.rs index 15e3da224..faa4f5fa5 100644 --- a/cynic-parser/benches/schema.rs +++ b/cynic-parser/benches/schema.rs @@ -1,7 +1,7 @@ -// use divan::AllocProfiler; +use divan::AllocProfiler; -// #[global_allocator] -// static ALLOC: AllocProfiler = AllocProfiler::system(); +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); fn main() { // Run registered benchmarks. diff --git a/cynic-parser/src/type_system/mod.rs b/cynic-parser/src/type_system/mod.rs index f8340f226..f9fd2859a 100644 --- a/cynic-parser/src/type_system/mod.rs +++ b/cynic-parser/src/type_system/mod.rs @@ -241,6 +241,13 @@ impl TypeSystemDocument { DefinitionRecord::Directive(id) => Definition::Directive(self.read(*id)), }) } + + pub fn directive_definitions(&self) -> impl Iterator> + '_ { + self.directive_definitions + .iter() + .enumerate() + .map(|(index, _)| self.read(DirectiveDefinitionId::new(index))) + } } pub mod storage { diff --git a/cynic-parser/tests/snapshots/actual_schemas__test_cases__snapshot.snap b/cynic-parser/tests/snapshots/actual_schemas__test_cases__snapshot.snap index 6449caee7..a742da6d9 100644 --- a/cynic-parser/tests/snapshots/actual_schemas__test_cases__snapshot.snap +++ b/cynic-parser/tests/snapshots/actual_schemas__test_cases__snapshot.snap @@ -50,3 +50,7 @@ type FlattenableEnums { states: [States] } +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + diff --git a/cynic-proc-macros/Cargo.toml b/cynic-proc-macros/Cargo.toml index a42f22608..bbee19460 100644 --- a/cynic-proc-macros/Cargo.toml +++ b/cynic-proc-macros/Cargo.toml @@ -16,6 +16,7 @@ rust-version = { workspace = true } [features] default = [] rkyv = ["cynic-codegen/rkyv"] +directives = [] [lib] proc-macro = true diff --git a/cynic-proc-macros/src/lib.rs b/cynic-proc-macros/src/lib.rs index 72473334e..3c1e738be 100644 --- a/cynic-proc-macros/src/lib.rs +++ b/cynic-proc-macros/src/lib.rs @@ -38,7 +38,7 @@ pub fn use_schema(input: TokenStream) -> TokenStream { /// Derives `cynic::QueryFragment` /// /// See [the book for usage details](https://cynic-rs.dev/derives/query-fragments.html) -#[proc_macro_derive(QueryFragment, attributes(cynic, arguments))] +#[proc_macro_derive(QueryFragment, attributes(cynic, arguments, directives))] pub fn query_fragment_derive(input: TokenStream) -> TokenStream { let ast = syn::parse_macro_input!(input as syn::DeriveInput); diff --git a/cynic/Cargo.toml b/cynic/Cargo.toml index a38240da3..d8de8ff45 100644 --- a/cynic/Cargo.toml +++ b/cynic/Cargo.toml @@ -22,6 +22,7 @@ http-surf = ["surf", "serde_json"] http-reqwest = ["reqwest", "serde_json"] http-reqwest-blocking = ["http-reqwest", "reqwest/blocking", "serde_json"] rkyv = ["cynic-proc-macros/rkyv"] +directives = ["cynic-proc-macros/directives"] [dependencies] cynic-proc-macros = { path = "../cynic-proc-macros", version = "3.7.3" } @@ -41,7 +42,7 @@ reqwest = { version = "0.12", optional = true, features = ["json"], default-feat assert_matches = "1.4" chrono = { version = "0.4.19", features = ["serde"] } graphql-parser = "0.4" -insta = { version = "1.17", features = ["yaml"] } +insta = { version = "1.17", features = ["yaml", "json"] } maplit = "1.0.2" mockito = "1.4.0" rstest.workspace = true diff --git a/cynic/src/queries/ast.rs b/cynic/src/queries/ast.rs index f09fafcf3..a7202ad9a 100644 --- a/cynic/src/queries/ast.rs +++ b/cynic/src/queries/ast.rs @@ -23,6 +23,7 @@ pub struct FieldSelection { pub(super) name: &'static str, pub(super) alias: Option>, pub(super) arguments: Vec, + pub(super) directives: Vec, pub(super) children: SelectionSet, } @@ -75,6 +76,13 @@ pub enum InputLiteral { EnumValue(&'static str), } +#[derive(Debug, PartialEq)] +/// A directive +pub struct Directive { + pub(super) name: Cow<'static, str>, + pub(super) arguments: Vec, +} + #[derive(Debug, Default)] /// An inline fragment that selects fields from one possible type pub struct InlineFragment { @@ -89,6 +97,7 @@ impl FieldSelection { name, alias: None, arguments: Vec::new(), + directives: Vec::new(), children: SelectionSet::default(), } } @@ -129,6 +138,22 @@ impl std::fmt::Display for Selection { } write!(f, ")")?; } + + for Directive { name, arguments } in &field_selection.directives { + write!(f, " @{name}")?; + if !arguments.is_empty() { + write!(f, "(")?; + let mut first = true; + for arg in arguments { + if !first { + write!(f, ", ")?; + } + first = false; + write!(f, "{}", arg)?; + } + write!(f, ")")?; + } + } write!(f, "{}", field_selection.children) } Selection::InlineFragment(inline_fragment) => { @@ -165,15 +190,23 @@ impl std::fmt::Display for InputLiteral { InputLiteral::Id(val) => write!(f, "\"{}\"", val), InputLiteral::Object(fields) => { write!(f, "{{")?; - for field in fields { - write!(f, "{}: {}, ", field.name, field.value)?; + let mut field_iter = fields.iter().peekable(); + while let Some(field) = field_iter.next() { + write!(f, "{}: {}", field.name, field.value)?; + if field_iter.peek().is_some() { + write!(f, ", ")?; + } } write!(f, "}}") } InputLiteral::List(vals) => { write!(f, "[")?; - for val in vals { - write!(f, "{}, ", val)?; + let mut value_iter = vals.iter().peekable(); + while let Some(val) = value_iter.next() { + write!(f, "{}", val)?; + if value_iter.peek().is_some() { + write!(f, ", ")?; + } } write!(f, "]") } diff --git a/cynic/src/queries/builders.rs b/cynic/src/queries/builders.rs index 557cdab0d..ffdd42643 100644 --- a/cynic/src/queries/builders.rs +++ b/cynic/src/queries/builders.rs @@ -211,6 +211,29 @@ impl<'a, Field, FieldSchemaType, VariablesFields> } } + /// Adds an argument to this field. + /// + /// Accepts `ArgumentName` - the schema marker struct for the argument you + /// wish to add. + pub fn directive( + &'_ mut self, + ) -> DirectiveBuilder<'_, DirectiveMarker, VariablesFields> + where + DirectiveMarker: schema::FieldDirective, + { + self.field.directives.push(Directive { + name: Cow::Borrowed(DirectiveMarker::NAME), + arguments: vec![], + }); + let directive = self.field.directives.last_mut().unwrap(); + + DirectiveBuilder { + arguments: &mut directive.arguments, + context: self.context, + phantom: PhantomData, + } + } + /// Returns a SelectionBuilder that can be used to select fields /// within this field. pub fn select_children( @@ -429,6 +452,34 @@ impl<'a> InputLiteralContainer<'a> { } } +pub struct DirectiveBuilder<'a, DirectiveMarker, VariablesFields> { + arguments: &'a mut Vec, + context: BuilderContext<'a>, + phantom: PhantomData (DirectiveMarker, VariablesFields)>, +} + +impl<'a, DirectiveMarker, VariablesFields> DirectiveBuilder<'a, DirectiveMarker, VariablesFields> { + /// Adds an argument to this directive. + /// + /// Accepts `ArgumentName` - the schema marker struct for the argument you + /// wish to add. + pub fn argument( + &'_ mut self, + ) -> InputBuilder<'_, DirectiveMarker::ArgumentType, VariablesFields> + where + DirectiveMarker: schema::HasArgument, + { + InputBuilder { + destination: InputLiteralContainer::object( + >::NAME, + self.arguments, + ), + context: self.context, + phantom: PhantomData, + } + } +} + /// Enforces type equality on a VariablesFields struct. /// /// Each `crate::QueryVariablesFields` implementation should also implement this diff --git a/cynic/src/schema.rs b/cynic/src/schema.rs index ea49701de..0431075ba 100644 --- a/cynic/src/schema.rs +++ b/cynic/src/schema.rs @@ -176,3 +176,10 @@ impl NamedType for crate::Id { /// Indicates that a type is an `InputObject` pub trait InputObjectMarker {} + +/// Indicates that a type represents a GraphQL directive that can be used +/// in field position. +pub trait FieldDirective { + /// The name of the directive in GraphQL + const NAME: &'static str; +} diff --git a/cynic/tests/arguments.rs b/cynic/tests/arguments.rs index 20262ae27..2599aecd0 100644 --- a/cynic/tests/arguments.rs +++ b/cynic/tests/arguments.rs @@ -28,7 +28,7 @@ fn test_literal_object_inside_list() { insta::assert_display_snapshot!(query.query, @r###" query Query { - filteredPosts(filters: {any: [{states: [DRAFT, ], }, ], }) { + filteredPosts(filters: {any: [{states: [DRAFT]}]}) { hasMetadata } } diff --git a/cynic/tests/enum-arguments.rs b/cynic/tests/enum-arguments.rs index 2dd844ed0..efc5aa1e8 100644 --- a/cynic/tests/enum-arguments.rs +++ b/cynic/tests/enum-arguments.rs @@ -28,7 +28,7 @@ fn test_enum_argument_literal() { insta::assert_display_snapshot!(query.query, @r###" query Query { - filteredPosts(filters: {states: [DRAFT, ], }) { + filteredPosts(filters: {states: [DRAFT]}) { hasMetadata } } @@ -65,7 +65,7 @@ fn test_enum_argument() { insta::assert_display_snapshot!(query.query, @r###" query Query { - filteredPosts(filters: {states: [POSTED, ], }) { + filteredPosts(filters: {states: [POSTED]}) { hasMetadata } } diff --git a/cynic/tests/field-directives.rs b/cynic/tests/field-directives.rs new file mode 100644 index 000000000..22be3a639 --- /dev/null +++ b/cynic/tests/field-directives.rs @@ -0,0 +1,234 @@ +//! Tests of skip & include directives + +use serde::Serialize; + +mod schema { + cynic::use_schema!("tests/test-schema.graphql"); +} + +mod skip_directive { + use serde::Deserialize; + use serde_json::json; + + use super::*; + + #[derive(cynic::QueryVariables)] + struct Vars { + should_skip: bool, + } + + #[derive(cynic::QueryFragment, Serialize)] + #[cynic(schema_path = "tests/test-schema.graphql", variables = "Vars")] + struct BlogPost { + #[directives(skip(if: $should_skip))] + id: Option, + + #[directives(skip(if: true))] + has_metadata: Option, + + #[directives(skip(if: false))] + state: Option, + } + + #[derive(cynic::Enum)] + #[cynic(schema_path = "tests/test-schema.graphql")] + enum PostState { + Posted, + Draft, + } + + #[derive(cynic::QueryFragment, Serialize)] + #[cynic(schema_path = "tests/test-schema.graphql", variables = "Vars")] + struct Query { + #[allow(dead_code)] + #[arguments(filters: { states: DRAFT })] + filtered_posts: Vec, + } + + #[test] + fn test_query() { + use cynic::QueryBuilder; + + let query = Query::build(Vars { should_skip: true }); + + insta::assert_display_snapshot!(query.query, @r###" + query Query($shouldSkip: Boolean!) { + filteredPosts(filters: {states: [DRAFT]}) { + id @skip(if: $shouldSkip) + hasMetadata @skip(if: true) + state @skip(if: false) + } + } + + "###); + } + + #[test] + fn test_deser() { + let decoded = Query::deserialize(json!({ + "filteredPosts": [ + {}, + {"id": "1", "hasMetadata": true, "state": "DRAFT"} + ] + })) + .unwrap(); + insta::assert_json_snapshot!(decoded, @r###" + { + "filtered_posts": [ + { + "id": null, + "has_metadata": null, + "state": null + }, + { + "id": "1", + "has_metadata": true, + "state": "DRAFT" + } + ] + } + "###) + } +} + +mod include_directive { + use serde::Deserialize; + use serde_json::json; + + use super::*; + + #[derive(cynic::QueryVariables)] + struct Vars { + should_include: bool, + } + + #[derive(cynic::QueryFragment, Serialize)] + #[cynic(schema_path = "tests/test-schema.graphql", variables = "Vars")] + struct BlogPost { + #[directives(include(if: $should_include))] + id: Option, + + #[directives(include(if: true))] + has_metadata: Option, + + #[directives(include(if: false))] + state: Option, + } + + #[derive(cynic::Enum)] + #[cynic(schema_path = "tests/test-schema.graphql")] + enum PostState { + Posted, + Draft, + } + + #[derive(cynic::QueryFragment, Serialize)] + #[cynic(schema_path = "tests/test-schema.graphql", variables = "Vars")] + struct Query { + #[allow(dead_code)] + #[arguments(filters: { states: DRAFT })] + filtered_posts: Vec, + } + + #[test] + fn test_query() { + use cynic::QueryBuilder; + + let query = Query::build(Vars { + should_include: true, + }); + + insta::assert_display_snapshot!(query.query, @r###" + query Query($shouldInclude: Boolean!) { + filteredPosts(filters: {states: [DRAFT]}) { + id @include(if: $shouldInclude) + hasMetadata @include(if: true) + state @include(if: false) + } + } + + "###); + } + + #[test] + fn test_deser() { + let decoded = Query::deserialize(json!({ + "filteredPosts": [ + {}, + {"id": "1", "hasMetadata": true, "state": "DRAFT"} + ] + })) + .unwrap(); + insta::assert_json_snapshot!(decoded, @r###" + { + "filtered_posts": [ + { + "id": null, + "has_metadata": null, + "state": null + }, + { + "id": "1", + "has_metadata": true, + "state": "DRAFT" + } + ] + } + "###) + } +} + +mod other_directives { + use super::*; + + #[derive(cynic::QueryVariables)] + struct Vars { + an_int: i32, + } + + #[derive(cynic::QueryFragment, Serialize)] + #[cynic(schema_path = "tests/test-schema.graphql", variables = "Vars")] + struct BlogPost { + #[directives(foo)] + id: Option, + + #[directives(foo(blah: {optionalInt: $an_int}))] + has_metadata: Option, + + #[directives(foo(blah: {optionalInt: 1}))] + state: Option, + } + + #[derive(cynic::Enum)] + #[cynic(schema_path = "tests/test-schema.graphql")] + enum PostState { + Posted, + Draft, + } + + #[derive(cynic::QueryFragment, Serialize)] + #[cynic(schema_path = "tests/test-schema.graphql", variables = "Vars")] + struct Query { + #[allow(dead_code)] + #[arguments(filters: { states: DRAFT })] + filtered_posts: Vec, + } + + #[test] + fn test_query() { + use cynic::QueryBuilder; + + let query = Query::build(Vars { an_int: 120 }); + + insta::assert_display_snapshot!(query.query, @r###" + query Query($anInt: Int!) { + filteredPosts(filters: {states: [DRAFT]}) { + id @foo + hasMetadata @foo(blah: {optionalInt: $anInt}) + state @foo(blah: {optionalInt: 1}) + } + } + + "###); + } +} diff --git a/cynic/tests/test-schema.graphql b/cynic/tests/test-schema.graphql index 468eb14aa..61c04f9bd 100644 --- a/cynic/tests/test-schema.graphql +++ b/cynic/tests/test-schema.graphql @@ -102,3 +102,5 @@ enum WeirdENUM { schema { query: Query } + +directive @foo(blah: InputWithDefaults) repeatable on FIELD diff --git a/examples/examples/snapshots/github_mutation__test__snapshot_test_query.snap b/examples/examples/snapshots/github_mutation__test__snapshot_test_query.snap index d5e8ce500..7e89a1eb6 100644 --- a/examples/examples/snapshots/github_mutation__test__snapshot_test_query.snap +++ b/examples/examples/snapshots/github_mutation__test__snapshot_test_query.snap @@ -3,7 +3,7 @@ source: examples/examples/github-mutation.rs expression: query.query --- mutation CommentOnMutationSupportIssue($commentBody: String!) { - addComment(input: {body: $commentBody, subjectId: "MDU6SXNzdWU2ODU4NzUxMzQ=", clientMutationId: null, }) { + addComment(input: {body: $commentBody, subjectId: "MDU6SXNzdWU2ODU4NzUxMzQ=", clientMutationId: null}) { commentEdge { node { id diff --git a/schemas/test_cases.graphql b/schemas/test_cases.graphql index 9d67f9d85..2d46ec35b 100644 --- a/schemas/test_cases.graphql +++ b/schemas/test_cases.graphql @@ -48,3 +48,7 @@ input RecursiveInputChild { type FlattenableEnums { states: [States] } + +# Specifically including these here to make sure we can handle that +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT