Skip to main content

sui_graphql_macros/
query.rs

1//! The `graphql_query!` function-style macro: compile-time validation and
2//! canonical formatting of GraphQL queries and mutations against the embedded
3//! Sui schema.
4//!
5//! Parsing, validation, and printing are delegated to Bluejay. On success, the
6//! macro emits a `&'static str` literal containing the canonically formatted
7//! query. On failure, every diagnostic becomes a `syn::Error` anchored at the
8//! input literal's span.
9//!
10//! The macro is intentionally decoupled from any consumer crate: it produces
11//! a plain string literal so callers can wrap it in their own type. The
12//! `sui-graphql` crate ships a `macro_rules!` wrapper that nests this macro
13//! inside a `ValidatedQuery` constructor, but this proc macro itself has no
14//! dependency on or knowledge of that type.
15
16use std::path::PathBuf;
17use std::sync::LazyLock;
18
19use bluejay_parser::Error as BluejayError;
20use bluejay_parser::ast::Parse as _;
21use bluejay_parser::ast::definition::DefinitionDocument;
22use bluejay_parser::ast::definition::SchemaDefinition;
23use bluejay_parser::ast::executable::ExecutableDocument;
24use bluejay_parser::error::Location;
25use bluejay_printer::executable::ExecutableDocumentPrinter;
26use bluejay_validator::definition::BuiltinRulesValidator as SchemaValidator;
27use bluejay_validator::executable::Cache;
28use bluejay_validator::executable::document::BuiltinRulesValidator as ExecutableValidator;
29use proc_macro::TokenStream;
30use proc_macro2::TokenStream as TokenStream2;
31use quote::quote;
32use syn::LitStr;
33use syn::Token;
34use syn::parse::Parse;
35use syn::parse::ParseStream;
36use syn::parse::Parser;
37use syn::punctuated::Punctuated;
38
39/// Diagnostic label for the embedded SDL. Bluejay embeds this string into
40/// formatted schema errors; it does not open any file. The actual SDL bytes
41/// come from [`crate::schema::SCHEMA_SDL`].
42const SCHEMA_DIAGNOSTIC_LABEL: &str = "<sui schema>";
43
44/// The parsed `SCHEMA_SDL` document.
45///
46/// Bluejay's schema representation borrows its parsed definition document, so
47/// the two values are cached separately. The SDL itself is embedded with a
48/// `'static` lifetime, and proc macros are loaded once per build, allowing both
49/// caches to be reused for every `graphql_query!` call.
50static SCHEMA_DOCUMENT: LazyLock<Result<DefinitionDocument<'static>, String>> =
51    LazyLock::new(|| {
52        DefinitionDocument::parse(crate::schema::SCHEMA_SDL)
53            .map_err(|errors| format_schema_errors("parse", errors))
54    });
55
56/// The resolved and validated Sui schema used to type-check incoming queries.
57static VALIDATED_SCHEMA: LazyLock<Result<SchemaDefinition<'static>, String>> =
58    LazyLock::new(|| {
59        let document = SCHEMA_DOCUMENT.as_ref().map_err(Clone::clone)?;
60        let schema = SchemaDefinition::try_from(document)
61            .map_err(|errors| format_schema_errors("resolve", errors))?;
62        let errors = SchemaValidator::validate(&schema).collect::<Vec<_>>();
63
64        if errors.is_empty() {
65            Ok(schema)
66        } else {
67            Err(format_schema_errors("validate", errors))
68        }
69    });
70
71/// A piece of GraphQL source supplied inline or loaded from a file.
72enum Source {
73    Inline(LitStr),
74    File(LitStr),
75}
76
77impl Parse for Source {
78    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
79        if input.peek(Token![@]) {
80            input.parse::<Token![@]>()?;
81            input.parse().map(Self::File)
82        } else {
83            input.parse().map(Self::Inline)
84        }
85    }
86}
87
88pub fn expand(input: TokenStream) -> TokenStream {
89    match expand_impl(input) {
90        Ok(tokens) => tokens.into(),
91        Err(err) => {
92            // Block-wrap with a `&str` tail; `compile_error!{...}` doesn't
93            // parse on its own in expression position.
94            let compile_error = err.to_compile_error();
95            quote!({ #compile_error "" }).into()
96        }
97    }
98}
99
100fn combine_query_errors<E>(source: &str, errors: impl IntoIterator<Item = E>) -> Option<syn::Error>
101where
102    E: Into<BluejayError>,
103{
104    let mut combined: Option<syn::Error> = None;
105
106    for error in errors {
107        let error = error.into();
108        let message = error.message().to_owned();
109        let graph_errors = BluejayError::into_graphql_errors(source, [error]);
110        let graph_error = graph_errors.first();
111
112        // Parser errors use a generic top-level message and put the useful
113        // detail in their primary annotation. Validation errors have a useful
114        // top-level message already, so retain it.
115        let message = match graph_error {
116            Some(error) if message == "Parse error" => error.message.to_string(),
117            _ => message,
118        };
119
120        let message = if let Some(Location { line, col }) =
121            graph_error.and_then(|error| error.locations.first())
122        {
123            format!("GraphQL [line {}, col {}]: {message}", line, col)
124        } else {
125            format!("GraphQL: {message}")
126        };
127
128        let error = syn::Error::new(proc_macro2::Span::call_site(), message);
129        if let Some(existing) = &mut combined {
130            existing.combine(error);
131        } else {
132            combined = Some(error);
133        }
134    }
135
136    combined
137}
138
139fn expand_impl(input: TokenStream) -> Result<TokenStream2, syn::Error> {
140    let sources = Punctuated::<Source, Token![,]>::parse_terminated.parse(input)?;
141    if sources.is_empty() {
142        return Err(syn::Error::new(
143            proc_macro2::Span::call_site(),
144            "expected at least one GraphQL source",
145        ));
146    }
147
148    // Concatenate inline literals exactly like Rust's `concat!`, so callers
149    // can even split tokens. Terminate file sources with a newline to keep
150    // trailing comments from swallowing the next source. Parse, validate,
151    // and format the complete document as one unit.
152    let mut query = String::new();
153    let mut dependencies = Vec::new();
154    for source in sources {
155        match source {
156            Source::Inline(literal) => query.push_str(&literal.value()),
157            Source::File(literal) => {
158                let path = source_relative(&literal)?;
159                let contents = std::fs::read_to_string(&path).map_err(|error| {
160                    syn::Error::new(
161                        literal.span(),
162                        format!(
163                            "failed to read GraphQL source from '{}': {error}",
164                            path.display()
165                        ),
166                    )
167                })?;
168
169                query.push_str(&contents);
170                query.push('\n');
171
172                dependencies.push(literal);
173            }
174        }
175    }
176
177    let schema = VALIDATED_SCHEMA
178        .as_ref()
179        .map_err(|error| syn::Error::new(proc_macro2::Span::call_site(), error.clone()))?;
180
181    let document = ExecutableDocument::parse(query.as_str()).map_err(|errors| {
182        combine_query_errors(query.as_str(), errors).unwrap_or_else(|| {
183            syn::Error::new(
184                proc_macro2::Span::call_site(),
185                "GraphQL parsing failed with no diagnostics",
186            )
187        })
188    })?;
189
190    let cache = Cache::new(&document, schema);
191    if let Some(error) = combine_query_errors(
192        query.as_str(),
193        ExecutableValidator::validate(&document, schema, &cache),
194    ) {
195        return Err(error);
196    }
197
198    let formatted = ExecutableDocumentPrinter::to_string(&document);
199    // Keep an `include_str!` for every loaded source in the expansion so
200    // rustc and Cargo rebuild when one of them changes.
201    Ok(quote!({
202        #(const _: &str = ::core::include_str!(#dependencies);)*
203        #formatted
204    }))
205}
206
207fn format_schema_errors<E>(action: &str, errors: impl IntoIterator<Item = E>) -> String
208where
209    E: Into<BluejayError>,
210{
211    let formatted = BluejayError::format_errors(
212        crate::schema::SCHEMA_SDL,
213        Some(SCHEMA_DIAGNOSTIC_LABEL),
214        errors,
215    );
216
217    format!("Failed to {action} Sui GraphQL schema:\n{formatted}")
218}
219
220/// Interpret `path` as a path relative to the source file containing the macro invocation.
221fn source_relative(literal: &LitStr) -> Result<PathBuf, syn::Error> {
222    let path = PathBuf::from(literal.value());
223    if path.is_absolute() {
224        return Ok(path);
225    }
226
227    let source = literal.span().local_file();
228    let Some(source_dir) = source.as_ref().and_then(|file| file.parent()) else {
229        return Err(syn::Error::new(
230            literal.span(),
231            "cannot resolve GraphQL path relative to source file",
232        ));
233    };
234
235    Ok(source_dir.join(path))
236}