Skip to main content

sui_graphql_macros/
lib.rs

1//! Compile-time validated macros for the Sui GraphQL API.
2//!
3//! Two macros, both validated against the embedded Sui GraphQL schema:
4//!
5//! - [`Response`] — derive macro for response types. Generates JSON
6//!   deserialization from declarative field paths, catching unknown fields
7//!   and type mismatches before your code runs.
8//! - [`graphql_query!`] — function-style macro for query/mutation strings.
9//!   Validates the source against the schema, so unknown fields, undefined
10//!   variables, and bad arguments fail to compile.
11//!
12//! For a complete client that uses both, see
13//! [`sui-graphql`](https://docs.rs/sui-graphql).
14//!
15//! # Quick Start
16//!
17//! ```no_run
18//! use sui_graphql_macros::Response;
19//!
20//! #[derive(Response)]
21//! struct ObjectData {
22//!     #[field(path = "object.address")]
23//!     address: String,
24//!     #[field(path = "object.version")]
25//!     version: u64,
26//! }
27//! fn main() {}
28//! ```
29//!
30//! The macro validates that `object.address` and `object.version` exist in the schema
31//! and that their types match at compile time. It then generates a
32//! `from_value(serde_json::Value) -> Result<Self, String>` method, a borrowed
33//! `extract(&serde_json::Value) -> Result<Self, String>` variant, and a `Deserialize`
34//! implementation, so the struct can be used directly with
35//! `serde_json::from_value` or as a response type in GraphQL client calls.
36//!
37//! # Path Syntax
38//!
39//! Paths use dot-separated segments with optional suffixes:
40//!
41//! | Syntax | Meaning | Rust Type |
42//! |--------|---------|-----------|
43//! | `field` | Required field | `T` |
44//! | `field?` | Nullable field | `Option<T>` |
45//! | `field[]` | Required list | `Vec<T>` |
46//! | `field?[]` | Nullable list | `Option<Vec<T>>` |
47//! | `field[]?` | List with nullable elements | `Vec<Option<T>>` |
48//! | `field?[]?` | Nullable list, nullable elements | `Option<Vec<Option<T>>>` |
49//!
50//! Multiple `?` markers between `[]` boundaries share one `Option` wrapper.
51//! Each `?` controls null tolerance at that specific segment.
52//!
53//! The macro enforces that path suffixes match the Rust type at compile time.
54//! For example, `field?` requires `Option<T>`, and `field[]` requires `Vec<T>`.
55//! A mismatch (e.g., `field?` with `String` or `field` with `Option<String>`)
56//! produces a compile error.
57//!
58//! ## Null Handling
59//!
60//! ```no_run
61//! use sui_graphql_macros::Response;
62//!
63//! #[derive(Response)]
64//! struct Example {
65//!     // null at `object` → error, null at `address` → error
66//!     #[field(path = "object.address")]
67//!     strict: String,
68//!
69//!     // null at `object` → Ok(None), null at `address` → Ok(None)
70//!     #[field(path = "object?.address?")]
71//!     flexible: Option<String>,
72//!
73//!     // null at `object` → Ok(None), null at `address` → error
74//!     #[field(path = "object?.address")]
75//!     partial: Option<String>,
76//! }
77//! fn main() {}
78//! ```
79//!
80//! ## Lists
81//!
82//! Use `[]` to mark list fields. The macro validates this matches the schema.
83//!
84//! ```no_run
85//! use sui_graphql_macros::Response;
86//!
87//! #[derive(Response)]
88//! struct CheckpointDigests {
89//!     #[field(path = "checkpoints.nodes[].digest")]
90//!     digests: Vec<String>,
91//!
92//!     // Nullable list with nullable elements
93//!     #[field(path = "checkpoints?.nodes?[]?.digest?")]
94//!     maybe_digests: Option<Vec<Option<String>>>,
95//! }
96//! fn main() {}
97//! ```
98//!
99//! ## Aliases
100//!
101//! Use `alias:field` when your GraphQL query uses aliases. The alias (before `:`) is the
102//! JSON key used for extraction, while the field name (after `:`) is validated against
103//! the schema. The alias itself is not schema-validated since it is user-defined in the
104//! query.
105//!
106//! ```no_run
107//! use sui_graphql_macros::Response;
108//!
109//! #[derive(Response)]
110//! struct EpochCheckpoints {
111//!     // GraphQL alias "firstCp" maps to schema field "checkpoints"
112//!     #[field(path = "epoch.firstCp:checkpoints.nodes[].sequenceNumber")]
113//!     first_checkpoints: Vec<u64>,
114//! }
115//! fn main() {}
116//! ```
117//!
118//! ## Flattened Responses
119//!
120//! Use `#[field(flatten)]` to populate a field by passing the complete response value to
121//! that field type's borrowed `extract` method. This allows response types that read from
122//! the same root value to be composed without cloning it or repeating their field paths.
123//! `flatten` cannot be combined with `path`.
124//!
125//! ```no_run
126//! use sui_graphql_macros::Response;
127//!
128//! #[derive(Response)]
129//! struct ChainInfo {
130//!     #[field(path = "chainIdentifier")]
131//!     chain_id: String,
132//! }
133//!
134//! #[derive(Response)]
135//! struct ResponseData {
136//!     #[field(flatten)]
137//!     chain: ChainInfo,
138//! }
139//! fn main() {}
140//! ```
141//!
142//! ## Enums (GraphQL Unions)
143//!
144//! Use `#[response(root_type = "UnionType")]` on enums with newtype variants:
145//!
146//! ```ignore
147//! #[derive(Response)]
148//! #[response(root_type = "DynamicFieldValue")]
149//! enum FieldValue {
150//!     #[response(on = "MoveValue")]
151//!     Value(MoveValueData),
152//!     MoveObject(MoveObjectData), // `on` defaults to variant name
153//! }
154//! ```
155//!
156//! The macro dispatches on `__typename` in the JSON response.
157//!
158//! ## Attributes
159//!
160//! | Attribute | Level | Description |
161//! |-----------|-------|-------------|
162//! | `#[response(root_type = "Type")]` | struct/enum | Schema type to validate against (default: `"Query"`) |
163//! | `#[response(schema = "path")]` | struct/enum | Custom schema file (relative to `CARGO_MANIFEST_DIR`) |
164//! | `#[field(path = "...")]` | field | Dot-separated path with optional `?`/`[]`/alias |
165//! | `#[field(flatten)]` | field | Populate by calling the field type's `extract` with the complete response value |
166//! | `#[field(skip_schema_validation)]` | field | Skip compile-time schema checks for this field |
167//! | `#[response(on = "TypeName")]` | variant | GraphQL `__typename` to match (default: variant name) |
168
169extern crate proc_macro;
170
171mod path;
172mod query;
173mod schema;
174mod validation;
175
176use darling::FromDeriveInput;
177use darling::FromField;
178use darling::FromMeta;
179use darling::FromVariant;
180use darling::ast::NestedMeta;
181use darling::util::Flag;
182use darling::util::SpannedValue;
183use proc_macro::TokenStream;
184use proc_macro2::TokenStream as TokenStream2;
185use quote::quote;
186use syn::DeriveInput;
187use syn::parse_macro_input;
188
189// ---------------------------------------------------------------------------
190// Darling input structures — define the "schema" for macro input.
191// Darling generates parsing code automatically, including error messages.
192// ---------------------------------------------------------------------------
193
194#[derive(Debug, FromDeriveInput)]
195#[darling(attributes(response), supports(struct_named, enum_newtype))]
196struct ResponseInput {
197    ident: syn::Ident,
198    generics: syn::Generics,
199    data: darling::ast::Data<ResponseVariant, ResponseField>,
200    #[darling(default)]
201    schema: Option<String>,
202    #[darling(default)]
203    root_type: Option<SpannedValue<String>>,
204}
205
206/// A struct field (requires either `path = "..."` or `flatten`).
207#[derive(Debug, FromField)]
208#[darling(attributes(field))]
209struct ResponseField {
210    ident: Option<syn::Ident>,
211    ty: syn::Type,
212    #[darling(flatten)]
213    options: ResponseFieldOptions,
214}
215
216#[derive(Debug)]
217struct ResponseFieldOptions {
218    path: Option<SpannedValue<String>>,
219    flatten: Flag,
220    skip_schema_validation: bool,
221}
222
223#[derive(Debug, FromMeta)]
224struct ParsedResponseFieldOptions {
225    path: Option<SpannedValue<String>>,
226    flatten: Flag,
227    #[darling(default)]
228    skip_schema_validation: bool,
229}
230
231/// The inner type of a newtype enum variant.
232#[derive(Debug, FromField)]
233struct VariantInner {
234    ty: syn::Type,
235}
236
237/// An enum variant mapping to a GraphQL union member.
238#[derive(Debug, FromVariant)]
239#[darling(attributes(response))]
240struct ResponseVariant {
241    ident: syn::Ident,
242    fields: darling::ast::Fields<VariantInner>,
243    /// The GraphQL type name this variant maps to (e.g., `#[response(on = "MoveValue")]`).
244    /// Defaults to the variant ident if not specified.
245    #[darling(default)]
246    on: Option<SpannedValue<String>>,
247}
248
249impl FromMeta for ResponseFieldOptions {
250    fn from_list(items: &[NestedMeta]) -> darling::Result<Self> {
251        // Keep parsing and source validation errors separate so malformed input can still
252        // report a missing `path`, matching Darling's required-field error accumulation.
253        let mut errors = darling::Error::accumulator();
254        let parsed = errors.handle(ParsedResponseFieldOptions::from_list(items));
255
256        let path = items.iter().find_map(|item| match item {
257            NestedMeta::Meta(meta) if meta.path().is_ident("path") => Some(meta),
258            _ => None,
259        });
260
261        let flatten = items.iter().find_map(|item| match item {
262            NestedMeta::Meta(meta) if meta.path().is_ident("flatten") => Some(meta),
263            _ => None,
264        });
265
266        match (path, flatten) {
267            (None, None) => errors.push(darling::Error::missing_field("path")),
268            (Some(_), Some(flatten)) => errors.push(
269                darling::Error::custom("`flatten` cannot be used together with `path`")
270                    .with_span(flatten),
271            ),
272            _ => {}
273        }
274
275        errors.finish()?;
276        let parsed = parsed.expect("parsed options are present when there are no errors");
277        Ok(Self {
278            path: parsed.path,
279            flatten: parsed.flatten,
280            skip_schema_validation: parsed.skip_schema_validation,
281        })
282    }
283}
284
285/// Derive macro for GraphQL response types with nested field extraction.
286///
287/// Use `#[field(path = "...")]` to specify the JSON path to extract each field.
288/// Paths are dot-separated (e.g., `"object.address"` extracts `json["object"]["address"]`).
289/// Use `#[field(flatten)]` to pass the complete response value to the field type's
290/// borrowed `extract` method instead.
291///
292/// # Root Type
293///
294/// By default, field paths are validated against the `Query` type. Use
295/// `#[response(root_type = "...")]` to validate against a different type instead.
296///
297/// # Generated Code
298///
299/// The macro generates:
300/// - `from_value(serde_json::Value) -> Result<Self, String>` method
301/// - `extract(&serde_json::Value) -> Result<Self, String>` borrowed method
302/// - `Deserialize` implementation that uses `from_value`
303///
304/// # Example
305///
306/// ```ignore
307/// // Query response (default)
308/// #[derive(Response)]
309/// struct ChainInfo {
310///     #[field(path = "chainIdentifier")]
311///     chain_id: String,
312///
313///     #[field(path = "epoch.epochId")]
314///     epoch_id: Option<u64>,
315/// }
316///
317/// // Mutation response
318/// #[derive(Response)]
319/// #[response(root_type = "Mutation")]
320/// struct ExecuteResult {
321///     #[field(path = "executeTransaction.effects.effectsBcs")]
322///     effects_bcs: Option<String>,
323/// }
324/// ```
325#[proc_macro_derive(Response, attributes(response, field))]
326pub fn derive_query_response(input: TokenStream) -> TokenStream {
327    let input = parse_macro_input!(input as DeriveInput);
328
329    match derive_query_response_impl(input) {
330        Ok(tokens) => tokens.into(),
331        Err(err) => err.to_compile_error().into(),
332    }
333}
334
335/// Validate a GraphQL query or mutation against the embedded Sui schema at
336/// compile time and return it as a `&'static str`.
337///
338/// One or more sources can be supplied. An inline source is a string literal;
339/// prefix a path with `@` to load it from a UTF-8 file relative to the Rust
340/// source file containing the macro invocation. Sources are concatenated in
341/// order before the complete document is validated. File sources are terminated
342/// with a newline so trailing comments cannot consume the next source; inline
343/// literals are concatenated verbatim. This allows operations and fragments to
344/// be kept separately:
345///
346/// ```ignore
347/// const QUERY: &str = graphql_query!(
348///     @"queries/get-chain.graphql",
349///     @"queries/chain-fragment.graphql",
350/// );
351/// ```
352///
353/// On a syntactically or semantically invalid input (unknown field, wrong
354/// argument type, undefined variable, etc.) the macro emits one
355/// `compile_error!` per Bluejay diagnostic, so the offending call site fails to
356/// build with the diagnostic text inline.
357///
358/// Inline inputs must be literals. A procedural macro runs before Rust name
359/// resolution and therefore cannot read the value behind a path to a Rust
360/// string constant while retaining compile-time GraphQL validation.
361#[proc_macro]
362pub fn graphql_query(input: TokenStream) -> TokenStream {
363    query::expand(input)
364}
365
366fn derive_query_response_impl(input: DeriveInput) -> Result<TokenStream2, syn::Error> {
367    let parsed = ResponseInput::from_derive_input(&input)?;
368
369    // Load the GraphQL schema for validation.
370    // If a custom schema path is provided, load it; otherwise use the embedded Sui schema.
371    let loaded_schema = if let Some(path) = &parsed.schema {
372        // Resolve path relative to the crate's directory.
373        // SUI_GRAPHQL_SCHEMA_DIR is used by trybuild tests (which run from a temp directory).
374        let base_dir = std::env::var("SUI_GRAPHQL_SCHEMA_DIR")
375            .or_else(|_| std::env::var("CARGO_MANIFEST_DIR"))
376            .unwrap();
377        let full_path = std::path::Path::new(&base_dir).join(path);
378        let sdl = std::fs::read_to_string(&full_path).map_err(|e| {
379            syn::Error::new(
380                proc_macro2::Span::call_site(),
381                format!(
382                    "Failed to read schema from '{}': {}",
383                    full_path.display(),
384                    e
385                ),
386            )
387        })?;
388        Some(schema::Schema::from_sdl(&sdl)?)
389    } else {
390        None
391    };
392    let schema = if let Some(schema) = &loaded_schema {
393        schema
394    } else {
395        schema::Schema::load()?
396    };
397
398    // Determine root type: use specified root_type or default to "Query"
399    let root_type = parsed
400        .root_type
401        .as_ref()
402        .map(|s| s.as_str())
403        .unwrap_or("Query");
404
405    // Validate that the root type exists in the schema
406    if !schema.has_type(root_type) {
407        use std::fmt::Write;
408
409        let type_names = schema.type_names();
410        let suggestion = validation::find_similar(&type_names, root_type);
411
412        let mut msg = format!("Type '{}' not found in GraphQL schema", root_type);
413        if let Some(suggested) = suggestion {
414            write!(msg, ". Did you mean '{}'?", suggested).unwrap();
415        }
416
417        // We only enter this block if root_type was explicitly specified (and invalid),
418        // since "Query" (the default) always exists in a valid schema.
419        let span = parsed.root_type.as_ref().unwrap().span();
420
421        return Err(syn::Error::new(span, msg));
422    }
423
424    match parsed.data {
425        darling::ast::Data::Struct(ref fields) => {
426            generate_struct_impl(&parsed, &fields.fields, schema, root_type)
427        }
428        darling::ast::Data::Enum(ref variants) => {
429            generate_enum_impl(&parsed, variants, schema, root_type)
430        }
431    }
432}
433
434/// Generate value conversion methods and `Deserialize` for a struct.
435fn generate_struct_impl(
436    input: &ResponseInput,
437    fields: &[ResponseField],
438    schema: &schema::Schema,
439    root_type: &str,
440) -> Result<TokenStream2, syn::Error> {
441    let ident = &input.ident;
442    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
443
444    // Generate extraction code for each field
445    let mut field_initializers = vec![];
446
447    for field in fields {
448        let field_ident = field
449            .ident
450            .as_ref()
451            .expect("darling ensures named fields only");
452
453        if field.options.flatten.is_present() {
454            let field_ty = &field.ty;
455            field_initializers.push(quote! {
456                #field_ident: <#field_ty>::extract(value)?
457            });
458            continue;
459        }
460
461        let spanned_path = field
462            .options
463            .path
464            .as_ref()
465            .expect("validated: non-flattened fields require a path");
466        let parsed_path = path::ParsedPath::parse(spanned_path.as_str())
467            .map_err(|e| syn::Error::new(spanned_path.span(), e.to_string()))?;
468
469        let terminal_type = if !field.options.skip_schema_validation {
470            Some(validation::validate_path_against_schema(
471                schema,
472                root_type,
473                &parsed_path,
474                spanned_path.span(),
475            )?)
476        } else {
477            None
478        };
479
480        // Skip Vec excess check when schema validation is skipped (user takes full
481        // responsibility) or when the terminal type is an object-like scalar (e.g., JSON)
482        // whose value can be an array.
483        let skip_vec_excess_check = field.options.skip_schema_validation
484            || terminal_type.is_some_and(validation::is_object_like_scalar);
485        validation::validate_type_matches_path(&parsed_path, &field.ty, skip_vec_excess_check)?;
486
487        // Generate extraction code using the same parsed path
488        let type_structure = validation::analyze_type(&field.ty);
489        let extraction = generate_field_extraction(&parsed_path, &type_structure);
490        field_initializers.push(quote! {
491            #field_ident: #extraction
492        });
493    }
494
495    // Generate both value conversion methods and `Deserialize`:
496    //
497    // - `from_value`: Owned convenience API retained for compatibility
498    // - `extract`: Core extraction logic that borrows a serde_json::Value
499    // - `Deserialize`: Allows direct use with serde (e.g., `serde_json::from_str::<MyStruct>(...)`)
500    //   and with the GraphQL client's `query::<T>()` which requires `T: DeserializeOwned`
501    Ok(quote! {
502        impl #impl_generics #ident #ty_generics #where_clause {
503            pub fn from_value(value: serde_json::Value) -> Result<Self, String> {
504                Self::extract(&value)
505            }
506
507            pub fn extract(value: &serde_json::Value) -> Result<Self, String> {
508                Ok(Self {
509                    #(#field_initializers),*
510                })
511            }
512        }
513
514        // TODO: Implement efficient deserialization that only extracts the fields we need.
515        impl<'de> serde::Deserialize<'de> for #ident #ty_generics #where_clause {
516            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
517            where
518                D: serde::Deserializer<'de>,
519            {
520                let value = serde_json::Value::deserialize(deserializer)?;
521                Self::from_value(value).map_err(serde::de::Error::custom)
522            }
523        }
524    })
525}
526
527/// Generate value conversion methods and `Deserialize` for an enum (GraphQL union).
528///
529/// Each variant wraps a type that implements `extract`. Dispatches on `__typename`.
530fn generate_enum_impl(
531    input: &ResponseInput,
532    variants: &[ResponseVariant],
533    schema: &schema::Schema,
534    root_type: &str,
535) -> Result<TokenStream2, syn::Error> {
536    let ident = &input.ident;
537    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
538
539    let root_type_span = input
540        .root_type
541        .as_ref()
542        .map(|s| s.span())
543        .unwrap_or_else(|| ident.span());
544
545    if !schema.is_union(root_type) {
546        return Err(syn::Error::new(
547            root_type_span,
548            format!(
549                "'{}' is not a union type. \
550                 Enum Response requires root_type to be a GraphQL union",
551                root_type
552            ),
553        ));
554    }
555
556    let mut match_arms = Vec::new();
557
558    for variant in variants {
559        let variant_ident = &variant.ident;
560
561        // Resolve the GraphQL typename: explicit `on` or variant ident
562        let graphql_typename = variant
563            .on
564            .as_ref()
565            .map(|s| s.as_str().to_string())
566            .unwrap_or_else(|| variant_ident.to_string());
567
568        let span = variant
569            .on
570            .as_ref()
571            .map(|s| s.span())
572            .unwrap_or_else(|| variant_ident.span());
573
574        if let Err(mut err) =
575            validation::validate_union_member(schema, root_type, &graphql_typename, span)
576        {
577            if variant.on.is_none() {
578                err.combine(syn::Error::new(
579                    span,
580                    "hint: use #[response(on = \"...\")] to specify a GraphQL type name different from the variant name",
581                ));
582            }
583            return Err(err);
584        }
585
586        // Newtype variant: delegate to the inner type's borrowed parser.
587        let inner_ty = &variant.fields.fields[0].ty;
588        match_arms.push(quote! {
589            #graphql_typename => {
590                Ok(Self::#variant_ident(
591                    <#inner_ty>::extract(value)?
592                ))
593            }
594        });
595    }
596
597    let root_type_str = root_type;
598    let enum_name_str = ident.to_string();
599
600    Ok(quote! {
601        impl #impl_generics #ident #ty_generics #where_clause {
602            pub fn from_value(value: serde_json::Value) -> Result<Self, String> {
603                Self::extract(&value)
604            }
605
606            pub fn extract(value: &serde_json::Value) -> Result<Self, String> {
607                let typename = value.get("__typename")
608                    .and_then(|v| v.as_str())
609                    .ok_or_else(|| format!(
610                        "union '{}' requires '__typename' in the response to distinguish variants. \
611                         Make sure your query requests '__typename' on this field ({})",
612                        #root_type_str, #enum_name_str
613                    ))?;
614
615                match typename {
616                    #(#match_arms)*
617                    other => Err(format!(
618                        "unknown __typename '{}' for union '{}' ({})",
619                        other, #root_type_str, #enum_name_str
620                    )),
621                }
622            }
623        }
624
625        impl<'de> serde::Deserialize<'de> for #ident #ty_generics #where_clause {
626            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
627            where
628                D: serde::Deserializer<'de>,
629            {
630                let value = serde_json::Value::deserialize(deserializer)?;
631                Self::from_value(value).map_err(serde::de::Error::custom)
632            }
633        }
634    })
635}
636
637/// Generate code to extract a single field from JSON using its path.
638///
639/// Supports multiple path formats:
640/// - Simple: `"object.address"` - navigates to nested field
641/// - Array: `"nodes[].name"` - iterates over array, extracts field from each element
642/// - Nested arrays: `"nodes[].edges[].id"` - nested iteration, returns `Vec<Vec<T>>`
643/// - Aliased: `"alias:field"` - uses alias for JSON extraction, field for validation
644fn generate_field_extraction(
645    path: &path::ParsedPath,
646    type_structure: &validation::TypeStructure,
647) -> TokenStream2 {
648    let full_path = &path.raw;
649    let inner = generate_from_segments(full_path, &path.segments, type_structure);
650    // The inner expression returns Result<T, String>, so we use ? to unwrap
651    quote! {{
652        let current = value;
653        #inner?
654    }}
655}
656
657/// Recursively generate extraction code by traversing path segments.
658///
659/// For JSON extraction, uses the alias if present, otherwise uses the field name.
660/// Returns code that evaluates to `Result<T, String>` (caller adds `?` to unwrap).
661///
662/// ## Example: `"data.nodes[].edges[].id"` with `Option<Vec<Vec<String>>>`
663///
664/// Each `[]` in the path corresponds to one `Vec<_>` wrapper in the type.
665///
666/// For `Option<_>` types, null at the outer level returns `Ok(None)`. This is achieved
667/// by wrapping the extraction in a closure to capture early returns. However, once
668/// inside an array iteration, the element type (`Vec<String>`) is not Optional, so
669/// null values there return errors instead.
670///
671/// ```ignore
672/// (|| {
673///     // "data" (non-list) - missing/null returns None (outer Optional)
674///     let current = current.get("data").unwrap_or(&serde_json::Value::Null);
675///     if current.is_null() { return Ok(None); }
676///
677///     // "nodes[]" (list) - missing/null returns None (outer Optional)
678///     let field_value = current.get("nodes").unwrap_or(&serde_json::Value::Null);
679///     if field_value.is_null() { return Ok(None); }
680///     let array = field_value.as_array().ok_or_else(|| "expected array")?;
681///     array.iter().map(|current| {
682///         // Element type: Vec<String> (not Optional, so null = error)
683///
684///         // "edges[]" (list) - missing/null returns Err
685///         let field_value = current.get("edges").unwrap_or(&serde_json::Value::Null);
686///         if field_value.is_null() { return Err("null at 'edges'"); }
687///         let array = field_value.as_array().ok_or_else(|| "expected array")?;
688///         array.iter().map(|current| {
689///             // Element type: String (not Optional, so null = error)
690///
691///             // "id" (scalar) - missing/null returns Err
692///             let current = current.get("id").unwrap_or(&serde_json::Value::Null);
693///             if current.is_null() { return Err("null at 'id'"); }
694///             serde_json::from_value(current.clone())
695///         }).collect::<Result<Vec<_>, _>>()
696///     }).collect::<Result<Vec<_>, _>>()
697///     .map(Some)  // Wrap in Some for Option
698/// })()
699/// ```
700fn generate_from_segments(
701    full_path: &str,
702    segments: &[path::PathSegment],
703    type_structure: &validation::TypeStructure,
704) -> TokenStream2 {
705    // Step 1: Check if outer type is Optional and unwrap it
706    let (is_optional, inner_type) = match type_structure {
707        validation::TypeStructure::Optional(inner) => (true, inner.as_ref()),
708        other => (false, other),
709    };
710
711    // Step 2: Generate core extraction code
712    let core = generate_from_segments_core(full_path, segments, inner_type);
713
714    // Step 3: Wrap Optional types in a closure so `return Ok(None)` stays local to this field.
715    if is_optional {
716        quote! {
717            (|| {
718                // Handle null elements (from `[]?`) and null top-level values
719                if current.is_null() { return Ok(None) }
720                #core.map(Some)
721            })()
722        }
723    } else {
724        core
725    }
726}
727
728/// Core extraction logic that handles both list and non-list segments.
729///
730/// Each segment determines its own null behavior via `is_nullable`:
731/// - `is_nullable = true` (`?` marker): null → `return Ok(None)`
732/// - `is_nullable = false` (no `?`): null → `return Err(...)`
733fn generate_from_segments_core(
734    full_path: &str,
735    segments: &[path::PathSegment],
736    type_structure: &validation::TypeStructure,
737) -> TokenStream2 {
738    // Base case: no more segments, deserialize the current value
739    let Some((segment, rest)) = segments.split_first() else {
740        return quote! {
741            serde_json::from_value(current.clone())
742                .map_err(|e| format!("failed to deserialize '{}': {}", #full_path, e))
743        };
744    };
745
746    let name = segment.field;
747    // Use alias for JSON extraction if present, otherwise use field name
748    let json_key = segment.json_key();
749
750    // Generate null handling based on this segment's `?` marker
751    let on_null = if segment.is_nullable {
752        quote! { return Ok(None) }
753    } else {
754        quote! {
755            return Err(format!("null value at '{}' in path '{}'", #name, #full_path))
756        }
757    };
758
759    if segment.is_list() {
760        // For list segments, unwrap Vector to get element type
761        let element_type = match type_structure {
762            validation::TypeStructure::Vector(inner) => inner.as_ref(),
763            _ => unreachable!("validated: list segment requires Vec type"),
764        };
765
766        // Each array element is processed independently with its own type structure.
767        // Use generate_from_segments (not _core) to handle element-level Optional.
768        let rest_code = generate_from_segments(full_path, rest, element_type);
769
770        quote! {
771            // Treat missing fields as null (allows Option<T> to deserialize as None)
772            let field_value = current.get(#json_key).unwrap_or(&serde_json::Value::Null);
773            if field_value.is_null() {
774                #on_null
775            }
776            let array = field_value.as_array()
777                .ok_or_else(|| format!("expected array at '{}' in path '{}'", #json_key, #full_path))?;
778            array.iter()
779                .map(|current| { #rest_code })
780                .collect::<Result<Vec<_>, String>>()
781        }
782    } else {
783        // For non-list segments, pass type unchanged to handle nested structures
784        let rest_code = generate_from_segments_core(full_path, rest, type_structure);
785
786        quote! {
787            // Treat missing fields as null (allows Option<T> to deserialize as None)
788            let current = current.get(#json_key).unwrap_or(&serde_json::Value::Null);
789            if current.is_null() {
790                #on_null
791            }
792            #rest_code
793        }
794    }
795}