Skip to main content

rustango_macros/
lib.rs

1//! Proc-macros for rustango.
2//!
3//! v0.1 ships `#[derive(Model)]`, which emits:
4//! * a `Model` impl carrying a static `ModelSchema`,
5//! * an `inventory::submit!` so the model is discoverable from the registry,
6//! * an inherent `objects()` returning a `QuerySet<Self>`,
7//! * a `sqlx::FromRow` impl so query results decode into the struct.
8
9use proc_macro::TokenStream;
10use proc_macro2::TokenStream as TokenStream2;
11use quote::quote;
12use syn::{
13    parse_macro_input, spanned::Spanned, Data, DeriveInput, Fields, GenericArgument, LitStr,
14    PathArguments, Type, TypePath,
15};
16
17/// Derive a `Model` impl. See crate docs for the supported attributes.
18#[proc_macro_derive(Model, attributes(rustango))]
19pub fn derive_model(input: TokenStream) -> TokenStream {
20    let input = parse_macro_input!(input as DeriveInput);
21    expand(&input)
22        .unwrap_or_else(syn::Error::into_compile_error)
23        .into()
24}
25
26/// Derive a `router(prefix, pool) -> axum::Router` associated method on a
27/// marker struct, wiring the full CRUD ViewSet in one annotation.
28///
29/// ```ignore
30/// #[derive(ViewSet)]
31/// #[viewset(
32///     model        = Post,
33///     fields       = "id, title, body, author_id",
34///     filter_fields = "author_id",
35///     search_fields = "title, body",
36///     ordering     = "-published_at",
37///     page_size    = 20,
38/// )]
39/// pub struct PostViewSet;
40///
41/// // Mount into your app:
42/// let app = Router::new()
43///     .merge(PostViewSet::router("/api/posts", pool.clone()));
44/// ```
45///
46/// Attributes:
47/// * `model = TypeName` — *required*. The `#[derive(Model)]` struct whose
48///   `SCHEMA` constant drives the endpoints.
49/// * `fields = "a, b, c"` — scalar fields included in list/retrieve JSON
50///   and accepted on create/update (default: all scalar fields).
51/// * `filter_fields = "a, b"` — fields filterable via `?a=v` query params.
52/// * `search_fields = "a, b"` — fields searched by `?search=...`.
53/// * `ordering = "a, -b"` — default list ordering; prefix `-` for DESC.
54/// * `page_size = N` — default page size (default: 20, max: 1000).
55/// * `read_only` — flag; wires only `list` + `retrieve` (no mutations).
56/// * `permissions(list = "...", retrieve = "...", create = "...",
57///   update = "...", destroy = "...")` — codenames required per action.
58#[proc_macro_derive(ViewSet, attributes(viewset))]
59pub fn derive_viewset(input: TokenStream) -> TokenStream {
60    let input = parse_macro_input!(input as DeriveInput);
61    expand_viewset(&input)
62        .unwrap_or_else(syn::Error::into_compile_error)
63        .into()
64}
65
66/// Derive `rustango::forms::Form` (slice 8.4B). Generates a
67/// `parse(&HashMap<String, String>) -> Result<Self, FormErrors>` impl
68/// that walks every named field and:
69///
70/// * Parses the string value into the field's Rust type (`String`,
71///   `i32`, `i64`, `f32`, `f64`, `bool`, plus `Option<T>` for the
72///   nullable case).
73/// * Applies any `#[form(min = ..)]` / `#[form(max = ..)]` /
74///   `#[form(min_length = ..)]` / `#[form(max_length = ..)]`
75///   validators in declaration order, returning `FormError::Parse`
76///   on the first failure.
77///
78/// Example:
79///
80/// ```ignore
81/// #[derive(Form)]
82/// pub struct CreateItemForm {
83///     #[form(min_length = 1, max_length = 64)]
84///     pub name: String,
85///     #[form(min = 0, max = 150)]
86///     pub age: i32,
87///     pub active: bool,
88///     pub email: Option<String>,
89/// }
90///
91/// let parsed = CreateItemForm::parse(&form_map)?;
92/// ```
93#[proc_macro_derive(Form, attributes(form))]
94pub fn derive_form(input: TokenStream) -> TokenStream {
95    let input = parse_macro_input!(input as DeriveInput);
96    expand_form(&input)
97        .unwrap_or_else(syn::Error::into_compile_error)
98        .into()
99}
100
101/// Derive `rustango::serializer::ModelSerializer` for a struct.
102/// (intra-doc link disabled — the macro crate doesn't depend on
103/// `rustango` itself, so rustdoc can't resolve the path.)
104///
105/// # Container attribute (required)
106/// `#[serializer(model = TypeName)]` — the [`Model`] type this serializer maps from.
107///
108/// # Field attributes
109/// - `#[serializer(read_only)]` — mapped from model; included in JSON output; excluded from `writable_fields()`
110/// - `#[serializer(write_only)]` — `Default::default()` in `from_model`; excluded from JSON output; included in `writable_fields()`
111/// - `#[serializer(source = "field_name")]` — reads from `model.field_name` instead of `model.<field_ident>`
112/// - `#[serializer(skip)]` — `Default::default()` in `from_model`; included in JSON output; excluded from `writable_fields()` (user sets manually)
113/// - `#[serializer(method = "fn_name")]` — DRF `SerializerMethodField`: calls `Self::fn_name(&model)` for the field value; excluded from `writable_fields()`
114/// - `#[serializer(nested)]` / `nested(strict)` — auto-resolves nested serializer from a loaded `ForeignKey`; excluded from `writable_fields()`
115/// - `#[serializer(many = ChildSerializer)]` — collection of nested serializers; populated via macro-emitted `set_<field>(&[Child::Model])`; excluded from `writable_fields()`
116/// - `#[serializer(slug = "name")]` — DRF `SlugRelatedField`: clones `model.<source>.value()?.name`; excluded from `writable_fields()` (v0.44)
117/// - `#[serializer(validate = "fn_name")]` — per-field validator surfaced by `Self::validate(&self)`
118///
119/// The macro also emits a custom `impl serde::Serialize` — do **not** also `#[derive(Serialize)]`.
120#[proc_macro_derive(Serializer, attributes(serializer))]
121pub fn derive_serializer(input: TokenStream) -> TokenStream {
122    let input = parse_macro_input!(input as DeriveInput);
123    expand_serializer(&input)
124        .unwrap_or_else(syn::Error::into_compile_error)
125        .into()
126}
127
128/// Bake every `*.json` migration file in a directory into the binary
129/// at compile time. Returns a `&'static [(&'static str, &'static str)]`
130/// of `(name, json_content)` pairs, lex-sorted by file stem.
131///
132/// Pair with `rustango::migrate::migrate_embedded` at runtime — same
133/// behaviour as `migrate(pool, dir)` but with no filesystem access.
134/// The path is interpreted relative to the user's `CARGO_MANIFEST_DIR`
135/// (i.e. the crate that invokes the macro). Default is
136/// `"./migrations"` if no argument is supplied.
137///
138/// ```ignore
139/// const EMBEDDED: &[(&str, &str)] = rustango::embed_migrations!();
140/// // or:
141/// const EMBEDDED: &[(&str, &str)] = rustango::embed_migrations!("./migrations");
142///
143/// rustango::migrate::migrate_embedded(&pool, EMBEDDED).await?;
144/// ```
145///
146/// **Compile-time guarantees** (rustango v0.4+, slice 5): every JSON
147/// file's `name` field must equal its file stem, every `prev`
148/// reference must point to another migration in the same directory,
149/// and the JSON must parse. A broken chain — orphan `prev`, missing
150/// predecessor, malformed file — fails at macro-expansion time with
151/// a clear `compile_error!`. *No other Django-shape Rust framework
152/// validates migration chains at compile time*: Cot's migrations are
153/// imperative Rust code (no static chain), Loco's are SeaORM
154/// up/down (same), Rwf's are raw SQL (no chain at all).
155///
156/// Each migration is included via `include_str!` so cargo's rebuild
157/// detection picks up file *content* changes. **Caveat:** cargo
158/// doesn't watch directory listings, so adding or removing a
159/// migration file inside the dir won't auto-trigger a rebuild — run
160/// `cargo clean` (or just bump any other source file) when you add
161/// new migrations during embedded development.
162#[proc_macro]
163pub fn embed_migrations(input: TokenStream) -> TokenStream {
164    expand_embed_migrations(input.into())
165        .unwrap_or_else(syn::Error::into_compile_error)
166        .into()
167}
168
169/// `Q!()` — Django-shape filter syntax compile-time-resolved against
170/// typed columns. Issue #269 / T1.7.
171///
172/// Each invocation lowers to the equivalent typed-column method call:
173///
174/// ```ignore
175/// // These expand identically:
176/// Q!(User.email__icontains = "alice")
177/// User::email.ilike("%alice%")
178/// ```
179///
180/// Field-name typos fail the build (the macro emits `User::no_such_field`
181/// which doesn't exist) — the headline ergonomic win of this slice over
182/// Django's stringly-typed `__lookup` filters.
183///
184/// # Supported lookup suffixes
185///
186/// * bare `=` / `__exact` → `.eq(value)`
187/// * `__iexact` → `.ilike(value)` (case-insensitive equality, no wildcards)
188/// * `__ne` → `.ne(value)`
189/// * `__gt` / `__gte` / `__lt` / `__lte` → corresponding comparison
190/// * `__contains` / `__icontains` → `.like("%v%")` / `.ilike("%v%")`
191/// * `__startswith` / `__istartswith` → `.like("v%")` / `.ilike("v%")`
192/// * `__endswith` / `__iendswith` → `.like("%v")` / `.ilike("%v")`
193/// * `__in` → `.is_in(iterable)`
194/// * `__not_in` → `.not_in(iterable)`
195/// * `__isnull = true` → `.is_null()`; `__isnull = false` → `.is_not_null()`
196/// * `__between` accepts a tuple literal `(lo, hi)` → `.between(lo, hi)`
197/// * `__regex` / `__iregex` → `.regex(pattern)` / `.iregex(pattern)`
198///
199/// Unknown suffixes fail the build with a `compile_error!` pointing at
200/// the lookup token.
201///
202/// # Combine
203///
204/// Each `Q!()` returns a `TypedFilter<Model>` — chain via the existing
205/// `.and()` / `.or()` / `.not()` methods:
206///
207/// ```ignore
208/// User::objects()
209///     .where_(
210///         Q!(User.active = true)
211///             .and(Q!(User.email__icontains = "alice"))
212///     )
213///     .fetch_pool(&pool).await?;
214/// ```
215///
216/// All emitted code routes through existing per-dialect writers — no new
217/// SQL emission machinery. Tri-dialect support is inherent.
218#[allow(non_snake_case)]
219#[proc_macro]
220pub fn Q(input: TokenStream) -> TokenStream {
221    expand_q(input.into())
222        .unwrap_or_else(syn::Error::into_compile_error)
223        .into()
224}
225
226/// `#[rustango::main]` — the Django-shape runserver entrypoint. Wraps
227/// `#[tokio::main]` and a default `tracing_subscriber` initialisation
228/// (env-filter, falling back to `info,sqlx=warn`) so user `main`
229/// functions are zero-boilerplate:
230///
231/// ```ignore
232/// #[rustango::main]
233/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
234///     rustango::server::Builder::from_env().await?
235///         .migrate("migrations").await?
236///         .api(my_app::urls::api())
237///         .seed_with(my_app::seed::run).await?
238///         .serve("0.0.0.0:8080").await
239/// }
240/// ```
241///
242/// Optional `flavor = "current_thread"` passes through to
243/// `#[tokio::main]`; default is the multi-threaded runtime.
244///
245/// Pulls `tracing-subscriber` into the rustango crate behind the
246/// `runtime` sub-feature (implied by `tenancy`), so apps that opt
247/// out get plain `#[tokio::main]` ergonomics without the dependency.
248#[proc_macro_attribute]
249pub fn main(args: TokenStream, item: TokenStream) -> TokenStream {
250    expand_main(args.into(), item.into())
251        .unwrap_or_else(syn::Error::into_compile_error)
252        .into()
253}
254
255fn expand_main(args: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
256    let mut input: syn::ItemFn = syn::parse2(item)?;
257    if input.sig.asyncness.is_none() {
258        return Err(syn::Error::new(
259            input.sig.ident.span(),
260            "`#[rustango::main]` must wrap an `async fn`",
261        ));
262    }
263
264    // v0.31.1 (#4): hand-roll the tokio runtime instead of delegating
265    // to `#[tokio::main]`. Tokio's proc-macro internally emits
266    // `::tokio::*` paths that resolve against the user crate's deps,
267    // so calling it through the rustango re-export still requires the
268    // user to add tokio to their own Cargo.toml. Building the
269    // runtime ourselves keeps the dep transitive through the
270    // `runtime` feature on rustango.
271    //
272    // Parse optional `flavor = "current_thread"` / `flavor =
273    // "multi_thread"` from the attribute args. Unknown args are
274    // tolerated (forward-compat with tokio's own arg surface).
275    let flavor = parse_flavor(&args);
276    let builder_call = match flavor {
277        Flavor::CurrentThread => quote! {
278            ::rustango::__private_runtime::tokio::runtime::Builder::new_current_thread()
279        },
280        Flavor::MultiThread => quote! {
281            ::rustango::__private_runtime::tokio::runtime::Builder::new_multi_thread()
282        },
283    };
284
285    // Detach the user body and rewrite `main` as a sync fn that
286    // builds the runtime and blocks on the async body.
287    let user_body = input.block.clone();
288    input.sig.asyncness = None;
289    input.block = syn::parse2(quote! {{
290        {
291            use ::rustango::__private_runtime::tracing_subscriber::{self, EnvFilter};
292            // `try_init` so duplicate installers (e.g. tests already
293            // holding a subscriber) don't panic.
294            let _ = tracing_subscriber::fmt()
295                .with_env_filter(
296                    EnvFilter::try_from_default_env()
297                        .unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn")),
298                )
299                .try_init();
300        }
301        let __rt = #builder_call
302            .enable_all()
303            .build()
304            .expect("failed to build tokio runtime");
305        __rt.block_on(async move #user_body)
306    }})?;
307
308    Ok(quote! {
309        #input
310    })
311}
312
313enum Flavor {
314    MultiThread,
315    CurrentThread,
316}
317
318fn parse_flavor(args: &TokenStream2) -> Flavor {
319    // Cheap parser: look for the literal token sequence
320    // `flavor = "current_thread"`. Everything else (including
321    // bare `multi_thread` or no args) defaults to multi-thread.
322    let s = args.to_string();
323    if s.contains("current_thread") {
324        Flavor::CurrentThread
325    } else {
326        Flavor::MultiThread
327    }
328}
329
330/// Parse form for `Q!()` — `<TypePath>.<Ident> = <Expr>`.
331struct QInput {
332    base_path: syn::Path,
333    field: syn::Ident,
334    value: syn::Expr,
335}
336
337impl syn::parse::Parse for QInput {
338    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
339        let base_path: syn::Path = input.parse()?;
340        input.parse::<syn::Token![.]>()?;
341        let field: syn::Ident = input.parse()?;
342        input.parse::<syn::Token![=]>()?;
343        let value: syn::Expr = input.parse()?;
344        Ok(QInput {
345            base_path,
346            field,
347            value,
348        })
349    }
350}
351
352fn expand_q(input: TokenStream2) -> syn::Result<TokenStream2> {
353    let q: QInput = syn::parse2(input)?;
354    let field_str = q.field.to_string();
355    let field_span = q.field.span();
356    let (base, suffix) = match field_str.find("__") {
357        Some(idx) => (&field_str[..idx], &field_str[idx + 2..]),
358        None => (field_str.as_str(), ""),
359    };
360    if base.is_empty() {
361        return Err(syn::Error::new(
362            field_span,
363            "Q!(): field name is empty before `__` suffix",
364        ));
365    }
366    let base_ident = syn::Ident::new(base, field_span);
367    let value = &q.value;
368    let path = &q.base_path;
369
370    // Most suffixes map directly to a Column method with the value
371    // forwarded unchanged. Some need value-shape massaging (wildcards
372    // for LIKE-family, tuple destructure for BETWEEN, literal-bool for
373    // ISNULL). Unknown suffixes fail the build.
374    let expanded = match suffix {
375        "" | "exact" => quote! {
376            ::rustango::core::Column::eq(#path::#base_ident, #value)
377        },
378        "ne" => quote! {
379            ::rustango::core::Column::ne(#path::#base_ident, #value)
380        },
381        "gt" => quote! {
382            ::rustango::core::Column::gt(#path::#base_ident, #value)
383        },
384        "gte" => quote! {
385            ::rustango::core::Column::gte(#path::#base_ident, #value)
386        },
387        "lt" => quote! {
388            ::rustango::core::Column::lt(#path::#base_ident, #value)
389        },
390        "lte" => quote! {
391            ::rustango::core::Column::lte(#path::#base_ident, #value)
392        },
393        "iexact" => quote! {
394            // Django emulates `__iexact` as case-insensitive equality.
395            // The non-wildcard `ILIKE value` is semantically identical
396            // for plain strings; LIKE-metachars `%` `_` in the rhs would
397            // accidentally match more — document the caveat.
398            ::rustango::core::Column::ilike(#path::#base_ident, ::std::string::ToString::to_string(&(#value)))
399        },
400        "contains" => quote! {
401            ::rustango::core::Column::like(
402                #path::#base_ident,
403                ::std::format!("%{}%", #value),
404            )
405        },
406        "icontains" => quote! {
407            ::rustango::core::Column::ilike(
408                #path::#base_ident,
409                ::std::format!("%{}%", #value),
410            )
411        },
412        "startswith" => quote! {
413            ::rustango::core::Column::like(
414                #path::#base_ident,
415                ::std::format!("{}%", #value),
416            )
417        },
418        "istartswith" => quote! {
419            ::rustango::core::Column::ilike(
420                #path::#base_ident,
421                ::std::format!("{}%", #value),
422            )
423        },
424        "endswith" => quote! {
425            ::rustango::core::Column::like(
426                #path::#base_ident,
427                ::std::format!("%{}", #value),
428            )
429        },
430        "iendswith" => quote! {
431            ::rustango::core::Column::ilike(
432                #path::#base_ident,
433                ::std::format!("%{}", #value),
434            )
435        },
436        "in" => quote! {
437            ::rustango::core::Column::is_in(#path::#base_ident, #value)
438        },
439        "not_in" => quote! {
440            ::rustango::core::Column::not_in(#path::#base_ident, #value)
441        },
442        "isnull" => {
443            // Must be a bool literal at macro time so we can route to
444            // is_null vs is_not_null without a runtime branch.
445            let b = match value {
446                syn::Expr::Lit(syn::ExprLit {
447                    lit: syn::Lit::Bool(b),
448                    ..
449                }) => b.value(),
450                _ => {
451                    return Err(syn::Error::new_spanned(
452                        value,
453                        "Q!(): `__isnull` requires a `true` or `false` literal",
454                    ));
455                }
456            };
457            if b {
458                quote! { ::rustango::core::Column::is_null(#path::#base_ident) }
459            } else {
460                quote! { ::rustango::core::Column::is_not_null(#path::#base_ident) }
461            }
462        }
463        "between" => {
464            // Accept a tuple literal `(lo, hi)`.
465            let tuple = match value {
466                syn::Expr::Tuple(t) if t.elems.len() == 2 => t,
467                _ => {
468                    return Err(syn::Error::new_spanned(
469                        value,
470                        "Q!(): `__between` requires a tuple literal `(lo, hi)`",
471                    ));
472                }
473            };
474            let lo = &tuple.elems[0];
475            let hi = &tuple.elems[1];
476            quote! { ::rustango::core::Column::between(#path::#base_ident, #lo, #hi) }
477        }
478        "regex" => quote! {
479            ::rustango::core::Column::regex(#path::#base_ident, #value)
480        },
481        "iregex" => quote! {
482            ::rustango::core::Column::iregex(#path::#base_ident, #value)
483        },
484        _ => {
485            return Err(syn::Error::new(
486                field_span,
487                format!(
488                    "Q!(): unknown lookup suffix `__{}`. Supported: __exact / __iexact / __ne / __gt / __gte / __lt / __lte / __contains / __icontains / __startswith / __istartswith / __endswith / __iendswith / __in / __not_in / __isnull / __between / __regex / __iregex",
489                    suffix
490                ),
491            ));
492        }
493    };
494    Ok(expanded)
495}
496
497fn expand_embed_migrations(input: TokenStream2) -> syn::Result<TokenStream2> {
498    // Default to "./migrations" if invoked without args.
499    let path_str = if input.is_empty() {
500        "./migrations".to_string()
501    } else {
502        let lit: LitStr = syn::parse2(input)?;
503        lit.value()
504    };
505
506    let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
507        syn::Error::new(
508            proc_macro2::Span::call_site(),
509            "embed_migrations! must be invoked during a Cargo build (CARGO_MANIFEST_DIR not set)",
510        )
511    })?;
512    let abs = std::path::Path::new(&manifest).join(&path_str);
513
514    let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
515    if abs.is_dir() {
516        let read = std::fs::read_dir(&abs).map_err(|e| {
517            syn::Error::new(
518                proc_macro2::Span::call_site(),
519                format!("embed_migrations!: cannot read {}: {e}", abs.display()),
520            )
521        })?;
522        for entry in read.flatten() {
523            let path = entry.path();
524            if !path.is_file() {
525                continue;
526            }
527            if path.extension().and_then(|s| s.to_str()) != Some("json") {
528                continue;
529            }
530            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
531                continue;
532            };
533            entries.push((stem.to_owned(), path));
534        }
535    }
536    entries.sort_by(|a, b| a.0.cmp(&b.0));
537
538    // Compile-time chain validation: read each migration's JSON,
539    // pull `name` and `prev` (file-stem-keyed for the chain check),
540    // and verify every `prev` points to another migration in the
541    // slice. Mismatches between the file stem and the embedded
542    // `name` field — and broken `prev` chains — fail at MACRO
543    // EXPANSION time so a misshapen migration set never compiles.
544    //
545    // This is the v0.4 Slice 5 distinguisher: rustango's JSON
546    // migrations + a Rust proc-macro that reads them is the unique
547    // combo nothing else in the Django-shape Rust camp can match
548    // (Cot's are imperative Rust code, Loco's are SeaORM up/down,
549    // Rwf's are raw SQL — none have a static chain to validate).
550    let mut chain_names: Vec<String> = Vec::with_capacity(entries.len());
551    let mut prev_refs: Vec<(String, Option<String>)> = Vec::with_capacity(entries.len());
552    for (stem, path) in &entries {
553        let raw = std::fs::read_to_string(path).map_err(|e| {
554            syn::Error::new(
555                proc_macro2::Span::call_site(),
556                format!(
557                    "embed_migrations!: cannot read {} for chain validation: {e}",
558                    path.display()
559                ),
560            )
561        })?;
562        let json: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
563            syn::Error::new(
564                proc_macro2::Span::call_site(),
565                format!(
566                    "embed_migrations!: {} is not valid JSON: {e}",
567                    path.display()
568                ),
569            )
570        })?;
571        let name = json
572            .get("name")
573            .and_then(|v| v.as_str())
574            .ok_or_else(|| {
575                syn::Error::new(
576                    proc_macro2::Span::call_site(),
577                    format!(
578                        "embed_migrations!: {} is missing the `name` field",
579                        path.display()
580                    ),
581                )
582            })?
583            .to_owned();
584        if name != *stem {
585            return Err(syn::Error::new(
586                proc_macro2::Span::call_site(),
587                format!(
588                    "embed_migrations!: file stem `{stem}` does not match the migration's \
589                     `name` field `{name}` — rename the file or fix the JSON",
590                ),
591            ));
592        }
593        let prev = json.get("prev").and_then(|v| v.as_str()).map(str::to_owned);
594        chain_names.push(name.clone());
595        prev_refs.push((name, prev));
596    }
597
598    let name_set: std::collections::HashSet<&str> =
599        chain_names.iter().map(String::as_str).collect();
600    for (name, prev) in &prev_refs {
601        if let Some(p) = prev {
602            if !name_set.contains(p.as_str()) {
603                return Err(syn::Error::new(
604                    proc_macro2::Span::call_site(),
605                    format!(
606                        "embed_migrations!: broken migration chain — `{name}` declares \
607                         prev=`{p}` but no migration with that name exists in {}",
608                        abs.display()
609                    ),
610                ));
611            }
612        }
613    }
614
615    let pairs: Vec<TokenStream2> = entries
616        .iter()
617        .map(|(name, path)| {
618            let path_lit = path.display().to_string();
619            quote! { (#name, ::core::include_str!(#path_lit)) }
620        })
621        .collect();
622
623    Ok(quote! {
624        {
625            const __RUSTANGO_EMBEDDED: &[(&'static str, &'static str)] = &[#(#pairs),*];
626            __RUSTANGO_EMBEDDED
627        }
628    })
629}
630
631fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
632    let struct_name = &input.ident;
633
634    let Data::Struct(data) = &input.data else {
635        return Err(syn::Error::new_spanned(
636            struct_name,
637            "Model can only be derived on structs",
638        ));
639    };
640    let Fields::Named(named) = &data.fields else {
641        return Err(syn::Error::new_spanned(
642            struct_name,
643            "Model requires a struct with named fields",
644        ));
645    };
646
647    let container = parse_container_attrs(input)?;
648    let table = container
649        .table
650        .unwrap_or_else(|| to_snake_case(&struct_name.to_string()));
651    let model_name = struct_name.to_string();
652
653    let collected = collect_fields(named, &table)?;
654
655    // Validate that #[rustango(display = "…")] names a real field.
656    if let Some((ref display, span)) = container.display {
657        if !collected.field_names.iter().any(|n| n == display) {
658            return Err(syn::Error::new(
659                span,
660                format!("`display = \"{display}\"` does not match any field on this struct"),
661            ));
662        }
663    }
664    let display = container.display.map(|(name, _)| name);
665    let app_label = container.app.clone();
666
667    // Validate admin field-name lists against declared field names.
668    // Note: `list_display` is intentionally NOT validated here. As of
669    // v0.32 it may also reference inventory-registered computed
670    // fields (via `register_admin_computed!`) whose existence the
671    // macro can't see at compile time — they're submitted from any
672    // crate that depends on rustango. The runtime list-view resolves
673    // unknown names against the inventory + silently drops the
674    // truly-bogus ones, which is the cheaper trade-off versus
675    // forcing a per-Model attr to opt out.
676    if let Some(admin) = &container.admin {
677        for (label, list) in [
678            ("search_fields", &admin.search_fields),
679            ("readonly_fields", &admin.readonly_fields),
680            ("list_filter", &admin.list_filter),
681        ] {
682            if let Some((names, span)) = list {
683                for name in names {
684                    if !collected.field_names.iter().any(|n| n == name) {
685                        return Err(syn::Error::new(
686                            *span,
687                            format!(
688                                "`{label} = \"{name}\"`: \"{name}\" is not a declared field on this struct"
689                            ),
690                        ));
691                    }
692                }
693            }
694        }
695        if let Some((pairs, span)) = &admin.ordering {
696            for (name, _) in pairs {
697                if !collected.field_names.iter().any(|n| n == name) {
698                    return Err(syn::Error::new(
699                        *span,
700                        format!(
701                            "`ordering = \"{name}\"`: \"{name}\" is not a declared field on this struct"
702                        ),
703                    ));
704                }
705            }
706        }
707        if let Some((groups, span)) = &admin.fieldsets {
708            for (_, fields) in groups {
709                for name in fields {
710                    if !collected.field_names.iter().any(|n| n == name) {
711                        return Err(syn::Error::new(
712                            *span,
713                            format!(
714                                "`fieldsets`: \"{name}\" is not a declared field on this struct"
715                            ),
716                        ));
717                    }
718                }
719            }
720        }
721    }
722    if let Some(audit) = &container.audit {
723        if let Some((names, span)) = &audit.track {
724            for name in names {
725                if !collected.field_names.iter().any(|n| n == name) {
726                    return Err(syn::Error::new(
727                        *span,
728                        format!(
729                            "`audit(track = \"{name}\")`: \"{name}\" is not a declared field on this struct"
730                        ),
731                    ));
732                }
733            }
734        }
735    }
736
737    // Issue #291 / T2.5 — validate each `default_order` column name
738    // against the model's collected fields. Typos fail at macro-expand
739    // time, not at the database.
740    for (col, _desc, span) in &container.default_order {
741        if !collected.field_names.iter().any(|n| n == col) {
742            return Err(syn::Error::new(
743                *span,
744                format!(
745                    "`default_order = \"...\"`: \"{col}\" is not a declared field on this struct"
746                ),
747            ));
748        }
749    }
750
751    // Build the audit_track list for ModelSchema: None when no audit attr,
752    // Some(empty) when audit present without track, Some(names) when explicit.
753    let audit_track_names: Option<Vec<String>> = container.audit.as_ref().map(|audit| {
754        audit
755            .track
756            .as_ref()
757            .map(|(names, _)| names.clone())
758            .unwrap_or_default()
759    });
760
761    // Merge field-level indexes into the container's index list.
762    let mut all_indexes: Vec<IndexAttr> = container.indexes;
763    for field in &named.named {
764        let ident = field.ident.as_ref().expect("named");
765        let col = to_snake_case(&ident.to_string()); // column name fallback
766                                                     // Re-parse field attrs to check for index flag
767        if let Ok(fa) = parse_field_attrs(field) {
768            if fa.index {
769                let col_name = fa.column.clone().unwrap_or_else(|| col.clone());
770                let auto_name = if fa.index_unique {
771                    format!("{table}_{col_name}_uq_idx")
772                } else {
773                    format!("{table}_{col_name}_idx")
774                };
775                all_indexes.push(IndexAttr {
776                    name: fa.index_name.or(Some(auto_name)),
777                    columns: vec![col_name],
778                    unique: fa.index_unique,
779                    method: fa.index_method,
780                    where_clause: None,
781                });
782            }
783        }
784    }
785
786    let model_impl = model_impl_tokens(
787        struct_name,
788        &model_name,
789        &table,
790        display.as_deref(),
791        app_label.as_deref(),
792        container.admin.as_ref(),
793        &container.default_order,
794        &collected.field_schemas,
795        collected.soft_delete_column.as_deref(),
796        container.permissions,
797        audit_track_names.as_deref(),
798        &container.m2m,
799        &all_indexes,
800        &container.checks,
801        &container.composite_fks,
802        &container.generic_fks,
803        container.scope.as_deref(),
804        container.is_view,
805        container.verbose_name.as_deref(),
806        container.verbose_name_plural.as_deref(),
807    );
808    let module_ident = column_module_ident(struct_name);
809    let column_consts = column_const_tokens(&module_ident, &collected.column_entries);
810    let audited_fields: Option<Vec<&ColumnEntry>> = container.audit.as_ref().map(|audit| {
811        let track_set: Option<std::collections::HashSet<&str>> = audit
812            .track
813            .as_ref()
814            .map(|(names, _)| names.iter().map(String::as_str).collect());
815        collected
816            .column_entries
817            .iter()
818            .filter(|c| {
819                track_set
820                    .as_ref()
821                    .map_or(true, |s| s.contains(c.name.as_str()))
822            })
823            .collect()
824    });
825    let inherent_impl = inherent_impl_tokens(
826        struct_name,
827        &collected,
828        collected.primary_key.as_ref(),
829        &column_consts,
830        audited_fields.as_deref(),
831        &all_indexes,
832        &container.manager_fns,
833    );
834    let column_module = column_module_tokens(&module_ident, struct_name, &collected.column_entries);
835    let from_row_impl = from_row_impl_tokens(struct_name, &collected.from_row_inits);
836    let reverse_helpers = reverse_helper_tokens(struct_name, &collected.fk_relations);
837    let m2m_accessors = m2m_accessor_tokens(struct_name, &container.m2m);
838    let generic_fk_accessors = generic_fk_accessor_tokens(
839        struct_name,
840        &container.generic_fks,
841        &collected.column_entries,
842    );
843
844    // Issue #271 / T1.9 — `#[rustango(manager(ext = "FooManagerExt"))]`
845    // emits an empty extension trait so users can add methods via
846    // `impl FooManagerExt for QuerySet<Foo>` without hand-writing the
847    // trait declaration. See `crates/rustango/src/manager.rs` for the
848    // pattern this replaces.
849    let manager_trait = container.manager_ext.as_ref().map(|name| {
850        let model_name_str = struct_name.to_string();
851        let doc = format!(
852            "Custom-Manager extension trait for [`{model_name_str}`]. \
853             Generated by `#[rustango(manager(ext = ...))]`. Add methods \
854             via `impl {name} for QuerySet<{model_name_str}> {{ ... }}`."
855        );
856        quote! {
857            #[doc = #doc]
858            pub trait #name: ::core::marker::Sized {}
859        }
860    });
861
862    Ok(quote! {
863        #model_impl
864        #inherent_impl
865        #from_row_impl
866        #column_module
867        #reverse_helpers
868        #m2m_accessors
869        #generic_fk_accessors
870        #manager_trait
871
872        ::rustango::core::inventory::submit! {
873            ::rustango::core::ModelEntry {
874                schema: <#struct_name as ::rustango::core::Model>::SCHEMA,
875                // `module_path!()` evaluates at the registration site,
876                // so a Model declared in `crate::blog::models` records
877                // `"<crate>::blog::models"` and `resolved_app_label()`
878                // can infer "blog" without an explicit attribute.
879                module_path: ::core::module_path!(),
880            }
881        }
882    })
883}
884
885/// Emit `impl LoadRelated for #StructName` — slice 9.0d. Pattern-
886/// matches `field_name` against the model's FK fields and, for a
887/// match, decodes the FK target via the parent's macro-generated
888/// `__rustango_from_aliased_row`, reads the parent's PK, and stores
889/// `ForeignKey::Loaded` on `self`.
890///
891/// Always emitted (with empty arms for FK-less models, which
892/// return `Ok(false)` for any field name) so the `T: LoadRelated`
893/// trait bound on `fetch_on` is universally satisfied — users
894/// never have to think about implementing it.
895fn load_related_impl_tokens(struct_name: &syn::Ident, fk_relations: &[FkRelation]) -> TokenStream2 {
896    let arms = fk_relations.iter().map(|rel| {
897        let parent_ty = &rel.parent_type;
898        let fk_col = rel.fk_column.as_str();
899        // FK field's Rust ident matches its SQL column name in v0.8
900        // (no `column = "..."` rename ships on FK fields).
901        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
902        let (variant_ident, default_expr) = rel.pk_kind.sqlvalue_match_arm();
903        let assign = if rel.nullable {
904            quote! {
905                self.#field_ident = ::core::option::Option::Some(
906                    ::rustango::sql::ForeignKey::loaded(_pk, _parent),
907                );
908            }
909        } else {
910            quote! {
911                self.#field_ident = ::rustango::sql::ForeignKey::loaded(_pk, _parent);
912            }
913        };
914        quote! {
915            #fk_col => {
916                let _parent: #parent_ty = <#parent_ty>::__rustango_from_aliased_row(row, alias)?;
917                // Loud-in-debug, default-in-release: a divergence
918                // between the FK field's declared `K` (drives the
919                // expected `SqlValue::<Variant>`) and the parent's
920                // `__rustango_pk_value` output is a macro-internal
921                // invariant break — surfacing the panic in dev
922                // catches it before users hit silent PK=0 corruption.
923                let _pk = match <#parent_ty>::__rustango_pk_value(&_parent) {
924                    ::rustango::core::SqlValue::#variant_ident(v) => v,
925                    _other => {
926                        ::core::debug_assert!(
927                            false,
928                            "rustango macro bug: load_related on FK `{}` expected \
929                             SqlValue::{} from parent's __rustango_pk_value but got \
930                             {:?} — file a bug at https://github.com/ujeenet/rustango",
931                            #fk_col,
932                            ::core::stringify!(#variant_ident),
933                            _other,
934                        );
935                        #default_expr
936                    }
937                };
938                #assign
939                ::core::result::Result::Ok(true)
940            }
941        }
942    });
943    quote! {
944        #[cfg(feature = "postgres")]
945        impl ::rustango::sql::LoadRelated for #struct_name {
946            #[allow(unused_variables)]
947            fn __rustango_load_related(
948                &mut self,
949                row: &::rustango::sql::sqlx::postgres::PgRow,
950                field_name: &str,
951                alias: &str,
952            ) -> ::core::result::Result<bool, ::rustango::sql::sqlx::Error> {
953                match field_name {
954                    #( #arms )*
955                    _ => ::core::result::Result::Ok(false),
956                }
957            }
958        }
959    }
960}
961
962/// MySQL counterpart of [`load_related_impl_tokens`] — v0.23.0-batch8.
963/// Emits a call to the cfg-gated `__impl_my_load_related!` macro_rules,
964/// which expands to a `LoadRelatedMy` impl when rustango is built with
965/// the `mysql` feature, and to nothing otherwise. The decoded parent
966/// is read via `__rustango_from_aliased_my_row` (the MySQL aliased
967/// decoder, also batch8) so the dual emission is symmetric across
968/// backends.
969fn load_related_impl_my_tokens(
970    struct_name: &syn::Ident,
971    fk_relations: &[FkRelation],
972) -> TokenStream2 {
973    let arms = fk_relations.iter().map(|rel| {
974        let parent_ty = &rel.parent_type;
975        let fk_col = rel.fk_column.as_str();
976        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
977        let (variant_ident, default_expr) = rel.pk_kind.sqlvalue_match_arm();
978        let assign = if rel.nullable {
979            quote! {
980                __self.#field_ident = ::core::option::Option::Some(
981                    ::rustango::sql::ForeignKey::loaded(_pk, _parent),
982                );
983            }
984        } else {
985            quote! {
986                __self.#field_ident = ::rustango::sql::ForeignKey::loaded(_pk, _parent);
987            }
988        };
989        // `self` IS hygiene-tracked through macro_rules — emitted from
990        // a different context than the `&mut self` parameter inside
991        // the macro_rules-expanded fn. Pass it through as `__self`
992        // and let the macro_rules rebind it to the receiver.
993        quote! {
994            #fk_col => {
995                let _parent: #parent_ty =
996                    <#parent_ty>::__rustango_from_aliased_my_row(row, alias)?;
997                // See note in `load_related_impl_tokens` (PG twin) —
998                // the same loud-in-debug invariant guard.
999                let _pk = match <#parent_ty>::__rustango_pk_value(&_parent) {
1000                    ::rustango::core::SqlValue::#variant_ident(v) => v,
1001                    _other => {
1002                        ::core::debug_assert!(
1003                            false,
1004                            "rustango macro bug: load_related on FK `{}` expected \
1005                             SqlValue::{} from parent's __rustango_pk_value but got \
1006                             {:?} — file a bug at https://github.com/ujeenet/rustango",
1007                            #fk_col,
1008                            ::core::stringify!(#variant_ident),
1009                            _other,
1010                        );
1011                        #default_expr
1012                    }
1013                };
1014                #assign
1015                ::core::result::Result::Ok(true)
1016            }
1017        }
1018    });
1019    quote! {
1020        ::rustango::__impl_my_load_related!(#struct_name, |__self, row, field_name, alias| {
1021            #( #arms )*
1022        });
1023    }
1024}
1025
1026/// Same shape as [`load_related_impl_my_tokens`] but for SQLite.
1027/// Emits a call to `__impl_sqlite_load_related!` which expands to a
1028/// `LoadRelatedSqlite` impl when the `sqlite` feature is on.
1029fn load_related_impl_sqlite_tokens(
1030    struct_name: &syn::Ident,
1031    fk_relations: &[FkRelation],
1032) -> TokenStream2 {
1033    let arms = fk_relations.iter().map(|rel| {
1034        let parent_ty = &rel.parent_type;
1035        let fk_col = rel.fk_column.as_str();
1036        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
1037        let (variant_ident, default_expr) = rel.pk_kind.sqlvalue_match_arm();
1038        let assign = if rel.nullable {
1039            quote! {
1040                __self.#field_ident = ::core::option::Option::Some(
1041                    ::rustango::sql::ForeignKey::loaded(_pk, _parent),
1042                );
1043            }
1044        } else {
1045            quote! {
1046                __self.#field_ident = ::rustango::sql::ForeignKey::loaded(_pk, _parent);
1047            }
1048        };
1049        quote! {
1050            #fk_col => {
1051                let _parent: #parent_ty =
1052                    <#parent_ty>::__rustango_from_aliased_sqlite_row(row, alias)?;
1053                let _pk = match <#parent_ty>::__rustango_pk_value(&_parent) {
1054                    ::rustango::core::SqlValue::#variant_ident(v) => v,
1055                    _other => {
1056                        ::core::debug_assert!(
1057                            false,
1058                            "rustango macro bug: load_related on FK `{}` expected \
1059                             SqlValue::{} from parent's __rustango_pk_value but got \
1060                             {:?} — file a bug at https://github.com/ujeenet/rustango",
1061                            #fk_col,
1062                            ::core::stringify!(#variant_ident),
1063                            _other,
1064                        );
1065                        #default_expr
1066                    }
1067                };
1068                #assign
1069                ::core::result::Result::Ok(true)
1070            }
1071        }
1072    });
1073    quote! {
1074        ::rustango::__impl_sqlite_load_related!(#struct_name, |__self, row, field_name, alias| {
1075            #( #arms )*
1076        });
1077    }
1078}
1079
1080/// Emit `impl FkPkAccess for #StructName` — slice 9.0e. Pattern-
1081/// matches `field_name` against the model's FK fields and returns
1082/// the FK's stored PK as `i64`. Used by `fetch_with_prefetch` to
1083/// group children by parent PK.
1084///
1085/// Always emitted (with `_ => None` for FK-less models) so the
1086/// trait bound on `fetch_with_prefetch` is universally satisfied.
1087fn fk_pk_access_impl_tokens(struct_name: &syn::Ident, fk_relations: &[FkRelation]) -> TokenStream2 {
1088    let arms = fk_relations.iter().map(|rel| {
1089        let fk_col = rel.fk_column.as_str();
1090        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
1091        if rel.pk_kind == DetectedKind::I64 {
1092            // i64 FK — return the stored PK so prefetch_related can
1093            // group children by it. Nullable variant unwraps via
1094            // `as_ref().map(...)`: an unset (NULL) FK column yields
1095            // `None` and that child sits out of the grouping (correct
1096            // semantics — it has no parent to attach to).
1097            if rel.nullable {
1098                quote! {
1099                    #fk_col => self.#field_ident
1100                        .as_ref()
1101                        .map(|fk| ::rustango::sql::ForeignKey::pk(fk)),
1102                }
1103            } else {
1104                quote! {
1105                    #fk_col => ::core::option::Option::Some(self.#field_ident.pk()),
1106                }
1107            }
1108        } else {
1109            // Non-i64 FK PKs (e.g. `ForeignKey<T, String>`,
1110            // `ForeignKey<T, Uuid>`) opt out of `prefetch_related`'s
1111            // i64-keyed grouping path — the trait signature is
1112            // `Option<i64>` and a non-i64 PK can't lower into it.
1113            // The FK still works for everything else (CRUD, lazy
1114            // load via `.get()`, select_related JOINs); only the
1115            // bulk prefetch grouper needs the integer key.
1116            quote! {
1117                #fk_col => ::core::option::Option::None,
1118            }
1119        }
1120    });
1121    // PK-type-agnostic version: every FK arm emits an
1122    // `Option<SqlValue>` so `fetch_with_prefetch` can group by any
1123    // PK type (i64, i32, String, Uuid). Models with non-i64 FK PKs
1124    // opt OUT of the legacy i64 method (it returns None) but opt IN
1125    // here.
1126    let value_arms = fk_relations.iter().map(|rel| {
1127        let fk_col = rel.fk_column.as_str();
1128        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
1129        if rel.nullable {
1130            quote! {
1131                #fk_col => self.#field_ident
1132                    .as_ref()
1133                    .map(|fk| ::core::convert::Into::<::rustango::core::SqlValue>::into(
1134                        ::rustango::sql::ForeignKey::pk(fk)
1135                    )),
1136            }
1137        } else {
1138            quote! {
1139                #fk_col => ::core::option::Option::Some(
1140                    ::core::convert::Into::<::rustango::core::SqlValue>::into(
1141                        self.#field_ident.pk()
1142                    )
1143                ),
1144            }
1145        }
1146    });
1147    quote! {
1148        impl ::rustango::sql::FkPkAccess for #struct_name {
1149            #[allow(unused_variables)]
1150            fn __rustango_fk_pk(&self, field_name: &str) -> ::core::option::Option<i64> {
1151                match field_name {
1152                    #( #arms )*
1153                    _ => ::core::option::Option::None,
1154                }
1155            }
1156            #[allow(unused_variables)]
1157            fn __rustango_fk_pk_value(
1158                &self,
1159                field_name: &str,
1160            ) -> ::core::option::Option<::rustango::core::SqlValue> {
1161                match field_name {
1162                    #( #value_arms )*
1163                    _ => ::core::option::Option::None,
1164                }
1165            }
1166        }
1167    }
1168}
1169
1170/// For every `ForeignKey<Parent>` field on `Child`, emit
1171/// `impl Parent { pub async fn <child_table>_set(&self, executor) -> Vec<Child> }`.
1172/// Reads the parent's PK via the macro-generated `__rustango_pk_value`
1173/// and runs a single `SELECT … FROM <child_table> WHERE <fk_column> = $1`
1174/// — the canonical reverse-FK fetch. One round trip, no N+1.
1175///
1176/// **PG-only emission**: the accessor is bounded on
1177/// `sqlx::Executor<Database = sqlx::Postgres>` and calls `fetch_on`,
1178/// both of which are gated behind the `postgres` cargo feature. The
1179/// emitted code is wrapped in `#[cfg(feature = "postgres")]` so the
1180/// model derive itself compiles on tri-dialect / sqlite-only
1181/// downstream builds — the accessor just isn't materialised. A tri-
1182/// dialect `_set_pool` variant is a separate follow-up.
1183fn reverse_helper_tokens(child_ident: &syn::Ident, fk_relations: &[FkRelation]) -> TokenStream2 {
1184    if fk_relations.is_empty() {
1185        return TokenStream2::new();
1186    }
1187    // Snake-case the child struct name to derive the method suffix —
1188    // `Post` → `post_set`, `BlogComment` → `blog_comment_set`. Avoids
1189    // English-plural edge cases (Django's `<child>_set` convention).
1190    let suffix = format!("{}_set", to_snake_case(&child_ident.to_string()));
1191    let method_ident = syn::Ident::new(&suffix, child_ident.span());
1192    let impls = fk_relations.iter().map(|rel| {
1193        let parent_ty = &rel.parent_type;
1194        let fk_col = rel.fk_column.as_str();
1195        let doc = format!(
1196            "Fetch every `{child_ident}` whose `{fk_col}` foreign key points at this row. \
1197             Single SQL query — `SELECT … FROM <{child_ident} table> WHERE {fk_col} = $1` — \
1198             generated from the FK declaration on `{child_ident}::{fk_col}`. Composes with \
1199             further `{child_ident}::objects()` filters via direct queryset use."
1200        );
1201        quote! {
1202            #[cfg(feature = "postgres")]
1203            impl #parent_ty {
1204                #[doc = #doc]
1205                ///
1206                /// # Errors
1207                /// Returns [`::rustango::sql::ExecError`] for SQL-writing
1208                /// or driver failures.
1209                pub async fn #method_ident<'_c, _E>(
1210                    &self,
1211                    _executor: _E,
1212                ) -> ::core::result::Result<
1213                    ::std::vec::Vec<#child_ident>,
1214                    ::rustango::sql::ExecError,
1215                >
1216                where
1217                    _E: ::rustango::sql::sqlx::Executor<
1218                        '_c,
1219                        Database = ::rustango::sql::sqlx::Postgres,
1220                    >,
1221                {
1222                    let _pk: ::rustango::core::SqlValue = self.__rustango_pk_value();
1223                    ::rustango::query::QuerySet::<#child_ident>::new()
1224                        .filter_op(#fk_col, ::rustango::core::Op::Eq, _pk)
1225                        .fetch_on(_executor)
1226                        .await
1227                }
1228            }
1229        }
1230    });
1231    quote! { #( #impls )* }
1232}
1233
1234/// Emit `<name>_m2m(&self) -> M2MManager` inherent methods for every M2M
1235/// relation declared on the model.
1236/// Emit `{name}_pool` accessor + `set_{name}_for` setter for every
1237/// `#[rustango(generic_fk(name, ct_column, pk_column))]` declaration.
1238///
1239/// Closes #239 + #240 — the Django-shape `comment.content_object` /
1240/// `comment.content_object = post` ergonomics on top of the existing
1241/// `GenericForeignKey { content_type_id, object_pk }` primitive.
1242///
1243/// `column_entries` is passed so we can resolve each `ct_column` /
1244/// `pk_column` SQL name back to its Rust field ident — the macro
1245/// only sees the column-side strings in the attribute, but the
1246/// emitted accessor needs to read the actual struct field.
1247fn generic_fk_accessor_tokens(
1248    struct_name: &syn::Ident,
1249    generic_fks: &[GenericFkAttr],
1250    column_entries: &[ColumnEntry],
1251) -> TokenStream2 {
1252    if generic_fks.is_empty() {
1253        return TokenStream2::new();
1254    }
1255    let methods = generic_fks.iter().filter_map(|gfk| {
1256        // Resolve `ct_column` + `pk_column` to the struct's Rust
1257        // field idents. A typo (column name doesn't match any field)
1258        // emits no method for that registration — the user will see
1259        // the compiler reject the SCHEMA literal anyway, so there's
1260        // a clear error path without us double-reporting.
1261        let ct_ident = column_entries
1262            .iter()
1263            .find(|c| c.column == gfk.ct_column)
1264            .map(|c| c.ident.clone())?;
1265        let pk_ident = column_entries
1266            .iter()
1267            .find(|c| c.column == gfk.pk_column)
1268            .map(|c| c.ident.clone())?;
1269
1270        let accessor_ident =
1271            syn::Ident::new(&format!("{}_pool", gfk.name), struct_name.span());
1272        let setter_ident =
1273            syn::Ident::new(&format!("set_{}_for", gfk.name), struct_name.span());
1274        let name_literal = gfk.name.as_str();
1275
1276        Some(quote! {
1277            #[doc = concat!(
1278                "Resolve the polymorphic `",
1279                #name_literal,
1280                "` relation. Reads `self.",
1281                stringify!(#ct_ident),
1282                "` + `self.",
1283                stringify!(#pk_ident),
1284                "`, looks up the matching `ContentType`, and fetches the target row as a JSON map.\n\n",
1285                "Returns `Ok(None)` when the ContentType is stale / unseeded or the target row was deleted. Emitted by `#[rustango(generic_fk(name = \"",
1286                #name_literal,
1287                "\", ...))]`."
1288            )]
1289            pub async fn #accessor_ident(
1290                &self,
1291                pool: &::rustango::sql::Pool,
1292            ) -> ::core::result::Result<
1293                ::core::option::Option<::serde_json::Value>,
1294                ::rustango::sql::ExecError,
1295            > {
1296                let gfk = ::rustango::contenttypes::GenericForeignKey::new(
1297                    self.#ct_ident as i64,
1298                    self.#pk_ident as i64,
1299                );
1300                gfk.get_object(pool).await
1301            }
1302
1303            #[doc = concat!(
1304                "Set the polymorphic `",
1305                #name_literal,
1306                "` target. Looks up the `ContentType` for `T` via the cached registry, then assigns both `self.",
1307                stringify!(#ct_ident),
1308                "` and `self.",
1309                stringify!(#pk_ident),
1310                "`.\n\nFollow with `self.insert(pool)` or `self.update(pool)` to persist. Emitted by `#[rustango(generic_fk(name = \"",
1311                #name_literal,
1312                "\", ...))]`."
1313            )]
1314            pub async fn #setter_ident<T: ::rustango::core::Model>(
1315                &mut self,
1316                pool: &::rustango::sql::Pool,
1317                target_pk: i64,
1318            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
1319                let gfk = ::rustango::contenttypes::GenericForeignKey::for_target::<T>(
1320                    pool,
1321                    target_pk,
1322                ).await?;
1323                self.#ct_ident = gfk.content_type_id as _;
1324                self.#pk_ident = gfk.object_pk as _;
1325                ::core::result::Result::Ok(())
1326            }
1327        })
1328    });
1329    quote! {
1330        impl #struct_name {
1331            #( #methods )*
1332        }
1333    }
1334}
1335
1336fn m2m_accessor_tokens(struct_name: &syn::Ident, m2m_relations: &[M2MAttr]) -> TokenStream2 {
1337    if m2m_relations.is_empty() {
1338        return TokenStream2::new();
1339    }
1340    let methods = m2m_relations.iter().map(|rel| {
1341        let method_name = format!("{}_m2m", rel.name);
1342        let method_ident = syn::Ident::new(&method_name, struct_name.span());
1343        let through = rel.through.as_str();
1344        let src_col = rel.src.as_str();
1345        let dst_col = rel.dst.as_str();
1346        quote! {
1347            pub fn #method_ident(&self) -> ::rustango::sql::M2MManager {
1348                ::rustango::sql::M2MManager {
1349                    src_pk: self.__rustango_pk_value(),
1350                    through: #through,
1351                    src_col: #src_col,
1352                    dst_col: #dst_col,
1353                }
1354            }
1355        }
1356    });
1357    quote! {
1358        impl #struct_name {
1359            #( #methods )*
1360        }
1361    }
1362}
1363
1364struct ColumnEntry {
1365    /// The struct field ident, used both for the inherent const name on
1366    /// the model and for the inner column type's name.
1367    ident: syn::Ident,
1368    /// The struct's field type, used as `Column::Value`.
1369    value_ty: Type,
1370    /// Rust-side field name (e.g. `"id"`).
1371    name: String,
1372    /// SQL-side column name (e.g. `"user_id"`).
1373    column: String,
1374    /// `::rustango::core::FieldType::I64` etc.
1375    field_type_tokens: TokenStream2,
1376}
1377
1378struct CollectedFields {
1379    field_schemas: Vec<TokenStream2>,
1380    from_row_inits: Vec<TokenStream2>,
1381    /// Aliased counterparts of `from_row_inits` — read columns via
1382    /// `format!("{prefix}__{col}")` aliases so a Model can be
1383    /// decoded from a JOINed row's projected target columns.
1384    from_aliased_row_inits: Vec<TokenStream2>,
1385    /// Static column-name list — used by the simple insert path
1386    /// (no `Auto<T>` fields). Aligned with `insert_values`.
1387    insert_columns: Vec<TokenStream2>,
1388    /// Static `Into<SqlValue>` expressions, one per field. Aligned
1389    /// with `insert_columns`. Used by the simple insert path only.
1390    insert_values: Vec<TokenStream2>,
1391    /// Per-field push expressions for the dynamic (Auto-aware)
1392    /// insert path. Each statement either unconditionally pushes
1393    /// `(column, value)` or, for an `Auto<T>` field, conditionally
1394    /// pushes only when `Auto::Set(_)`. Built only when `has_auto`.
1395    insert_pushes: Vec<TokenStream2>,
1396    /// SQL columns for `RETURNING` — one per `Auto<T>` field. Empty
1397    /// when `has_auto == false`.
1398    returning_cols: Vec<TokenStream2>,
1399    /// `self.<field> = Row::try_get(&row, "<col>")?;` for each Auto
1400    /// field. Run after `insert_returning` to populate the model.
1401    auto_assigns: Vec<TokenStream2>,
1402    /// `(ident, column_literal)` pairs for every Auto field. Used by
1403    /// the bulk_insert codegen to rebuild assigns against `_row_mut`
1404    /// instead of `self`.
1405    auto_field_idents: Vec<(syn::Ident, String)>,
1406    /// Inner `T` of the first `Auto<T>` field, for the MySQL
1407    /// `LAST_INSERT_ID()` assignment in `AssignAutoPkPool`.
1408    first_auto_value_ty: Option<Type>,
1409    /// Bulk-insert per-row pushes for **non-Auto fields only**. Used
1410    /// by the all-Auto-Unset bulk path (Auto cols dropped from
1411    /// `columns`).
1412    bulk_pushes_no_auto: Vec<TokenStream2>,
1413    /// Bulk-insert per-row pushes for **all fields including Auto**.
1414    /// Used by the all-Auto-Set bulk path (Auto col included with the
1415    /// caller-supplied value).
1416    bulk_pushes_all: Vec<TokenStream2>,
1417    /// Column-name literals for non-Auto fields only (paired with
1418    /// `bulk_pushes_no_auto`).
1419    bulk_columns_no_auto: Vec<TokenStream2>,
1420    /// Column-name literals for every field including Auto (paired
1421    /// with `bulk_pushes_all`).
1422    bulk_columns_all: Vec<TokenStream2>,
1423    /// `let _i_unset_<n> = matches!(rows[0].<auto_field>, Auto::Unset);`
1424    /// + the loop that asserts every row matches. One pair per Auto
1425    /// field. Empty when `has_auto == false`.
1426    bulk_auto_uniformity: Vec<TokenStream2>,
1427    /// Identifier of the first Auto field, used as the witness for
1428    /// "all rows agree on Set vs Unset". Set only when `has_auto`.
1429    first_auto_ident: Option<syn::Ident>,
1430    /// `true` if any field on the struct is `Auto<T>`.
1431    has_auto: bool,
1432    /// `true` when the primary-key field's Rust type is `Auto<T>`.
1433    /// Gates `save()` codegen — only Auto PKs let us infer
1434    /// insert-vs-update from the in-memory value.
1435    pk_is_auto: bool,
1436    /// `Assignment` constructors for every non-PK column. Drives the
1437    /// UPDATE branch of `save()`.
1438    update_assignments: Vec<TokenStream2>,
1439    /// Column name literals (`"col"`) for every non-PK, non-auto_now_add column.
1440    /// Drives the `ON CONFLICT ... DO UPDATE SET` clause in `upsert_on`.
1441    upsert_update_columns: Vec<TokenStream2>,
1442    primary_key: Option<(syn::Ident, String)>,
1443    column_entries: Vec<ColumnEntry>,
1444    /// Rust-side field names, in declaration order. Used to validate
1445    /// container attributes like `display = "…"`.
1446    field_names: Vec<String>,
1447    /// FK fields on this child model. Drives the reverse-relation
1448    /// helper emit — for each FK, the macro adds an inherent
1449    /// `<parent>::<child_table>_set(&self, executor) -> Vec<Self>`
1450    /// method on the parent type.
1451    fk_relations: Vec<FkRelation>,
1452    /// SQL column name of the `#[rustango(soft_delete)]` field, if
1453    /// the model has one. Drives emission of the `soft_delete_on` /
1454    /// `restore_on` inherent methods. At most one such column per
1455    /// model is allowed; collect_fields rejects duplicates.
1456    soft_delete_column: Option<String>,
1457}
1458
1459#[derive(Clone)]
1460struct FkRelation {
1461    /// Inner type of `ForeignKey<T, K>` — the parent model. The reverse
1462    /// helper is emitted as `impl <ParentType> { … }`.
1463    parent_type: Type,
1464    /// SQL column name on the child table for this FK (e.g. `"author"`).
1465    /// Used in the generated `WHERE <fk_column> = $1` clause.
1466    fk_column: String,
1467    /// `K`'s underlying scalar kind — drives the `match SqlValue { … }`
1468    /// arm emitted by [`load_related_impl_tokens`]. `I64` for the
1469    /// default `ForeignKey<T>` (no explicit K); other kinds when the
1470    /// user wrote `ForeignKey<T, String>`, `ForeignKey<T, Uuid>`, etc.
1471    pk_kind: DetectedKind,
1472    /// `true` when the field is `Option<ForeignKey<T, K>>` (nullable
1473    /// FK column). Drives the `Some(...)` wrapping in load_related
1474    /// assignment and `.as_ref().map(...)` in the FK PK accessor so
1475    /// the codegen matches the field's declared shape.
1476    nullable: bool,
1477}
1478
1479fn collect_fields(named: &syn::FieldsNamed, table: &str) -> syn::Result<CollectedFields> {
1480    let cap = named.named.len();
1481    let mut out = CollectedFields {
1482        field_schemas: Vec::with_capacity(cap),
1483        from_row_inits: Vec::with_capacity(cap),
1484        from_aliased_row_inits: Vec::with_capacity(cap),
1485        insert_columns: Vec::with_capacity(cap),
1486        insert_values: Vec::with_capacity(cap),
1487        insert_pushes: Vec::with_capacity(cap),
1488        returning_cols: Vec::new(),
1489        auto_assigns: Vec::new(),
1490        auto_field_idents: Vec::new(),
1491        first_auto_value_ty: None,
1492        bulk_pushes_no_auto: Vec::with_capacity(cap),
1493        bulk_pushes_all: Vec::with_capacity(cap),
1494        bulk_columns_no_auto: Vec::with_capacity(cap),
1495        bulk_columns_all: Vec::with_capacity(cap),
1496        bulk_auto_uniformity: Vec::new(),
1497        first_auto_ident: None,
1498        has_auto: false,
1499        pk_is_auto: false,
1500        update_assignments: Vec::with_capacity(cap),
1501        upsert_update_columns: Vec::with_capacity(cap),
1502        primary_key: None,
1503        column_entries: Vec::with_capacity(cap),
1504        field_names: Vec::with_capacity(cap),
1505        fk_relations: Vec::new(),
1506        soft_delete_column: None,
1507    };
1508
1509    for field in &named.named {
1510        let info = process_field(field, table)?;
1511        out.field_names.push(info.ident.to_string());
1512        out.field_schemas.push(info.schema);
1513        out.from_row_inits.push(info.from_row_init);
1514        out.from_aliased_row_inits.push(info.from_aliased_row_init);
1515        if let Some(parent_ty) = info.fk_inner.clone() {
1516            out.fk_relations.push(FkRelation {
1517                parent_type: parent_ty,
1518                fk_column: info.column.clone(),
1519                pk_kind: info.fk_pk_kind,
1520                nullable: info.nullable,
1521            });
1522        }
1523        if info.soft_delete {
1524            if out.soft_delete_column.is_some() {
1525                return Err(syn::Error::new_spanned(
1526                    field,
1527                    "only one field may be marked `#[rustango(soft_delete)]`",
1528                ));
1529            }
1530            out.soft_delete_column = Some(info.column.clone());
1531        }
1532        let column = info.column.as_str();
1533        let ident = info.ident;
1534        // Generated columns (`#[rustango(generated_as = "EXPR")]`)
1535        // skip every write path — Postgres recomputes the value
1536        // from EXPR. Push only the column-entry record (so typed
1537        // column constants still exist for filtering / projection)
1538        // and the schema literal (already pushed above) and move
1539        // on. No insert_columns/values, no insert_pushes, no
1540        // bulk_*, no update_assignments, no upsert_update_columns,
1541        // no returning_cols.
1542        if info.generated_as.is_some() {
1543            out.column_entries.push(ColumnEntry {
1544                ident: ident.clone(),
1545                value_ty: info.value_ty.clone(),
1546                name: ident.to_string(),
1547                column: info.column.clone(),
1548                field_type_tokens: info.field_type_tokens,
1549            });
1550            continue;
1551        }
1552        out.insert_columns.push(quote!(#column));
1553        out.insert_values.push(quote! {
1554            ::core::convert::Into::<::rustango::core::SqlValue>::into(
1555                ::core::clone::Clone::clone(&self.#ident)
1556            )
1557        });
1558        if info.auto {
1559            out.has_auto = true;
1560            if out.first_auto_ident.is_none() {
1561                out.first_auto_ident = Some(ident.clone());
1562                out.first_auto_value_ty = auto_inner_type(info.value_ty).cloned();
1563            }
1564            out.returning_cols.push(quote!(#column));
1565            out.auto_field_idents
1566                .push((ident.clone(), info.column.clone()));
1567            out.auto_assigns.push(quote! {
1568                self.#ident = ::rustango::sql::try_get_returning(_returning_row, #column)?;
1569            });
1570            out.insert_pushes.push(quote! {
1571                if let ::rustango::sql::Auto::Set(_v) = &self.#ident {
1572                    _columns.push(#column);
1573                    _values.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
1574                        ::core::clone::Clone::clone(_v)
1575                    ));
1576                }
1577            });
1578            // Bulk: Auto fields appear only in the all-Set path,
1579            // never in the Unset path (we drop them from `columns`).
1580            out.bulk_columns_all.push(quote!(#column));
1581            out.bulk_pushes_all.push(quote! {
1582                _row_vals.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
1583                    ::core::clone::Clone::clone(&_row.#ident)
1584                ));
1585            });
1586            // Uniformity check: every row's Auto state must match the
1587            // first row's. Mixed Set/Unset within one bulk_insert is
1588            // rejected here so the column list stays consistent.
1589            let ident_clone = ident.clone();
1590            out.bulk_auto_uniformity.push(quote! {
1591                for _r in rows.iter().skip(1) {
1592                    if matches!(_r.#ident_clone, ::rustango::sql::Auto::Unset) != _first_unset {
1593                        return ::core::result::Result::Err(
1594                            ::rustango::sql::ExecError::Sql(
1595                                ::rustango::sql::SqlError::BulkAutoMixed
1596                            )
1597                        );
1598                    }
1599                }
1600            });
1601        } else {
1602            out.insert_pushes.push(quote! {
1603                _columns.push(#column);
1604                _values.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
1605                    ::core::clone::Clone::clone(&self.#ident)
1606                ));
1607            });
1608            // Bulk: non-Auto fields appear in BOTH paths.
1609            out.bulk_columns_no_auto.push(quote!(#column));
1610            out.bulk_columns_all.push(quote!(#column));
1611            let push_expr = quote! {
1612                _row_vals.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
1613                    ::core::clone::Clone::clone(&_row.#ident)
1614                ));
1615            };
1616            out.bulk_pushes_no_auto.push(push_expr.clone());
1617            out.bulk_pushes_all.push(push_expr);
1618        }
1619        if info.primary_key {
1620            if out.primary_key.is_some() {
1621                return Err(syn::Error::new_spanned(
1622                    field,
1623                    "only one field may be marked `#[rustango(primary_key)]`",
1624                ));
1625            }
1626            out.primary_key = Some((ident.clone(), info.column.clone()));
1627            if info.auto {
1628                out.pk_is_auto = true;
1629            }
1630        } else if info.auto_now_add {
1631            // Immutable post-insert: skip from UPDATE entirely.
1632        } else if info.auto_now {
1633            // `auto_now` columns: bind `chrono::Utc::now()` on every
1634            // UPDATE so the column is always overridden with the
1635            // wall-clock at write time, regardless of what value the
1636            // user left in the struct field.
1637            out.update_assignments.push(quote! {
1638                ::rustango::core::Assignment {
1639                    column: #column,
1640                    value: ::core::convert::Into::<::rustango::core::Expr>::into(
1641                        ::core::convert::Into::<::rustango::core::SqlValue>::into(
1642                            ::chrono::Utc::now()
1643                        )
1644                    ),
1645                }
1646            });
1647            out.upsert_update_columns.push(quote!(#column));
1648        } else {
1649            out.update_assignments.push(quote! {
1650                ::rustango::core::Assignment {
1651                    column: #column,
1652                    value: ::core::convert::Into::<::rustango::core::Expr>::into(
1653                        ::core::convert::Into::<::rustango::core::SqlValue>::into(
1654                            ::core::clone::Clone::clone(&self.#ident)
1655                        )
1656                    ),
1657                }
1658            });
1659            out.upsert_update_columns.push(quote!(#column));
1660        }
1661        out.column_entries.push(ColumnEntry {
1662            ident: ident.clone(),
1663            value_ty: info.value_ty.clone(),
1664            name: ident.to_string(),
1665            column: info.column.clone(),
1666            field_type_tokens: info.field_type_tokens,
1667        });
1668    }
1669    Ok(out)
1670}
1671
1672fn model_impl_tokens(
1673    struct_name: &syn::Ident,
1674    model_name: &str,
1675    table: &str,
1676    display: Option<&str>,
1677    app_label: Option<&str>,
1678    admin: Option<&AdminAttrs>,
1679    default_order: &[(String, bool, proc_macro2::Span)],
1680    field_schemas: &[TokenStream2],
1681    soft_delete_column: Option<&str>,
1682    permissions: bool,
1683    audit_track: Option<&[String]>,
1684    m2m_relations: &[M2MAttr],
1685    indexes: &[IndexAttr],
1686    checks: &[CheckAttr],
1687    composite_fks: &[CompositeFkAttr],
1688    generic_fks: &[GenericFkAttr],
1689    scope: Option<&str>,
1690    is_view: bool,
1691    verbose_name: Option<&str>,
1692    verbose_name_plural: Option<&str>,
1693) -> TokenStream2 {
1694    let display_tokens = if let Some(name) = display {
1695        quote!(::core::option::Option::Some(#name))
1696    } else {
1697        quote!(::core::option::Option::None)
1698    };
1699    let app_label_tokens = if let Some(name) = app_label {
1700        quote!(::core::option::Option::Some(#name))
1701    } else {
1702        quote!(::core::option::Option::None)
1703    };
1704    let soft_delete_tokens = if let Some(col) = soft_delete_column {
1705        quote!(::core::option::Option::Some(#col))
1706    } else {
1707        quote!(::core::option::Option::None)
1708    };
1709    let audit_track_tokens = match audit_track {
1710        None => quote!(::core::option::Option::None),
1711        Some(names) => {
1712            let lits = names.iter().map(|n| n.as_str());
1713            quote!(::core::option::Option::Some(&[ #(#lits),* ]))
1714        }
1715    };
1716    let admin_tokens = admin_config_tokens(admin);
1717    // Default `tenant` so single-tenant projects (no `scope` attr
1718    // anywhere) keep the v0.24.x behavior. Container-attr parser
1719    // already validated the value is "registry" or "tenant".
1720    let scope_tokens = match scope.map(|s| s.to_ascii_lowercase()).as_deref() {
1721        Some("registry") => quote!(::rustango::core::ModelScope::Registry),
1722        _ => quote!(::rustango::core::ModelScope::Tenant),
1723    };
1724    let verbose_name_tokens = optional_str(verbose_name);
1725    let verbose_name_plural_tokens = optional_str(verbose_name_plural);
1726    let indexes_tokens = indexes.iter().map(|idx| {
1727        let name = idx.name.as_deref().unwrap_or("unnamed_index");
1728        let cols: Vec<&str> = idx.columns.iter().map(String::as_str).collect();
1729        let unique = idx.unique;
1730        // Map the parsed method string onto the IndexMethod enum
1731        // variant — kept at the codegen layer so the IR doesn't
1732        // carry the string form.
1733        let method_variant = match idx.method.as_str() {
1734            "gin" => quote!(::rustango::core::IndexMethod::Gin),
1735            "gist" => quote!(::rustango::core::IndexMethod::Gist),
1736            "brin" => quote!(::rustango::core::IndexMethod::Brin),
1737            "spgist" => quote!(::rustango::core::IndexMethod::SpGist),
1738            "hash" => quote!(::rustango::core::IndexMethod::Hash),
1739            "bloom" => quote!(::rustango::core::IndexMethod::Bloom),
1740            _ => quote!(::rustango::core::IndexMethod::BTree),
1741        };
1742        let where_clause = match &idx.where_clause {
1743            Some(s) => quote!(::core::option::Option::Some(#s)),
1744            None => quote!(::core::option::Option::None),
1745        };
1746        quote! {
1747            ::rustango::core::IndexSchema {
1748                name: #name,
1749                columns: &[ #(#cols),* ],
1750                unique: #unique,
1751                method: #method_variant,
1752                where_clause: #where_clause,
1753            }
1754        }
1755    });
1756    let checks_tokens = checks.iter().map(|c| {
1757        let name = c.name.as_str();
1758        let expr = c.expr.as_str();
1759        quote! {
1760            ::rustango::core::CheckConstraint {
1761                name: #name,
1762                expr: #expr,
1763            }
1764        }
1765    });
1766    let composite_fk_tokens = composite_fks.iter().map(|rel| {
1767        let name = rel.name.as_str();
1768        let to = rel.to.as_str();
1769        let from_cols: Vec<&str> = rel.from.iter().map(String::as_str).collect();
1770        let on_cols: Vec<&str> = rel.on.iter().map(String::as_str).collect();
1771        quote! {
1772            ::rustango::core::CompositeFkRelation {
1773                name: #name,
1774                to: #to,
1775                from: &[ #(#from_cols),* ],
1776                on: &[ #(#on_cols),* ],
1777            }
1778        }
1779    });
1780    let generic_fk_tokens = generic_fks.iter().map(|rel| {
1781        let name = rel.name.as_str();
1782        let ct_col = rel.ct_column.as_str();
1783        let pk_col = rel.pk_column.as_str();
1784        quote! {
1785            ::rustango::core::GenericRelation {
1786                name: #name,
1787                ct_column: #ct_col,
1788                pk_column: #pk_col,
1789            }
1790        }
1791    });
1792    // Issue #291 / T2.5 — `default_order` slice literal. Empty when
1793    // no `#[rustango(default_order = "...")]` attribute was supplied.
1794    let default_order_tokens = default_order.iter().map(|(col, desc, _)| {
1795        let col_lit = col.as_str();
1796        quote! { (#col_lit, #desc) }
1797    });
1798
1799    let m2m_tokens = m2m_relations.iter().map(|rel| {
1800        let name = rel.name.as_str();
1801        let to = rel.to.as_str();
1802        let through = rel.through.as_str();
1803        let src = rel.src.as_str();
1804        let dst = rel.dst.as_str();
1805        let auto_create = rel.auto_create;
1806        quote! {
1807            ::rustango::core::M2MRelation {
1808                name: #name,
1809                to: #to,
1810                through: #through,
1811                src_col: #src,
1812                dst_col: #dst,
1813                auto_create: #auto_create,
1814            }
1815        }
1816    });
1817    quote! {
1818        impl ::rustango::core::Model for #struct_name {
1819            const SCHEMA: &'static ::rustango::core::ModelSchema = &::rustango::core::ModelSchema {
1820                name: #model_name,
1821                table: #table,
1822                fields: &[ #(#field_schemas),* ],
1823                display: #display_tokens,
1824                app_label: #app_label_tokens,
1825                admin: #admin_tokens,
1826                soft_delete_column: #soft_delete_tokens,
1827                permissions: #permissions,
1828                audit_track: #audit_track_tokens,
1829                m2m: &[ #(#m2m_tokens),* ],
1830                indexes: &[ #(#indexes_tokens),* ],
1831                check_constraints: &[ #(#checks_tokens),* ],
1832                composite_relations: &[ #(#composite_fk_tokens),* ],
1833                generic_relations: &[ #(#generic_fk_tokens),* ],
1834                scope: #scope_tokens,
1835                default_order: &[ #(#default_order_tokens),* ],
1836                is_view: #is_view,
1837                verbose_name: #verbose_name_tokens,
1838                verbose_name_plural: #verbose_name_plural_tokens,
1839            };
1840        }
1841    }
1842}
1843
1844/// Emit the `admin: Option<&'static AdminConfig>` field for the model
1845/// schema. `None` when the user wrote no `#[rustango(admin(...))]`;
1846/// otherwise a static reference to a populated `AdminConfig`.
1847fn admin_config_tokens(admin: Option<&AdminAttrs>) -> TokenStream2 {
1848    let Some(admin) = admin else {
1849        return quote!(::core::option::Option::None);
1850    };
1851
1852    let list_display = admin
1853        .list_display
1854        .as_ref()
1855        .map(|(v, _)| v.as_slice())
1856        .unwrap_or(&[]);
1857    let list_display_lits = list_display.iter().map(|s| s.as_str());
1858
1859    let search_fields = admin
1860        .search_fields
1861        .as_ref()
1862        .map(|(v, _)| v.as_slice())
1863        .unwrap_or(&[]);
1864    let search_fields_lits = search_fields.iter().map(|s| s.as_str());
1865
1866    let readonly_fields = admin
1867        .readonly_fields
1868        .as_ref()
1869        .map(|(v, _)| v.as_slice())
1870        .unwrap_or(&[]);
1871    let readonly_fields_lits = readonly_fields.iter().map(|s| s.as_str());
1872
1873    let list_filter = admin
1874        .list_filter
1875        .as_ref()
1876        .map(|(v, _)| v.as_slice())
1877        .unwrap_or(&[]);
1878    let list_filter_lits = list_filter.iter().map(|s| s.as_str());
1879
1880    let actions = admin
1881        .actions
1882        .as_ref()
1883        .map(|(v, _)| v.as_slice())
1884        .unwrap_or(&[]);
1885    let actions_lits = actions.iter().map(|s| s.as_str());
1886
1887    let fieldsets = admin
1888        .fieldsets
1889        .as_ref()
1890        .map(|(v, _)| v.as_slice())
1891        .unwrap_or(&[]);
1892    let fieldset_tokens = fieldsets.iter().map(|(title, fields)| {
1893        let title = title.as_str();
1894        let field_lits = fields.iter().map(|s| s.as_str());
1895        quote!(::rustango::core::Fieldset {
1896            title: #title,
1897            fields: &[ #( #field_lits ),* ],
1898        })
1899    });
1900
1901    let list_display_links = admin
1902        .list_display_links
1903        .as_ref()
1904        .map(|(v, _)| v.as_slice())
1905        .unwrap_or(&[]);
1906    let list_display_links_lits = list_display_links.iter().map(|s| s.as_str());
1907
1908    let search_help_text = admin.search_help_text.as_deref().unwrap_or("");
1909    let actions_on_top = admin.actions_on_top.unwrap_or(true);
1910    let actions_on_bottom = admin.actions_on_bottom.unwrap_or(false);
1911    let date_hierarchy = admin.date_hierarchy.as_deref().unwrap_or("");
1912
1913    let prepopulated = admin
1914        .prepopulated_fields
1915        .as_ref()
1916        .map(|(v, _)| v.as_slice())
1917        .unwrap_or(&[]);
1918    let prepopulated_tokens = prepopulated.iter().map(|(target, sources)| {
1919        let target = target.as_str();
1920        let source_lits = sources.iter().map(|s| s.as_str());
1921        quote!(::rustango::core::PrepopulatedField {
1922            target: #target,
1923            sources: &[ #( #source_lits ),* ],
1924        })
1925    });
1926
1927    let raw_id_fields = admin
1928        .raw_id_fields
1929        .as_ref()
1930        .map(|(v, _)| v.as_slice())
1931        .unwrap_or(&[]);
1932    let raw_id_fields_lits = raw_id_fields.iter().map(|s| s.as_str());
1933
1934    let autocomplete_fields = admin
1935        .autocomplete_fields
1936        .as_ref()
1937        .map(|(v, _)| v.as_slice())
1938        .unwrap_or(&[]);
1939    let autocomplete_fields_lits = autocomplete_fields.iter().map(|s| s.as_str());
1940
1941    // #352 — list_select_related accepts "all" | "none" | "field, field, …".
1942    let list_select_related_tokens = match admin.list_select_related.as_deref() {
1943        None | Some("all") => quote!(::rustango::core::ListSelectRelated::All),
1944        Some("none") => quote!(::rustango::core::ListSelectRelated::None),
1945        Some(raw) => {
1946            let names: Vec<&str> = raw
1947                .split(',')
1948                .map(str::trim)
1949                .filter(|s| !s.is_empty())
1950                .collect();
1951            quote!(::rustango::core::ListSelectRelated::Only(&[ #( #names ),* ]))
1952        }
1953    };
1954
1955    // #359 — formfield_overrides: parse "field:widget,field2:widget2" into
1956    // a Vec<(String, String)>. Empty / unset → no overrides.
1957    let formfield_pairs: Vec<(&str, &str)> = admin
1958        .formfield_overrides
1959        .as_ref()
1960        .map(|(v, _)| v.iter().map(|(f, w)| (f.as_str(), w.as_str())).collect())
1961        .unwrap_or_default();
1962    let formfield_tokens = formfield_pairs.iter().map(|(field, widget)| {
1963        let field = *field;
1964        let widget = *widget;
1965        quote!((#field, #widget))
1966    });
1967
1968    let list_per_page = admin.list_per_page.unwrap_or(0);
1969
1970    let ordering_pairs = admin
1971        .ordering
1972        .as_ref()
1973        .map(|(v, _)| v.as_slice())
1974        .unwrap_or(&[]);
1975    let ordering_tokens = ordering_pairs.iter().map(|(name, desc)| {
1976        let name = name.as_str();
1977        let desc = *desc;
1978        quote!((#name, #desc))
1979    });
1980
1981    quote! {
1982        ::core::option::Option::Some(&::rustango::core::AdminConfig {
1983            list_display: &[ #( #list_display_lits ),* ],
1984            search_fields: &[ #( #search_fields_lits ),* ],
1985            list_per_page: #list_per_page,
1986            ordering: &[ #( #ordering_tokens ),* ],
1987            readonly_fields: &[ #( #readonly_fields_lits ),* ],
1988            list_filter: &[ #( #list_filter_lits ),* ],
1989            actions: &[ #( #actions_lits ),* ],
1990            fieldsets: &[ #( #fieldset_tokens ),* ],
1991            list_display_links: &[ #( #list_display_links_lits ),* ],
1992            search_help_text: #search_help_text,
1993            actions_on_top: #actions_on_top,
1994            actions_on_bottom: #actions_on_bottom,
1995            date_hierarchy: #date_hierarchy,
1996            prepopulated_fields: &[ #( #prepopulated_tokens ),* ],
1997            raw_id_fields: &[ #( #raw_id_fields_lits ),* ],
1998            autocomplete_fields: &[ #( #autocomplete_fields_lits ),* ],
1999            list_select_related: #list_select_related_tokens,
2000            formfield_overrides: &[ #( #formfield_tokens ),* ],
2001        })
2002    }
2003}
2004
2005fn inherent_impl_tokens(
2006    struct_name: &syn::Ident,
2007    fields: &CollectedFields,
2008    primary_key: Option<&(syn::Ident, String)>,
2009    column_consts: &TokenStream2,
2010    audited_fields: Option<&[&ColumnEntry]>,
2011    indexes: &[IndexAttr],
2012    manager_fns: &[syn::Ident],
2013) -> TokenStream2 {
2014    // Audit-emit fragments threaded into write paths. Non-empty only
2015    // when the model carries `#[rustango(audit(...))]`. They reborrow
2016    // `_executor` (a `&mut PgConnection` for audited models — the
2017    // macro switches the signature below) so the data write and the
2018    // audit INSERT both run on the same caller-supplied connection.
2019    let executor_passes_to_data_write = if audited_fields.is_some() {
2020        quote!(&mut *_executor)
2021    } else {
2022        quote!(_executor)
2023    };
2024    let executor_param = if audited_fields.is_some() {
2025        quote!(_executor: &mut ::rustango::sql::sqlx::PgConnection)
2026    } else {
2027        quote!(_executor: _E)
2028    };
2029    let executor_generics = if audited_fields.is_some() {
2030        quote!()
2031    } else {
2032        quote!(<'_c, _E>)
2033    };
2034    let executor_where = if audited_fields.is_some() {
2035        quote!()
2036    } else {
2037        quote! {
2038            where
2039                _E: ::rustango::sql::sqlx::Executor<'_c, Database = ::rustango::sql::sqlx::Postgres>,
2040        }
2041    };
2042    // For audited models the `_on` methods take `&mut PgConnection`, so
2043    // the &PgPool convenience wrappers (`save`, `insert`, `delete`)
2044    // must acquire a connection first. Non-audited models keep the
2045    // direct delegation since `&PgPool` IS an Executor.
2046    let pool_to_save_on = if audited_fields.is_some() {
2047        quote! {
2048            let mut _conn = pool.acquire().await?;
2049            self.save_on(&mut *_conn).await
2050        }
2051    } else {
2052        quote!(self.save_on(pool).await)
2053    };
2054    let pool_to_insert_on = if audited_fields.is_some() {
2055        quote! {
2056            let mut _conn = pool.acquire().await?;
2057            self.insert_on(&mut *_conn).await
2058        }
2059    } else {
2060        quote!(self.insert_on(pool).await)
2061    };
2062    let pool_to_delete_on = if audited_fields.is_some() {
2063        quote! {
2064            let mut _conn = pool.acquire().await?;
2065            self.delete_on(&mut *_conn).await
2066        }
2067    } else {
2068        quote!(self.delete_on(pool).await)
2069    };
2070    let pool_to_bulk_insert_on = if audited_fields.is_some() {
2071        quote! {
2072            let mut _conn = pool.acquire().await?;
2073            Self::bulk_insert_on(rows, &mut *_conn).await
2074        }
2075    } else {
2076        quote!(Self::bulk_insert_on(rows, pool).await)
2077    };
2078    // Pre-existing bug surfaced by batch 22's first audited Auto<T>
2079    // PK test model: `upsert(&PgPool)` body called `self.upsert_on(pool)`
2080    // directly, but `upsert_on` for audited models takes
2081    // `&mut PgConnection` (the audit emit needs a real connection).
2082    // Add the missing acquire shim to keep audited Auto-PK upsert
2083    // compiling.
2084    let pool_to_upsert_on = if audited_fields.is_some() {
2085        quote! {
2086            let mut _conn = pool.acquire().await?;
2087            self.upsert_on(&mut *_conn).await
2088        }
2089    } else {
2090        quote!(self.upsert_on(pool).await)
2091    };
2092
2093    // `insert_pool(&Pool)` — v0.23.0-batch9. Non-audited models only
2094    // (audit-on-connection over &Pool needs a bi-dialect transaction
2095    // helper, deferred). Two body shapes:
2096    // - has_auto: build InsertQuery skipping Auto::Unset columns,
2097    //   request Auto cols in `returning`, dispatch via
2098    //   `insert_returning_pool`, then on the returned `PgRow` /
2099    //   `MySqlAutoId(id)` enum — pull each Auto field from the PG
2100    //   row OR drop the single i64 into the first Auto field on MySQL
2101    //   (multi-Auto models on MySQL error at runtime since
2102    //   `LAST_INSERT_ID()` only reports one)
2103    // - non-Auto: build InsertQuery with explicit columns/values and
2104    //   call `insert_pool` (no returning needed)
2105    // pool_insert_method body for the audited Auto-PK case is moved
2106    // to after audit_pair_tokens / audit_pk_to_string (they live
2107    // ~150 lines below). This block keeps the non-audited and
2108    // non-Auto branches in place — the audited Auto-PK arm is
2109    // computed below and merged via the dispatch helper variable.
2110    let pool_insert_method = if audited_fields.is_some() && !fields.has_auto {
2111        // Audited models with explicit (non-Auto) PKs go through
2112        // the non-Auto insert path below — the audit emit is one
2113        // round-trip after the INSERT inside the same tx via
2114        // audit::save_one_with_audit? No, INSERT semantics
2115        // differ. For non-Auto PK + audited, route through a
2116        // dedicated insert + audit emit on the same tx, but defer
2117        // the macro emission to the audit-bundle-aware block below
2118        // — this `quote!()` placeholder gets overwritten there.
2119        quote!()
2120    } else if audited_fields.is_some() && fields.has_auto {
2121        // Audited Auto-PK insert_pool — assembled after the audit
2122        // bundles. Placeholder; real emission below.
2123        quote!()
2124    } else if fields.has_auto {
2125        let pushes = &fields.insert_pushes;
2126        let returning_cols = &fields.returning_cols;
2127        quote! {
2128            /// Insert this row against either backend, populating any
2129            /// `Auto<T>` PK from the auto-assigned value.
2130            ///
2131            /// # Errors
2132            /// As [`Self::insert`].
2133            pub async fn insert_pool(
2134                &mut self,
2135                pool: &::rustango::sql::Pool,
2136            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2137                let mut _columns: ::std::vec::Vec<&'static str> =
2138                    ::std::vec::Vec::new();
2139                let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
2140                    ::std::vec::Vec::new();
2141                #( #pushes )*
2142                let _query = ::rustango::core::InsertQuery {
2143                    model: <Self as ::rustango::core::Model>::SCHEMA,
2144                    columns: _columns,
2145                    values: _values,
2146                    returning: ::std::vec![ #( #returning_cols ),* ],
2147                    on_conflict: ::core::option::Option::None,
2148                };
2149                let _result = ::rustango::sql::insert_returning_pool(
2150                    pool, &_query,
2151                ).await?;
2152                ::rustango::sql::apply_auto_pk(_result, self)
2153            }
2154        }
2155    } else {
2156        let insert_columns = &fields.insert_columns;
2157        let insert_values = &fields.insert_values;
2158        quote! {
2159            /// Insert this row into its table against either backend.
2160            /// Equivalent to [`Self::insert`] but takes
2161            /// [`::rustango::sql::Pool`].
2162            ///
2163            /// # Errors
2164            /// As [`Self::insert`].
2165            pub async fn insert_pool(
2166                &self,
2167                pool: &::rustango::sql::Pool,
2168            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2169                let _query = ::rustango::core::InsertQuery {
2170                    model: <Self as ::rustango::core::Model>::SCHEMA,
2171                    columns: ::std::vec![ #( #insert_columns ),* ],
2172                    values: ::std::vec![ #( #insert_values ),* ],
2173                    returning: ::std::vec::Vec::new(),
2174                    on_conflict: ::core::option::Option::None,
2175                };
2176                ::rustango::sql::insert_pool(pool, &_query).await
2177            }
2178        }
2179    };
2180
2181    // pool_save_method moved to after audit_pair_tokens /
2182    // audit_pk_to_string (they live ~70 lines below) — needed for
2183    // the audited branch which builds an UpdateQuery + PendingEntry
2184    // and dispatches via audit::save_one_with_audit.
2185
2186    // pool_delete_method moved to after audit_pair_tokens / audit_pk_to_string
2187    // are computed (they live ~80 lines below).
2188
2189    // Build the (column, JSON value) pair list used by every
2190    // snapshot-style audit emission. Reused across delete_on,
2191    // soft_delete_on, restore_on, and (later) bulk paths. Empty
2192    // when the model isn't audited.
2193    let audit_pair_tokens: Vec<TokenStream2> = audited_fields
2194        .map(|tracked| {
2195            tracked
2196                .iter()
2197                .map(|c| {
2198                    let column_lit = c.column.as_str();
2199                    let ident = &c.ident;
2200                    quote! {
2201                        (
2202                            #column_lit,
2203                            ::serde_json::to_value(&self.#ident)
2204                                .unwrap_or(::serde_json::Value::Null),
2205                        )
2206                    }
2207                })
2208                .collect()
2209        })
2210        .unwrap_or_default();
2211    let audit_pk_to_string = if let Some((pk_ident, _)) = primary_key {
2212        if fields.pk_is_auto {
2213            quote!(self.#pk_ident.get().map(|v| ::std::format!("{}", v)).unwrap_or_default())
2214        } else {
2215            quote!(::std::format!("{}", &self.#pk_ident))
2216        }
2217    } else {
2218        quote!(::std::string::String::new())
2219    };
2220    let make_op_emit = |op_path: TokenStream2| -> TokenStream2 {
2221        if audited_fields.is_some() {
2222            let pairs = audit_pair_tokens.iter();
2223            let pk_str = audit_pk_to_string.clone();
2224            quote! {
2225                let _audit_entry = ::rustango::audit::PendingEntry {
2226                    entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
2227                    entity_pk: #pk_str,
2228                    operation: #op_path,
2229                    source: ::rustango::audit::current_source(),
2230                    changes: ::rustango::audit::snapshot_changes(&[
2231                        #( #pairs ),*
2232                    ]),
2233                };
2234                ::rustango::audit::emit_one(&mut *_executor, &_audit_entry).await?;
2235            }
2236        } else {
2237            quote!()
2238        }
2239    };
2240    let audit_insert_emit = make_op_emit(quote!(::rustango::audit::AuditOp::Create));
2241    let audit_delete_emit = make_op_emit(quote!(::rustango::audit::AuditOp::Delete));
2242    let audit_softdelete_emit = make_op_emit(quote!(::rustango::audit::AuditOp::SoftDelete));
2243    let audit_restore_emit = make_op_emit(quote!(::rustango::audit::AuditOp::Restore));
2244
2245    // `save_pool(&Pool)` — emitted for every model with a PK.
2246    // Audited Auto-PK models are deferred (the Auto::Unset →
2247    // insert_pool path needs the audited-insert flow from a future
2248    // batch). Three body shapes:
2249    // - non-audited, plain PK: build UpdateQuery + dispatch through
2250    //   sql::update_pool
2251    // - non-audited, Auto-PK: same, but Auto::Unset routes to
2252    //   self.insert_pool which already handles RETURNING / LAST_INSERT_ID
2253    // - audited, plain PK: build UpdateQuery + PendingEntry, dispatch
2254    //   through audit::save_one_with_audit (per-backend tx wraps
2255    //   UPDATE + audit emit atomically). Snapshot-style audit (post-
2256    //   write field values) — diff-style audit (with pre-UPDATE
2257    //   SELECT for `before` values) needs per-tracked-column codegen
2258    //   that doesn't fit the runtime-helper pattern; legacy &PgPool
2259    //   `save` keeps the diff for now.
2260    let pool_save_method = if let Some((pk_ident, pk_col)) = primary_key {
2261        let pk_column_lit = pk_col.as_str();
2262        let assignments = &fields.update_assignments;
2263        if audited_fields.is_some() {
2264            if fields.pk_is_auto {
2265                // Auto-PK + audited: defer. The Auto::Unset insert
2266                // path needs a transactional INSERT + LAST_INSERT_ID
2267                // + audit emit flow — that's a follow-up batch.
2268                quote!()
2269            } else {
2270                let pairs = audit_pair_tokens.iter();
2271                let pairs2 = audit_pair_tokens.iter();
2272                let pk_str = audit_pk_to_string.clone();
2273                let pk_str2 = audit_pk_to_string.clone();
2274                quote! {
2275                    /// Save (UPDATE) this row against either backend
2276                    /// with audit emission inside the same transaction.
2277                    /// Bi-dialect counterpart of [`Self::save`] for
2278                    /// audited models with non-`Auto<T>` PKs.
2279                    ///
2280                    /// Captures **post-write** field state (snapshot
2281                    /// audit). The legacy &PgPool [`Self::save`]
2282                    /// captures BEFORE+AFTER for true diff audit;
2283                    /// porting that to the &Pool path needs runtime
2284                    /// per-tracked-column decoding and is deferred.
2285                    ///
2286                    /// # Errors
2287                    /// As [`Self::save`].
2288                    pub async fn save_pool(
2289                        &mut self,
2290                        pool: &::rustango::sql::Pool,
2291                    ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2292                        let _query = ::rustango::core::UpdateQuery {
2293                            model: <Self as ::rustango::core::Model>::SCHEMA,
2294                            set: ::std::vec![ #( #assignments ),* ],
2295                            where_clause: ::rustango::core::WhereExpr::Predicate(
2296                                ::rustango::core::Filter {
2297                                    column: #pk_column_lit,
2298                                    op: ::rustango::core::Op::Eq,
2299                                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2300                                        ::core::clone::Clone::clone(&self.#pk_ident)
2301                                    ),
2302                                }
2303                            ),
2304                        };
2305                        let _audit_entry = ::rustango::audit::PendingEntry {
2306                            entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
2307                            entity_pk: #pk_str,
2308                            operation: ::rustango::audit::AuditOp::Update,
2309                            source: ::rustango::audit::current_source(),
2310                            changes: ::rustango::audit::snapshot_changes(&[
2311                                #( #pairs ),*
2312                            ]),
2313                        };
2314                        let _ = ::rustango::audit::save_one_with_audit(
2315                            pool, &_query, &_audit_entry,
2316                        ).await?;
2317                        ::core::result::Result::Ok(())
2318                    }
2319
2320                    /// `save_pool` narrowed to a Rust-field allowlist — issue #66
2321                    /// (Django `Model.save(update_fields=[...])`).
2322                    /// Audit emission shrinks to the same column set so
2323                    /// the audit log reflects exactly what was written.
2324                    ///
2325                    /// # Errors
2326                    /// As [`Self::save_pool`], plus
2327                    /// [`::rustango::core::QueryError::UnknownField`] wrapped
2328                    /// in `ExecError::Query` for unknown field names.
2329                    pub async fn save_partial(
2330                        &mut self,
2331                        fields: &[&str],
2332                        pool: &::rustango::sql::Pool,
2333                    ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2334                        if fields.is_empty() {
2335                            ::tracing::warn!(
2336                                target: "rustango::save_partial",
2337                                model = <Self as ::rustango::core::Model>::SCHEMA.name,
2338                                "save_partial called with empty field list — no-op"
2339                            );
2340                            return ::core::result::Result::Ok(());
2341                        }
2342                        let _schema = <Self as ::rustango::core::Model>::SCHEMA;
2343                        let mut _wanted_cols: ::std::collections::HashSet<&'static str> =
2344                            ::std::collections::HashSet::with_capacity(fields.len());
2345                        for f in fields {
2346                            match _schema.field(f) {
2347                                ::core::option::Option::Some(fs) => {
2348                                    _wanted_cols.insert(fs.column);
2349                                }
2350                                ::core::option::Option::None => {
2351                                    return ::core::result::Result::Err(
2352                                        ::rustango::sql::ExecError::Query(
2353                                            ::rustango::core::QueryError::UnknownField {
2354                                                model: _schema.name,
2355                                                field: (*f).to_owned(),
2356                                            }
2357                                        )
2358                                    );
2359                                }
2360                            }
2361                        }
2362                        let _full: ::std::vec::Vec<::rustango::core::Assignment> =
2363                            ::std::vec![ #( #assignments ),* ];
2364                        let _filtered: ::std::vec::Vec<::rustango::core::Assignment> = _full
2365                            .into_iter()
2366                            .filter(|a| _wanted_cols.contains(a.column))
2367                            .collect();
2368                        if _filtered.is_empty() {
2369                            ::tracing::warn!(
2370                                target: "rustango::save_partial",
2371                                model = _schema.name,
2372                                "save_partial: every named field maps to a non-assignable column — no-op"
2373                            );
2374                            return ::core::result::Result::Ok(());
2375                        }
2376                        let _query = ::rustango::core::UpdateQuery {
2377                            model: _schema,
2378                            set: _filtered,
2379                            where_clause: ::rustango::core::WhereExpr::Predicate(
2380                                ::rustango::core::Filter {
2381                                    column: #pk_column_lit,
2382                                    op: ::rustango::core::Op::Eq,
2383                                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2384                                        ::core::clone::Clone::clone(&self.#pk_ident)
2385                                    ),
2386                                }
2387                            ),
2388                        };
2389                        // Narrow the audit snapshot to the same column set.
2390                        let _all_pairs: ::std::vec::Vec<(&'static str, ::serde_json::Value)> =
2391                            ::std::vec![ #( #pairs2 ),* ];
2392                        let _narrowed: ::std::vec::Vec<(&'static str, ::serde_json::Value)> =
2393                            _all_pairs
2394                                .into_iter()
2395                                .filter(|(col, _)| _wanted_cols.contains(col))
2396                                .collect();
2397                        let _audit_entry = ::rustango::audit::PendingEntry {
2398                            entity_table: _schema.table,
2399                            entity_pk: #pk_str2,
2400                            operation: ::rustango::audit::AuditOp::Update,
2401                            source: ::rustango::audit::current_source(),
2402                            changes: ::rustango::audit::snapshot_changes(&_narrowed),
2403                        };
2404                        let _ = ::rustango::audit::save_one_with_audit(
2405                            pool, &_query, &_audit_entry,
2406                        ).await?;
2407                        ::core::result::Result::Ok(())
2408                    }
2409
2410                    /// Typed-column counterpart of [`Self::save_partial`] —
2411                    /// issue #67. `fields` is a tuple of [`Column`]
2412                    /// constants whose `Model` matches `Self`; typos and
2413                    /// model mismatches surface at *compile time*
2414                    /// (`Author::name` inside a `Post::save_partial_typed`
2415                    /// call is a type error, no runtime check).
2416                    ///
2417                    /// ```ignore
2418                    /// post.save_partial_typed((Post::title, Post::slug), &pool).await?;
2419                    /// ```
2420                    ///
2421                    /// Lowers to [`Self::save_partial`] under the hood;
2422                    /// audit narrowing + every other semantic is identical.
2423                    ///
2424                    /// [`Column`]: ::rustango::core::Column
2425                    ///
2426                    /// # Errors
2427                    /// As [`Self::save_partial`].
2428                    pub async fn save_partial_typed<
2429                        L: ::rustango::core::TypedFieldList<Self>,
2430                    >(
2431                        &mut self,
2432                        fields: L,
2433                        pool: &::rustango::sql::Pool,
2434                    ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2435                        let _names = fields.rust_field_names();
2436                        let _refs: ::std::vec::Vec<&str> =
2437                            _names.iter().copied().collect();
2438                        self.save_partial(&_refs, pool).await
2439                    }
2440                }
2441            }
2442        } else {
2443            let dispatch_unset = if fields.pk_is_auto {
2444                quote! {
2445                    if matches!(self.#pk_ident, ::rustango::sql::Auto::Unset) {
2446                        return self.insert_pool(pool).await;
2447                    }
2448                }
2449            } else {
2450                quote!()
2451            };
2452            quote! {
2453                /// Save this row to its table against either backend.
2454                /// `INSERT` when the `Auto<T>` PK is `Unset`, else
2455                /// `UPDATE` keyed on the PK.
2456                ///
2457                /// # Errors
2458                /// As [`Self::save`].
2459                pub async fn save_pool(
2460                    &mut self,
2461                    pool: &::rustango::sql::Pool,
2462                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2463                    #dispatch_unset
2464                    let _query = ::rustango::core::UpdateQuery {
2465                        model: <Self as ::rustango::core::Model>::SCHEMA,
2466                        set: ::std::vec![ #( #assignments ),* ],
2467                        where_clause: ::rustango::core::WhereExpr::Predicate(
2468                            ::rustango::core::Filter {
2469                                column: #pk_column_lit,
2470                                op: ::rustango::core::Op::Eq,
2471                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2472                                    ::core::clone::Clone::clone(&self.#pk_ident)
2473                                ),
2474                            }
2475                        ),
2476                    };
2477                    let _ = ::rustango::sql::update_pool(pool, &_query).await?;
2478                    ::core::result::Result::Ok(())
2479                }
2480
2481                /// Save (UPDATE) only the listed Rust-side fields,
2482                /// leaving every other column untouched. Issue #66 —
2483                /// Django's `Model.save(update_fields=[...])` shape.
2484                ///
2485                /// `fields` are Rust-side struct field names; the macro
2486                /// resolves each to its SQL column. Unknown field
2487                /// names return [`::rustango::core::QueryError::UnknownField`]
2488                /// wrapped in `ExecError::Query`. An empty list is a
2489                /// no-op (returns `Ok(())` and logs a `tracing::warn!`),
2490                /// matching Django's "nothing to do" semantic.
2491                ///
2492                /// Use this when:
2493                /// * you only mutated a couple of fields on a wide row
2494                ///   (avoid re-writing every column on every save), or
2495                /// * two writers diverged after their initial read and
2496                ///   you want to preserve the other writer's changes to
2497                ///   columns you didn't touch.
2498                ///
2499                /// Auto-PK models with an unset PK return
2500                /// [`::rustango::core::QueryError::UnknownField`] with
2501                /// field name `<pk>` — `save_partial` is an
2502                /// UPDATE-only path. Call [`Self::insert_pool`]
2503                /// (or [`Self::save_pool`] which dispatches based on
2504                /// PK state) for the INSERT case.
2505                ///
2506                /// # Errors
2507                /// As [`Self::save_pool`], plus `UnknownField` for
2508                /// unknown / empty / Auto-Unset cases.
2509                pub async fn save_partial(
2510                    &mut self,
2511                    fields: &[&str],
2512                    pool: &::rustango::sql::Pool,
2513                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2514                    if fields.is_empty() {
2515                        ::tracing::warn!(
2516                            target: "rustango::save_partial",
2517                            model = <Self as ::rustango::core::Model>::SCHEMA.name,
2518                            "save_partial called with empty field list — no-op"
2519                        );
2520                        return ::core::result::Result::Ok(());
2521                    }
2522                    let _schema = <Self as ::rustango::core::Model>::SCHEMA;
2523                    // Validate field names against the schema.
2524                    let mut _wanted_cols: ::std::collections::HashSet<&'static str> =
2525                        ::std::collections::HashSet::with_capacity(fields.len());
2526                    for f in fields {
2527                        match _schema.field(f) {
2528                            ::core::option::Option::Some(fs) => {
2529                                _wanted_cols.insert(fs.column);
2530                            }
2531                            ::core::option::Option::None => {
2532                                return ::core::result::Result::Err(
2533                                    ::rustango::sql::ExecError::Query(
2534                                        ::rustango::core::QueryError::UnknownField {
2535                                            model: _schema.name,
2536                                            field: (*f).to_owned(),
2537                                        }
2538                                    )
2539                                );
2540                            }
2541                        }
2542                    }
2543                    // Build the full assignment vec, then keep only the
2544                    // assignments whose column is in `_wanted_cols`.
2545                    let _full: ::std::vec::Vec<::rustango::core::Assignment> =
2546                        ::std::vec![ #( #assignments ),* ];
2547                    let _filtered: ::std::vec::Vec<::rustango::core::Assignment> = _full
2548                        .into_iter()
2549                        .filter(|a| _wanted_cols.contains(a.column))
2550                        .collect();
2551                    if _filtered.is_empty() {
2552                        // All field names valid, but they all map to
2553                        // non-assignable slots (PK column, computed/
2554                        // virtual fields, relations without an
2555                        // assignment). Same no-op semantic as Django.
2556                        ::tracing::warn!(
2557                            target: "rustango::save_partial",
2558                            model = _schema.name,
2559                            "save_partial: every named field maps to a non-assignable column — no-op"
2560                        );
2561                        return ::core::result::Result::Ok(());
2562                    }
2563                    let _query = ::rustango::core::UpdateQuery {
2564                        model: _schema,
2565                        set: _filtered,
2566                        where_clause: ::rustango::core::WhereExpr::Predicate(
2567                            ::rustango::core::Filter {
2568                                column: #pk_column_lit,
2569                                op: ::rustango::core::Op::Eq,
2570                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2571                                    ::core::clone::Clone::clone(&self.#pk_ident)
2572                                ),
2573                            }
2574                        ),
2575                    };
2576                    let _ = ::rustango::sql::update_pool(pool, &_query).await?;
2577                    ::core::result::Result::Ok(())
2578                }
2579
2580                /// Typed-column counterpart of [`Self::save_partial`] —
2581                /// issue #67. `fields` is a tuple of [`Column`]
2582                /// constants whose `Model` matches `Self`; typos and
2583                /// model mismatches surface at *compile time*
2584                /// (`Author::name` inside a `Post::save_partial_typed`
2585                /// call is a type error, no runtime check).
2586                ///
2587                /// ```ignore
2588                /// post.save_partial_typed((Post::title, Post::slug), &pool).await?;
2589                /// ```
2590                ///
2591                /// Lowers to [`Self::save_partial`] under the hood — the
2592                /// tuple is reduced to a `&[&str]` slice of Rust-side
2593                /// field names and forwarded.
2594                ///
2595                /// [`Column`]: ::rustango::core::Column
2596                ///
2597                /// # Errors
2598                /// As [`Self::save_partial`].
2599                pub async fn save_partial_typed<
2600                    L: ::rustango::core::TypedFieldList<Self>,
2601                >(
2602                    &mut self,
2603                    fields: L,
2604                    pool: &::rustango::sql::Pool,
2605                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2606                    let _names = fields.rust_field_names();
2607                    let _refs: ::std::vec::Vec<&str> =
2608                        _names.iter().copied().collect();
2609                    self.save_partial(&_refs, pool).await
2610                }
2611            }
2612        }
2613    } else {
2614        quote!()
2615    };
2616
2617    // Audited `insert_pool` (overrides the placeholder set higher up
2618    // in the function). v0.23.0-batch22 — both Auto-PK and non-Auto-PK
2619    // audited models get insert_pool routing through
2620    // audit::insert_one_with_audit (per-backend tx wraps INSERT
2621    // + auto-PK readback + audit emit). Snapshot-style audit (the
2622    // PendingEntry's `changes` carries post-write field values).
2623    let pool_insert_method = if audited_fields.is_some() {
2624        if let Some(_) = primary_key {
2625            let pushes = if fields.has_auto {
2626                fields.insert_pushes.clone()
2627            } else {
2628                // For non-Auto-PK models, the macro normally builds
2629                // {columns, values} from fields.insert_columns +
2630                // fields.insert_values rather than insert_pushes.
2631                // Map those into the pushes shape.
2632                fields
2633                    .insert_columns
2634                    .iter()
2635                    .zip(&fields.insert_values)
2636                    .map(|(col, val)| {
2637                        quote! {
2638                            _columns.push(#col);
2639                            _values.push(#val);
2640                        }
2641                    })
2642                    .collect()
2643            };
2644            let returning_cols: Vec<proc_macro2::TokenStream> = if fields.has_auto {
2645                fields.returning_cols.clone()
2646            } else {
2647                // Non-Auto-PK: still need RETURNING something for the
2648                // audit helper's contract (it errors on empty
2649                // returning). Return the PK column so the audit row
2650                // can carry the assigned PK back. Some non-Auto PKs
2651                // are server-side-default (e.g. UUIDv4 default), so
2652                // RETURNING is genuinely useful.
2653                primary_key
2654                    .map(|(_, col)| {
2655                        let lit = col.as_str();
2656                        vec![quote!(#lit)]
2657                    })
2658                    .unwrap_or_default()
2659            };
2660            let pairs = audit_pair_tokens.iter();
2661            let pk_str = audit_pk_to_string.clone();
2662            quote! {
2663                /// Insert this row against either backend with audit
2664                /// emission inside the same transaction. Bi-dialect
2665                /// counterpart of [`Self::insert`] for audited models.
2666                ///
2667                /// Snapshot-style audit (post-write field values).
2668                ///
2669                /// # Errors
2670                /// As [`Self::insert`].
2671                pub async fn insert_pool(
2672                    &mut self,
2673                    pool: &::rustango::sql::Pool,
2674                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2675                    let mut _columns: ::std::vec::Vec<&'static str> =
2676                        ::std::vec::Vec::new();
2677                    let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
2678                        ::std::vec::Vec::new();
2679                    #( #pushes )*
2680                    let _query = ::rustango::core::InsertQuery {
2681                        model: <Self as ::rustango::core::Model>::SCHEMA,
2682                        columns: _columns,
2683                        values: _values,
2684                        returning: ::std::vec![ #( #returning_cols ),* ],
2685                        on_conflict: ::core::option::Option::None,
2686                    };
2687                    let _audit_entry = ::rustango::audit::PendingEntry {
2688                        entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
2689                        entity_pk: #pk_str,
2690                        operation: ::rustango::audit::AuditOp::Create,
2691                        source: ::rustango::audit::current_source(),
2692                        changes: ::rustango::audit::snapshot_changes(&[
2693                            #( #pairs ),*
2694                        ]),
2695                    };
2696                    let _result = ::rustango::audit::insert_one_with_audit(
2697                        pool, &_query, &_audit_entry,
2698                    ).await?;
2699                    ::rustango::sql::apply_auto_pk(_result, self)
2700                }
2701            }
2702        } else {
2703            quote!()
2704        }
2705    } else {
2706        // Keep the non-audited pool_insert_method we built earlier.
2707        pool_insert_method
2708    };
2709
2710    // Update audited save_pool: now that insert_pool is wired for
2711    // audited Auto-PK models, save_pool can dispatch Auto::Unset →
2712    // insert_pool. Non-audited save_pool already does this.
2713    // v0.23.0-batch25 — diff-style audit on the audited save_pool path.
2714    // Replaces the snapshot-only emission with a per-backend transaction
2715    // body that:
2716    //  1. SELECTs the tracked columns by PK (typed Row::try_get per
2717    //     column), capturing BEFORE values
2718    //  2. compiles the UPDATE via pool.dialect() and runs it on the tx
2719    //  3. builds AFTER pairs from &self
2720    //  4. diffs BEFORE/AFTER, emits one PendingEntry with
2721    //     AuditOp::Update + diff_changes(...) on the same tx connection
2722    //  5. commits
2723    //
2724    // Per-backend arms inline the SQL string + placeholder shape, then
2725    // share the `audit_before_pair_tokens` decoder block (Row::try_get
2726    // is polymorphic over Row type — the same tokens work against
2727    // PgRow and MySqlRow as long as the field's Rust type implements
2728    // both Decode<Postgres> and Decode<MySql>, which Auto<T> +
2729    // primitives + chrono/uuid/serde_json::Value all do).
2730    let pool_save_method = if let Some(tracked) = audited_fields {
2731        if let Some((pk_ident, pk_col)) = primary_key {
2732            let pk_column_lit = pk_col.as_str();
2733            // Two iterators — quote!'s `#(#var)*` consumes the
2734            // iterator, and we need to splice the same after-pairs
2735            // sequence into both per-backend arms.
2736            let after_pairs_pg = audit_pair_tokens.iter().collect::<Vec<_>>();
2737            let pk_str = audit_pk_to_string.clone();
2738            // Per-tracked-column BEFORE-pair token list. Each entry
2739            // is `(col_lit, try_get_returning<value_ty>(row, col_lit) → Json)`.
2740            // The Row alias resolves to PgRow / MySqlRow per call site,
2741            // so the same template generates both the PG and MySQL bodies.
2742            let mk_before_pairs =
2743                |getter: proc_macro2::TokenStream| -> Vec<proc_macro2::TokenStream> {
2744                    tracked
2745                        .iter()
2746                        .map(|c| {
2747                            let column_lit = c.column.as_str();
2748                            let value_ty = &c.value_ty;
2749                            quote! {
2750                                (
2751                                    #column_lit,
2752                                    match #getter::<#value_ty>(
2753                                        _audit_before_row, #column_lit,
2754                                    ) {
2755                                        ::core::result::Result::Ok(v) => {
2756                                            ::serde_json::to_value(&v)
2757                                                .unwrap_or(::serde_json::Value::Null)
2758                                        }
2759                                        ::core::result::Result::Err(_) => ::serde_json::Value::Null,
2760                                    },
2761                                )
2762                            }
2763                        })
2764                        .collect()
2765                };
2766            let before_pairs_pg: Vec<proc_macro2::TokenStream> =
2767                mk_before_pairs(quote!(::rustango::sql::try_get_returning));
2768            let before_pairs_my: Vec<proc_macro2::TokenStream> =
2769                mk_before_pairs(quote!(::rustango::sql::try_get_returning_my));
2770            let before_pairs_sqlite: Vec<proc_macro2::TokenStream> =
2771                mk_before_pairs(quote!(::rustango::sql::try_get_returning_sqlite));
2772            let pg_select_cols: String = tracked
2773                .iter()
2774                .map(|c| format!("\"{}\"", c.column.replace('"', "\"\"")))
2775                .collect::<Vec<_>>()
2776                .join(", ");
2777            let my_select_cols: String = tracked
2778                .iter()
2779                .map(|c| format!("`{}`", c.column.replace('`', "``")))
2780                .collect::<Vec<_>>()
2781                .join(", ");
2782            // SQLite uses double-quote identifier quoting (same as
2783            // Postgres in default config), so the column-list shape
2784            // matches PG.
2785            let sqlite_select_cols: String = pg_select_cols.clone();
2786            let pk_value_for_bind = if fields.pk_is_auto {
2787                quote!(self.#pk_ident.get().copied().unwrap_or_default())
2788            } else {
2789                quote!(::core::clone::Clone::clone(&self.#pk_ident))
2790            };
2791            let assignments = &fields.update_assignments;
2792            let unset_dispatch = if fields.has_auto {
2793                quote! {
2794                    if matches!(self.#pk_ident, ::rustango::sql::Auto::Unset) {
2795                        return self.insert_pool(pool).await;
2796                    }
2797                }
2798            } else {
2799                quote!()
2800            };
2801            quote! {
2802                /// Save this row against either backend with audit
2803                /// emission (diff-style: BEFORE+AFTER) inside the
2804                /// same transaction. Auto::Unset PK routes to
2805                /// insert_pool. Bi-dialect counterpart of
2806                /// [`Self::save`] for audited models.
2807                ///
2808                /// The audit row's `changes` JSON contains one
2809                /// `{ "field": { "before": …, "after": … } }` entry
2810                /// per tracked column whose value actually changed
2811                /// — same shape as the existing &PgPool save() emits.
2812                ///
2813                /// # Errors
2814                /// As [`Self::save`].
2815                pub async fn save_pool(
2816                    &mut self,
2817                    pool: &::rustango::sql::Pool,
2818                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2819                    #unset_dispatch
2820                    let _query = ::rustango::core::UpdateQuery {
2821                        model: <Self as ::rustango::core::Model>::SCHEMA,
2822                        set: ::std::vec![ #( #assignments ),* ],
2823                        where_clause: ::rustango::core::WhereExpr::Predicate(
2824                            ::rustango::core::Filter {
2825                                column: #pk_column_lit,
2826                                op: ::rustango::core::Op::Eq,
2827                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2828                                    ::core::clone::Clone::clone(&self.#pk_ident)
2829                                ),
2830                            }
2831                        ),
2832                    };
2833                    let _after_pairs: ::std::vec::Vec<(&'static str, ::serde_json::Value)> =
2834                        ::std::vec![ #( #after_pairs_pg ),* ];
2835                    ::rustango::audit::save_one_with_diff(
2836                        pool,
2837                        &_query,
2838                        #pk_column_lit,
2839                        ::core::convert::Into::<::rustango::core::SqlValue>::into(
2840                            #pk_value_for_bind,
2841                        ),
2842                        <Self as ::rustango::core::Model>::SCHEMA.table,
2843                        #pk_str,
2844                        _after_pairs,
2845                        #pg_select_cols,
2846                        #my_select_cols,
2847                        #sqlite_select_cols,
2848                        |_audit_before_row| ::std::vec![ #( #before_pairs_pg ),* ],
2849                        |_audit_before_row| ::std::vec![ #( #before_pairs_my ),* ],
2850                        |_audit_before_row| ::std::vec![ #( #before_pairs_sqlite ),* ],
2851                    ).await
2852                }
2853            }
2854        } else {
2855            quote!()
2856        }
2857    } else {
2858        pool_save_method
2859    };
2860
2861    // `delete_pool(&Pool)` — emitted for every model with a PK. Two
2862    // body shapes:
2863    // - non-audited: simple dispatch through `sql::delete_pool`
2864    // - audited: routes through `audit::delete_one_with_audit`,
2865    //   which opens a per-backend transaction wrapping DELETE +
2866    //   audit emit so the data write and audit row commit atomically.
2867    let pool_delete_method = {
2868        let pk_column_lit = primary_key.map(|(_, col)| col.as_str()).unwrap_or("id");
2869        let pk_ident_for_pool = primary_key.map(|(ident, _)| ident);
2870        if let Some(pk_ident) = pk_ident_for_pool {
2871            if audited_fields.is_some() {
2872                let pairs = audit_pair_tokens.iter();
2873                let pk_str = audit_pk_to_string.clone();
2874                quote! {
2875                    /// Delete this row against either backend with audit
2876                    /// emission inside the same transaction. Bi-dialect
2877                    /// counterpart of [`Self::delete`] for audited models.
2878                    ///
2879                    /// # Errors
2880                    /// As [`Self::delete`].
2881                    pub async fn delete_pool(
2882                        &self,
2883                        pool: &::rustango::sql::Pool,
2884                    ) -> ::core::result::Result<u64, ::rustango::sql::ExecError> {
2885                        let _query = ::rustango::core::DeleteQuery {
2886                            model: <Self as ::rustango::core::Model>::SCHEMA,
2887                            where_clause: ::rustango::core::WhereExpr::Predicate(
2888                                ::rustango::core::Filter {
2889                                    column: #pk_column_lit,
2890                                    op: ::rustango::core::Op::Eq,
2891                                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2892                                        ::core::clone::Clone::clone(&self.#pk_ident)
2893                                    ),
2894                                }
2895                            ),
2896                        };
2897                        let _audit_entry = ::rustango::audit::PendingEntry {
2898                            entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
2899                            entity_pk: #pk_str,
2900                            operation: ::rustango::audit::AuditOp::Delete,
2901                            source: ::rustango::audit::current_source(),
2902                            changes: ::rustango::audit::snapshot_changes(&[
2903                                #( #pairs ),*
2904                            ]),
2905                        };
2906                        ::rustango::audit::delete_one_with_audit(
2907                            pool, &_query, &_audit_entry,
2908                        ).await
2909                    }
2910                }
2911            } else {
2912                quote! {
2913                    /// Delete the row identified by this instance's primary key
2914                    /// against either backend. Equivalent to [`Self::delete`] but
2915                    /// takes [`::rustango::sql::Pool`] and dispatches per backend.
2916                    ///
2917                    /// # Errors
2918                    /// As [`Self::delete`].
2919                    pub async fn delete_pool(
2920                        &self,
2921                        pool: &::rustango::sql::Pool,
2922                    ) -> ::core::result::Result<u64, ::rustango::sql::ExecError> {
2923                        let _query = ::rustango::core::DeleteQuery {
2924                            model: <Self as ::rustango::core::Model>::SCHEMA,
2925                            where_clause: ::rustango::core::WhereExpr::Predicate(
2926                                ::rustango::core::Filter {
2927                                    column: #pk_column_lit,
2928                                    op: ::rustango::core::Op::Eq,
2929                                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2930                                        ::core::clone::Clone::clone(&self.#pk_ident)
2931                                    ),
2932                                }
2933                            ),
2934                        };
2935                        ::rustango::sql::delete_pool(pool, &_query).await
2936                    }
2937                }
2938            }
2939        } else {
2940            quote!()
2941        }
2942    };
2943
2944    // `_tx` family — `insert_tx`, `save_tx`, `delete_tx`. These mirror
2945    // the non-audited `_pool` methods but execute against an open
2946    // `PoolTx` so the writes participate in the caller's transaction.
2947    // Auditing inside TX is deferred; these always use the plain
2948    // executor primitives regardless of whether the model is audited.
2949    let tx_insert_method = if fields.has_auto {
2950        let pushes = &fields.insert_pushes;
2951        let returning_cols = &fields.returning_cols;
2952        quote! {
2953            /// Insert this row inside an open transaction, populating
2954            /// any `Auto<T>` PK from the auto-assigned value. Works
2955            /// against any backend that `tx` wraps.
2956            ///
2957            /// # Errors
2958            /// As [`Self::insert_pool`].
2959            pub async fn insert_tx(
2960                &mut self,
2961                tx: &mut ::rustango::sql::PoolTx<'_>,
2962            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2963                let mut _columns: ::std::vec::Vec<&'static str> =
2964                    ::std::vec::Vec::new();
2965                let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
2966                    ::std::vec::Vec::new();
2967                #( #pushes )*
2968                let _query = ::rustango::core::InsertQuery {
2969                    model: <Self as ::rustango::core::Model>::SCHEMA,
2970                    columns: _columns,
2971                    values: _values,
2972                    returning: ::std::vec![ #( #returning_cols ),* ],
2973                    on_conflict: ::core::option::Option::None,
2974                };
2975                let _result = ::rustango::sql::insert_returning_tx(tx, &_query).await?;
2976                ::rustango::sql::apply_auto_pk(_result, self)
2977            }
2978        }
2979    } else {
2980        let insert_columns = &fields.insert_columns;
2981        let insert_values = &fields.insert_values;
2982        quote! {
2983            /// Insert this row inside an open transaction.
2984            ///
2985            /// # Errors
2986            /// As [`Self::insert_pool`].
2987            pub async fn insert_tx(
2988                &self,
2989                tx: &mut ::rustango::sql::PoolTx<'_>,
2990            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2991                let _query = ::rustango::core::InsertQuery {
2992                    model: <Self as ::rustango::core::Model>::SCHEMA,
2993                    columns: ::std::vec![ #( #insert_columns ),* ],
2994                    values: ::std::vec![ #( #insert_values ),* ],
2995                    returning: ::std::vec::Vec::new(),
2996                    on_conflict: ::core::option::Option::None,
2997                };
2998                ::rustango::sql::insert_tx(tx, &_query).await
2999            }
3000        }
3001    };
3002
3003    let tx_save_method = if let Some((pk_ident, pk_col)) = primary_key {
3004        let pk_column_lit = pk_col.as_str();
3005        let assignments = &fields.update_assignments;
3006        let dispatch_unset = if fields.pk_is_auto {
3007            quote! {
3008                if matches!(self.#pk_ident, ::rustango::sql::Auto::Unset) {
3009                    return self.insert_tx(tx).await;
3010                }
3011            }
3012        } else {
3013            quote!()
3014        };
3015        quote! {
3016            /// Save this row inside an open transaction. `INSERT` when
3017            /// the `Auto<T>` PK is `Unset`, else `UPDATE` keyed on the
3018            /// PK. Works against any backend that `tx` wraps.
3019            ///
3020            /// # Errors
3021            /// As [`Self::save_pool`].
3022            pub async fn save_tx(
3023                &mut self,
3024                tx: &mut ::rustango::sql::PoolTx<'_>,
3025            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3026                #dispatch_unset
3027                let _query = ::rustango::core::UpdateQuery {
3028                    model: <Self as ::rustango::core::Model>::SCHEMA,
3029                    set: ::std::vec![ #( #assignments ),* ],
3030                    where_clause: ::rustango::core::WhereExpr::Predicate(
3031                        ::rustango::core::Filter {
3032                            column: #pk_column_lit,
3033                            op: ::rustango::core::Op::Eq,
3034                            value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
3035                                ::core::clone::Clone::clone(&self.#pk_ident)
3036                            ),
3037                        }
3038                    ),
3039                };
3040                let _ = ::rustango::sql::update_tx(tx, &_query).await?;
3041                ::core::result::Result::Ok(())
3042            }
3043        }
3044    } else {
3045        quote!()
3046    };
3047
3048    let tx_delete_method = {
3049        let pk_column_lit = primary_key.map(|(_, col)| col.as_str()).unwrap_or("id");
3050        let pk_ident_for_tx = primary_key.map(|(ident, _)| ident);
3051        if let Some(pk_ident) = pk_ident_for_tx {
3052            quote! {
3053                /// Delete the row identified by this instance's PK
3054                /// inside an open transaction. Works against any backend
3055                /// that `tx` wraps.
3056                ///
3057                /// # Errors
3058                /// As [`Self::delete_pool`].
3059                pub async fn delete_tx(
3060                    &self,
3061                    tx: &mut ::rustango::sql::PoolTx<'_>,
3062                ) -> ::core::result::Result<u64, ::rustango::sql::ExecError> {
3063                    let _query = ::rustango::core::DeleteQuery {
3064                        model: <Self as ::rustango::core::Model>::SCHEMA,
3065                        where_clause: ::rustango::core::WhereExpr::Predicate(
3066                            ::rustango::core::Filter {
3067                                column: #pk_column_lit,
3068                                op: ::rustango::core::Op::Eq,
3069                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
3070                                    ::core::clone::Clone::clone(&self.#pk_ident)
3071                                ),
3072                            }
3073                        ),
3074                    };
3075                    ::rustango::sql::delete_tx(tx, &_query).await
3076                }
3077            }
3078        } else {
3079            quote!()
3080        }
3081    };
3082
3083    // Update emission captures both BEFORE and AFTER state — runs an
3084    // extra SELECT against `_executor` BEFORE the UPDATE, captures
3085    // each tracked field's prior value, then after the UPDATE diffs
3086    // against the in-memory `&self`. `diff_changes` drops unchanged
3087    // columns so the JSON only contains the actual delta.
3088    //
3089    // Two-fragment shape: `audit_update_pre` runs before the UPDATE
3090    // and binds `_audit_before_pairs`; `audit_update_post` runs
3091    // after the UPDATE and emits the PendingEntry.
3092    let (audit_update_pre, audit_update_post): (TokenStream2, TokenStream2) = if let Some(tracked) =
3093        audited_fields
3094    {
3095        if tracked.is_empty() {
3096            (quote!(), quote!())
3097        } else {
3098            let select_cols: String = tracked
3099                .iter()
3100                .map(|c| format!("\"{}\"", c.column.replace('"', "\"\"")))
3101                .collect::<Vec<_>>()
3102                .join(", ");
3103            let pk_column_for_select = primary_key.map(|(_, col)| col.clone()).unwrap_or_default();
3104            let select_cols_lit = select_cols;
3105            let pk_column_lit_for_select = pk_column_for_select;
3106            let pk_value_for_bind = if let Some((pk_ident, _)) = primary_key {
3107                if fields.pk_is_auto {
3108                    quote!(self.#pk_ident.get().copied().unwrap_or_default())
3109                } else {
3110                    quote!(::core::clone::Clone::clone(&self.#pk_ident))
3111                }
3112            } else {
3113                quote!(0_i64)
3114            };
3115            let before_pairs = tracked.iter().map(|c| {
3116                let column_lit = c.column.as_str();
3117                let value_ty = &c.value_ty;
3118                quote! {
3119                    (
3120                        #column_lit,
3121                        match ::rustango::sql::sqlx::Row::try_get::<#value_ty, _>(
3122                            &_audit_before_row, #column_lit,
3123                        ) {
3124                            ::core::result::Result::Ok(v) => {
3125                                ::serde_json::to_value(&v)
3126                                    .unwrap_or(::serde_json::Value::Null)
3127                            }
3128                            ::core::result::Result::Err(_) => ::serde_json::Value::Null,
3129                        },
3130                    )
3131                }
3132            });
3133            let after_pairs = tracked.iter().map(|c| {
3134                let column_lit = c.column.as_str();
3135                let ident = &c.ident;
3136                quote! {
3137                    (
3138                        #column_lit,
3139                        ::serde_json::to_value(&self.#ident)
3140                            .unwrap_or(::serde_json::Value::Null),
3141                    )
3142                }
3143            });
3144            let pk_str = audit_pk_to_string.clone();
3145            let pre = quote! {
3146                let _audit_select_sql = ::std::format!(
3147                    r#"SELECT {} FROM "{}" WHERE "{}" = $1"#,
3148                    #select_cols_lit,
3149                    <Self as ::rustango::core::Model>::SCHEMA.table,
3150                    #pk_column_lit_for_select,
3151                );
3152                let _audit_before_pairs:
3153                    ::std::option::Option<::std::vec::Vec<(&'static str, ::serde_json::Value)>> =
3154                    match ::rustango::sql::sqlx::query(&_audit_select_sql)
3155                        .bind(#pk_value_for_bind)
3156                        .fetch_optional(&mut *_executor)
3157                        .await
3158                    {
3159                        ::core::result::Result::Ok(::core::option::Option::Some(_audit_before_row)) => {
3160                            ::core::option::Option::Some(::std::vec![ #( #before_pairs ),* ])
3161                        }
3162                        _ => ::core::option::Option::None,
3163                    };
3164            };
3165            let post = quote! {
3166                if let ::core::option::Option::Some(_audit_before) = _audit_before_pairs {
3167                    let _audit_after:
3168                        ::std::vec::Vec<(&'static str, ::serde_json::Value)> =
3169                        ::std::vec![ #( #after_pairs ),* ];
3170                    let _audit_entry = ::rustango::audit::PendingEntry {
3171                        entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
3172                        entity_pk: #pk_str,
3173                        operation: ::rustango::audit::AuditOp::Update,
3174                        source: ::rustango::audit::current_source(),
3175                        changes: ::rustango::audit::diff_changes(
3176                            &_audit_before,
3177                            &_audit_after,
3178                        ),
3179                    };
3180                    ::rustango::audit::emit_one(&mut *_executor, &_audit_entry).await?;
3181                }
3182            };
3183            (pre, post)
3184        }
3185    } else {
3186        (quote!(), quote!())
3187    };
3188
3189    // Bulk-insert audit: capture every row's tracked fields after the
3190    // RETURNING populates each PK, then push one batched INSERT INTO
3191    // audit_log via `emit_many`. One round-trip regardless of N rows.
3192    let audit_bulk_insert_emit: TokenStream2 = if audited_fields.is_some() {
3193        let row_pk_str = if let Some((pk_ident, _)) = primary_key {
3194            if fields.pk_is_auto {
3195                quote!(_row.#pk_ident.get().map(|v| ::std::format!("{}", v)).unwrap_or_default())
3196            } else {
3197                quote!(::std::format!("{}", &_row.#pk_ident))
3198            }
3199        } else {
3200            quote!(::std::string::String::new())
3201        };
3202        let row_pairs = audited_fields.unwrap_or(&[]).iter().map(|c| {
3203            let column_lit = c.column.as_str();
3204            let ident = &c.ident;
3205            quote! {
3206                (
3207                    #column_lit,
3208                    ::serde_json::to_value(&_row.#ident)
3209                        .unwrap_or(::serde_json::Value::Null),
3210                )
3211            }
3212        });
3213        quote! {
3214            let _audit_source = ::rustango::audit::current_source();
3215            let mut _audit_entries:
3216                ::std::vec::Vec<::rustango::audit::PendingEntry> =
3217                    ::std::vec::Vec::with_capacity(rows.len());
3218            for _row in rows.iter() {
3219                _audit_entries.push(::rustango::audit::PendingEntry {
3220                    entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
3221                    entity_pk: #row_pk_str,
3222                    operation: ::rustango::audit::AuditOp::Create,
3223                    source: _audit_source.clone(),
3224                    changes: ::rustango::audit::snapshot_changes(&[
3225                        #( #row_pairs ),*
3226                    ]),
3227                });
3228            }
3229            ::rustango::audit::emit_many(&mut *_executor, &_audit_entries).await?;
3230        }
3231    } else {
3232        quote!()
3233    };
3234
3235    let save_method = if fields.pk_is_auto {
3236        let (pk_ident, pk_column) = primary_key.expect("pk_is_auto implies primary_key is Some");
3237        let pk_column_lit = pk_column.as_str();
3238        let assignments = &fields.update_assignments;
3239        let upsert_cols = &fields.upsert_update_columns;
3240        let upsert_pushes = &fields.insert_pushes;
3241        let upsert_returning = &fields.returning_cols;
3242        let upsert_auto_assigns = &fields.auto_assigns;
3243        // Conflict target: prefer the first declared `unique_together`
3244        // when it exists. Plain `Auto<T>` PKs are server-assigned via
3245        // `BIGSERIAL` and never collide on insert, so a PK-only target
3246        // would silently turn `upsert()` into "always-insert" for
3247        // surrogate-PK models with composite UNIQUE constraints — see
3248        // `RolePermission` / `UserRole` / `UserPermission` in the
3249        // tenancy permission engine. When no `unique_together` is
3250        // declared we keep the PK target (the original behaviour).
3251        let upsert_target_columns: Vec<String> = indexes
3252            .iter()
3253            .find(|i| i.unique && !i.columns.is_empty())
3254            .map(|i| i.columns.clone())
3255            .unwrap_or_else(|| vec![pk_column.clone()]);
3256        let upsert_target_lits = upsert_target_columns
3257            .iter()
3258            .map(String::as_str)
3259            .collect::<Vec<_>>();
3260        let conflict_clause = if fields.upsert_update_columns.is_empty() {
3261            quote!(::rustango::core::ConflictClause::DoNothing)
3262        } else {
3263            quote!(::rustango::core::ConflictClause::DoUpdate {
3264                target: ::std::vec![ #( #upsert_target_lits ),* ],
3265                update_columns: ::std::vec![ #( #upsert_cols ),* ],
3266            })
3267        };
3268        Some(quote! {
3269            /// Insert this row if its `Auto<T>` primary key is
3270            /// `Unset`, otherwise update the existing row matching the
3271            /// PK. Mirrors Django's `save()` — caller doesn't need to
3272            /// pick `insert` vs the bulk-update path manually.
3273            ///
3274            /// On the insert branch, populates the PK from `RETURNING`
3275            /// (same behavior as `insert`). On the update branch,
3276            /// writes every non-PK column back; if no row matches the
3277            /// PK, returns `Ok(())` silently.
3278            ///
3279            /// Only generated when the primary key is declared as
3280            /// `Auto<T>`. Models with a manually-managed PK must use
3281            /// `insert` or the QuerySet update builder.
3282            ///
3283            /// # Errors
3284            /// Returns [`::rustango::sql::ExecError`] for SQL-writing
3285            /// or driver failures.
3286            #[cfg(feature = "postgres")]
3287            pub async fn save(
3288                &mut self,
3289                pool: &::rustango::sql::sqlx::PgPool,
3290            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3291                #pool_to_save_on
3292            }
3293
3294            /// Like [`Self::save`] but accepts any sqlx executor —
3295            /// `&PgPool`, `&mut PgConnection`, or a transaction. The
3296            /// escape hatch for tenant-scoped writes: schema-mode
3297            /// tenants share the registry pool but rely on a per-
3298            /// checkout `SET search_path`, so passing `&PgPool` would
3299            /// silently hit the wrong schema. Acquire a connection
3300            /// via `TenantPools::acquire(&org)` and pass `&mut *conn`.
3301            ///
3302            /// # Errors
3303            /// As [`Self::save`].
3304            #[cfg(feature = "postgres")]
3305            pub async fn save_on #executor_generics (
3306                &mut self,
3307                #executor_param,
3308            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
3309            #executor_where
3310            {
3311                if matches!(self.#pk_ident, ::rustango::sql::Auto::Unset) {
3312                    return self.insert_on(#executor_passes_to_data_write).await;
3313                }
3314                #audit_update_pre
3315                let _query = ::rustango::core::UpdateQuery {
3316                    model: <Self as ::rustango::core::Model>::SCHEMA,
3317                    set: ::std::vec![ #( #assignments ),* ],
3318                    where_clause: ::rustango::core::WhereExpr::Predicate(
3319                        ::rustango::core::Filter {
3320                            column: #pk_column_lit,
3321                            op: ::rustango::core::Op::Eq,
3322                            value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
3323                                ::core::clone::Clone::clone(&self.#pk_ident)
3324                            ),
3325                        }
3326                    ),
3327                };
3328                let _ = ::rustango::sql::__macro_internals::update_on(
3329                    #executor_passes_to_data_write,
3330                    &_query,
3331                ).await?;
3332                #audit_update_post
3333                ::core::result::Result::Ok(())
3334            }
3335
3336            /// Per-call override for the audit source. Runs
3337            /// [`Self::save_on`] inside an [`::rustango::audit::with_source`]
3338            /// scope so the resulting audit entry records `source`
3339            /// instead of the task-local default. Useful for seed
3340            /// scripts and one-off CLI tools that don't sit inside an
3341            /// admin handler. The override applies only to this call;
3342            /// no global state changes.
3343            ///
3344            /// # Errors
3345            /// As [`Self::save_on`].
3346            #[cfg(feature = "postgres")]
3347            pub async fn save_on_with #executor_generics (
3348                &mut self,
3349                #executor_param,
3350                source: ::rustango::audit::AuditSource,
3351            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
3352            #executor_where
3353            {
3354                ::rustango::audit::with_source(source, self.save_on(_executor)).await
3355            }
3356
3357            /// Insert this row or update it in-place if the primary key already
3358            /// exists — single round-trip via `INSERT … ON CONFLICT (pk) DO UPDATE`.
3359            ///
3360            /// With `Auto::Unset` PK the server assigns a new key and no conflict
3361            /// can occur (equivalent to `insert`). With `Auto::Set` PK the row is
3362            /// inserted if absent or all non-PK columns are overwritten if present.
3363            ///
3364            /// # Errors
3365            /// As [`Self::insert_on`].
3366            #[cfg(feature = "postgres")]
3367            pub async fn upsert(
3368                &mut self,
3369                pool: &::rustango::sql::sqlx::PgPool,
3370            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3371                #pool_to_upsert_on
3372            }
3373
3374            /// Like [`Self::upsert`] but accepts any sqlx executor.
3375            /// See [`Self::save_on`] for tenancy-scoped rationale.
3376            ///
3377            /// # Errors
3378            /// As [`Self::upsert`].
3379            #[cfg(feature = "postgres")]
3380            pub async fn upsert_on #executor_generics (
3381                &mut self,
3382                #executor_param,
3383            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
3384            #executor_where
3385            {
3386                let mut _columns: ::std::vec::Vec<&'static str> =
3387                    ::std::vec::Vec::new();
3388                let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
3389                    ::std::vec::Vec::new();
3390                #( #upsert_pushes )*
3391                let query = ::rustango::core::InsertQuery {
3392                    model: <Self as ::rustango::core::Model>::SCHEMA,
3393                    columns: _columns,
3394                    values: _values,
3395                    returning: ::std::vec![ #( #upsert_returning ),* ],
3396                    on_conflict: ::core::option::Option::Some(#conflict_clause),
3397                };
3398                let _returning_row_v = ::rustango::sql::__macro_internals::insert_returning_on(
3399                    #executor_passes_to_data_write,
3400                    &query,
3401                ).await?;
3402                let _returning_row = &_returning_row_v;
3403                #( #upsert_auto_assigns )*
3404                ::core::result::Result::Ok(())
3405            }
3406        })
3407    } else {
3408        None
3409    };
3410
3411    let pk_methods = primary_key.map(|(pk_ident, pk_column)| {
3412        let pk_column_lit = pk_column.as_str();
3413        // Optional `soft_delete_on` / `restore_on` companions when the
3414        // model has a `#[rustango(soft_delete)]` column. They land
3415        // alongside the regular `delete_on` so callers have both
3416        // options — a hard delete (audit-tracked as a real DELETE) and
3417        // a logical delete (audit-tracked as an UPDATE setting the
3418        // deleted_at column to NOW()).
3419        let soft_delete_methods = if let Some(col) = fields.soft_delete_column.as_deref() {
3420            let col_lit = col;
3421            quote! {
3422                /// Soft-delete this row by setting its
3423                /// `#[rustango(soft_delete)]` column to `NOW()`.
3424                /// Mirrors Django's `SoftDeleteModel.delete()` shape:
3425                /// the row stays in the table; query helpers can
3426                /// filter it out by checking the column for `IS NOT
3427                /// NULL`.
3428                ///
3429                /// # Errors
3430                /// As [`Self::delete`].
3431                pub async fn soft_delete_on #executor_generics (
3432                    &self,
3433                    #executor_param,
3434                ) -> ::core::result::Result<u64, ::rustango::sql::ExecError>
3435                #executor_where
3436                {
3437                    let _query = ::rustango::core::UpdateQuery {
3438                        model: <Self as ::rustango::core::Model>::SCHEMA,
3439                        set: ::std::vec![
3440                            ::rustango::core::Assignment {
3441                                column: #col_lit,
3442                                value: ::core::convert::Into::<::rustango::core::Expr>::into(
3443                                    ::core::convert::Into::<::rustango::core::SqlValue>::into(
3444                                        ::chrono::Utc::now()
3445                                    )
3446                                ),
3447                            },
3448                        ],
3449                        where_clause: ::rustango::core::WhereExpr::Predicate(
3450                            ::rustango::core::Filter {
3451                                column: #pk_column_lit,
3452                                op: ::rustango::core::Op::Eq,
3453                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
3454                                    ::core::clone::Clone::clone(&self.#pk_ident)
3455                                ),
3456                            }
3457                        ),
3458                    };
3459                    let _affected = ::rustango::sql::__macro_internals::update_on(
3460                        #executor_passes_to_data_write,
3461                        &_query,
3462                    ).await?;
3463                    #audit_softdelete_emit
3464                    ::core::result::Result::Ok(_affected)
3465                }
3466
3467                /// Inverse of [`Self::soft_delete_on`] — clears the
3468                /// soft-delete column back to NULL so the row is
3469                /// considered live again.
3470                ///
3471                /// # Errors
3472                /// As [`Self::delete`].
3473                pub async fn restore_on #executor_generics (
3474                    &self,
3475                    #executor_param,
3476                ) -> ::core::result::Result<u64, ::rustango::sql::ExecError>
3477                #executor_where
3478                {
3479                    let _query = ::rustango::core::UpdateQuery {
3480                        model: <Self as ::rustango::core::Model>::SCHEMA,
3481                        set: ::std::vec![
3482                            ::rustango::core::Assignment {
3483                                column: #col_lit,
3484                                value: ::core::convert::Into::<::rustango::core::Expr>::into(
3485                                    ::rustango::core::SqlValue::Null
3486                                ),
3487                            },
3488                        ],
3489                        where_clause: ::rustango::core::WhereExpr::Predicate(
3490                            ::rustango::core::Filter {
3491                                column: #pk_column_lit,
3492                                op: ::rustango::core::Op::Eq,
3493                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
3494                                    ::core::clone::Clone::clone(&self.#pk_ident)
3495                                ),
3496                            }
3497                        ),
3498                    };
3499                    let _affected = ::rustango::sql::__macro_internals::update_on(
3500                        #executor_passes_to_data_write,
3501                        &_query,
3502                    ).await?;
3503                    #audit_restore_emit
3504                    ::core::result::Result::Ok(_affected)
3505                }
3506            }
3507        } else {
3508            quote!()
3509        };
3510        quote! {
3511            /// Delete the row identified by this instance's primary key.
3512            ///
3513            /// Returns the number of rows affected (0 or 1).
3514            ///
3515            /// # Errors
3516            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
3517            /// driver failures.
3518            #[cfg(feature = "postgres")]
3519            pub async fn delete(
3520                &self,
3521                pool: &::rustango::sql::sqlx::PgPool,
3522            ) -> ::core::result::Result<u64, ::rustango::sql::ExecError> {
3523                #pool_to_delete_on
3524            }
3525
3526            /// Like [`Self::delete`] but accepts any sqlx executor —
3527            /// for tenant-scoped deletes against an explicitly-acquired
3528            /// connection. See [`Self::save_on`] for the rationale.
3529            ///
3530            /// # Errors
3531            /// As [`Self::delete`].
3532            #[cfg(feature = "postgres")]
3533            pub async fn delete_on #executor_generics (
3534                &self,
3535                #executor_param,
3536            ) -> ::core::result::Result<u64, ::rustango::sql::ExecError>
3537            #executor_where
3538            {
3539                let query = ::rustango::core::DeleteQuery {
3540                    model: <Self as ::rustango::core::Model>::SCHEMA,
3541                    where_clause: ::rustango::core::WhereExpr::Predicate(
3542                        ::rustango::core::Filter {
3543                            column: #pk_column_lit,
3544                            op: ::rustango::core::Op::Eq,
3545                            value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
3546                                ::core::clone::Clone::clone(&self.#pk_ident)
3547                            ),
3548                        }
3549                    ),
3550                };
3551                let _affected = ::rustango::sql::__macro_internals::delete_on(
3552                    #executor_passes_to_data_write,
3553                    &query,
3554                ).await?;
3555                #audit_delete_emit
3556                ::core::result::Result::Ok(_affected)
3557            }
3558
3559            /// Per-call audit-source override for [`Self::delete_on`].
3560            /// See [`Self::save_on_with`] for shape rationale.
3561            ///
3562            /// # Errors
3563            /// As [`Self::delete_on`].
3564            #[cfg(feature = "postgres")]
3565            pub async fn delete_on_with #executor_generics (
3566                &self,
3567                #executor_param,
3568                source: ::rustango::audit::AuditSource,
3569            ) -> ::core::result::Result<u64, ::rustango::sql::ExecError>
3570            #executor_where
3571            {
3572                ::rustango::audit::with_source(source, self.delete_on(_executor)).await
3573            }
3574            #pool_delete_method
3575            #pool_insert_method
3576            #pool_save_method
3577            #tx_delete_method
3578            #tx_insert_method
3579            #tx_save_method
3580            #soft_delete_methods
3581        }
3582    });
3583
3584    let insert_method = if fields.has_auto {
3585        let pushes = &fields.insert_pushes;
3586        let returning_cols = &fields.returning_cols;
3587        let auto_assigns = &fields.auto_assigns;
3588        quote! {
3589            /// Insert this row into its table. Skips columns whose
3590            /// `Auto<T>` value is `Unset` so Postgres' SERIAL/BIGSERIAL
3591            /// sequence fills them in, then reads each `Auto` column
3592            /// back via `RETURNING` and stores it on `self`.
3593            ///
3594            /// # Errors
3595            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
3596            /// driver failures.
3597            #[cfg(feature = "postgres")]
3598            pub async fn insert(
3599                &mut self,
3600                pool: &::rustango::sql::sqlx::PgPool,
3601            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3602                #pool_to_insert_on
3603            }
3604
3605            /// Like [`Self::insert`] but accepts any sqlx executor.
3606            /// See [`Self::save_on`] for tenancy-scoped rationale.
3607            ///
3608            /// # Errors
3609            /// As [`Self::insert`].
3610            #[cfg(feature = "postgres")]
3611            pub async fn insert_on #executor_generics (
3612                &mut self,
3613                #executor_param,
3614            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
3615            #executor_where
3616            {
3617                let mut _columns: ::std::vec::Vec<&'static str> =
3618                    ::std::vec::Vec::new();
3619                let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
3620                    ::std::vec::Vec::new();
3621                #( #pushes )*
3622                let query = ::rustango::core::InsertQuery {
3623                    model: <Self as ::rustango::core::Model>::SCHEMA,
3624                    columns: _columns,
3625                    values: _values,
3626                    returning: ::std::vec![ #( #returning_cols ),* ],
3627                    on_conflict: ::core::option::Option::None,
3628                };
3629                let _returning_row_v = ::rustango::sql::__macro_internals::insert_returning_on(
3630                    #executor_passes_to_data_write,
3631                    &query,
3632                ).await?;
3633                let _returning_row = &_returning_row_v;
3634                #( #auto_assigns )*
3635                #audit_insert_emit
3636                ::core::result::Result::Ok(())
3637            }
3638
3639            /// Per-call audit-source override for [`Self::insert_on`].
3640            /// See [`Self::save_on_with`] for shape rationale.
3641            ///
3642            /// # Errors
3643            /// As [`Self::insert_on`].
3644            #[cfg(feature = "postgres")]
3645            pub async fn insert_on_with #executor_generics (
3646                &mut self,
3647                #executor_param,
3648                source: ::rustango::audit::AuditSource,
3649            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
3650            #executor_where
3651            {
3652                ::rustango::audit::with_source(source, self.insert_on(_executor)).await
3653            }
3654        }
3655    } else {
3656        let insert_columns = &fields.insert_columns;
3657        let insert_values = &fields.insert_values;
3658        quote! {
3659            /// Insert this row into its table.
3660            ///
3661            /// # Errors
3662            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
3663            /// driver failures.
3664            #[cfg(feature = "postgres")]
3665            pub async fn insert(
3666                &self,
3667                pool: &::rustango::sql::sqlx::PgPool,
3668            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3669                self.insert_on(pool).await
3670            }
3671
3672            /// Like [`Self::insert`] but accepts any sqlx executor.
3673            /// See [`Self::save_on`] for tenancy-scoped rationale.
3674            ///
3675            /// # Errors
3676            /// As [`Self::insert`].
3677            #[cfg(feature = "postgres")]
3678            pub async fn insert_on<'_c, _E>(
3679                &self,
3680                _executor: _E,
3681            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
3682            where
3683                _E: ::rustango::sql::sqlx::Executor<'_c, Database = ::rustango::sql::sqlx::Postgres>,
3684            {
3685                let query = ::rustango::core::InsertQuery {
3686                    model: <Self as ::rustango::core::Model>::SCHEMA,
3687                    columns: ::std::vec![ #( #insert_columns ),* ],
3688                    values: ::std::vec![ #( #insert_values ),* ],
3689                    returning: ::std::vec::Vec::new(),
3690                    on_conflict: ::core::option::Option::None,
3691                };
3692                ::rustango::sql::__macro_internals::insert_on(_executor, &query).await
3693            }
3694        }
3695    };
3696
3697    let bulk_insert_method = if fields.has_auto {
3698        let cols_no_auto = &fields.bulk_columns_no_auto;
3699        let cols_all = &fields.bulk_columns_all;
3700        let pushes_no_auto = &fields.bulk_pushes_no_auto;
3701        let pushes_all = &fields.bulk_pushes_all;
3702        let returning_cols = &fields.returning_cols;
3703        let auto_assigns_for_row = bulk_auto_assigns_for_row(fields);
3704        let uniformity = &fields.bulk_auto_uniformity;
3705        let first_auto_ident = fields
3706            .first_auto_ident
3707            .as_ref()
3708            .expect("has_auto implies first_auto_ident is Some");
3709        quote! {
3710            /// Bulk-insert `rows` in a single round-trip. Every row's
3711            /// `Auto<T>` PK fields must uniformly be `Auto::Unset`
3712            /// (sequence fills them in) or uniformly `Auto::Set(_)`
3713            /// (caller-supplied values). Mixed Set/Unset is rejected
3714            /// — call `insert` per row for that case.
3715            ///
3716            /// Empty slice is a no-op. Each row's `Auto` fields are
3717            /// populated from the `RETURNING` clause in input order
3718            /// before this returns.
3719            ///
3720            /// # Errors
3721            /// Returns [`::rustango::sql::ExecError`] for validation,
3722            /// SQL-writing, mixed-Auto rejection, or driver failures.
3723            #[cfg(feature = "postgres")]
3724            pub async fn bulk_insert(
3725                rows: &mut [Self],
3726                pool: &::rustango::sql::sqlx::PgPool,
3727            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3728                #pool_to_bulk_insert_on
3729            }
3730
3731            /// Like [`Self::bulk_insert`] but accepts any sqlx executor.
3732            /// See [`Self::save_on`] for tenancy-scoped rationale.
3733            ///
3734            /// # Errors
3735            /// As [`Self::bulk_insert`].
3736            #[cfg(feature = "postgres")]
3737            pub async fn bulk_insert_on #executor_generics (
3738                rows: &mut [Self],
3739                #executor_param,
3740            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
3741            #executor_where
3742            {
3743                if rows.is_empty() {
3744                    return ::core::result::Result::Ok(());
3745                }
3746                let _first_unset = matches!(
3747                    rows[0].#first_auto_ident,
3748                    ::rustango::sql::Auto::Unset
3749                );
3750                #( #uniformity )*
3751
3752                let mut _all_rows: ::std::vec::Vec<
3753                    ::std::vec::Vec<::rustango::core::SqlValue>,
3754                > = ::std::vec::Vec::with_capacity(rows.len());
3755                let _columns: ::std::vec::Vec<&'static str> = if _first_unset {
3756                    for _row in rows.iter() {
3757                        let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
3758                            ::std::vec::Vec::new();
3759                        #( #pushes_no_auto )*
3760                        _all_rows.push(_row_vals);
3761                    }
3762                    ::std::vec![ #( #cols_no_auto ),* ]
3763                } else {
3764                    for _row in rows.iter() {
3765                        let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
3766                            ::std::vec::Vec::new();
3767                        #( #pushes_all )*
3768                        _all_rows.push(_row_vals);
3769                    }
3770                    ::std::vec![ #( #cols_all ),* ]
3771                };
3772
3773                let _query = ::rustango::core::BulkInsertQuery {
3774                    model: <Self as ::rustango::core::Model>::SCHEMA,
3775                    columns: _columns,
3776                    rows: _all_rows,
3777                    returning: ::std::vec![ #( #returning_cols ),* ],
3778                    on_conflict: ::core::option::Option::None,
3779                };
3780                let _returned = ::rustango::sql::__macro_internals::bulk_insert_on(
3781                    #executor_passes_to_data_write,
3782                    &_query,
3783                ).await?;
3784                if _returned.len() != rows.len() {
3785                    return ::core::result::Result::Err(
3786                        ::rustango::sql::ExecError::Sql(
3787                            ::rustango::sql::SqlError::BulkInsertReturningMismatch {
3788                                expected: rows.len(),
3789                                actual: _returned.len(),
3790                            }
3791                        )
3792                    );
3793                }
3794                for (_returning_row, _row_mut) in _returned.iter().zip(rows.iter_mut()) {
3795                    #auto_assigns_for_row
3796                }
3797                #audit_bulk_insert_emit
3798                ::core::result::Result::Ok(())
3799            }
3800        }
3801    } else {
3802        let cols_all = &fields.bulk_columns_all;
3803        let pushes_all = &fields.bulk_pushes_all;
3804        quote! {
3805            /// Bulk-insert `rows` in a single round-trip. Every row's
3806            /// fields are written verbatim — there are no `Auto<T>`
3807            /// fields on this model.
3808            ///
3809            /// Empty slice is a no-op.
3810            ///
3811            /// # Errors
3812            /// Returns [`::rustango::sql::ExecError`] for validation,
3813            /// SQL-writing, or driver failures.
3814            #[cfg(feature = "postgres")]
3815            pub async fn bulk_insert(
3816                rows: &[Self],
3817                pool: &::rustango::sql::sqlx::PgPool,
3818            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3819                Self::bulk_insert_on(rows, pool).await
3820            }
3821
3822            /// Like [`Self::bulk_insert`] but accepts any sqlx executor.
3823            /// See [`Self::save_on`] for tenancy-scoped rationale.
3824            ///
3825            /// # Errors
3826            /// As [`Self::bulk_insert`].
3827            #[cfg(feature = "postgres")]
3828            pub async fn bulk_insert_on<'_c, _E>(
3829                rows: &[Self],
3830                _executor: _E,
3831            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
3832            where
3833                _E: ::rustango::sql::sqlx::Executor<'_c, Database = ::rustango::sql::sqlx::Postgres>,
3834            {
3835                if rows.is_empty() {
3836                    return ::core::result::Result::Ok(());
3837                }
3838                let mut _all_rows: ::std::vec::Vec<
3839                    ::std::vec::Vec<::rustango::core::SqlValue>,
3840                > = ::std::vec::Vec::with_capacity(rows.len());
3841                for _row in rows.iter() {
3842                    let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
3843                        ::std::vec::Vec::new();
3844                    #( #pushes_all )*
3845                    _all_rows.push(_row_vals);
3846                }
3847                let _query = ::rustango::core::BulkInsertQuery {
3848                    model: <Self as ::rustango::core::Model>::SCHEMA,
3849                    columns: ::std::vec![ #( #cols_all ),* ],
3850                    rows: _all_rows,
3851                    returning: ::std::vec::Vec::new(),
3852                    on_conflict: ::core::option::Option::None,
3853                };
3854                let _ = ::rustango::sql::__macro_internals::bulk_insert_on(_executor, &_query).await?;
3855                ::core::result::Result::Ok(())
3856            }
3857        }
3858    };
3859
3860    // Tri-dialect `bulk_upsert_pool` — issue #267 / T1.5. Always emitted
3861    // (no postgres-feature gate); routes through the existing
3862    // `bulk_insert_pool` + per-dialect conflict writer.
3863    //
3864    // Auto<T> PKs are required to be `Auto::Unset` for every row so the
3865    // sequence picks the PK for fresh inserts; the UPDATE branch never
3866    // touches the Auto column.
3867    let bulk_upsert_pool_method = {
3868        // Pick the "no Auto" columns when the model has Auto fields,
3869        // else every column.
3870        let (upsert_cols, upsert_pushes): (Vec<_>, Vec<_>) = if fields.has_auto {
3871            (
3872                fields.bulk_columns_no_auto.clone(),
3873                fields.bulk_pushes_no_auto.clone(),
3874            )
3875        } else {
3876            (
3877                fields.bulk_columns_all.clone(),
3878                fields.bulk_pushes_all.clone(),
3879            )
3880        };
3881        quote! {
3882            /// Tri-dialect `bulk_create(update_conflicts=True)` — Django's
3883            /// canonical "import a batch idempotently" shape. Issue #267
3884            /// / T1.5.
3885            ///
3886            /// Per-row values are extracted and lowered into a
3887            /// [`::rustango::core::BulkInsertQuery`] with
3888            /// `on_conflict = DoUpdate { target, update_columns }`. The
3889            /// writer dispatches per-dialect:
3890            /// * Postgres / SQLite: `INSERT … ON CONFLICT (target) DO UPDATE SET col = EXCLUDED.col`
3891            /// * MySQL: `INSERT … ON DUPLICATE KEY UPDATE col = VALUES(col)` (target ignored — MySQL matches every UNIQUE index)
3892            ///
3893            /// `target` names the column(s) whose unique constraint
3894            /// defines the conflict (typically a `unique` or
3895            /// `unique_together` natural-key column, NOT the `Auto<T>`
3896            /// PK). `update_cols` names the columns to overwrite on
3897            /// conflict — every other column is left untouched on the
3898            /// existing row.
3899            ///
3900            /// Auto-PK rows must all have `Auto::Unset` (the sequence
3901            /// picks the PK on insert; the update path never touches
3902            /// the Auto column). Auto-set rows trigger a hard error.
3903            /// Empty slice is a no-op.
3904            ///
3905            /// # Errors
3906            /// Returns [`::rustango::sql::ExecError`] for validation,
3907            /// SQL-writing, or driver failures.
3908            pub async fn bulk_upsert_pool(
3909                rows: &[Self],
3910                target: &[&'static str],
3911                update_cols: &[&'static str],
3912                pool: &::rustango::sql::Pool,
3913            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3914                if rows.is_empty() {
3915                    return ::core::result::Result::Ok(());
3916                }
3917                let mut _all_rows: ::std::vec::Vec<
3918                    ::std::vec::Vec<::rustango::core::SqlValue>,
3919                > = ::std::vec::Vec::with_capacity(rows.len());
3920                for _row in rows.iter() {
3921                    let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
3922                        ::std::vec::Vec::new();
3923                    #( #upsert_pushes )*
3924                    _all_rows.push(_row_vals);
3925                }
3926                let _query = ::rustango::core::BulkInsertQuery {
3927                    model: <Self as ::rustango::core::Model>::SCHEMA,
3928                    columns: ::std::vec![ #( #upsert_cols ),* ],
3929                    rows: _all_rows,
3930                    returning: ::std::vec::Vec::new(),
3931                    on_conflict: ::core::option::Option::Some(
3932                        ::rustango::core::ConflictClause::DoUpdate {
3933                            target: target.to_vec(),
3934                            update_columns: update_cols.to_vec(),
3935                        }
3936                    ),
3937                };
3938                ::rustango::sql::bulk_insert_pool(pool, &_query).await
3939            }
3940
3941            /// Tri-dialect `bulk_create(ignore_conflicts=True)` — silently
3942            /// skip rows that would violate a unique constraint. Issue
3943            /// #267 / T1.5. Same per-dialect dispatch as
3944            /// [`Self::bulk_upsert_pool`] but with `ON CONFLICT … DO
3945            /// NOTHING` (Postgres / SQLite) / `ON DUPLICATE KEY UPDATE
3946            /// <pivot> = <pivot>` (MySQL no-op write).
3947            ///
3948            /// # Errors
3949            /// As [`Self::bulk_upsert_pool`].
3950            pub async fn bulk_insert_or_ignore_pool(
3951                rows: &[Self],
3952                pool: &::rustango::sql::Pool,
3953            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3954                if rows.is_empty() {
3955                    return ::core::result::Result::Ok(());
3956                }
3957                let mut _all_rows: ::std::vec::Vec<
3958                    ::std::vec::Vec<::rustango::core::SqlValue>,
3959                > = ::std::vec::Vec::with_capacity(rows.len());
3960                for _row in rows.iter() {
3961                    let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
3962                        ::std::vec::Vec::new();
3963                    #( #upsert_pushes )*
3964                    _all_rows.push(_row_vals);
3965                }
3966                let _query = ::rustango::core::BulkInsertQuery {
3967                    model: <Self as ::rustango::core::Model>::SCHEMA,
3968                    columns: ::std::vec![ #( #upsert_cols ),* ],
3969                    rows: _all_rows,
3970                    returning: ::std::vec::Vec::new(),
3971                    on_conflict: ::core::option::Option::Some(
3972                        ::rustango::core::ConflictClause::DoNothing
3973                    ),
3974                };
3975                ::rustango::sql::bulk_insert_pool(pool, &_query).await
3976            }
3977        }
3978    };
3979
3980    let pk_value_helper = primary_key.map(|(pk_ident, _)| {
3981        quote! {
3982            /// Hidden runtime accessor for the primary-key value as a
3983            /// [`SqlValue`]. Used by reverse-relation helpers
3984            /// (`<parent>::<child>_set`) emitted from sibling models'
3985            /// FK fields. Not part of the public API.
3986            #[doc(hidden)]
3987            pub fn __rustango_pk_value(&self) -> ::rustango::core::SqlValue {
3988                ::core::convert::Into::<::rustango::core::SqlValue>::into(
3989                    ::core::clone::Clone::clone(&self.#pk_ident)
3990                )
3991            }
3992        }
3993    });
3994
3995    let has_pk_value_impl = primary_key.map(|(pk_ident, _)| {
3996        quote! {
3997            impl ::rustango::sql::HasPkValue for #struct_name {
3998                fn __rustango_pk_value_impl(&self) -> ::rustango::core::SqlValue {
3999                    ::core::convert::Into::<::rustango::core::SqlValue>::into(
4000                        ::core::clone::Clone::clone(&self.#pk_ident)
4001                    )
4002                }
4003            }
4004        }
4005    });
4006
4007    let fk_pk_access_impl = fk_pk_access_impl_tokens(struct_name, &fields.fk_relations);
4008
4009    // Slice 17.1 — `AssignAutoPkPool` impl lets `apply_auto_pk`
4010    // dispatch to the right per-backend body without the macro emitting
4011    // any `#[cfg(feature = …)]` arm into consumer code. Always emitted
4012    // so audited models with non-Auto PKs (which still go through
4013    // `insert_one_with_audit` → `apply_auto_pk`) link.
4014    let assign_auto_pk_pool_impl = {
4015        let auto_assigns = &fields.auto_assigns;
4016        // SQLite ≥ 3.35 supports the same RETURNING shape as Postgres,
4017        // so the body is structurally identical to `auto_assigns` —
4018        // only the helper name swaps from `try_get_returning` to
4019        // `try_get_returning_sqlite` so the closure typechecks against
4020        // a `SqliteRow` instead of a `PgRow`.
4021        let auto_assigns_sqlite: Vec<TokenStream2> = fields
4022            .auto_field_idents
4023            .iter()
4024            .map(|(ident, column)| {
4025                quote! {
4026                    self.#ident = ::rustango::sql::try_get_returning_sqlite(
4027                        _returning_row, #column
4028                    )?;
4029                }
4030            })
4031            .collect();
4032        let mysql_body = if let Some(first) = fields.first_auto_ident.as_ref() {
4033            // The MySQL `LAST_INSERT_ID()` is always i64. Route through
4034            // `MysqlAutoIdSet` so Auto<i32> narrows safely and
4035            // Auto<Uuid>/etc. fail to link against MySQL (intended —
4036            // those models can't use AUTO_INCREMENT). The trait is only
4037            // touched on the MySQL arm at runtime, so PG-only consumers
4038            // never see the bound failure.
4039            //
4040            // Pre-v0.20: models with multiple `Auto<T>` fields (e.g.
4041            // Auto<i64> PK + auto_now_add timestamp) errored hard at
4042            // runtime with "multi-column RETURNING". MySQL has no
4043            // multi-column RETURNING semantic and a follow-up SELECT
4044            // would need cross-trait plumbing. Pragmatic shape: succeed
4045            // with the FIRST Auto field populated from LAST_INSERT_ID();
4046            // any other Auto fields stay `Auto::Unset`. Callers that
4047            // need the DB-defaulted timestamp / UUID can re-fetch the
4048            // row by PK after `save_pool`. Fixes the cookbook chapter
4049            // 12 dialect divergence.
4050            let value_ty = fields
4051                .first_auto_value_ty
4052                .as_ref()
4053                .expect("first_auto_value_ty set whenever first_auto_ident is");
4054            quote! {
4055                let _converted = <#value_ty as ::rustango::sql::MysqlAutoIdSet>
4056                    ::rustango_from_mysql_auto_id(_id)?;
4057                self.#first = ::rustango::sql::Auto::Set(_converted);
4058                ::core::result::Result::Ok(())
4059            }
4060        } else {
4061            quote! {
4062                let _ = _id;
4063                ::core::result::Result::Ok(())
4064            }
4065        };
4066        quote! {
4067            impl ::rustango::sql::AssignAutoPkPool for #struct_name {
4068                fn __rustango_assign_from_pg_row(
4069                    &mut self,
4070                    _returning_row: &::rustango::sql::PgReturningRow,
4071                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
4072                    #( #auto_assigns )*
4073                    ::core::result::Result::Ok(())
4074                }
4075                fn __rustango_assign_from_mysql_id(
4076                    &mut self,
4077                    _id: i64,
4078                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
4079                    #mysql_body
4080                }
4081                fn __rustango_assign_from_sqlite_row(
4082                    &mut self,
4083                    _returning_row: &::rustango::sql::SqliteReturningRow,
4084                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
4085                    #( #auto_assigns_sqlite )*
4086                    ::core::result::Result::Ok(())
4087                }
4088            }
4089        }
4090    };
4091
4092    let from_aliased_row_inits = &fields.from_aliased_row_inits;
4093    let aliased_row_helper = quote! {
4094        /// Decode a row's aliased target columns (produced by
4095        /// `select_related`'s LEFT JOIN) into a fresh instance of
4096        /// this model. Reads each column via
4097        /// `format!("{prefix}__{col}")`, matching the alias the
4098        /// SELECT writer emitted. Slice 9.0d.
4099        #[doc(hidden)]
4100        #[cfg(feature = "postgres")]
4101        pub fn __rustango_from_aliased_row(
4102            row: &::rustango::sql::sqlx::postgres::PgRow,
4103            prefix: &str,
4104        ) -> ::core::result::Result<Self, ::rustango::sql::sqlx::Error> {
4105            ::core::result::Result::Ok(Self {
4106                #( #from_aliased_row_inits ),*
4107            })
4108        }
4109    };
4110    // v0.23.0-batch8 — MySQL counterpart, gated through the
4111    // cfg-aware macro_rules so PG-only builds expand to nothing.
4112    let aliased_row_helper_my = quote! {
4113        ::rustango::__impl_my_aliased_row_decoder!(#struct_name, |row, prefix| {
4114            #( #from_aliased_row_inits ),*
4115        });
4116    };
4117
4118    // v0.27 Phase 3 — SQLite counterpart, same hygiene-aware closure
4119    // pattern + cfg gate on the `sqlite` feature.
4120    let aliased_row_helper_sqlite = quote! {
4121        ::rustango::__impl_sqlite_aliased_row_decoder!(#struct_name, |row, prefix| {
4122            #( #from_aliased_row_inits ),*
4123        });
4124    };
4125
4126    let load_related_impl = load_related_impl_tokens(struct_name, &fields.fk_relations);
4127    let load_related_impl_my = load_related_impl_my_tokens(struct_name, &fields.fk_relations);
4128    let load_related_impl_sqlite =
4129        load_related_impl_sqlite_tokens(struct_name, &fields.fk_relations);
4130
4131    // Issue #289 / T2.6 — `#[rustango(manager_fn = "active")]` emits
4132    // extra `Self::<name>() -> QuerySet<Self>` accessors next to the
4133    // default `Self::objects()`. Each accessor returns a fresh
4134    // QuerySet that resolves any `impl <FooManagerExt> for QuerySet<Foo>`
4135    // methods the user defined.
4136    let extra_manager_fns: Vec<TokenStream2> = manager_fns
4137        .iter()
4138        .map(|fn_ident| {
4139            let model_name_str = struct_name.to_string();
4140            let fn_name_str = fn_ident.to_string();
4141            let doc = format!(
4142                "Custom-named QuerySet accessor for [`{model_name_str}`]. \
4143                 Generated by `#[rustango(manager_fn = \"{fn_name_str}\")]` — \
4144                 equivalent to `Self::objects()`. Chains with any \
4145                 `impl ... for QuerySet<{model_name_str}> {{ ... }}` \
4146                 extension methods."
4147            );
4148            quote! {
4149                #[doc = #doc]
4150                #[must_use]
4151                pub fn #fn_ident() -> ::rustango::query::QuerySet<#struct_name> {
4152                    ::rustango::query::QuerySet::new()
4153                }
4154            }
4155        })
4156        .collect();
4157
4158    quote! {
4159        impl #struct_name {
4160            /// Start a new `QuerySet` over this model.
4161            #[must_use]
4162            pub fn objects() -> ::rustango::query::QuerySet<#struct_name> {
4163                ::rustango::query::QuerySet::new()
4164            }
4165
4166            #( #extra_manager_fns )*
4167
4168            #insert_method
4169
4170            #bulk_insert_method
4171
4172            #bulk_upsert_pool_method
4173
4174            #save_method
4175
4176            #pk_methods
4177
4178            #pk_value_helper
4179
4180            #aliased_row_helper
4181
4182            #column_consts
4183        }
4184
4185        #aliased_row_helper_my
4186
4187        #aliased_row_helper_sqlite
4188
4189        #load_related_impl
4190
4191        #load_related_impl_my
4192
4193        #load_related_impl_sqlite
4194
4195        #has_pk_value_impl
4196
4197        #fk_pk_access_impl
4198
4199        #assign_auto_pk_pool_impl
4200    }
4201}
4202
4203/// Per-row Auto-field assigns for `bulk_insert` — equivalent to
4204/// `auto_assigns` but reading from `_returning_row` and writing to
4205/// `_row_mut` instead of `self`.
4206fn bulk_auto_assigns_for_row(fields: &CollectedFields) -> TokenStream2 {
4207    let lines = fields.auto_field_idents.iter().map(|(ident, column)| {
4208        let col_lit = column.as_str();
4209        quote! {
4210            _row_mut.#ident = ::rustango::sql::sqlx::Row::try_get(
4211                _returning_row,
4212                #col_lit,
4213            )?;
4214        }
4215    });
4216    quote! { #( #lines )* }
4217}
4218
4219/// Emit `pub const id: …Id = …Id;` per field, inside the inherent impl.
4220fn column_const_tokens(module_ident: &syn::Ident, entries: &[ColumnEntry]) -> TokenStream2 {
4221    let lines = entries.iter().map(|e| {
4222        let ident = &e.ident;
4223        let col_ty = column_type_ident(ident);
4224        quote! {
4225            #[allow(non_upper_case_globals)]
4226            pub const #ident: #module_ident::#col_ty = #module_ident::#col_ty;
4227        }
4228    });
4229    quote! { #(#lines)* }
4230}
4231
4232/// Emit a hidden per-model module carrying one zero-sized type per field,
4233/// each with a `Column` impl pointing back at the model.
4234fn column_module_tokens(
4235    module_ident: &syn::Ident,
4236    struct_name: &syn::Ident,
4237    entries: &[ColumnEntry],
4238) -> TokenStream2 {
4239    let items = entries.iter().map(|e| {
4240        let col_ty = column_type_ident(&e.ident);
4241        let value_ty = &e.value_ty;
4242        let name = &e.name;
4243        let column = &e.column;
4244        let field_type_tokens = &e.field_type_tokens;
4245        quote! {
4246            #[derive(::core::clone::Clone, ::core::marker::Copy)]
4247            pub struct #col_ty;
4248
4249            impl ::rustango::core::Column for #col_ty {
4250                type Model = super::#struct_name;
4251                type Value = #value_ty;
4252                const NAME: &'static str = #name;
4253                const COLUMN: &'static str = #column;
4254                const FIELD_TYPE: ::rustango::core::FieldType = #field_type_tokens;
4255            }
4256        }
4257    });
4258    quote! {
4259        #[doc(hidden)]
4260        #[allow(non_camel_case_types, non_snake_case)]
4261        pub mod #module_ident {
4262            // Re-import the parent scope so field types referencing
4263            // sibling models (e.g. `ForeignKey<Author>`) resolve
4264            // inside this submodule. Without this we'd hit
4265            // `proc_macro_derive_resolution_fallback` warnings.
4266            #[allow(unused_imports)]
4267            use super::*;
4268            #(#items)*
4269        }
4270    }
4271}
4272
4273fn column_type_ident(field_ident: &syn::Ident) -> syn::Ident {
4274    syn::Ident::new(&format!("{field_ident}_col"), field_ident.span())
4275}
4276
4277fn column_module_ident(struct_name: &syn::Ident) -> syn::Ident {
4278    syn::Ident::new(
4279        &format!("__rustango_cols_{struct_name}"),
4280        struct_name.span(),
4281    )
4282}
4283
4284fn from_row_impl_tokens(struct_name: &syn::Ident, from_row_inits: &[TokenStream2]) -> TokenStream2 {
4285    // The Postgres impl is always emitted — every rustango build pulls in
4286    // sqlx-postgres via the default `postgres` feature. The MySQL impl is
4287    // routed through `::rustango::__impl_my_from_row!`, a cfg-gated
4288    // macro_rules whose body collapses to nothing when rustango is built
4289    // without the `mysql` feature. No user-facing feature shim required.
4290    //
4291    // The macro_rules pattern expects `[ field: expr, … ]` — we need to
4292    // re-shape `from_row_inits` (each token is `field: row.try_get(...)`)
4293    // back into a comma-separated list inside square brackets. Since each
4294    // entry is already in `field: expr` shape, the existing tokens slot in.
4295    quote! {
4296        #[cfg(feature = "postgres")]
4297        impl<'r> ::rustango::sql::sqlx::FromRow<'r, ::rustango::sql::sqlx::postgres::PgRow>
4298            for #struct_name
4299        {
4300            fn from_row(
4301                row: &'r ::rustango::sql::sqlx::postgres::PgRow,
4302            ) -> ::core::result::Result<Self, ::rustango::sql::sqlx::Error> {
4303                ::core::result::Result::Ok(Self {
4304                    #( #from_row_inits ),*
4305                })
4306            }
4307        }
4308
4309        ::rustango::__impl_my_from_row!(#struct_name, |row| {
4310            #( #from_row_inits ),*
4311        });
4312
4313        ::rustango::__impl_sqlite_from_row!(#struct_name, |row| {
4314            #( #from_row_inits ),*
4315        });
4316    }
4317}
4318
4319struct ContainerAttrs {
4320    table: Option<String>,
4321    display: Option<(String, proc_macro2::Span)>,
4322    /// Explicit Django-style app label from `#[rustango(app = "blog")]`.
4323    /// Recorded on the emitted `ModelSchema.app_label`. When unset,
4324    /// `ModelEntry::resolved_app_label()` infers from `module_path!()`
4325    /// at runtime — this attribute is the override for cases where
4326    /// the inference is wrong (e.g. a model that conceptually belongs
4327    /// to one app but is physically in another module).
4328    app: Option<String>,
4329    /// Django ModelAdmin-shape per-model knobs from
4330    /// `#[rustango(admin(...))]`. `None` when the user didn't write the
4331    /// attribute — the emitted `ModelSchema.admin` becomes `None` and
4332    /// admin code falls back to `AdminConfig::DEFAULT`.
4333    admin: Option<AdminAttrs>,
4334    /// Per-model audit configuration from `#[rustango(audit(...))]`.
4335    /// `None` when the model isn't audited — write paths emit no
4336    /// audit entries. When present, single-row writes capture
4337    /// before/after for the listed fields and bulk writes batch
4338    /// snapshots into one INSERT into `rustango_audit_log`.
4339    audit: Option<AuditAttrs>,
4340    /// `true` when `#[rustango(permissions)]` is present. Signals that
4341    /// `auto_create_permissions` should seed the four CRUD codenames for
4342    /// this model.
4343    permissions: bool,
4344    /// Many-to-many relations declared via
4345    /// `#[rustango(m2m(name = "tags", to = "app_tags", through = "post_tags",
4346    ///                 src = "post_id", dst = "tag_id"))]`.
4347    m2m: Vec<M2MAttr>,
4348    /// Composite indexes declared via
4349    /// `#[rustango(index("col1, col2"))]` or
4350    /// `#[rustango(index("col1, col2", unique, name = "my_idx"))]`.
4351    /// Single-column indexes from `#[rustango(index)]` on fields are
4352    /// accumulated here during field collection.
4353    indexes: Vec<IndexAttr>,
4354    /// Table-level CHECK constraints declared via
4355    /// `#[rustango(check(name = "…", expr = "…"))]`.
4356    checks: Vec<CheckAttr>,
4357    /// Composite (multi-column) FKs declared via
4358    /// `#[rustango(fk_composite(name = "…", to = "…", on = (…), from = (…)))]`.
4359    /// Sub-slice F.2 of the v0.15.0 ContentType plan.
4360    composite_fks: Vec<CompositeFkAttr>,
4361    /// Generic ("any model") FKs declared via
4362    /// `#[rustango(generic_fk(name = "…", ct_column = "…", pk_column = "…"))]`.
4363    /// Sub-slice F.4 of the v0.15.0 ContentType plan.
4364    generic_fks: Vec<GenericFkAttr>,
4365    /// Where this model lives in a tenancy deployment, declared via
4366    /// `#[rustango(scope = "registry")]` or `#[rustango(scope = "tenant")]`.
4367    /// Defaults to `"tenant"` when unset; `makemigrations` uses this
4368    /// to partition diff output between registry-scoped and
4369    /// tenant-scoped migration files.
4370    scope: Option<String>,
4371    /// Custom-Manager extension-trait name from
4372    /// `#[rustango(manager(ext = "FooManagerExt"))]`. Issue #271 / T1.9.
4373    /// When set, the macro emits an empty `pub trait <name>: Sized {}`
4374    /// adjacent to the model so users can write
4375    /// `impl FooManagerExt for QuerySet<Foo> { fn published(self) -> Self ... }`
4376    /// and discover the convention from the model definition.
4377    manager_ext: Option<syn::Ident>,
4378    /// Extra QuerySet accessor names from
4379    /// `#[rustango(manager_fn = "active")]`. Issue #289 / T2.6.
4380    /// Each value adds a `pub fn <name>() -> QuerySet<Self>` next to
4381    /// the default `Self::objects()`. Multiple attributes allowed.
4382    manager_fns: Vec<syn::Ident>,
4383    /// Default ordering declared via `#[rustango(default_order =
4384    /// "-created_at, status")]`. Issue #291 / T2.5. Each entry is
4385    /// `(column_name, desc_flag, span_for_error_reporting)` — the
4386    /// `-` prefix means descending; the `+` prefix or no prefix means
4387    /// ascending.
4388    default_order: Vec<(String, bool, proc_macro2::Span)>,
4389    /// `true` when `#[rustango(view)]` is present. Issue #293 / T2.10.
4390    /// Routes the emitted schema's `is_view = true` so the migration
4391    /// snapshot skips this model (its underlying SQL view is operator-
4392    /// managed, not rustango-managed).
4393    is_view: bool,
4394    /// `#[rustango(verbose_name = "blog post")]` — Django-shape
4395    /// human-readable singular label for the model. Threaded into
4396    /// `ModelSchema::verbose_name` so admin section headers /
4397    /// breadcrumbs / "Add X" buttons can prefer the friendly caption
4398    /// over the Rust struct identifier.
4399    verbose_name: Option<String>,
4400    /// `#[rustango(verbose_name_plural = "blog posts")]` — explicit
4401    /// plural form. Threaded into `ModelSchema::verbose_name_plural`.
4402    /// When unset, `display_label_plural()` auto-suffixes `s`.
4403    verbose_name_plural: Option<String>,
4404}
4405
4406/// Parsed form of one index declaration (field-level or container-level).
4407struct IndexAttr {
4408    /// Index name; auto-derived when `None` at parse time.
4409    name: Option<String>,
4410    /// Column names in the index.
4411    columns: Vec<String>,
4412    /// `true` for `CREATE UNIQUE INDEX`.
4413    unique: bool,
4414    /// Access method token (`"btree"`, `"gin"`, `"gist"`, `"brin"`,
4415    /// `"spgist"`, `"hash"`, `"bloom"`). Issue #34. Defaults to
4416    /// `"btree"` when the attribute is absent — the DDL writer omits
4417    /// the `USING` clause and the backend uses its own default
4418    /// (btree on every supported dialect).
4419    method: String,
4420    /// Optional `WHERE <expr>` clause for partial indexes. Issue #265 /
4421    /// T1.3. Set via `#[rustango(unique_when(columns = "...",
4422    /// condition = "...", name = "..."))]`. `None` for plain indexes.
4423    where_clause: Option<String>,
4424}
4425
4426/// Parsed form of one `#[rustango(check(name = "…", expr = "…"))]` declaration.
4427struct CheckAttr {
4428    name: String,
4429    expr: String,
4430}
4431
4432/// Parsed form of one `#[rustango(fk_composite(name = "audit_target",
4433/// to = "rustango_audit_log", on = ("entity_table", "entity_pk"),
4434/// from = ("table_name", "row_pk")))]` declaration. Sub-slice F.2 of
4435/// the v0.15.0 ContentType plan — multi-column foreign keys live on
4436/// the model, not the field.
4437struct CompositeFkAttr {
4438    /// Logical relation name (free-form Rust identifier).
4439    name: String,
4440    /// SQL table name of the target.
4441    to: String,
4442    /// Source-side column names, in declaration order.
4443    from: Vec<String>,
4444    /// Target-side column names, same length / order as `from`.
4445    on: Vec<String>,
4446}
4447
4448/// Parsed form of one `#[rustango(generic_fk(name = "target",
4449/// ct_column = "content_type_id", pk_column = "object_pk"))]`
4450/// declaration. Sub-slice F.4 of the v0.15.0 ContentType plan —
4451/// generic ("any model") FKs live on the model, not the field.
4452struct GenericFkAttr {
4453    /// Logical relation name (free-form Rust identifier).
4454    name: String,
4455    /// Source-side column carrying the `content_type_id` value.
4456    ct_column: String,
4457    /// Source-side column carrying the target row's primary key.
4458    pk_column: String,
4459}
4460
4461/// Parsed form of one `#[rustango(m2m(...))]` declaration.
4462struct M2MAttr {
4463    /// Accessor suffix: `tags` → generates `tags_m2m()`.
4464    name: String,
4465    /// Target table (e.g. `"app_tags"`).
4466    to: String,
4467    /// Junction table (e.g. `"post_tags"`).
4468    through: String,
4469    /// Source FK column in the junction table (e.g. `"post_id"`).
4470    src: String,
4471    /// Destination FK column in the junction table (e.g. `"tag_id"`).
4472    dst: String,
4473    /// Whether the migration writer should auto-create the junction
4474    /// table. Default `true`. Set `auto_create = false` (#324) when
4475    /// the operator declares the through table as its own
4476    /// `#[derive(Model)]` struct with extra columns.
4477    auto_create: bool,
4478}
4479
4480/// Parsed shape of `#[rustango(audit(track = "name, body", source =
4481/// "user"))]`. `track` is a comma-separated list of field names whose
4482/// before/after values land in the JSONB `changes` column. `source`
4483/// is informational only — it pins a default source when the model
4484/// is written outside any `audit::with_source(...)` scope (rare).
4485#[derive(Default)]
4486struct AuditAttrs {
4487    /// Field names to capture in the `changes` JSONB. Validated
4488    /// against declared scalar fields at compile time. Empty means
4489    /// "track every scalar field" — Django's audit-everything default.
4490    track: Option<(Vec<String>, proc_macro2::Span)>,
4491}
4492
4493/// Parsed shape of `#[rustango(admin(list_display = "…", search_fields =
4494/// "…", list_per_page = N, ordering = "…"))]`. Field-name lists are
4495/// comma-separated strings; we validate each ident against the model's
4496/// declared fields at compile time.
4497#[derive(Default)]
4498struct AdminAttrs {
4499    list_display: Option<(Vec<String>, proc_macro2::Span)>,
4500    search_fields: Option<(Vec<String>, proc_macro2::Span)>,
4501    list_per_page: Option<usize>,
4502    ordering: Option<(Vec<(String, bool)>, proc_macro2::Span)>,
4503    readonly_fields: Option<(Vec<String>, proc_macro2::Span)>,
4504    list_filter: Option<(Vec<String>, proc_macro2::Span)>,
4505    /// Bulk action names. No field-validation against model fields —
4506    /// these are action handlers, not column references.
4507    actions: Option<(Vec<String>, proc_macro2::Span)>,
4508    /// Form fieldsets — `Vec<(title, [field_names])>`. Pipe-separated
4509    /// sections, comma-separated fields per section, optional
4510    /// `Title:` prefix. Empty title omits the `<legend>`.
4511    fieldsets: Option<(Vec<(String, Vec<String>)>, proc_macro2::Span)>,
4512    /// `admin(list_display_links = "title")` — Django-shape. Names
4513    /// from `list_display` whose cells should link to detail/edit.
4514    /// Issue #350.
4515    list_display_links: Option<(Vec<String>, proc_macro2::Span)>,
4516    /// `admin(search_help_text = "...")` — Django-shape. Short
4517    /// caption rendered beside the admin list view's search box.
4518    /// Issue #353.
4519    search_help_text: Option<String>,
4520    /// `admin(actions_on_top = false)` — Django-shape. Hides the
4521    /// action-bar above the table. Default `true`. Issue #354.
4522    actions_on_top: Option<bool>,
4523    /// `admin(actions_on_bottom = true)` — Django-shape. Renders an
4524    /// additional action-bar below the table. Default `false`.
4525    /// Issue #354.
4526    actions_on_bottom: Option<bool>,
4527    /// `admin(date_hierarchy = "created_at")` — Django-shape. Name of
4528    /// a date / datetime field whose values render as a clickable
4529    /// year / month / day drill-down strip above the list table.
4530    /// Empty / unset disables the strip. Issue #355.
4531    date_hierarchy: Option<String>,
4532    /// `admin(prepopulated_fields = "slug:title")` — Django-shape.
4533    /// Each entry is `target:source[+source2]`; multiple entries are
4534    /// comma-separated, e.g. `"slug:title,short_code:section+title"`.
4535    /// The admin change-form emits JS that slugifies the source values
4536    /// into the target field on every keystroke. Issue #356.
4537    prepopulated_fields: Option<(Vec<(String, Vec<String>)>, proc_macro2::Span)>,
4538    /// `admin(raw_id_fields = "parent, owner")` — Django-shape. Names
4539    /// of FK fields whose change-form widget renders a lookup link
4540    /// next to the input. Issue #357.
4541    raw_id_fields: Option<(Vec<String>, proc_macro2::Span)>,
4542    /// `admin(autocomplete_fields = "author_id")` — Django-shape.
4543    /// Names of FK fields whose change-form widget renders an
4544    /// Ajax-driven typeahead populated from a `__autocomplete`
4545    /// endpoint on the target model. Issue #358.
4546    autocomplete_fields: Option<(Vec<String>, proc_macro2::Span)>,
4547    /// `admin(list_select_related = "all" | "none" | "author, …")`
4548    /// — Django-shape. Tunes the admin list view's FK auto-JOIN
4549    /// policy. Default `"all"` matches rustango's join-everything
4550    /// behavior; `"none"` opts out; CSV restricts. Issue #352.
4551    list_select_related: Option<String>,
4552    /// `admin(formfield_overrides = "field:widget, field2:widget2")` —
4553    /// Django-shape. Each entry is `field_name:widget_name`; multiple
4554    /// entries comma-separated. Empty / unset → no overrides. The
4555    /// list of widget names supported is documented on
4556    /// `AdminConfig::formfield_overrides`. Issue #359.
4557    formfield_overrides: Option<(Vec<(String, String)>, proc_macro2::Span)>,
4558}
4559
4560fn parse_container_attrs(input: &DeriveInput) -> syn::Result<ContainerAttrs> {
4561    let mut out = ContainerAttrs {
4562        table: None,
4563        display: None,
4564        app: None,
4565        admin: None,
4566        audit: None,
4567        // Default `permissions = true` so every `#[derive(Model)]`
4568        // gets the four CRUD codenames seeded by `auto_create_permissions`
4569        // and is visible to non-superusers in the tenant admin without
4570        // manual per-model annotation. Models that intentionally don't
4571        // want permission rows (registry-internal types, framework
4572        // tables operators shouldn't manage directly) opt out via
4573        // `#[rustango(permissions = false)]`. v0.27.2 — fixes the
4574        // out-of-the-box admin invisibility regression (#62).
4575        permissions: true,
4576        m2m: Vec::new(),
4577        indexes: Vec::new(),
4578        checks: Vec::new(),
4579        composite_fks: Vec::new(),
4580        generic_fks: Vec::new(),
4581        scope: None,
4582        manager_ext: None,
4583        manager_fns: Vec::new(),
4584        default_order: Vec::new(),
4585        is_view: false,
4586        verbose_name: None,
4587        verbose_name_plural: None,
4588    };
4589    for attr in &input.attrs {
4590        if !attr.path().is_ident("rustango") {
4591            continue;
4592        }
4593        attr.parse_nested_meta(|meta| {
4594            if meta.path.is_ident("table") {
4595                let s: LitStr = meta.value()?.parse()?;
4596                let name = s.value();
4597                // v0.27.3 (#65) — macro-time guard against table names
4598                // that compile but break SQL downstream. Hyphens are
4599                // the common footgun: PostgreSQL accepts a quoted
4600                // `"intermediate-region"` in CREATE TABLE, but the
4601                // FK / index name derivation in `migrate::ddl`
4602                // emits `intermediate-region_field_fkey` unquoted,
4603                // which then fails the SQL parser. Same shape rule
4604                // as Postgres regular identifiers so the safe path
4605                // is the only path.
4606                validate_table_name(&name, s.span())?;
4607                out.table = Some(name);
4608                return Ok(());
4609            }
4610            if meta.path.is_ident("display") {
4611                let s: LitStr = meta.value()?.parse()?;
4612                out.display = Some((s.value(), s.span()));
4613                return Ok(());
4614            }
4615            if meta.path.is_ident("app") {
4616                let s: LitStr = meta.value()?.parse()?;
4617                out.app = Some(s.value());
4618                return Ok(());
4619            }
4620            if meta.path.is_ident("scope") {
4621                let s: LitStr = meta.value()?.parse()?;
4622                let val = s.value();
4623                if !matches!(val.to_ascii_lowercase().as_str(), "registry" | "tenant") {
4624                    return Err(meta.error(format!(
4625                        "`scope` must be \"registry\" or \"tenant\", got {val:?}"
4626                    )));
4627                }
4628                out.scope = Some(val);
4629                return Ok(());
4630            }
4631            if meta.path.is_ident("admin") {
4632                let mut admin = AdminAttrs::default();
4633                meta.parse_nested_meta(|inner| {
4634                    if inner.path.is_ident("list_display") {
4635                        let s: LitStr = inner.value()?.parse()?;
4636                        admin.list_display =
4637                            Some((split_field_list(&s.value()), s.span()));
4638                        return Ok(());
4639                    }
4640                    if inner.path.is_ident("search_fields") {
4641                        let s: LitStr = inner.value()?.parse()?;
4642                        admin.search_fields =
4643                            Some((split_field_list(&s.value()), s.span()));
4644                        return Ok(());
4645                    }
4646                    if inner.path.is_ident("readonly_fields") {
4647                        let s: LitStr = inner.value()?.parse()?;
4648                        admin.readonly_fields =
4649                            Some((split_field_list(&s.value()), s.span()));
4650                        return Ok(());
4651                    }
4652                    if inner.path.is_ident("list_per_page") {
4653                        let lit: syn::LitInt = inner.value()?.parse()?;
4654                        admin.list_per_page = Some(lit.base10_parse::<usize>()?);
4655                        return Ok(());
4656                    }
4657                    if inner.path.is_ident("ordering") {
4658                        let s: LitStr = inner.value()?.parse()?;
4659                        admin.ordering = Some((
4660                            parse_ordering_list(&s.value()),
4661                            s.span(),
4662                        ));
4663                        return Ok(());
4664                    }
4665                    if inner.path.is_ident("list_filter") {
4666                        let s: LitStr = inner.value()?.parse()?;
4667                        admin.list_filter =
4668                            Some((split_field_list(&s.value()), s.span()));
4669                        return Ok(());
4670                    }
4671                    if inner.path.is_ident("actions") {
4672                        let s: LitStr = inner.value()?.parse()?;
4673                        admin.actions =
4674                            Some((split_field_list(&s.value()), s.span()));
4675                        return Ok(());
4676                    }
4677                    if inner.path.is_ident("fieldsets") {
4678                        let s: LitStr = inner.value()?.parse()?;
4679                        admin.fieldsets =
4680                            Some((parse_fieldset_list(&s.value()), s.span()));
4681                        return Ok(());
4682                    }
4683                    if inner.path.is_ident("list_display_links") {
4684                        let s: LitStr = inner.value()?.parse()?;
4685                        admin.list_display_links =
4686                            Some((split_field_list(&s.value()), s.span()));
4687                        return Ok(());
4688                    }
4689                    if inner.path.is_ident("search_help_text") {
4690                        let s: LitStr = inner.value()?.parse()?;
4691                        admin.search_help_text = Some(s.value());
4692                        return Ok(());
4693                    }
4694                    if inner.path.is_ident("actions_on_top") {
4695                        let lit: syn::LitBool = inner.value()?.parse()?;
4696                        admin.actions_on_top = Some(lit.value);
4697                        return Ok(());
4698                    }
4699                    if inner.path.is_ident("actions_on_bottom") {
4700                        let lit: syn::LitBool = inner.value()?.parse()?;
4701                        admin.actions_on_bottom = Some(lit.value);
4702                        return Ok(());
4703                    }
4704                    if inner.path.is_ident("date_hierarchy") {
4705                        let s: LitStr = inner.value()?.parse()?;
4706                        admin.date_hierarchy = Some(s.value());
4707                        return Ok(());
4708                    }
4709                    if inner.path.is_ident("prepopulated_fields") {
4710                        let s: LitStr = inner.value()?.parse()?;
4711                        admin.prepopulated_fields =
4712                            Some((parse_prepopulated_list(&s.value()), s.span()));
4713                        return Ok(());
4714                    }
4715                    if inner.path.is_ident("raw_id_fields") {
4716                        let s: LitStr = inner.value()?.parse()?;
4717                        admin.raw_id_fields =
4718                            Some((split_field_list(&s.value()), s.span()));
4719                        return Ok(());
4720                    }
4721                    if inner.path.is_ident("autocomplete_fields") {
4722                        let s: LitStr = inner.value()?.parse()?;
4723                        admin.autocomplete_fields =
4724                            Some((split_field_list(&s.value()), s.span()));
4725                        return Ok(());
4726                    }
4727                    if inner.path.is_ident("list_select_related") {
4728                        let s: LitStr = inner.value()?.parse()?;
4729                        admin.list_select_related = Some(s.value());
4730                        return Ok(());
4731                    }
4732                    if inner.path.is_ident("formfield_overrides") {
4733                        let s: LitStr = inner.value()?.parse()?;
4734                        admin.formfield_overrides =
4735                            Some((parse_formfield_overrides(&s.value()), s.span()));
4736                        return Ok(());
4737                    }
4738                    Err(inner.error(
4739                        "unknown admin attribute (supported: \
4740                         `list_display`, `list_display_links`, \
4741                         `search_fields`, `search_help_text`, \
4742                         `readonly_fields`, \
4743                         `list_filter`, `list_per_page`, `ordering`, `actions`, \
4744                         `actions_on_top`, `actions_on_bottom`, \
4745                         `date_hierarchy`, \
4746                         `prepopulated_fields`, \
4747                         `raw_id_fields`, \
4748                         `autocomplete_fields`, \
4749                         `list_select_related`, \
4750                         `formfield_overrides`, \
4751                         `fieldsets`)",
4752                    ))
4753                })?;
4754                out.admin = Some(admin);
4755                return Ok(());
4756            }
4757            if meta.path.is_ident("manager") {
4758                // `#[rustango(manager(ext = "FooManagerExt"))]`. Issue #271 / T1.9.
4759                // Stretch `from_queryset = "..."` (Django Manager.from_queryset
4760                // shape) is left as a follow-up — the issue's primary
4761                // acceptance is the `ext = ...` trait emission.
4762                meta.parse_nested_meta(|inner| {
4763                    if inner.path.is_ident("ext") {
4764                        let s: LitStr = inner.value()?.parse()?;
4765                        let name = s.value();
4766                        if name.is_empty() {
4767                            return Err(inner.error("manager(ext = \"...\") cannot be empty"));
4768                        }
4769                        out.manager_ext =
4770                            Some(syn::Ident::new(&name, s.span()));
4771                        return Ok(());
4772                    }
4773                    Err(inner.error(
4774                        "unknown manager attribute (supported: `ext = \"TraitName\"`)",
4775                    ))
4776                })?;
4777                return Ok(());
4778            }
4779            if meta.path.is_ident("manager_fn") {
4780                // `#[rustango(manager_fn = "active")]` — issue #289 / T2.6.
4781                // Adds a `pub fn <name>() -> QuerySet<Self>` accessor
4782                // next to the default `Self::objects()`. Multiple
4783                // attributes accumulate.
4784                let s: LitStr = meta.value()?.parse()?;
4785                let name = s.value();
4786                if name.is_empty() {
4787                    return Err(meta.error("`manager_fn = \"...\"` cannot be empty"));
4788                }
4789                if name == "objects" {
4790                    return Err(meta.error(
4791                        "`manager_fn = \"objects\"` collides with the default \
4792                         accessor — pick a different name",
4793                    ));
4794                }
4795                let ident = syn::Ident::new(&name, s.span());
4796                if out.manager_fns.iter().any(|prev| *prev == ident) {
4797                    return Err(meta.error(format!(
4798                        "duplicate `manager_fn = \"{name}\"`"
4799                    )));
4800                }
4801                out.manager_fns.push(ident);
4802                return Ok(());
4803            }
4804            if meta.path.is_ident("default_order") {
4805                // `#[rustango(default_order = "-created_at, status")]`
4806                // — issue #291 / T2.5. Comma-separated list; `-prefix`
4807                // means descending, `+prefix` or bare name means ascending.
4808                // Per-query opt-in via `QuerySet::with_default_order()`.
4809                let s: LitStr = meta.value()?.parse()?;
4810                let raw = s.value();
4811                let span = s.span();
4812                let mut parsed: Vec<(String, bool, proc_macro2::Span)> =
4813                    Vec::new();
4814                for entry in raw.split(',') {
4815                    let trimmed = entry.trim();
4816                    if trimmed.is_empty() {
4817                        return Err(syn::Error::new(
4818                            span,
4819                            "`default_order = \"...\"` has an empty entry — \
4820                             check for a stray comma",
4821                        ));
4822                    }
4823                    let (desc, name) = if let Some(rest) = trimmed.strip_prefix('-') {
4824                        (true, rest.trim().to_owned())
4825                    } else if let Some(rest) = trimmed.strip_prefix('+') {
4826                        (false, rest.trim().to_owned())
4827                    } else {
4828                        (false, trimmed.to_owned())
4829                    };
4830                    if name.is_empty() {
4831                        return Err(syn::Error::new(
4832                            span,
4833                            "`default_order` entry has no column name after the prefix",
4834                        ));
4835                    }
4836                    if parsed.iter().any(|(n, _, _)| *n == name) {
4837                        return Err(syn::Error::new(
4838                            span,
4839                            format!("duplicate column `{name}` in `default_order`"),
4840                        ));
4841                    }
4842                    parsed.push((name, desc, span));
4843                }
4844                if parsed.is_empty() {
4845                    return Err(syn::Error::new(
4846                        span,
4847                        "`default_order = \"...\"` cannot be empty",
4848                    ));
4849                }
4850                out.default_order = parsed;
4851                return Ok(());
4852            }
4853            if meta.path.is_ident("audit") {
4854                let mut audit = AuditAttrs::default();
4855                meta.parse_nested_meta(|inner| {
4856                    if inner.path.is_ident("track") {
4857                        let s: LitStr = inner.value()?.parse()?;
4858                        audit.track =
4859                            Some((split_field_list(&s.value()), s.span()));
4860                        return Ok(());
4861                    }
4862                    Err(inner.error(
4863                        "unknown audit attribute (supported: `track`)",
4864                    ))
4865                })?;
4866                out.audit = Some(audit);
4867                return Ok(());
4868            }
4869            if meta.path.is_ident("permissions") {
4870                // Two forms accepted:
4871                //   #[rustango(permissions)]          — flag form, true
4872                //   #[rustango(permissions = false)]  — explicit opt-out
4873                //   #[rustango(permissions = true)]   — explicit opt-in
4874                if let Ok(v) = meta.value() {
4875                    let lit: syn::LitBool = v.parse()?;
4876                    out.permissions = lit.value;
4877                } else {
4878                    out.permissions = true;
4879                }
4880                return Ok(());
4881            }
4882            if meta.path.is_ident("view") {
4883                // Issue #293 / T2.10. Two forms accepted, matching
4884                // the `permissions` flag pattern:
4885                //   #[rustango(view)]          — flag form, true
4886                //   #[rustango(view = false)]  — explicit opt-out
4887                //   #[rustango(view = true)]   — explicit opt-in
4888                if let Ok(v) = meta.value() {
4889                    let lit: syn::LitBool = v.parse()?;
4890                    out.is_view = lit.value;
4891                } else {
4892                    out.is_view = true;
4893                }
4894                return Ok(());
4895            }
4896            if meta.path.is_ident("verbose_name") {
4897                let s: LitStr = meta.value()?.parse()?;
4898                out.verbose_name = Some(s.value());
4899                return Ok(());
4900            }
4901            if meta.path.is_ident("verbose_name_plural") {
4902                let s: LitStr = meta.value()?.parse()?;
4903                out.verbose_name_plural = Some(s.value());
4904                return Ok(());
4905            }
4906            if meta.path.is_ident("unique_together") {
4907                // Django-shape composite UNIQUE index. Two syntaxes:
4908                //
4909                //   #[rustango(unique_together = "org_id, user_id")]                       — auto-derived name
4910                //   #[rustango(unique_together(columns = "org_id, user_id", name = "x"))]  — explicit name
4911                //
4912                // Both produce `CREATE UNIQUE INDEX <name> ON <table>
4913                // (col1, col2)`, where <name> defaults to
4914                // `<table>_<col1>_<col2>_uq` when not supplied.
4915                let (columns, name) = parse_together_attr(&meta, "unique_together")?;
4916                out.indexes.push(IndexAttr {
4917                    name,
4918                    columns,
4919                    unique: true,
4920                    method: "btree".to_owned(),
4921                    where_clause: None,
4922                });
4923                return Ok(());
4924            }
4925            if meta.path.is_ident("index_together") {
4926                // Django-shape composite (non-unique) index. Two syntaxes
4927                // mirroring `unique_together`.
4928                //
4929                //   #[rustango(index_together = "created_at, status")]
4930                //   #[rustango(index_together(columns = "created_at, status", name = "x"))]
4931                let (columns, name) = parse_together_attr(&meta, "index_together")?;
4932                out.indexes.push(IndexAttr {
4933                    name,
4934                    columns,
4935                    unique: false,
4936                    method: "btree".to_owned(),
4937                    where_clause: None,
4938                });
4939                return Ok(());
4940            }
4941            if meta.path.is_ident("unique_when") {
4942                // Django 4.0+ `UniqueConstraint(condition=Q(...))` —
4943                // partial unique index. Issue #265 / T1.3.
4944                //
4945                //   #[rustango(unique_when(
4946                //       columns   = "email",
4947                //       condition = "deleted_at IS NULL",
4948                //       name      = "unique_active_email"
4949                //   ))]
4950                //
4951                // → `CREATE UNIQUE INDEX <name> ON <table> (cols) WHERE <condition>`
4952                // on PG / SQLite (both ship partial indexes natively).
4953                // MySQL falls back to a plain UNIQUE index — the
4954                // condition is lost; document the limitation in the
4955                // generated migration.
4956                let mut columns: Option<Vec<String>> = None;
4957                let mut condition: Option<String> = None;
4958                let mut name: Option<String> = None;
4959                meta.parse_nested_meta(|inner| {
4960                    if inner.path.is_ident("columns") {
4961                        let s: LitStr = inner.value()?.parse()?;
4962                        columns = Some(split_field_list(&s.value()));
4963                        return Ok(());
4964                    }
4965                    if inner.path.is_ident("condition") {
4966                        let s: LitStr = inner.value()?.parse()?;
4967                        condition = Some(s.value());
4968                        return Ok(());
4969                    }
4970                    if inner.path.is_ident("name") {
4971                        let s: LitStr = inner.value()?.parse()?;
4972                        name = Some(s.value());
4973                        return Ok(());
4974                    }
4975                    Err(inner.error(
4976                        "unknown unique_when attribute (supported: \
4977                         `columns = \"...\"`, `condition = \"...\"`, \
4978                         `name = \"...\"`)",
4979                    ))
4980                })?;
4981                let columns = columns.ok_or_else(|| {
4982                    meta.error("`unique_when(...)` requires `columns = \"...\"`")
4983                })?;
4984                let condition = condition.ok_or_else(|| {
4985                    meta.error("`unique_when(...)` requires `condition = \"...\"`")
4986                })?;
4987                if columns.is_empty() {
4988                    return Err(meta.error("`unique_when(columns = \"\")` is empty"));
4989                }
4990                out.indexes.push(IndexAttr {
4991                    name,
4992                    columns,
4993                    unique: true,
4994                    method: "btree".to_owned(),
4995                    where_clause: Some(condition),
4996                });
4997                return Ok(());
4998            }
4999            if meta.path.is_ident("index") {
5000                // Container-level composite index — legacy entry that
5001                // was advertised with a trailing `, unique, name = ...`
5002                // flag block which doesn't actually compose under
5003                // `parse_nested_meta`. Prefer `unique_together` /
5004                // `index_together` (above) for new code. The bare
5005                // `index = "..."` form is kept for back-compat: it
5006                // emits a non-unique composite index.
5007                let cols_lit: LitStr = meta.value()?.parse()?;
5008                let columns = split_field_list(&cols_lit.value());
5009                out.indexes.push(IndexAttr {
5010                    name: None,
5011                    columns,
5012                    unique: false,
5013                    method: "btree".to_owned(),
5014                    where_clause: None,
5015                });
5016                return Ok(());
5017            }
5018            if meta.path.is_ident("check") {
5019                // #[rustango(check(name = "…", expr = "…"))]
5020                let mut name: Option<String> = None;
5021                let mut expr: Option<String> = None;
5022                meta.parse_nested_meta(|inner| {
5023                    if inner.path.is_ident("name") {
5024                        let s: LitStr = inner.value()?.parse()?;
5025                        name = Some(s.value());
5026                        return Ok(());
5027                    }
5028                    if inner.path.is_ident("expr") {
5029                        let s: LitStr = inner.value()?.parse()?;
5030                        expr = Some(s.value());
5031                        return Ok(());
5032                    }
5033                    Err(inner.error("unknown check attribute (supported: `name`, `expr`)"))
5034                })?;
5035                let name = name.ok_or_else(|| meta.error("check requires `name = \"...\"`"))?;
5036                let expr = expr.ok_or_else(|| meta.error("check requires `expr = \"...\"`"))?;
5037                out.checks.push(CheckAttr { name, expr });
5038                return Ok(());
5039            }
5040            if meta.path.is_ident("generic_fk") {
5041                let mut gfk = GenericFkAttr {
5042                    name: String::new(),
5043                    ct_column: String::new(),
5044                    pk_column: String::new(),
5045                };
5046                meta.parse_nested_meta(|inner| {
5047                    if inner.path.is_ident("name") {
5048                        let s: LitStr = inner.value()?.parse()?;
5049                        gfk.name = s.value();
5050                        return Ok(());
5051                    }
5052                    if inner.path.is_ident("ct_column") {
5053                        let s: LitStr = inner.value()?.parse()?;
5054                        gfk.ct_column = s.value();
5055                        return Ok(());
5056                    }
5057                    if inner.path.is_ident("pk_column") {
5058                        let s: LitStr = inner.value()?.parse()?;
5059                        gfk.pk_column = s.value();
5060                        return Ok(());
5061                    }
5062                    Err(inner.error(
5063                        "unknown generic_fk attribute (supported: `name`, `ct_column`, `pk_column`)",
5064                    ))
5065                })?;
5066                if gfk.name.is_empty() {
5067                    return Err(meta.error("generic_fk requires `name = \"...\"`"));
5068                }
5069                if gfk.ct_column.is_empty() {
5070                    return Err(meta.error("generic_fk requires `ct_column = \"...\"`"));
5071                }
5072                if gfk.pk_column.is_empty() {
5073                    return Err(meta.error("generic_fk requires `pk_column = \"...\"`"));
5074                }
5075                out.generic_fks.push(gfk);
5076                return Ok(());
5077            }
5078            if meta.path.is_ident("fk_composite") {
5079                let mut fk = CompositeFkAttr {
5080                    name: String::new(),
5081                    to: String::new(),
5082                    from: Vec::new(),
5083                    on: Vec::new(),
5084                };
5085                meta.parse_nested_meta(|inner| {
5086                    if inner.path.is_ident("name") {
5087                        let s: LitStr = inner.value()?.parse()?;
5088                        fk.name = s.value();
5089                        return Ok(());
5090                    }
5091                    if inner.path.is_ident("to") {
5092                        let s: LitStr = inner.value()?.parse()?;
5093                        fk.to = s.value();
5094                        return Ok(());
5095                    }
5096                    // `on = ("col1", "col2", ...)` — parse a parenthesised
5097                    // comma-list of string literals.
5098                    if inner.path.is_ident("on") || inner.path.is_ident("from") {
5099                        let value = inner.value()?;
5100                        let content;
5101                        syn::parenthesized!(content in value);
5102                        let lits: syn::punctuated::Punctuated<syn::LitStr, syn::Token![,]> =
5103                            content.parse_terminated(
5104                                |p| p.parse::<syn::LitStr>(),
5105                                syn::Token![,],
5106                            )?;
5107                        let cols: Vec<String> = lits.iter().map(syn::LitStr::value).collect();
5108                        if inner.path.is_ident("on") {
5109                            fk.on = cols;
5110                        } else {
5111                            fk.from = cols;
5112                        }
5113                        return Ok(());
5114                    }
5115                    Err(inner.error(
5116                        "unknown fk_composite attribute (supported: `name`, `to`, `on`, `from`)",
5117                    ))
5118                })?;
5119                if fk.name.is_empty() {
5120                    return Err(meta.error("fk_composite requires `name = \"...\"`"));
5121                }
5122                if fk.to.is_empty() {
5123                    return Err(meta.error("fk_composite requires `to = \"...\"`"));
5124                }
5125                if fk.from.is_empty() || fk.on.is_empty() {
5126                    return Err(meta.error(
5127                        "fk_composite requires non-empty `from = (...)` and `on = (...)` tuples",
5128                    ));
5129                }
5130                if fk.from.len() != fk.on.len() {
5131                    return Err(meta.error(format!(
5132                        "fk_composite `from` ({} cols) and `on` ({} cols) must be the same length",
5133                        fk.from.len(),
5134                        fk.on.len(),
5135                    )));
5136                }
5137                out.composite_fks.push(fk);
5138                return Ok(());
5139            }
5140            if meta.path.is_ident("m2m") {
5141                let mut m2m = M2MAttr {
5142                    name: String::new(),
5143                    to: String::new(),
5144                    through: String::new(),
5145                    src: String::new(),
5146                    dst: String::new(),
5147                    auto_create: true,
5148                };
5149                meta.parse_nested_meta(|inner| {
5150                    if inner.path.is_ident("name") {
5151                        let s: LitStr = inner.value()?.parse()?;
5152                        m2m.name = s.value();
5153                        return Ok(());
5154                    }
5155                    if inner.path.is_ident("to") {
5156                        let s: LitStr = inner.value()?.parse()?;
5157                        m2m.to = s.value();
5158                        return Ok(());
5159                    }
5160                    if inner.path.is_ident("through") {
5161                        let s: LitStr = inner.value()?.parse()?;
5162                        m2m.through = s.value();
5163                        return Ok(());
5164                    }
5165                    if inner.path.is_ident("src") {
5166                        let s: LitStr = inner.value()?.parse()?;
5167                        m2m.src = s.value();
5168                        return Ok(());
5169                    }
5170                    if inner.path.is_ident("dst") {
5171                        let s: LitStr = inner.value()?.parse()?;
5172                        m2m.dst = s.value();
5173                        return Ok(());
5174                    }
5175                    if inner.path.is_ident("auto_create") {
5176                        let lit: syn::LitBool = inner.value()?.parse()?;
5177                        m2m.auto_create = lit.value;
5178                        return Ok(());
5179                    }
5180                    Err(inner.error("unknown m2m attribute (supported: `name`, `to`, `through`, `src`, `dst`, `auto_create`)"))
5181                })?;
5182                if m2m.name.is_empty() {
5183                    return Err(meta.error("m2m requires `name = \"...\"`"));
5184                }
5185                if m2m.to.is_empty() {
5186                    return Err(meta.error("m2m requires `to = \"...\"`"));
5187                }
5188                if m2m.through.is_empty() {
5189                    return Err(meta.error("m2m requires `through = \"...\"`"));
5190                }
5191                if m2m.src.is_empty() {
5192                    return Err(meta.error("m2m requires `src = \"...\"`"));
5193                }
5194                if m2m.dst.is_empty() {
5195                    return Err(meta.error("m2m requires `dst = \"...\"`"));
5196                }
5197                out.m2m.push(m2m);
5198                return Ok(());
5199            }
5200            Err(meta.error("unknown rustango container attribute"))
5201        })?;
5202    }
5203    Ok(out)
5204}
5205
5206/// Split a comma-separated field-name list (e.g. `"name, office"`) into
5207/// owned field names, trimming whitespace and skipping empty entries.
5208/// Field-name validation against the model is done by the caller.
5209fn split_field_list(raw: &str) -> Vec<String> {
5210    raw.split(',')
5211        .map(str::trim)
5212        .filter(|s| !s.is_empty())
5213        .map(str::to_owned)
5214        .collect()
5215}
5216
5217/// Shared parser for `unique_together` and `index_together` container
5218/// attrs. Accepts both shapes:
5219///
5220///   * `attr = "col1, col2"`              — auto-derived index name.
5221///   * `attr(columns = "col1, col2", name = "...")` — explicit name.
5222///
5223/// Returns `(columns, name)`.
5224fn parse_together_attr(
5225    meta: &syn::meta::ParseNestedMeta<'_>,
5226    attr: &str,
5227) -> syn::Result<(Vec<String>, Option<String>)> {
5228    // Disambiguate by whether the next token is `=` (key-value) or
5229    // `(` (parenthesized).
5230    if meta.input.peek(syn::Token![=]) {
5231        let cols_lit: LitStr = meta.value()?.parse()?;
5232        let columns = split_field_list(&cols_lit.value());
5233        check_together_columns(meta, attr, &columns)?;
5234        return Ok((columns, None));
5235    }
5236    let mut columns: Option<Vec<String>> = None;
5237    let mut name: Option<String> = None;
5238    meta.parse_nested_meta(|inner| {
5239        if inner.path.is_ident("columns") {
5240            let s: LitStr = inner.value()?.parse()?;
5241            columns = Some(split_field_list(&s.value()));
5242            return Ok(());
5243        }
5244        if inner.path.is_ident("name") {
5245            let s: LitStr = inner.value()?.parse()?;
5246            name = Some(s.value());
5247            return Ok(());
5248        }
5249        Err(inner.error("unknown sub-attribute (supported: `columns`, `name`)"))
5250    })?;
5251    let columns = columns.ok_or_else(|| {
5252        meta.error(format!(
5253            "{attr}(...) requires a `columns = \"col1, col2\"` argument",
5254        ))
5255    })?;
5256    check_together_columns(meta, attr, &columns)?;
5257    Ok((columns, name))
5258}
5259
5260fn check_together_columns(
5261    meta: &syn::meta::ParseNestedMeta<'_>,
5262    attr: &str,
5263    columns: &[String],
5264) -> syn::Result<()> {
5265    if columns.len() < 2 {
5266        let single = if attr == "unique_together" {
5267            "#[rustango(unique)] on the field"
5268        } else {
5269            "#[rustango(index)] on the field"
5270        };
5271        return Err(meta.error(format!(
5272            "{attr} expects two or more columns; for a single-column equivalent use {single}",
5273        )));
5274    }
5275    Ok(())
5276}
5277
5278/// Parse the fieldsets DSL: pipe-separated sections, optional
5279/// `"Title:"` prefix on each, comma-separated field names after.
5280/// Examples:
5281/// * `"name, office"` → one untitled section with two fields
5282/// * `"Identity: name, office | Metadata: created_at"` → two titled
5283///   sections
5284///
5285/// Returns `(title, fields)` pairs. Title is `""` when no prefix.
5286fn parse_fieldset_list(raw: &str) -> Vec<(String, Vec<String>)> {
5287    raw.split('|')
5288        .map(str::trim)
5289        .filter(|s| !s.is_empty())
5290        .map(|section| {
5291            // Split off an optional `Title:` prefix (first colon).
5292            let (title, rest) = match section.split_once(':') {
5293                Some((title, rest)) if !title.contains(',') => (title.trim().to_owned(), rest),
5294                _ => (String::new(), section),
5295            };
5296            let fields = split_field_list(rest);
5297            (title, fields)
5298        })
5299        .collect()
5300}
5301
5302/// Parse `prepopulated_fields = "target:source[+src2,...]"` — each
5303/// comma-separated entry maps a target field to one or more source
5304/// fields joined with `+`. Whitespace around tokens is trimmed.
5305/// Entries missing `:` or with empty target/source lists are dropped.
5306fn parse_prepopulated_list(raw: &str) -> Vec<(String, Vec<String>)> {
5307    raw.split(',')
5308        .map(str::trim)
5309        .filter(|s| !s.is_empty())
5310        .filter_map(|entry| {
5311            let (target, sources_raw) = entry.split_once(':')?;
5312            let target = target.trim().to_owned();
5313            if target.is_empty() {
5314                return None;
5315            }
5316            let sources: Vec<String> = sources_raw
5317                .split('+')
5318                .map(|s| s.trim().to_owned())
5319                .filter(|s| !s.is_empty())
5320                .collect();
5321            if sources.is_empty() {
5322                return None;
5323            }
5324            Some((target, sources))
5325        })
5326        .collect()
5327}
5328
5329/// Parse Django-shape `formfield_overrides` — `"field:widget,field2:widget2"`
5330/// into `(field_name, widget_name)` pairs. Empty entries, missing `:`,
5331/// and empty halves drop silently — the macro layer only enforces shape,
5332/// not field-name vs. widget-name validity (those checks happen at
5333/// `AdminConfig` consumption time). Issue #359.
5334fn parse_formfield_overrides(raw: &str) -> Vec<(String, String)> {
5335    raw.split(',')
5336        .map(str::trim)
5337        .filter(|s| !s.is_empty())
5338        .filter_map(|entry| {
5339            let (field, widget) = entry.split_once(':')?;
5340            let field = field.trim().to_owned();
5341            let widget = widget.trim().to_owned();
5342            if field.is_empty() || widget.is_empty() {
5343                return None;
5344            }
5345            Some((field, widget))
5346        })
5347        .collect()
5348}
5349
5350/// Parse Django-shape ordering — `"name"` is ASC, `"-name"` is DESC.
5351/// Returns `(field_name, desc)` pairs in the same order as the input.
5352fn parse_ordering_list(raw: &str) -> Vec<(String, bool)> {
5353    raw.split(',')
5354        .map(str::trim)
5355        .filter(|s| !s.is_empty())
5356        .map(|spec| {
5357            spec.strip_prefix('-')
5358                .map_or((spec.to_owned(), false), |rest| {
5359                    (rest.trim().to_owned(), true)
5360                })
5361        })
5362        .collect()
5363}
5364
5365struct FieldAttrs {
5366    column: Option<String>,
5367    primary_key: bool,
5368    fk: Option<String>,
5369    o2o: Option<String>,
5370    on: Option<String>,
5371    max_length: Option<u32>,
5372    min: Option<i64>,
5373    max: Option<i64>,
5374    default: Option<String>,
5375    /// `#[rustango(auto_uuid)]` — UUID PK generated by Postgres
5376    /// `gen_random_uuid()`. Implies `auto + primary_key + default =
5377    /// "gen_random_uuid()"`. The Rust field type must be
5378    /// `uuid::Uuid` (or `Auto<Uuid>`); the column is excluded from
5379    /// INSERTs so the DB DEFAULT fires.
5380    auto_uuid: bool,
5381    /// `#[rustango(auto_now_add)]` — `created_at`-shape column.
5382    /// Server-set on insert, immutable from app code afterwards.
5383    /// Implies `auto + default = "now()"`. Field type must be
5384    /// `DateTime<Utc>`.
5385    auto_now_add: bool,
5386    /// `#[rustango(auto_now)]` — `updated_at`-shape column. Set on
5387    /// every insert AND every update. Implies `auto + default =
5388    /// "now()"`; the macro additionally rewrites `update_on` /
5389    /// `save_on` to bind `chrono::Utc::now()` instead of the user's
5390    /// field value.
5391    auto_now: bool,
5392    /// `#[rustango(soft_delete)]` — `deleted_at`-shape column. Type
5393    /// must be `Option<DateTime<Utc>>`. Triggers macro emission of
5394    /// `soft_delete_on(executor)` and `restore_on(executor)`
5395    /// methods on the model.
5396    soft_delete: bool,
5397    /// `#[rustango(unique)]` — adds a `UNIQUE` constraint inline on
5398    /// the column in the generated DDL.
5399    unique: bool,
5400    /// `#[rustango(index)]` or `#[rustango(index(name = "…", unique))]` —
5401    /// generates a `CREATE INDEX` for this column. `unique` here means
5402    /// `CREATE UNIQUE INDEX` (distinct from the `unique` constraint above).
5403    index: bool,
5404    index_unique: bool,
5405    index_name: Option<String>,
5406    /// Index access method (`"btree"` / `"gin"` / …). Defaults to
5407    /// `"btree"`. Issue #34.
5408    index_method: String,
5409    /// `#[rustango(generated_as = "EXPR")]` — emit `GENERATED ALWAYS
5410    /// AS (EXPR) STORED` in the column DDL. Read-only from app code:
5411    /// the macro skips this column from every INSERT and UPDATE
5412    /// path, so the database always recomputes the value from
5413    /// `EXPR`. Backlog item #35.
5414    generated_as: Option<String>,
5415    /// `#[rustango(help_text = "…")]` — Django-shape help text
5416    /// rendered below the admin form's input. Threaded into
5417    /// `FieldSchema::help_text` so admin / serializer / OpenAPI
5418    /// layers can read it.
5419    help_text: Option<String>,
5420    /// `#[rustango(choices = "value:Label, value:Label")]` — Django-shape
5421    /// enumerated allowed values. Threaded into `FieldSchema::choices`
5422    /// as a `&'static [(&'static str, &'static str)]` slice. When
5423    /// present, the admin form renders a `<select>` instead of `<input>`
5424    /// and the validator rejects values not in the list. Only meaningful
5425    /// for `FieldType::String`; the macro errors at compile time if
5426    /// applied to a non-string field.
5427    choices: Option<Vec<(String, String)>>,
5428    /// `#[rustango(db_comment = "…")]` — Django-shape DB-side column
5429    /// comment. Threaded into `FieldSchema::db_comment`. MySQL inlines
5430    /// the comment in CREATE TABLE; Postgres emits a separate
5431    /// `COMMENT ON COLUMN` statement after the table is created;
5432    /// SQLite silently drops the value (no native column comments).
5433    db_comment: Option<String>,
5434    /// `#[rustango(verbose_name = "…")]` — Django-shape human-readable
5435    /// label for the field. Threaded into `FieldSchema::verbose_name`
5436    /// so admin column headers, form labels, and other display
5437    /// surfaces can prefer the friendly caption over the Rust
5438    /// identifier. `None` means renderers fall back to the field name.
5439    verbose_name: Option<String>,
5440    /// `#[rustango(editable = false)]` — Django-shape opt-out from
5441    /// auto-generated form rendering. Defaults to `true` so existing
5442    /// fields keep their current admin / form behavior; setting
5443    /// `false` removes the field from the admin change-form entirely
5444    /// (the value is still visible on detail / list views, just not
5445    /// editable).
5446    editable: bool,
5447    /// `#[rustango(blank)]` / `#[rustango(blank = true)]` — Django-shape
5448    /// "form may submit empty even when DB is NOT NULL". Threaded into
5449    /// `FieldSchema::blank`. Defaults to `false`.
5450    blank: bool,
5451    /// `#[rustango(validators = "email,url")]` — Django-shape
5452    /// model-level validator chain. Comma-separated names that
5453    /// dispatch to the `validators::*` family in `validate_value`.
5454    /// Empty by default; fires on every typed INSERT/UPDATE.
5455    validators: Vec<String>,
5456}
5457
5458fn parse_field_attrs(field: &syn::Field) -> syn::Result<FieldAttrs> {
5459    let mut out = FieldAttrs {
5460        column: None,
5461        primary_key: false,
5462        fk: None,
5463        o2o: None,
5464        on: None,
5465        max_length: None,
5466        min: None,
5467        max: None,
5468        default: None,
5469        auto_uuid: false,
5470        auto_now_add: false,
5471        auto_now: false,
5472        soft_delete: false,
5473        unique: false,
5474        index: false,
5475        index_unique: false,
5476        index_name: None,
5477        index_method: "btree".to_owned(),
5478        generated_as: None,
5479        help_text: None,
5480        choices: None,
5481        db_comment: None,
5482        verbose_name: None,
5483        editable: true,
5484        blank: false,
5485        validators: Vec::new(),
5486    };
5487    for attr in &field.attrs {
5488        if !attr.path().is_ident("rustango") {
5489            continue;
5490        }
5491        attr.parse_nested_meta(|meta| {
5492            if meta.path.is_ident("column") {
5493                let s: LitStr = meta.value()?.parse()?;
5494                let name = s.value();
5495                validate_sql_identifier(&name, "column", s.span())?;
5496                out.column = Some(name);
5497                return Ok(());
5498            }
5499            if meta.path.is_ident("primary_key") {
5500                out.primary_key = true;
5501                return Ok(());
5502            }
5503            if meta.path.is_ident("fk") {
5504                let s: LitStr = meta.value()?.parse()?;
5505                out.fk = Some(s.value());
5506                return Ok(());
5507            }
5508            if meta.path.is_ident("o2o") {
5509                let s: LitStr = meta.value()?.parse()?;
5510                out.o2o = Some(s.value());
5511                return Ok(());
5512            }
5513            if meta.path.is_ident("on") {
5514                let s: LitStr = meta.value()?.parse()?;
5515                out.on = Some(s.value());
5516                return Ok(());
5517            }
5518            if meta.path.is_ident("max_length") {
5519                let lit: syn::LitInt = meta.value()?.parse()?;
5520                out.max_length = Some(lit.base10_parse::<u32>()?);
5521                return Ok(());
5522            }
5523            if meta.path.is_ident("min") {
5524                out.min = Some(parse_signed_i64(&meta)?);
5525                return Ok(());
5526            }
5527            if meta.path.is_ident("max") {
5528                out.max = Some(parse_signed_i64(&meta)?);
5529                return Ok(());
5530            }
5531            if meta.path.is_ident("default") {
5532                let s: LitStr = meta.value()?.parse()?;
5533                out.default = Some(s.value());
5534                return Ok(());
5535            }
5536            if meta.path.is_ident("generated_as") {
5537                let s: LitStr = meta.value()?.parse()?;
5538                out.generated_as = Some(s.value());
5539                return Ok(());
5540            }
5541            if meta.path.is_ident("help_text") {
5542                let s: LitStr = meta.value()?.parse()?;
5543                out.help_text = Some(s.value());
5544                return Ok(());
5545            }
5546            if meta.path.is_ident("choices") {
5547                let s: LitStr = meta.value()?.parse()?;
5548                let raw = s.value();
5549                let mut pairs: Vec<(String, String)> = Vec::new();
5550                for chunk in raw.split(',') {
5551                    let chunk = chunk.trim();
5552                    if chunk.is_empty() {
5553                        continue;
5554                    }
5555                    let (value, label) = match chunk.split_once(':') {
5556                        Some((v, l)) => (v.trim().to_owned(), l.trim().to_owned()),
5557                        None => (chunk.to_owned(), chunk.to_owned()),
5558                    };
5559                    if value.is_empty() {
5560                        return Err(syn::Error::new(
5561                            s.span(),
5562                            "`choices` entry has empty value before `:`",
5563                        ));
5564                    }
5565                    pairs.push((value, label));
5566                }
5567                if pairs.is_empty() {
5568                    return Err(syn::Error::new(
5569                        s.span(),
5570                        "`choices = \"…\"` must contain at least one value",
5571                    ));
5572                }
5573                out.choices = Some(pairs);
5574                return Ok(());
5575            }
5576            if meta.path.is_ident("db_comment") {
5577                let s: LitStr = meta.value()?.parse()?;
5578                out.db_comment = Some(s.value());
5579                return Ok(());
5580            }
5581            if meta.path.is_ident("verbose_name") {
5582                let s: LitStr = meta.value()?.parse()?;
5583                out.verbose_name = Some(s.value());
5584                return Ok(());
5585            }
5586            if meta.path.is_ident("editable") {
5587                // Two forms accepted:
5588                //   #[rustango(editable = false)] / true — explicit
5589                //   #[rustango(editable)] — flag form (= true, the
5590                //   default, so harmless; included for symmetry)
5591                if let Ok(v) = meta.value() {
5592                    let lit: syn::LitBool = v.parse()?;
5593                    out.editable = lit.value;
5594                } else {
5595                    out.editable = true;
5596                }
5597                return Ok(());
5598            }
5599            if meta.path.is_ident("blank") {
5600                // Two forms accepted:
5601                //   #[rustango(blank)] — flag form, true
5602                //   #[rustango(blank = false)] / true — explicit
5603                if let Ok(v) = meta.value() {
5604                    let lit: syn::LitBool = v.parse()?;
5605                    out.blank = lit.value;
5606                } else {
5607                    out.blank = true;
5608                }
5609                return Ok(());
5610            }
5611            if meta.path.is_ident("validators") {
5612                let s: LitStr = meta.value()?.parse()?;
5613                let raw = s.value();
5614                out.validators = raw
5615                    .split(',')
5616                    .map(str::trim)
5617                    .filter(|s| !s.is_empty())
5618                    .map(str::to_owned)
5619                    .collect();
5620                if out.validators.is_empty() {
5621                    return Err(syn::Error::new(
5622                        s.span(),
5623                        "`validators = \"…\"` must list at least one name",
5624                    ));
5625                }
5626                return Ok(());
5627            }
5628            if meta.path.is_ident("auto_uuid") {
5629                out.auto_uuid = true;
5630                // Implied: PK + auto + DEFAULT gen_random_uuid().
5631                // Each is also explicitly settable; the explicit
5632                // value wins if conflicting.
5633                out.primary_key = true;
5634                if out.default.is_none() {
5635                    out.default = Some("gen_random_uuid()".into());
5636                }
5637                return Ok(());
5638            }
5639            if meta.path.is_ident("auto_now_add") {
5640                out.auto_now_add = true;
5641                if out.default.is_none() {
5642                    out.default = Some("now()".into());
5643                }
5644                return Ok(());
5645            }
5646            if meta.path.is_ident("auto_now") {
5647                out.auto_now = true;
5648                if out.default.is_none() {
5649                    out.default = Some("now()".into());
5650                }
5651                return Ok(());
5652            }
5653            if meta.path.is_ident("soft_delete") {
5654                out.soft_delete = true;
5655                return Ok(());
5656            }
5657            if meta.path.is_ident("unique") {
5658                out.unique = true;
5659                return Ok(());
5660            }
5661            if meta.path.is_ident("index") {
5662                out.index = true;
5663                // Optional sub-attrs: #[rustango(index(unique, name = "…", method = "gin"))]
5664                if meta.input.peek(syn::token::Paren) {
5665                    meta.parse_nested_meta(|inner| {
5666                        if inner.path.is_ident("unique") {
5667                            out.index_unique = true;
5668                            return Ok(());
5669                        }
5670                        if inner.path.is_ident("name") {
5671                            let s: LitStr = inner.value()?.parse()?;
5672                            out.index_name = Some(s.value());
5673                            return Ok(());
5674                        }
5675                        if inner.path.is_ident("method") {
5676                            let s: LitStr = inner.value()?.parse()?;
5677                            let v = s.value();
5678                            match v.as_str() {
5679                                "btree" | "gin" | "gist" | "brin" | "spgist" | "hash" | "bloom" => {
5680                                    out.index_method = v;
5681                                }
5682                                other => {
5683                                    return Err(inner.error(format!(
5684                                        "unknown index method `{other}` (supported: btree, gin, gist, brin, spgist, hash, bloom)",
5685                                    )));
5686                                }
5687                            }
5688                            return Ok(());
5689                        }
5690                        Err(inner.error(
5691                            "unknown index sub-attribute (supported: `unique`, `name`, `method`)",
5692                        ))
5693                    })?;
5694                }
5695                return Ok(());
5696            }
5697            Err(meta.error("unknown rustango field attribute"))
5698        })?;
5699    }
5700    Ok(out)
5701}
5702
5703/// Parse a signed integer literal, accepting optional leading `-`.
5704fn parse_signed_i64(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<i64> {
5705    let expr: syn::Expr = meta.value()?.parse()?;
5706    match expr {
5707        syn::Expr::Lit(syn::ExprLit {
5708            lit: syn::Lit::Int(lit),
5709            ..
5710        }) => lit.base10_parse::<i64>(),
5711        syn::Expr::Unary(syn::ExprUnary {
5712            op: syn::UnOp::Neg(_),
5713            expr,
5714            ..
5715        }) => {
5716            if let syn::Expr::Lit(syn::ExprLit {
5717                lit: syn::Lit::Int(lit),
5718                ..
5719            }) = *expr
5720            {
5721                let v: i64 = lit.base10_parse()?;
5722                Ok(-v)
5723            } else {
5724                Err(syn::Error::new_spanned(expr, "expected integer literal"))
5725            }
5726        }
5727        other => Err(syn::Error::new_spanned(
5728            other,
5729            "expected integer literal (signed)",
5730        )),
5731    }
5732}
5733
5734struct FieldInfo<'a> {
5735    ident: &'a syn::Ident,
5736    column: String,
5737    primary_key: bool,
5738    /// `true` when the Rust type was `Auto<T>` — the INSERT path will
5739    /// skip this column when `Auto::Unset` and emit it under
5740    /// `RETURNING` so Postgres' sequence DEFAULT fills in the value.
5741    auto: bool,
5742    /// The original field type, e.g. `i64` or `Option<String>`. Emitted as
5743    /// the `Column::Value` associated type for typed-column tokens.
5744    value_ty: &'a Type,
5745    /// `FieldType` variant tokens (`::rustango::core::FieldType::I64`).
5746    field_type_tokens: TokenStream2,
5747    schema: TokenStream2,
5748    from_row_init: TokenStream2,
5749    /// Variant of [`Self::from_row_init`] that reads the column via
5750    /// `format!("{prefix}__{col}")` so a model can be decoded out of
5751    /// the aliased columns of a JOINed row. Drives slice 9.0d's
5752    /// `Self::__rustango_from_aliased_row(row, prefix)` per-Model
5753    /// helper that `select_related` calls when stitching loaded FKs.
5754    from_aliased_row_init: TokenStream2,
5755    /// Inner type from a `ForeignKey<T, K>` field, if any. The reverse-
5756    /// relation helper emit (`Author::<child>_set`) needs to know `T`
5757    /// to point the generated method at the right child model.
5758    fk_inner: Option<Type>,
5759    /// `K`'s scalar kind for a `ForeignKey<T, K>` field. Mirrors
5760    /// `kind` (since ForeignKey detection sets `kind` to K's
5761    /// underlying type) but stored separately for clarity at the
5762    /// `FkRelation` construction site, which only sees the FK's
5763    /// surface fields.
5764    fk_pk_kind: DetectedKind,
5765    /// `true` when the field is `Option<ForeignKey<T, K>>` rather than
5766    /// the bare `ForeignKey<T, K>`. Routes the load_related and
5767    /// fk_pk_access emitters to wrap assignments / accessors in
5768    /// `Some(...)` / `as_ref().map(...)` respectively, so a nullable
5769    /// FK column compiles end-to-end. The DDL writer reads this off
5770    /// the field schema (`nullable` flag); the macro just needs to
5771    /// keep the Rust-side codegen consistent.
5772    nullable: bool,
5773    /// `true` when this column was marked `#[rustango(auto_now)]` —
5774    /// `update_on` / `save_on` bind `chrono::Utc::now()` for this
5775    /// column instead of the user-supplied value, so `updated_at`
5776    /// always reflects the latest write without the caller having
5777    /// to remember to set it.
5778    auto_now: bool,
5779    /// `true` when this column was marked `#[rustango(auto_now_add)]`
5780    /// — the column is server-set on INSERT (DB DEFAULT) and
5781    /// **immutable** afterwards. `update_on` / `save_on` skip the
5782    /// column entirely so a stale `created_at` value in memory never
5783    /// rewrites the persisted timestamp.
5784    auto_now_add: bool,
5785    /// `true` when this column was marked `#[rustango(soft_delete)]`.
5786    /// Triggers emission of `soft_delete_on(executor)` and
5787    /// `restore_on(executor)` on the model's inherent impl. There is
5788    /// at most one such column per model — emission asserts this.
5789    soft_delete: bool,
5790    /// `Some` when this column was marked
5791    /// `#[rustango(generated_as = "EXPR")]`. The macro skips it from
5792    /// every INSERT and UPDATE path; the database recomputes the
5793    /// value from `EXPR`. Backlog item #35.
5794    generated_as: Option<String>,
5795}
5796
5797/// Reject table names that won't survive SQL identifier
5798/// derivation downstream. Postgres' regular-identifier rule
5799/// (`[a-zA-Z_][a-zA-Z0-9_]*`) is the safe shape: it round-trips
5800/// through the framework's unquoted FK / index / constraint name
5801/// emission without surprises. We also disallow leading-digit and
5802/// the empty string for clarity.
5803///
5804/// Reserved-word collisions (`select`, `from`, …) aren't flagged
5805/// here — those produce a runtime error from the SQL parser,
5806/// which is loud enough; statically enumerating reserved words
5807/// across the three supported dialects is more friction than help.
5808///
5809/// Backlog item #65.
5810fn validate_table_name(name: &str, span: proc_macro2::Span) -> syn::Result<()> {
5811    validate_sql_identifier(name, "table", span)
5812}
5813
5814/// Reject SQL identifiers that compile but break downstream SQL
5815/// generation. Same rule for tables and columns: `[a-zA-Z_][a-zA-Z0-9_]*`.
5816/// `kind` is "table" / "column" — used for the error message so users
5817/// see which attribute caused the failure.
5818fn validate_sql_identifier(name: &str, kind: &str, span: proc_macro2::Span) -> syn::Result<()> {
5819    if name.is_empty() {
5820        return Err(syn::Error::new(
5821            span,
5822            format!("`{kind} = \"\"` is not a valid SQL identifier"),
5823        ));
5824    }
5825    let mut chars = name.chars();
5826    let first = chars.next().unwrap();
5827    if !(first.is_ascii_alphabetic() || first == '_') {
5828        return Err(syn::Error::new(
5829            span,
5830            format!("{kind} name `{name}` must start with a letter or underscore (got {first:?})"),
5831        ));
5832    }
5833    for c in chars {
5834        if !(c.is_ascii_alphanumeric() || c == '_') {
5835            return Err(syn::Error::new(
5836                span,
5837                format!(
5838                    "{kind} name `{name}` contains invalid character {c:?} — \
5839                     SQL identifiers must match `[a-zA-Z_][a-zA-Z0-9_]*`. \
5840                     Hyphens in particular break FK / index name derivation \
5841                     downstream; use underscores instead (e.g. `{}`)",
5842                    name.replace(|x: char| !x.is_ascii_alphanumeric() && x != '_', "_"),
5843                ),
5844            ));
5845        }
5846    }
5847    Ok(())
5848}
5849
5850fn process_field<'a>(field: &'a syn::Field, table: &str) -> syn::Result<FieldInfo<'a>> {
5851    let attrs = parse_field_attrs(field)?;
5852    let ident = field
5853        .ident
5854        .as_ref()
5855        .ok_or_else(|| syn::Error::new(field.span(), "tuple structs are not supported"))?;
5856    let name = ident.to_string();
5857    let column = attrs.column.clone().unwrap_or_else(|| name.clone());
5858    let primary_key = attrs.primary_key;
5859    let DetectedType {
5860        kind,
5861        nullable,
5862        auto: detected_auto,
5863        fk_inner,
5864    } = detect_type(&field.ty)?;
5865    check_bound_compatibility(field, &attrs, kind)?;
5866    let auto = detected_auto;
5867    // Mixin attributes piggyback on the existing `Auto<T>` skip-on-
5868    // INSERT path: the user must wrap the field in `Auto<T>`, which
5869    // marks the column as DB-default-supplied. The mixin attrs then
5870    // layer in the SQL default (`now()` / `gen_random_uuid()`) and,
5871    // for `auto_now`, force the value on UPDATE too.
5872    if attrs.auto_uuid {
5873        if kind != DetectedKind::Uuid {
5874            return Err(syn::Error::new_spanned(
5875                field,
5876                "`#[rustango(auto_uuid)]` requires the field type to be \
5877                 `Auto<uuid::Uuid>`",
5878            ));
5879        }
5880        if !detected_auto {
5881            return Err(syn::Error::new_spanned(
5882                field,
5883                "`#[rustango(auto_uuid)]` requires the field type to be \
5884                 wrapped in `Auto<...>` so the macro skips the column on \
5885                 INSERT and the DB DEFAULT (`gen_random_uuid()`) fires",
5886            ));
5887        }
5888    }
5889    if attrs.auto_now_add || attrs.auto_now {
5890        if kind != DetectedKind::DateTime {
5891            return Err(syn::Error::new_spanned(
5892                field,
5893                "`#[rustango(auto_now_add)]` / `#[rustango(auto_now)]` require \
5894                 the field type to be `Auto<chrono::DateTime<chrono::Utc>>`",
5895            ));
5896        }
5897        if !detected_auto {
5898            return Err(syn::Error::new_spanned(
5899                field,
5900                "`#[rustango(auto_now_add)]` / `#[rustango(auto_now)]` require \
5901                 the field type to be wrapped in `Auto<...>` so the macro skips \
5902                 the column on INSERT and the DB DEFAULT (`now()`) fires",
5903            ));
5904        }
5905    }
5906    if attrs.soft_delete && !(kind == DetectedKind::DateTime && nullable) {
5907        return Err(syn::Error::new_spanned(
5908            field,
5909            "`#[rustango(soft_delete)]` requires the field type to be \
5910             `Option<chrono::DateTime<chrono::Utc>>`",
5911        ));
5912    }
5913    let is_mixin_auto = attrs.auto_uuid || attrs.auto_now_add || attrs.auto_now;
5914    if detected_auto && !primary_key && !is_mixin_auto {
5915        return Err(syn::Error::new_spanned(
5916            field,
5917            "`Auto<T>` is only valid on a `#[rustango(primary_key)]` field, \
5918             or on a field carrying one of `auto_uuid`, `auto_now_add`, or \
5919             `auto_now`",
5920        ));
5921    }
5922    if detected_auto && attrs.default.is_some() && !is_mixin_auto {
5923        return Err(syn::Error::new_spanned(
5924            field,
5925            "`#[rustango(default = \"…\")]` is redundant on an `Auto<T>` field — \
5926             SERIAL / BIGSERIAL already supplies a default sequence.",
5927        ));
5928    }
5929    if fk_inner.is_some() && primary_key {
5930        return Err(syn::Error::new_spanned(
5931            field,
5932            "`ForeignKey<T>` is not allowed on a primary-key field — \
5933             a row's PK is its own identity, not a reference to a parent.",
5934        ));
5935    }
5936    if attrs.generated_as.is_some() {
5937        if primary_key {
5938            return Err(syn::Error::new_spanned(
5939                field,
5940                "`#[rustango(generated_as = \"…\")]` is not allowed on a \
5941                 primary-key field — a PK must be writable so the row \
5942                 has an identity at INSERT time.",
5943            ));
5944        }
5945        if attrs.default.is_some() {
5946            return Err(syn::Error::new_spanned(
5947                field,
5948                "`#[rustango(generated_as = \"…\")]` cannot combine with \
5949                 `default = \"…\"` — Postgres rejects DEFAULT on \
5950                 generated columns. The expression IS the default.",
5951            ));
5952        }
5953        if detected_auto {
5954            return Err(syn::Error::new_spanned(
5955                field,
5956                "`#[rustango(generated_as = \"…\")]` is not allowed on \
5957                 an `Auto<T>` field — generated columns are computed \
5958                 by the DB, not server-assigned via a sequence. Use a \
5959                 plain Rust type (e.g. `f64`).",
5960            ));
5961        }
5962        if fk_inner.is_some() {
5963            return Err(syn::Error::new_spanned(
5964                field,
5965                "`#[rustango(generated_as = \"…\")]` is not allowed on a \
5966                 ForeignKey field.",
5967            ));
5968        }
5969    }
5970    let relation = relation_tokens(field, &attrs, fk_inner, table)?;
5971    let column_lit = column.as_str();
5972    let field_type_tokens = kind.variant_tokens();
5973    let max_length = optional_u32(attrs.max_length);
5974    let min = optional_i64(attrs.min);
5975    let max = optional_i64(attrs.max);
5976    let default = optional_str(attrs.default.as_deref());
5977
5978    let unique = attrs.unique;
5979    let generated_as = optional_str(attrs.generated_as.as_deref());
5980    let help_text = optional_str(attrs.help_text.as_deref());
5981    let choices = optional_choices(attrs.choices.as_deref());
5982    let db_comment = optional_str(attrs.db_comment.as_deref());
5983    let verbose_name = optional_str(attrs.verbose_name.as_deref());
5984    let editable = attrs.editable;
5985    let blank = attrs.blank;
5986    let validators_lits: Vec<&str> = attrs.validators.iter().map(String::as_str).collect();
5987    let schema = quote! {
5988        ::rustango::core::FieldSchema {
5989            name: #name,
5990            column: #column_lit,
5991            ty: #field_type_tokens,
5992            nullable: #nullable,
5993            primary_key: #primary_key,
5994            relation: #relation,
5995            max_length: #max_length,
5996            min: #min,
5997            max: #max,
5998            default: #default,
5999            auto: #auto,
6000            unique: #unique,
6001            generated_as: #generated_as,
6002            help_text: #help_text,
6003            choices: #choices,
6004            db_comment: #db_comment,
6005            verbose_name: #verbose_name,
6006            editable: #editable,
6007            blank: #blank,
6008            validators: &[ #(#validators_lits),* ],
6009        }
6010    };
6011
6012    let from_row_init = quote! {
6013        #ident: ::rustango::sql::sqlx::Row::try_get(row, #column_lit)?
6014    };
6015    let from_aliased_row_init = quote! {
6016        #ident: ::rustango::sql::sqlx::Row::try_get(
6017            row,
6018            ::std::format!("{}__{}", prefix, #column_lit).as_str(),
6019        )?
6020    };
6021
6022    Ok(FieldInfo {
6023        ident,
6024        column,
6025        primary_key,
6026        auto,
6027        value_ty: &field.ty,
6028        field_type_tokens,
6029        schema,
6030        from_row_init,
6031        from_aliased_row_init,
6032        fk_inner: fk_inner.cloned(),
6033        fk_pk_kind: kind,
6034        nullable,
6035        auto_now: attrs.auto_now,
6036        auto_now_add: attrs.auto_now_add,
6037        soft_delete: attrs.soft_delete,
6038        generated_as: attrs.generated_as.clone(),
6039    })
6040}
6041
6042fn check_bound_compatibility(
6043    field: &syn::Field,
6044    attrs: &FieldAttrs,
6045    kind: DetectedKind,
6046) -> syn::Result<()> {
6047    if attrs.max_length.is_some() && kind != DetectedKind::String {
6048        return Err(syn::Error::new_spanned(
6049            field,
6050            "`max_length` is only valid on `String` fields (or `Option<String>`)",
6051        ));
6052    }
6053    if attrs.choices.is_some() && kind != DetectedKind::String {
6054        return Err(syn::Error::new_spanned(
6055            field,
6056            "`choices` is only valid on `String` fields (or `Option<String>`) — \
6057             integer-valued enumerations should be modeled with a Rust enum and \
6058             custom (de)serializer for now",
6059        ));
6060    }
6061    if (attrs.min.is_some() || attrs.max.is_some()) && !kind.is_integer() {
6062        return Err(syn::Error::new_spanned(
6063            field,
6064            "`min` / `max` are only valid on integer fields (`i32`, `i64`, optionally Option-wrapped)",
6065        ));
6066    }
6067    if let (Some(min), Some(max)) = (attrs.min, attrs.max) {
6068        if min > max {
6069            return Err(syn::Error::new_spanned(
6070                field,
6071                format!("`min` ({min}) is greater than `max` ({max})"),
6072            ));
6073        }
6074    }
6075    Ok(())
6076}
6077
6078fn optional_u32(value: Option<u32>) -> TokenStream2 {
6079    if let Some(v) = value {
6080        quote!(::core::option::Option::Some(#v))
6081    } else {
6082        quote!(::core::option::Option::None)
6083    }
6084}
6085
6086fn optional_i64(value: Option<i64>) -> TokenStream2 {
6087    if let Some(v) = value {
6088        quote!(::core::option::Option::Some(#v))
6089    } else {
6090        quote!(::core::option::Option::None)
6091    }
6092}
6093
6094fn optional_str(value: Option<&str>) -> TokenStream2 {
6095    if let Some(v) = value {
6096        quote!(::core::option::Option::Some(#v))
6097    } else {
6098        quote!(::core::option::Option::None)
6099    }
6100}
6101
6102fn optional_choices(pairs: Option<&[(String, String)]>) -> TokenStream2 {
6103    let Some(pairs) = pairs else {
6104        return quote!(::core::option::Option::None);
6105    };
6106    let entries = pairs.iter().map(|(v, l)| quote!((#v, #l)));
6107    quote!(::core::option::Option::Some(&[#(#entries),*]))
6108}
6109
6110fn relation_tokens(
6111    field: &syn::Field,
6112    attrs: &FieldAttrs,
6113    fk_inner: Option<&syn::Type>,
6114    table: &str,
6115) -> syn::Result<TokenStream2> {
6116    if let Some(inner) = fk_inner {
6117        if attrs.fk.is_some() || attrs.o2o.is_some() {
6118            return Err(syn::Error::new_spanned(
6119                field,
6120                "`ForeignKey<T>` already declares the FK target via the type parameter — \
6121                 remove the `fk = \"…\"` / `o2o = \"…\"` attribute.",
6122            ));
6123        }
6124        let on = attrs.on.as_deref().unwrap_or("id");
6125        return Ok(quote! {
6126            ::core::option::Option::Some(::rustango::core::Relation::Fk {
6127                to: <#inner as ::rustango::core::Model>::SCHEMA.table,
6128                on: #on,
6129            })
6130        });
6131    }
6132    match (&attrs.fk, &attrs.o2o) {
6133        (Some(_), Some(_)) => Err(syn::Error::new_spanned(
6134            field,
6135            "`fk` and `o2o` are mutually exclusive",
6136        )),
6137        (Some(to), None) => {
6138            let on = attrs.on.as_deref().unwrap_or("id");
6139            // Self-FK sentinel — `#[rustango(fk = "self")]` resolves to
6140            // the model's own table. Threaded as a literal string at
6141            // macro-expansion time to sidestep the const-eval cycle
6142            // that `Self::SCHEMA.table` would create when referenced
6143            // inside Self::SCHEMA's own initializer.
6144            let resolved = if to == "self" { table } else { to };
6145            Ok(quote! {
6146                ::core::option::Option::Some(::rustango::core::Relation::Fk { to: #resolved, on: #on })
6147            })
6148        }
6149        (None, Some(to)) => {
6150            let on = attrs.on.as_deref().unwrap_or("id");
6151            let resolved = if to == "self" { table } else { to };
6152            Ok(quote! {
6153                ::core::option::Option::Some(::rustango::core::Relation::O2O { to: #resolved, on: #on })
6154            })
6155        }
6156        (None, None) => {
6157            if attrs.on.is_some() {
6158                return Err(syn::Error::new_spanned(
6159                    field,
6160                    "`on` requires `fk` or `o2o`",
6161                ));
6162            }
6163            Ok(quote!(::core::option::Option::None))
6164        }
6165    }
6166}
6167
6168/// Mirrors `rustango_core::FieldType`. Local copy so the macro can reason
6169/// about kinds without depending on `rustango-core` (which would require a
6170/// proc-macro/normal split it doesn't have today).
6171#[derive(Clone, Copy, PartialEq, Eq)]
6172enum DetectedKind {
6173    I16,
6174    I32,
6175    I64,
6176    F32,
6177    F64,
6178    Bool,
6179    String,
6180    DateTime,
6181    Date,
6182    Time,
6183    Uuid,
6184    Json,
6185    Decimal,
6186    Binary,
6187}
6188
6189impl DetectedKind {
6190    fn variant_tokens(self) -> TokenStream2 {
6191        match self {
6192            Self::I16 => quote!(::rustango::core::FieldType::I16),
6193            Self::I32 => quote!(::rustango::core::FieldType::I32),
6194            Self::I64 => quote!(::rustango::core::FieldType::I64),
6195            Self::F32 => quote!(::rustango::core::FieldType::F32),
6196            Self::F64 => quote!(::rustango::core::FieldType::F64),
6197            Self::Bool => quote!(::rustango::core::FieldType::Bool),
6198            Self::String => quote!(::rustango::core::FieldType::String),
6199            Self::DateTime => quote!(::rustango::core::FieldType::DateTime),
6200            Self::Date => quote!(::rustango::core::FieldType::Date),
6201            Self::Time => quote!(::rustango::core::FieldType::Time),
6202            Self::Uuid => quote!(::rustango::core::FieldType::Uuid),
6203            Self::Json => quote!(::rustango::core::FieldType::Json),
6204            Self::Decimal => quote!(::rustango::core::FieldType::Decimal),
6205            Self::Binary => quote!(::rustango::core::FieldType::Binary),
6206        }
6207    }
6208
6209    fn is_integer(self) -> bool {
6210        matches!(self, Self::I16 | Self::I32 | Self::I64)
6211    }
6212
6213    /// `(SqlValue::<Variant>, default expr)` for emitting the
6214    /// `match SqlValue { … }` arm in `LoadRelated::__rustango_load_related`
6215    /// for a `ForeignKey<T, K>` FK whose K maps to `self`. The default
6216    /// fires only when the parent's `__rustango_pk_value` returns a
6217    /// different variant than expected, which is a compile-time bug —
6218    /// but we still need a value-typed fallback to keep the match
6219    /// total.
6220    fn sqlvalue_match_arm(self) -> (TokenStream2, TokenStream2) {
6221        match self {
6222            Self::I16 => (quote!(I16), quote!(0i16)),
6223            Self::I32 => (quote!(I32), quote!(0i32)),
6224            Self::I64 => (quote!(I64), quote!(0i64)),
6225            Self::F32 => (quote!(F32), quote!(0f32)),
6226            Self::F64 => (quote!(F64), quote!(0f64)),
6227            Self::Bool => (quote!(Bool), quote!(false)),
6228            Self::String => (quote!(String), quote!(::std::string::String::new())),
6229            Self::DateTime => (
6230                quote!(DateTime),
6231                quote!(<::chrono::DateTime<::chrono::Utc> as ::std::default::Default>::default()),
6232            ),
6233            Self::Date => (
6234                quote!(Date),
6235                quote!(<::chrono::NaiveDate as ::std::default::Default>::default()),
6236            ),
6237            Self::Time => (
6238                quote!(Time),
6239                quote!(<::chrono::NaiveTime as ::std::default::Default>::default()),
6240            ),
6241            Self::Uuid => (quote!(Uuid), quote!(::uuid::Uuid::nil())),
6242            Self::Json => (quote!(Json), quote!(::serde_json::Value::Null)),
6243            Self::Decimal => (
6244                quote!(Decimal),
6245                quote!(<::rust_decimal::Decimal as ::std::default::Default>::default()),
6246            ),
6247            Self::Binary => (quote!(Binary), quote!(::std::vec::Vec::<u8>::new())),
6248        }
6249    }
6250}
6251
6252/// Result of walking a field's Rust type. `kind` is the underlying
6253/// `FieldType`; `nullable` is set by an outer `Option<T>`; `auto` is
6254/// set by an outer `Auto<T>` (server-assigned PK); `fk_inner` is
6255/// `Some(<T>)` when the field was `ForeignKey<T>` (or
6256/// `Option<ForeignKey<T>>`), letting the codegen reach `T::SCHEMA`.
6257#[derive(Clone, Copy)]
6258struct DetectedType<'a> {
6259    kind: DetectedKind,
6260    nullable: bool,
6261    auto: bool,
6262    fk_inner: Option<&'a syn::Type>,
6263}
6264
6265/// Extract the `T` from a `…::Auto<T>` field type. Returns `None` for
6266/// non-`Auto` types — the caller should already have routed Auto-only
6267/// codegen through this helper, so a `None` indicates a macro-internal
6268/// invariant break.
6269fn auto_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
6270    let Type::Path(TypePath { path, qself: None }) = ty else {
6271        return None;
6272    };
6273    let last = path.segments.last()?;
6274    if last.ident != "Auto" {
6275        return None;
6276    }
6277    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
6278        return None;
6279    };
6280    args.args.iter().find_map(|a| match a {
6281        syn::GenericArgument::Type(t) => Some(t),
6282        _ => None,
6283    })
6284}
6285
6286fn detect_type(ty: &syn::Type) -> syn::Result<DetectedType<'_>> {
6287    let Type::Path(TypePath { path, qself: None }) = ty else {
6288        return Err(syn::Error::new_spanned(ty, "unsupported field type"));
6289    };
6290    let last = path
6291        .segments
6292        .last()
6293        .ok_or_else(|| syn::Error::new_spanned(ty, "empty type path"))?;
6294
6295    if last.ident == "Option" {
6296        let inner = generic_inner(ty, &last.arguments, "Option")?;
6297        let inner_det = detect_type(inner)?;
6298        if inner_det.nullable {
6299            return Err(syn::Error::new_spanned(
6300                ty,
6301                "nested Option is not supported",
6302            ));
6303        }
6304        if inner_det.auto {
6305            return Err(syn::Error::new_spanned(
6306                ty,
6307                "`Option<Auto<T>>` is not supported — Auto fields are server-assigned and cannot be NULL",
6308            ));
6309        }
6310        return Ok(DetectedType {
6311            nullable: true,
6312            ..inner_det
6313        });
6314    }
6315
6316    if last.ident == "Auto" {
6317        let inner = generic_inner(ty, &last.arguments, "Auto")?;
6318        let inner_det = detect_type(inner)?;
6319        if inner_det.auto {
6320            return Err(syn::Error::new_spanned(ty, "nested Auto is not supported"));
6321        }
6322        if inner_det.nullable {
6323            return Err(syn::Error::new_spanned(
6324                ty,
6325                "`Auto<Option<T>>` is not supported — Auto fields are server-assigned and cannot be NULL",
6326            ));
6327        }
6328        if inner_det.fk_inner.is_some() {
6329            return Err(syn::Error::new_spanned(
6330                ty,
6331                "`Auto<ForeignKey<T>>` is not supported — Auto is for server-assigned PKs, ForeignKey is for parent references",
6332            ));
6333        }
6334        if !matches!(
6335            inner_det.kind,
6336            DetectedKind::I32 | DetectedKind::I64 | DetectedKind::Uuid | DetectedKind::DateTime
6337        ) {
6338            return Err(syn::Error::new_spanned(
6339                ty,
6340                "`Auto<T>` only supports integers (`i32` → SERIAL, `i64` → BIGSERIAL), \
6341                 `uuid::Uuid` (DEFAULT gen_random_uuid()), or `chrono::DateTime<chrono::Utc>` \
6342                 (DEFAULT now())",
6343            ));
6344        }
6345        return Ok(DetectedType {
6346            auto: true,
6347            ..inner_det
6348        });
6349    }
6350
6351    if last.ident == "ForeignKey" {
6352        let (inner, key_ty) = generic_pair(ty, &last.arguments, "ForeignKey")?;
6353        // Resolve the FK column's underlying SQL type from `K`. When the
6354        // user wrote `ForeignKey<T>` without a key parameter, the type
6355        // alias defaults to `i64` and we keep the v0.7 BIGINT shape.
6356        // When the user wrote `ForeignKey<T, K>` with an explicit `K`,
6357        // recurse into K so the column DDL emits the right SQL type
6358        // (VARCHAR for String, UUID for Uuid, …) and the load_related
6359        // emitter knows which `SqlValue` variant to match.
6360        let kind = match key_ty {
6361            Some(k) => detect_type(k)?.kind,
6362            None => DetectedKind::I64,
6363        };
6364        return Ok(DetectedType {
6365            kind,
6366            nullable: false,
6367            auto: false,
6368            fk_inner: Some(inner),
6369        });
6370    }
6371
6372    let kind = match last.ident.to_string().as_str() {
6373        "i16" => DetectedKind::I16,
6374        "i32" => DetectedKind::I32,
6375        "i64" => DetectedKind::I64,
6376        "f32" => DetectedKind::F32,
6377        "f64" => DetectedKind::F64,
6378        "bool" => DetectedKind::Bool,
6379        "String" => DetectedKind::String,
6380        "DateTime" => DetectedKind::DateTime,
6381        "NaiveDate" => DetectedKind::Date,
6382        "NaiveTime" => DetectedKind::Time,
6383        "Uuid" => DetectedKind::Uuid,
6384        "Value" => DetectedKind::Json,
6385        "Decimal" => DetectedKind::Decimal,
6386        // `Vec<u8>` → BYTEA / LONGBLOB / BLOB. Reject any other
6387        // `Vec<T>` so we don't silently accept e.g. `Vec<String>`
6388        // — that would emit Binary DDL and decode-fail at runtime.
6389        "Vec" => {
6390            let (inner, _) = generic_pair(ty, &last.arguments, "Vec")?;
6391            if let Type::Path(TypePath { path, qself: None }) = inner {
6392                if let Some(seg) = path.segments.last() {
6393                    if seg.ident == "u8" && seg.arguments.is_empty() {
6394                        return Ok(DetectedType {
6395                            kind: DetectedKind::Binary,
6396                            nullable: false,
6397                            auto: false,
6398                            fk_inner: None,
6399                        });
6400                    }
6401                }
6402            }
6403            return Err(syn::Error::new_spanned(
6404                ty,
6405                "unsupported `Vec<T>` field — only `Vec<u8>` (→ Binary) is supported",
6406            ));
6407        }
6408        other => {
6409            return Err(syn::Error::new_spanned(
6410                ty,
6411                format!("unsupported field type `{other}`; supports i16/i32/i64/f32/f64/bool/String/DateTime/NaiveDate/NaiveTime/Uuid/serde_json::Value/Decimal/Vec<u8>, optionally wrapped in Option or Auto (Auto only on integers/Uuid/DateTime)"),
6412            ));
6413        }
6414    };
6415    Ok(DetectedType {
6416        kind,
6417        nullable: false,
6418        auto: false,
6419        fk_inner: None,
6420    })
6421}
6422
6423fn generic_inner<'a>(
6424    ty: &'a Type,
6425    arguments: &'a PathArguments,
6426    wrapper: &str,
6427) -> syn::Result<&'a Type> {
6428    let PathArguments::AngleBracketed(args) = arguments else {
6429        return Err(syn::Error::new_spanned(
6430            ty,
6431            format!("{wrapper} requires a generic argument"),
6432        ));
6433    };
6434    args.args
6435        .iter()
6436        .find_map(|a| match a {
6437            GenericArgument::Type(t) => Some(t),
6438            _ => None,
6439        })
6440        .ok_or_else(|| {
6441            syn::Error::new_spanned(ty, format!("{wrapper}<T> requires a type argument"))
6442        })
6443}
6444
6445/// Like [`generic_inner`] but pulls *two* type args — the first is
6446/// required, the second is optional. Used by the `ForeignKey<T, K>`
6447/// detection where K defaults to `i64` when omitted.
6448fn generic_pair<'a>(
6449    ty: &'a Type,
6450    arguments: &'a PathArguments,
6451    wrapper: &str,
6452) -> syn::Result<(&'a Type, Option<&'a Type>)> {
6453    let PathArguments::AngleBracketed(args) = arguments else {
6454        return Err(syn::Error::new_spanned(
6455            ty,
6456            format!("{wrapper} requires a generic argument"),
6457        ));
6458    };
6459    let mut types = args.args.iter().filter_map(|a| match a {
6460        GenericArgument::Type(t) => Some(t),
6461        _ => None,
6462    });
6463    let first = types.next().ok_or_else(|| {
6464        syn::Error::new_spanned(ty, format!("{wrapper}<T> requires a type argument"))
6465    })?;
6466    let second = types.next();
6467    Ok((first, second))
6468}
6469
6470fn to_snake_case(s: &str) -> String {
6471    let mut out = String::with_capacity(s.len() + 4);
6472    for (i, ch) in s.chars().enumerate() {
6473        if ch.is_ascii_uppercase() {
6474            if i > 0 {
6475                out.push('_');
6476            }
6477            out.push(ch.to_ascii_lowercase());
6478        } else {
6479            out.push(ch);
6480        }
6481    }
6482    out
6483}
6484
6485// ============================================================
6486//  #[derive(Form)]  —  slice 8.4B
6487// ============================================================
6488
6489/// Per-field `#[form(...)]` attributes recognised by the derive.
6490#[derive(Default)]
6491struct FormFieldAttrs {
6492    min: Option<i64>,
6493    max: Option<i64>,
6494    min_length: Option<u32>,
6495    max_length: Option<u32>,
6496    /// `#[form(clean = "fn_name")]` — Django-shape `clean_<field>` hook.
6497    /// The named static method on the form struct is called after the
6498    /// field's typed parse + length/range checks; it gets the parsed
6499    /// value by reference and returns `Result<<FieldType>, String>`.
6500    /// On Ok, the returned value replaces the parsed one; on Err, the
6501    /// message is attached to the field error list. Issue #372.
6502    clean: Option<syn::Ident>,
6503}
6504
6505/// Container-level `#[form(...)]` attributes. Currently only the
6506/// Django-shape cross-field `validate` hook (issue #373).
6507#[derive(Default)]
6508struct FormContainerAttrs {
6509    /// `#[form(validate = "fn_name")]` — Django-shape `clean()` hook.
6510    /// After every per-field parse succeeds, the named method on the
6511    /// form struct is called with `&self` and may return
6512    /// `Result<(), FormErrors>`. Errors merge into the field error
6513    /// list. Issue #373.
6514    validate: Option<syn::Ident>,
6515}
6516
6517/// Detected shape of a form field's Rust type.
6518#[derive(Clone, Copy)]
6519enum FormFieldKind {
6520    String,
6521    I16,
6522    I32,
6523    I64,
6524    F32,
6525    F64,
6526    Bool,
6527}
6528
6529impl FormFieldKind {
6530    fn parse_method(self) -> &'static str {
6531        match self {
6532            Self::I16 => "i16",
6533            Self::I32 => "i32",
6534            Self::I64 => "i64",
6535            Self::F32 => "f32",
6536            Self::F64 => "f64",
6537            // String + Bool don't go through `str::parse`; the codegen
6538            // handles them inline.
6539            Self::String | Self::Bool => "",
6540        }
6541    }
6542}
6543
6544fn expand_form(input: &DeriveInput) -> syn::Result<TokenStream2> {
6545    let struct_name = &input.ident;
6546
6547    let Data::Struct(data) = &input.data else {
6548        return Err(syn::Error::new_spanned(
6549            struct_name,
6550            "Form can only be derived on structs",
6551        ));
6552    };
6553    let Fields::Named(named) = &data.fields else {
6554        return Err(syn::Error::new_spanned(
6555            struct_name,
6556            "Form requires a struct with named fields",
6557        ));
6558    };
6559
6560    // #373 — container-level `#[form(validate = "fn")]` hook.
6561    let container = parse_form_container_attrs(input)?;
6562    let post_field_clean: Vec<TokenStream2> = Vec::new();
6563    let _ = post_field_clean;
6564
6565    let mut field_blocks: Vec<TokenStream2> = Vec::with_capacity(named.named.len());
6566    let mut field_idents: Vec<&syn::Ident> = Vec::with_capacity(named.named.len());
6567
6568    for field in &named.named {
6569        let ident = field
6570            .ident
6571            .as_ref()
6572            .ok_or_else(|| syn::Error::new(field.span(), "tuple structs are not supported"))?;
6573        let attrs = parse_form_field_attrs(field)?;
6574        let (kind, nullable) = detect_form_field(&field.ty, field.span())?;
6575
6576        let name_lit = ident.to_string();
6577        let parse_block = render_form_field_parse(ident, &name_lit, kind, nullable, &attrs);
6578        // #372 — append the per-field `clean_<field>` call right after
6579        // the parse block when the attribute is set. The clean fn
6580        // takes &T and returns Result<T, String>; on Err we attach
6581        // the message to the field error list without aborting
6582        // (matches Django's "collect all field errors" shape).
6583        let clean_block = if let Some(clean_fn) = &attrs.clean {
6584            quote! {
6585                if __errors.fields().get(#name_lit).is_none() {
6586                    match Self::#clean_fn(&#ident) {
6587                        ::core::result::Result::Ok(__cleaned) => { #ident = __cleaned; }
6588                        ::core::result::Result::Err(__msg) => {
6589                            __errors.add(#name_lit, __msg);
6590                        }
6591                    }
6592                }
6593            }
6594        } else {
6595            quote! {}
6596        };
6597        field_blocks.push(quote! {
6598            #parse_block
6599            #clean_block
6600        });
6601        field_idents.push(ident);
6602    }
6603
6604    // #373 — after every per-field parse + clean succeeds, call the
6605    // cross-field validator if declared. Errors merge into the
6606    // outgoing FormErrors via the existing `FormErrors::merge` helper
6607    // (same primitive the DRF serializer cross-field hook uses).
6608    let cross_field_call = if let Some(validate_fn) = &container.validate {
6609        quote! {
6610            if __errors.is_empty() {
6611                let __candidate = Self { #( #field_idents ),* };
6612                if let ::core::result::Result::Err(__other) = Self::#validate_fn(&__candidate) {
6613                    __errors.merge(__other);
6614                }
6615                if !__errors.is_empty() {
6616                    return ::core::result::Result::Err(__errors);
6617                }
6618                return ::core::result::Result::Ok(__candidate);
6619            }
6620        }
6621    } else {
6622        quote! {}
6623    };
6624
6625    Ok(quote! {
6626        impl ::rustango::forms::Form for #struct_name {
6627            fn parse(
6628                data: &::std::collections::HashMap<::std::string::String, ::std::string::String>,
6629            ) -> ::core::result::Result<Self, ::rustango::forms::FormErrors> {
6630                let mut __errors = ::rustango::forms::FormErrors::default();
6631                #( #field_blocks )*
6632                #cross_field_call
6633                if !__errors.is_empty() {
6634                    return ::core::result::Result::Err(__errors);
6635                }
6636                ::core::result::Result::Ok(Self {
6637                    #( #field_idents ),*
6638                })
6639            }
6640        }
6641    })
6642}
6643
6644fn parse_form_container_attrs(input: &DeriveInput) -> syn::Result<FormContainerAttrs> {
6645    let mut out = FormContainerAttrs::default();
6646    for attr in &input.attrs {
6647        if !attr.path().is_ident("form") {
6648            continue;
6649        }
6650        attr.parse_nested_meta(|meta| {
6651            if meta.path.is_ident("validate") {
6652                let s: LitStr = meta.value()?.parse()?;
6653                out.validate = Some(syn::Ident::new(&s.value(), s.span()));
6654                return Ok(());
6655            }
6656            Err(meta.error("unknown form container attribute (supported: `validate`)"))
6657        })?;
6658    }
6659    Ok(out)
6660}
6661
6662fn parse_form_field_attrs(field: &syn::Field) -> syn::Result<FormFieldAttrs> {
6663    let mut out = FormFieldAttrs::default();
6664    for attr in &field.attrs {
6665        if !attr.path().is_ident("form") {
6666            continue;
6667        }
6668        attr.parse_nested_meta(|meta| {
6669            if meta.path.is_ident("min") {
6670                let lit: syn::LitInt = meta.value()?.parse()?;
6671                out.min = Some(lit.base10_parse::<i64>()?);
6672                return Ok(());
6673            }
6674            if meta.path.is_ident("max") {
6675                let lit: syn::LitInt = meta.value()?.parse()?;
6676                out.max = Some(lit.base10_parse::<i64>()?);
6677                return Ok(());
6678            }
6679            if meta.path.is_ident("min_length") {
6680                let lit: syn::LitInt = meta.value()?.parse()?;
6681                out.min_length = Some(lit.base10_parse::<u32>()?);
6682                return Ok(());
6683            }
6684            if meta.path.is_ident("max_length") {
6685                let lit: syn::LitInt = meta.value()?.parse()?;
6686                out.max_length = Some(lit.base10_parse::<u32>()?);
6687                return Ok(());
6688            }
6689            if meta.path.is_ident("clean") {
6690                let s: LitStr = meta.value()?.parse()?;
6691                out.clean = Some(syn::Ident::new(&s.value(), s.span()));
6692                return Ok(());
6693            }
6694            Err(meta.error(
6695                "unknown form field attribute (supported: `min`, `max`, `min_length`, `max_length`, `clean`)",
6696            ))
6697        })?;
6698    }
6699    Ok(out)
6700}
6701
6702fn detect_form_field(ty: &Type, span: proc_macro2::Span) -> syn::Result<(FormFieldKind, bool)> {
6703    let Type::Path(TypePath { path, qself: None }) = ty else {
6704        return Err(syn::Error::new(
6705            span,
6706            "Form field must be a simple typed path (e.g. `String`, `i32`, `Option<String>`)",
6707        ));
6708    };
6709    let last = path
6710        .segments
6711        .last()
6712        .ok_or_else(|| syn::Error::new(span, "empty type path"))?;
6713
6714    if last.ident == "Option" {
6715        let inner = generic_inner(ty, &last.arguments, "Option")?;
6716        let (kind, nested) = detect_form_field(inner, span)?;
6717        if nested {
6718            return Err(syn::Error::new(
6719                span,
6720                "nested Option in Form fields is not supported",
6721            ));
6722        }
6723        return Ok((kind, true));
6724    }
6725
6726    let kind = match last.ident.to_string().as_str() {
6727        "String" => FormFieldKind::String,
6728        "i16" => FormFieldKind::I16,
6729        "i32" => FormFieldKind::I32,
6730        "i64" => FormFieldKind::I64,
6731        "f32" => FormFieldKind::F32,
6732        "f64" => FormFieldKind::F64,
6733        "bool" => FormFieldKind::Bool,
6734        other => {
6735            return Err(syn::Error::new(
6736                span,
6737                format!(
6738                    "Form field type `{other}` is not supported in v0.8 — use String / \
6739                     i16 / i32 / i64 / f32 / f64 / bool, optionally wrapped in Option<…>"
6740                ),
6741            ));
6742        }
6743    };
6744    Ok((kind, false))
6745}
6746
6747#[allow(clippy::too_many_lines)]
6748fn render_form_field_parse(
6749    ident: &syn::Ident,
6750    name_lit: &str,
6751    kind: FormFieldKind,
6752    nullable: bool,
6753    attrs: &FormFieldAttrs,
6754) -> TokenStream2 {
6755    // Pull the raw &str from the payload. Uses variable name `data` to
6756    // match the new `Form::parse(data: &HashMap<…>)` signature.
6757    let lookup = quote! {
6758        let __raw: ::core::option::Option<&::std::string::String> = data.get(#name_lit);
6759    };
6760
6761    let parsed_value = match kind {
6762        FormFieldKind::Bool => quote! {
6763            let __v: bool = match __raw {
6764                ::core::option::Option::None => false,
6765                ::core::option::Option::Some(__s) => !matches!(
6766                    __s.to_ascii_lowercase().as_str(),
6767                    "" | "false" | "0" | "off" | "no"
6768                ),
6769            };
6770        },
6771        FormFieldKind::String => {
6772            if nullable {
6773                quote! {
6774                    let __v: ::core::option::Option<::std::string::String> = match __raw {
6775                        ::core::option::Option::None => ::core::option::Option::None,
6776                        ::core::option::Option::Some(__s) if __s.is_empty() => {
6777                            ::core::option::Option::None
6778                        }
6779                        ::core::option::Option::Some(__s) => {
6780                            ::core::option::Option::Some(::core::clone::Clone::clone(__s))
6781                        }
6782                    };
6783                }
6784            } else {
6785                quote! {
6786                    let __v: ::std::string::String = match __raw {
6787                        ::core::option::Option::Some(__s) if !__s.is_empty() => {
6788                            ::core::clone::Clone::clone(__s)
6789                        }
6790                        _ => {
6791                            __errors.add(#name_lit, "This field is required.");
6792                            ::std::string::String::new()
6793                        }
6794                    };
6795                }
6796            }
6797        }
6798        FormFieldKind::I16
6799        | FormFieldKind::I32
6800        | FormFieldKind::I64
6801        | FormFieldKind::F32
6802        | FormFieldKind::F64 => {
6803            let parse_ty = syn::Ident::new(kind.parse_method(), proc_macro2::Span::call_site());
6804            let ty_lit = kind.parse_method();
6805            let default_val = match kind {
6806                FormFieldKind::I16 => quote! { 0i16 },
6807                FormFieldKind::I32 => quote! { 0i32 },
6808                FormFieldKind::I64 => quote! { 0i64 },
6809                FormFieldKind::F32 => quote! { 0f32 },
6810                FormFieldKind::F64 => quote! { 0f64 },
6811                _ => quote! { Default::default() },
6812            };
6813            if nullable {
6814                quote! {
6815                    let __v: ::core::option::Option<#parse_ty> = match __raw {
6816                        ::core::option::Option::None => ::core::option::Option::None,
6817                        ::core::option::Option::Some(__s) if __s.is_empty() => {
6818                            ::core::option::Option::None
6819                        }
6820                        ::core::option::Option::Some(__s) => {
6821                            match __s.parse::<#parse_ty>() {
6822                                ::core::result::Result::Ok(__n) => {
6823                                    ::core::option::Option::Some(__n)
6824                                }
6825                                ::core::result::Result::Err(__e) => {
6826                                    __errors.add(
6827                                        #name_lit,
6828                                        ::std::format!("Enter a valid {} value: {}", #ty_lit, __e),
6829                                    );
6830                                    ::core::option::Option::None
6831                                }
6832                            }
6833                        }
6834                    };
6835                }
6836            } else {
6837                quote! {
6838                    let __v: #parse_ty = match __raw {
6839                        ::core::option::Option::Some(__s) if !__s.is_empty() => {
6840                            match __s.parse::<#parse_ty>() {
6841                                ::core::result::Result::Ok(__n) => __n,
6842                                ::core::result::Result::Err(__e) => {
6843                                    __errors.add(
6844                                        #name_lit,
6845                                        ::std::format!("Enter a valid {} value: {}", #ty_lit, __e),
6846                                    );
6847                                    #default_val
6848                                }
6849                            }
6850                        }
6851                        _ => {
6852                            __errors.add(#name_lit, "This field is required.");
6853                            #default_val
6854                        }
6855                    };
6856                }
6857            }
6858        }
6859    };
6860
6861    let validators = render_form_validators(name_lit, kind, nullable, attrs);
6862
6863    quote! {
6864        // `mut` so the per-field `clean` hook (#372) can rewrite the
6865        // parsed value in-place when it returns Ok with a normalized
6866        // form (e.g. trim / lowercase).
6867        let mut #ident = {
6868            #lookup
6869            #parsed_value
6870            #validators
6871            __v
6872        };
6873    }
6874}
6875
6876fn render_form_validators(
6877    name_lit: &str,
6878    kind: FormFieldKind,
6879    nullable: bool,
6880    attrs: &FormFieldAttrs,
6881) -> TokenStream2 {
6882    let mut checks: Vec<TokenStream2> = Vec::new();
6883
6884    let val_ref = if nullable {
6885        quote! { __v.as_ref() }
6886    } else {
6887        quote! { ::core::option::Option::Some(&__v) }
6888    };
6889
6890    let is_string = matches!(kind, FormFieldKind::String);
6891    let is_numeric = matches!(
6892        kind,
6893        FormFieldKind::I16
6894            | FormFieldKind::I32
6895            | FormFieldKind::I64
6896            | FormFieldKind::F32
6897            | FormFieldKind::F64
6898    );
6899
6900    if is_string {
6901        if let Some(min_len) = attrs.min_length {
6902            let min_len_usize = min_len as usize;
6903            checks.push(quote! {
6904                if let ::core::option::Option::Some(__s) = #val_ref {
6905                    if __s.len() < #min_len_usize {
6906                        __errors.add(
6907                            #name_lit,
6908                            ::std::format!("Ensure this value has at least {} characters.", #min_len_usize),
6909                        );
6910                    }
6911                }
6912            });
6913        }
6914        if let Some(max_len) = attrs.max_length {
6915            let max_len_usize = max_len as usize;
6916            checks.push(quote! {
6917                if let ::core::option::Option::Some(__s) = #val_ref {
6918                    if __s.len() > #max_len_usize {
6919                        __errors.add(
6920                            #name_lit,
6921                            ::std::format!("Ensure this value has at most {} characters.", #max_len_usize),
6922                        );
6923                    }
6924                }
6925            });
6926        }
6927    }
6928
6929    if is_numeric {
6930        if let Some(min) = attrs.min {
6931            checks.push(quote! {
6932                if let ::core::option::Option::Some(__n) = #val_ref {
6933                    if (*__n as f64) < (#min as f64) {
6934                        __errors.add(
6935                            #name_lit,
6936                            ::std::format!("Ensure this value is greater than or equal to {}.", #min),
6937                        );
6938                    }
6939                }
6940            });
6941        }
6942        if let Some(max) = attrs.max {
6943            checks.push(quote! {
6944                if let ::core::option::Option::Some(__n) = #val_ref {
6945                    if (*__n as f64) > (#max as f64) {
6946                        __errors.add(
6947                            #name_lit,
6948                            ::std::format!("Ensure this value is less than or equal to {}.", #max),
6949                        );
6950                    }
6951                }
6952            });
6953        }
6954    }
6955
6956    quote! { #( #checks )* }
6957}
6958
6959// ============================================================
6960//  #[derive(ViewSet)]
6961// ============================================================
6962
6963struct ViewSetAttrs {
6964    model: syn::Path,
6965    fields: Option<Vec<String>>,
6966    filter_fields: Vec<String>,
6967    search_fields: Vec<String>,
6968    /// (field_name, desc)
6969    ordering: Vec<(String, bool)>,
6970    page_size: Option<usize>,
6971    read_only: bool,
6972    perms: ViewSetPermsAttrs,
6973}
6974
6975#[derive(Default)]
6976struct ViewSetPermsAttrs {
6977    list: Vec<String>,
6978    retrieve: Vec<String>,
6979    create: Vec<String>,
6980    update: Vec<String>,
6981    destroy: Vec<String>,
6982}
6983
6984fn expand_viewset(input: &DeriveInput) -> syn::Result<TokenStream2> {
6985    let struct_name = &input.ident;
6986
6987    // Must be a unit struct or an empty named struct.
6988    match &input.data {
6989        Data::Struct(s) => match &s.fields {
6990            Fields::Unit | Fields::Named(_) => {}
6991            Fields::Unnamed(_) => {
6992                return Err(syn::Error::new_spanned(
6993                    struct_name,
6994                    "ViewSet can only be derived on a unit struct or an empty named struct",
6995                ));
6996            }
6997        },
6998        _ => {
6999            return Err(syn::Error::new_spanned(
7000                struct_name,
7001                "ViewSet can only be derived on a struct",
7002            ));
7003        }
7004    }
7005
7006    let attrs = parse_viewset_attrs(input)?;
7007    let model_path = &attrs.model;
7008
7009    // `.fields(&[...])` call — None means skip (use all scalar fields).
7010    let fields_call = if let Some(ref fields) = attrs.fields {
7011        let lits = fields.iter().map(|f| f.as_str());
7012        quote!(.fields(&[ #(#lits),* ]))
7013    } else {
7014        quote!()
7015    };
7016
7017    let filter_fields_call = if attrs.filter_fields.is_empty() {
7018        quote!()
7019    } else {
7020        let lits = attrs.filter_fields.iter().map(|f| f.as_str());
7021        quote!(.filter_fields(&[ #(#lits),* ]))
7022    };
7023
7024    let search_fields_call = if attrs.search_fields.is_empty() {
7025        quote!()
7026    } else {
7027        let lits = attrs.search_fields.iter().map(|f| f.as_str());
7028        quote!(.search_fields(&[ #(#lits),* ]))
7029    };
7030
7031    let ordering_call = if attrs.ordering.is_empty() {
7032        quote!()
7033    } else {
7034        let pairs = attrs.ordering.iter().map(|(f, desc)| {
7035            let f = f.as_str();
7036            quote!((#f, #desc))
7037        });
7038        quote!(.ordering(&[ #(#pairs),* ]))
7039    };
7040
7041    let page_size_call = if let Some(n) = attrs.page_size {
7042        quote!(.page_size(#n))
7043    } else {
7044        quote!()
7045    };
7046
7047    let read_only_call = if attrs.read_only {
7048        quote!(.read_only())
7049    } else {
7050        quote!()
7051    };
7052
7053    let perms = &attrs.perms;
7054    let perms_call = if perms.list.is_empty()
7055        && perms.retrieve.is_empty()
7056        && perms.create.is_empty()
7057        && perms.update.is_empty()
7058        && perms.destroy.is_empty()
7059    {
7060        quote!()
7061    } else {
7062        let list_lits = perms.list.iter().map(|s| s.as_str());
7063        let retrieve_lits = perms.retrieve.iter().map(|s| s.as_str());
7064        let create_lits = perms.create.iter().map(|s| s.as_str());
7065        let update_lits = perms.update.iter().map(|s| s.as_str());
7066        let destroy_lits = perms.destroy.iter().map(|s| s.as_str());
7067        quote! {
7068            .permissions(::rustango::viewset::ViewSetPerms {
7069                list:     ::std::vec![ #(#list_lits.to_owned()),* ],
7070                retrieve: ::std::vec![ #(#retrieve_lits.to_owned()),* ],
7071                create:   ::std::vec![ #(#create_lits.to_owned()),* ],
7072                update:   ::std::vec![ #(#update_lits.to_owned()),* ],
7073                destroy:  ::std::vec![ #(#destroy_lits.to_owned()),* ],
7074            })
7075        }
7076    };
7077
7078    Ok(quote! {
7079        impl #struct_name {
7080            /// Build an `axum::Router` with the six standard REST endpoints
7081            /// for this ViewSet, mounted at `prefix`.
7082            pub fn router(prefix: &str, pool: ::rustango::sql::sqlx::PgPool) -> ::axum::Router {
7083                ::rustango::viewset::ViewSet::for_model(
7084                    <#model_path as ::rustango::core::Model>::SCHEMA
7085                )
7086                    #fields_call
7087                    #filter_fields_call
7088                    #search_fields_call
7089                    #ordering_call
7090                    #page_size_call
7091                    #perms_call
7092                    #read_only_call
7093                    .router(prefix, pool)
7094            }
7095        }
7096    })
7097}
7098
7099fn parse_viewset_attrs(input: &DeriveInput) -> syn::Result<ViewSetAttrs> {
7100    let mut model: Option<syn::Path> = None;
7101    let mut fields: Option<Vec<String>> = None;
7102    let mut filter_fields: Vec<String> = Vec::new();
7103    let mut search_fields: Vec<String> = Vec::new();
7104    let mut ordering: Vec<(String, bool)> = Vec::new();
7105    let mut page_size: Option<usize> = None;
7106    let mut read_only = false;
7107    let mut perms = ViewSetPermsAttrs::default();
7108
7109    for attr in &input.attrs {
7110        if !attr.path().is_ident("viewset") {
7111            continue;
7112        }
7113        attr.parse_nested_meta(|meta| {
7114            if meta.path.is_ident("model") {
7115                let path: syn::Path = meta.value()?.parse()?;
7116                model = Some(path);
7117                return Ok(());
7118            }
7119            if meta.path.is_ident("fields") {
7120                let s: LitStr = meta.value()?.parse()?;
7121                fields = Some(split_field_list(&s.value()));
7122                return Ok(());
7123            }
7124            if meta.path.is_ident("filter_fields") {
7125                let s: LitStr = meta.value()?.parse()?;
7126                filter_fields = split_field_list(&s.value());
7127                return Ok(());
7128            }
7129            if meta.path.is_ident("search_fields") {
7130                let s: LitStr = meta.value()?.parse()?;
7131                search_fields = split_field_list(&s.value());
7132                return Ok(());
7133            }
7134            if meta.path.is_ident("ordering") {
7135                let s: LitStr = meta.value()?.parse()?;
7136                ordering = parse_ordering_list(&s.value());
7137                return Ok(());
7138            }
7139            if meta.path.is_ident("page_size") {
7140                let lit: syn::LitInt = meta.value()?.parse()?;
7141                page_size = Some(lit.base10_parse::<usize>()?);
7142                return Ok(());
7143            }
7144            if meta.path.is_ident("read_only") {
7145                read_only = true;
7146                return Ok(());
7147            }
7148            if meta.path.is_ident("permissions") {
7149                meta.parse_nested_meta(|inner| {
7150                    let parse_codenames = |inner: &syn::meta::ParseNestedMeta| -> syn::Result<Vec<String>> {
7151                        let s: LitStr = inner.value()?.parse()?;
7152                        Ok(split_field_list(&s.value()))
7153                    };
7154                    if inner.path.is_ident("list") {
7155                        perms.list = parse_codenames(&inner)?;
7156                    } else if inner.path.is_ident("retrieve") {
7157                        perms.retrieve = parse_codenames(&inner)?;
7158                    } else if inner.path.is_ident("create") {
7159                        perms.create = parse_codenames(&inner)?;
7160                    } else if inner.path.is_ident("update") {
7161                        perms.update = parse_codenames(&inner)?;
7162                    } else if inner.path.is_ident("destroy") {
7163                        perms.destroy = parse_codenames(&inner)?;
7164                    } else {
7165                        return Err(inner.error(
7166                            "unknown permissions key (supported: list, retrieve, create, update, destroy)",
7167                        ));
7168                    }
7169                    Ok(())
7170                })?;
7171                return Ok(());
7172            }
7173            Err(meta.error(
7174                "unknown viewset attribute (supported: model, fields, filter_fields, \
7175                 search_fields, ordering, page_size, read_only, permissions(...))",
7176            ))
7177        })?;
7178    }
7179
7180    let model = model.ok_or_else(|| {
7181        syn::Error::new_spanned(&input.ident, "`#[viewset(model = SomeModel)]` is required")
7182    })?;
7183
7184    Ok(ViewSetAttrs {
7185        model,
7186        fields,
7187        filter_fields,
7188        search_fields,
7189        ordering,
7190        page_size,
7191        read_only,
7192        perms,
7193    })
7194}
7195
7196// ============================================================ #[derive(Serializer)]
7197
7198struct SerializerContainerAttrs {
7199    model: syn::Path,
7200    /// `#[serializer(validate = "fn_name")]` on the struct — DRF-shape
7201    /// cross-field validation hook (#436). The named inherent method
7202    /// must take `&self` and return
7203    /// `Result<(), rustango::forms::FormErrors>`. The macro-emitted
7204    /// `validate()` runs every per-field validator first; then calls
7205    /// the cross-field method if declared; aggregates all errors into
7206    /// one `FormErrors`.
7207    cross_validate: Option<syn::Ident>,
7208}
7209
7210#[derive(Default)]
7211struct SerializerFieldAttrs {
7212    read_only: bool,
7213    write_only: bool,
7214    source: Option<String>,
7215    skip: bool,
7216    /// `#[serializer(method = "fn_name")]` — DRF SerializerMethodField
7217    /// analog. The macro emits `from_model` initializer that calls
7218    /// `Self::fn_name(&model)` and stores the return value.
7219    method: Option<String>,
7220    /// `#[serializer(validate = "fn_name")]` — per-field validator
7221    /// callable run by `Self::validate(&self)`. Must return
7222    /// `Result<(), String>`. Errors land in `FormErrors` keyed by
7223    /// the field name.
7224    validate: Option<String>,
7225    /// `#[serializer(nested)]` on a field whose type is another
7226    /// `Serializer` — the macro emits `from_model` initializer that
7227    /// reads the parent via `model.<source>.value()` then calls the
7228    /// child serializer's `from_model(parent)`. When the FK is
7229    /// unloaded the field falls back to `Default::default()` (does
7230    /// NOT panic) so a missing prefetch in prod degrades gracefully.
7231    /// Source field on the model defaults to the field name; override
7232    /// with `source = "..."`. Combine with `strict` to keep the v0.18.1
7233    /// panic-on-unloaded behavior for tests.
7234    nested: bool,
7235    /// `#[serializer(nested, strict)]` — opt back into the v0.18.1
7236    /// strict behavior: panic when the FK isn't loaded. Useful in
7237    /// test code where forgetting select_related must trip a hard
7238    /// failure rather than render a blank nested object.
7239    nested_strict: bool,
7240    /// `#[serializer(many = TagSerializer)]` — declare the field as
7241    /// a list of nested serializers. Field type must be `Vec<S>`
7242    /// where `S` is the inner serializer. The macro initializes the
7243    /// field to `Vec::new()` in `from_model` and emits a typed
7244    /// `set_<field>(&mut self, models: &[<S::Model>])` helper that
7245    /// maps each model row through `S::from_model`. Auto-load isn't
7246    /// possible (the M2M / one-to-many accessor is async); callers
7247    /// fetch the children + call the setter post-from_model.
7248    many: Option<syn::Type>,
7249    /// `#[serializer(slug = "name")]` — DRF `SlugRelatedField` analog.
7250    /// Source field on the model must be a `ForeignKey<T>`; the
7251    /// macro emits `from_model` glue that walks
7252    /// `model.<source>.value()?.<slug>` and clones it. Field type on
7253    /// the serializer is typically `String` (whatever type the slug
7254    /// column has). When the FK is unloaded the field falls back to
7255    /// `Default::default()`, same graceful-degrade contract as
7256    /// `nested`. Source defaults to the field name; override with
7257    /// `source = "..."`. v0.44.
7258    slug: Option<String>,
7259}
7260
7261fn parse_serializer_container_attrs(input: &DeriveInput) -> syn::Result<SerializerContainerAttrs> {
7262    let mut model: Option<syn::Path> = None;
7263    let mut cross_validate: Option<syn::Ident> = None;
7264    for attr in &input.attrs {
7265        if !attr.path().is_ident("serializer") {
7266            continue;
7267        }
7268        attr.parse_nested_meta(|meta| {
7269            if meta.path.is_ident("model") {
7270                let _eq: syn::Token![=] = meta.input.parse()?;
7271                model = Some(meta.input.parse()?);
7272                return Ok(());
7273            }
7274            if meta.path.is_ident("validate") {
7275                // #436 — container-level `validate = "fn_name"` for the
7276                // DRF cross-field-validation shape. Field-level
7277                // `#[serializer(validate = "...")]` on a field is
7278                // parsed separately in `parse_serializer_field_attrs`.
7279                let s: LitStr = meta.value()?.parse()?;
7280                cross_validate = Some(syn::Ident::new(&s.value(), s.span()));
7281                return Ok(());
7282            }
7283            Err(meta.error(
7284                "unknown serializer container attribute \
7285                 (supported: `model`, `validate`)",
7286            ))
7287        })?;
7288    }
7289    let model = model.ok_or_else(|| {
7290        syn::Error::new_spanned(
7291            &input.ident,
7292            "`#[serializer(model = SomeModel)]` is required",
7293        )
7294    })?;
7295    Ok(SerializerContainerAttrs {
7296        model,
7297        cross_validate,
7298    })
7299}
7300
7301fn parse_serializer_field_attrs(field: &syn::Field) -> syn::Result<SerializerFieldAttrs> {
7302    let mut out = SerializerFieldAttrs::default();
7303    for attr in &field.attrs {
7304        if !attr.path().is_ident("serializer") {
7305            continue;
7306        }
7307        attr.parse_nested_meta(|meta| {
7308            if meta.path.is_ident("read_only") {
7309                out.read_only = true;
7310                return Ok(());
7311            }
7312            if meta.path.is_ident("write_only") {
7313                out.write_only = true;
7314                return Ok(());
7315            }
7316            if meta.path.is_ident("skip") {
7317                out.skip = true;
7318                return Ok(());
7319            }
7320            if meta.path.is_ident("source") {
7321                let s: LitStr = meta.value()?.parse()?;
7322                out.source = Some(s.value());
7323                return Ok(());
7324            }
7325            if meta.path.is_ident("method") {
7326                let s: LitStr = meta.value()?.parse()?;
7327                out.method = Some(s.value());
7328                return Ok(());
7329            }
7330            if meta.path.is_ident("validate") {
7331                let s: LitStr = meta.value()?.parse()?;
7332                out.validate = Some(s.value());
7333                return Ok(());
7334            }
7335            if meta.path.is_ident("many") {
7336                let _eq: syn::Token![=] = meta.input.parse()?;
7337                out.many = Some(meta.input.parse()?);
7338                return Ok(());
7339            }
7340            if meta.path.is_ident("nested") {
7341                out.nested = true;
7342                // Optional strict flag inside parentheses:
7343                //   #[serializer(nested(strict))]
7344                if meta.input.peek(syn::token::Paren) {
7345                    meta.parse_nested_meta(|inner| {
7346                        if inner.path.is_ident("strict") {
7347                            out.nested_strict = true;
7348                            return Ok(());
7349                        }
7350                        Err(inner.error("unknown nested sub-attribute (supported: `strict`)"))
7351                    })?;
7352                }
7353                return Ok(());
7354            }
7355            if meta.path.is_ident("slug") {
7356                let s: LitStr = meta.value()?.parse()?;
7357                out.slug = Some(s.value());
7358                return Ok(());
7359            }
7360            Err(meta.error(
7361                "unknown serializer field attribute (supported: \
7362                 `read_only`, `write_only`, `source`, `skip`, `method`, \
7363                 `validate`, `nested`, `many`, `slug`)",
7364            ))
7365        })?;
7366    }
7367    // Validate: read_only + write_only is nonsensical
7368    if out.read_only && out.write_only {
7369        return Err(syn::Error::new_spanned(
7370            field,
7371            "a field cannot be both `read_only` and `write_only`",
7372        ));
7373    }
7374    if out.method.is_some() && out.source.is_some() {
7375        return Err(syn::Error::new_spanned(
7376            field,
7377            "`method` and `source` are mutually exclusive — `method` computes \
7378             the value from a method, `source` reads it from a different model field",
7379        ));
7380    }
7381    if out.slug.is_some() && (out.method.is_some() || out.nested || out.many.is_some()) {
7382        return Err(syn::Error::new_spanned(
7383            field,
7384            "`slug` is mutually exclusive with `method`, `nested`, and `many` \
7385             — pick one strategy for populating the field",
7386        ));
7387    }
7388    Ok(out)
7389}
7390
7391fn expand_serializer(input: &DeriveInput) -> syn::Result<TokenStream2> {
7392    let struct_name = &input.ident;
7393    let struct_name_lit = struct_name.to_string();
7394
7395    let Data::Struct(data) = &input.data else {
7396        return Err(syn::Error::new_spanned(
7397            struct_name,
7398            "Serializer can only be derived on structs",
7399        ));
7400    };
7401    let Fields::Named(named) = &data.fields else {
7402        return Err(syn::Error::new_spanned(
7403            struct_name,
7404            "Serializer requires a struct with named fields",
7405        ));
7406    };
7407
7408    let container = parse_serializer_container_attrs(input)?;
7409    let model_path = &container.model;
7410
7411    // Classify each field. `ty` is only consumed by the
7412    // `#[cfg(feature = "openapi")]` block below, but we always
7413    // capture it to keep the field-info build a single pass.
7414    #[allow(dead_code)]
7415    struct FieldInfo {
7416        ident: syn::Ident,
7417        ty: syn::Type,
7418        attrs: SerializerFieldAttrs,
7419    }
7420    let mut fields_info: Vec<FieldInfo> = Vec::new();
7421    for field in &named.named {
7422        let ident = field.ident.clone().expect("named field has ident");
7423        let attrs = parse_serializer_field_attrs(field)?;
7424        fields_info.push(FieldInfo {
7425            ident,
7426            ty: field.ty.clone(),
7427            attrs,
7428        });
7429    }
7430
7431    // Generate from_model body: struct literal with each field assigned.
7432    let from_model_fields = fields_info.iter().map(|fi| {
7433        let ident = &fi.ident;
7434        let ty = &fi.ty;
7435        if let Some(_inner) = &fi.attrs.many {
7436            // Many — collection field. Initialize empty; caller
7437            // populates via the macro-emitted set_<field> helper
7438            // after fetching the M2M children.
7439            quote! { #ident: ::std::vec::Vec::new() }
7440        } else if let Some(method) = &fi.attrs.method {
7441            // SerializerMethodField: call Self::<method>(&model) to
7442            // compute the value. Method signature must be
7443            // `fn <method>(model: &T) -> <field type>`.
7444            let method_ident = syn::Ident::new(method, ident.span());
7445            quote! { #ident: Self::#method_ident(model) }
7446        } else if let Some(slug_field) = &fi.attrs.slug {
7447            // v0.44 — SlugRelatedField. Source defaults to the field
7448            // name on this struct; override via `source = "..."`. The
7449            // source field on the model is expected to be a
7450            // `ForeignKey<T>`; the slug field on the parent is named
7451            // by the attribute value. When the FK is unloaded the
7452            // field falls back to `Default::default()` — same
7453            // graceful-degrade contract as `nested`.
7454            let src_name = fi
7455                .attrs
7456                .source
7457                .as_deref()
7458                .unwrap_or(&fi.ident.to_string())
7459                .to_owned();
7460            let src_ident = syn::Ident::new(&src_name, ident.span());
7461            let slug_ident = syn::Ident::new(slug_field, ident.span());
7462            quote! {
7463                #ident: match model.#src_ident.value() {
7464                    ::core::option::Option::Some(__loaded) =>
7465                        ::core::clone::Clone::clone(&__loaded.#slug_ident),
7466                    ::core::option::Option::None =>
7467                        ::core::default::Default::default(),
7468                }
7469            }
7470        } else if fi.attrs.nested {
7471            // Nested serializer. Source defaults to the field name on
7472            // this struct; override via `source = "..."`. The source
7473            // field on the model is expected to be a `ForeignKey<T>`
7474            // whose `.value()` returns `Option<&T>` after lazy-load.
7475            //
7476            // Behavior matrix (tweakable per-field):
7477            //   * FK loaded   → nested object materializes via
7478            //                   ChildSerializer::from_model(parent).
7479            //   * FK unloaded → fall back to ChildSerializer::default()
7480            //                   (so prod doesn't crash on a missing
7481            //                   prefetch — just renders a blank nested
7482            //                   object). Add `#[serializer(nested,
7483            //                   strict)]` to keep the v0.18.1
7484            //                   panic-on-unloaded behavior for tests
7485            //                   that want hard guardrails.
7486            let src_name = fi.attrs.source.as_deref().unwrap_or(&fi.ident.to_string()).to_owned();
7487            let src_ident = syn::Ident::new(&src_name, ident.span());
7488            if fi.attrs.nested_strict {
7489                let panic_msg = format!(
7490                    "nested(strict) serializer for `{ident}` requires `model.{src_name}` to be loaded — \
7491                     call .get(&pool).await? or .select_related(\"{src_name}\") on the model first",
7492                );
7493                quote! {
7494                    #ident: <#ty as ::rustango::serializer::ModelSerializer>::from_model(
7495                        model.#src_ident.value().expect(#panic_msg),
7496                    )
7497                }
7498            } else {
7499                quote! {
7500                    #ident: match model.#src_ident.value() {
7501                        ::core::option::Option::Some(__loaded) =>
7502                            <#ty as ::rustango::serializer::ModelSerializer>::from_model(__loaded),
7503                        ::core::option::Option::None =>
7504                            ::core::default::Default::default(),
7505                    }
7506                }
7507            }
7508        } else if fi.attrs.write_only || fi.attrs.skip {
7509            // Not read from model — use default
7510            quote! { #ident: ::core::default::Default::default() }
7511        } else if let Some(src) = &fi.attrs.source {
7512            let src_ident = syn::Ident::new(src, ident.span());
7513            quote! { #ident: ::core::clone::Clone::clone(&model.#src_ident) }
7514        } else {
7515            quote! { #ident: ::core::clone::Clone::clone(&model.#ident) }
7516        }
7517    });
7518
7519    // Per-field validators (DRF-shape `validators=[...]`). Emit a
7520    // `validate(&self)` method that runs each user-defined validator
7521    // and aggregates errors into `FormErrors`.
7522    let validator_calls: Vec<_> = fields_info
7523        .iter()
7524        .filter_map(|fi| {
7525            let ident = &fi.ident;
7526            let name_lit = ident.to_string();
7527            let method = fi.attrs.validate.as_ref()?;
7528            let method_ident = syn::Ident::new(method, ident.span());
7529            Some(quote! {
7530                if let ::core::result::Result::Err(__e) = Self::#method_ident(&self.#ident) {
7531                    __errors.add(#name_lit.to_owned(), __e);
7532                }
7533            })
7534        })
7535        .collect();
7536    // #436 — DRF cross-field `validate(self)` shape. If the
7537    // container declared `#[serializer(validate = "fn_name")]`,
7538    // the macro-generated `validate(&self)` runs every per-field
7539    // validator first, then calls the user's cross-field method,
7540    // merging its `FormErrors` into the per-field errors. Either
7541    // alone is enough to emit the wrapper.
7542    let cross_validate_call = container.cross_validate.as_ref().map(|method_ident| {
7543        quote! {
7544            // Merge cross-field errors into the per-field bucket so
7545            // a single .validate() call surfaces both layers.
7546            if let ::core::result::Result::Err(__cross) = self.#method_ident() {
7547                __errors.merge(__cross);
7548            }
7549        }
7550    });
7551    let validate_method = if validator_calls.is_empty() && container.cross_validate.is_none() {
7552        quote! {}
7553    } else {
7554        quote! {
7555            impl #struct_name {
7556                /// Run every `#[serializer(validate = "...")]` per-field
7557                /// validator and, when declared, the container-level
7558                /// cross-field validator. Aggregates errors into
7559                /// `FormErrors` keyed by the field name (plus any
7560                /// non-field keys the cross-field method adds).
7561                /// Returns `Ok(())` when all pass.
7562                pub fn validate(&self) -> ::core::result::Result<(), ::rustango::forms::FormErrors> {
7563                    let mut __errors = ::rustango::forms::FormErrors::default();
7564                    #( #validator_calls )*
7565                    #cross_validate_call
7566                    if __errors.is_empty() {
7567                        ::core::result::Result::Ok(())
7568                    } else {
7569                        ::core::result::Result::Err(__errors)
7570                    }
7571                }
7572            }
7573        }
7574    };
7575
7576    // For every `#[serializer(many = S)]` field, emit a
7577    // `pub fn set_<field>(&mut self, models: &[<S::Model>]) -> &mut Self`
7578    // helper that maps the parents through `S::from_model`.
7579    let many_setters: Vec<_> = fields_info
7580        .iter()
7581        .filter_map(|fi| {
7582            let many_ty = fi.attrs.many.as_ref()?;
7583            let ident = &fi.ident;
7584            let setter = syn::Ident::new(&format!("set_{ident}"), ident.span());
7585            Some(quote! {
7586                /// Populate this `many` field by mapping each parent model
7587                /// through the inner serializer's `from_model`. Call after
7588                /// fetching the M2M / one-to-many children since
7589                /// `from_model` itself can't await an SQL query.
7590                pub fn #setter(
7591                    &mut self,
7592                    models: &[<#many_ty as ::rustango::serializer::ModelSerializer>::Model],
7593                ) -> &mut Self {
7594                    self.#ident = models.iter()
7595                        .map(<#many_ty as ::rustango::serializer::ModelSerializer>::from_model)
7596                        .collect();
7597                    self
7598                }
7599            })
7600        })
7601        .collect();
7602    let many_setters_impl = if many_setters.is_empty() {
7603        quote! {}
7604    } else {
7605        quote! {
7606            impl #struct_name {
7607                #( #many_setters )*
7608            }
7609        }
7610    };
7611
7612    // Generate custom Serialize: skip write_only fields
7613    let output_fields: Vec<_> = fields_info
7614        .iter()
7615        .filter(|fi| !fi.attrs.write_only)
7616        .collect();
7617    let output_field_count = output_fields.len();
7618    let serialize_fields = output_fields.iter().map(|fi| {
7619        let ident = &fi.ident;
7620        let name_lit = ident.to_string();
7621        quote! { __state.serialize_field(#name_lit, &self.#ident)?; }
7622    });
7623
7624    // writable_fields: normal + write_only.
7625    // Exclude:
7626    //   - `read_only` — server-computed.
7627    //   - `skip` — caller sets manually post-from_model.
7628    //   - `method` — computed from a Self::fn(&model) call; accepting
7629    //     it on write is meaningless.
7630    //   - `nested` / `many` — populated from related-model data, not
7631    //     from a field on the wire body.
7632    // v0.44 fix: pre-v0.44 the macro included `method` / `nested` /
7633    // `many` in `writable_fields()`, which made the ViewSet write
7634    // path accept those fields from the JSON body and try to bind
7635    // them to the SQL UPDATE — a silent no-op at best, a type
7636    // mismatch at worst.
7637    let writable_lits: Vec<_> = fields_info
7638        .iter()
7639        .filter(|fi| {
7640            !fi.attrs.read_only
7641                && !fi.attrs.skip
7642                && fi.attrs.method.is_none()
7643                && !fi.attrs.nested
7644                && fi.attrs.many.is_none()
7645                && fi.attrs.slug.is_none()
7646        })
7647        .map(|fi| fi.ident.to_string())
7648        .collect();
7649
7650    // OpenAPI: emit `impl OpenApiSchema` when our `openapi` feature is on.
7651    // Only includes fields shown in JSON output (skips write_only). For each
7652    // `Option<T>` field, omit from `required` and add `.nullable()`.
7653    let openapi_impl = {
7654        #[cfg(feature = "openapi")]
7655        {
7656            let property_calls = output_fields.iter().map(|fi| {
7657                let ident = &fi.ident;
7658                let name_lit = ident.to_string();
7659                let ty = &fi.ty;
7660                let nullable_call = if is_option(ty) {
7661                    quote! { .nullable() }
7662                } else {
7663                    quote! {}
7664                };
7665                quote! {
7666                    .property(
7667                        #name_lit,
7668                        <#ty as ::rustango::openapi::OpenApiSchema>::openapi_schema()
7669                            #nullable_call,
7670                    )
7671                }
7672            });
7673            let required_lits: Vec<_> = output_fields
7674                .iter()
7675                .filter(|fi| !is_option(&fi.ty))
7676                .map(|fi| fi.ident.to_string())
7677                .collect();
7678            quote! {
7679                impl ::rustango::openapi::OpenApiSchema for #struct_name {
7680                    fn openapi_schema() -> ::rustango::openapi::Schema {
7681                        ::rustango::openapi::Schema::object()
7682                            #( #property_calls )*
7683                            .required([ #( #required_lits ),* ])
7684                    }
7685                }
7686            }
7687        }
7688        #[cfg(not(feature = "openapi"))]
7689        {
7690            quote! {}
7691        }
7692    };
7693
7694    Ok(quote! {
7695        impl ::rustango::serializer::ModelSerializer for #struct_name {
7696            type Model = #model_path;
7697
7698            fn from_model(model: &Self::Model) -> Self {
7699                Self {
7700                    #( #from_model_fields ),*
7701                }
7702            }
7703
7704            fn writable_fields() -> &'static [&'static str] {
7705                &[ #( #writable_lits ),* ]
7706            }
7707        }
7708
7709        impl ::serde::Serialize for #struct_name {
7710            fn serialize<S>(&self, serializer: S)
7711                -> ::core::result::Result<S::Ok, S::Error>
7712            where
7713                S: ::serde::Serializer,
7714            {
7715                use ::serde::ser::SerializeStruct;
7716                let mut __state = serializer.serialize_struct(
7717                    #struct_name_lit,
7718                    #output_field_count,
7719                )?;
7720                #( #serialize_fields )*
7721                __state.end()
7722            }
7723        }
7724
7725        #openapi_impl
7726
7727        #validate_method
7728
7729        #many_setters_impl
7730    })
7731}
7732
7733/// Returns true if `ty` looks like `Option<T>` (any path ending in `Option`).
7734/// Only used by the `openapi`-gated emission of `OpenApiSchema`; muted
7735/// when the feature is off.
7736#[cfg_attr(not(feature = "openapi"), allow(dead_code))]
7737fn is_option(ty: &syn::Type) -> bool {
7738    if let syn::Type::Path(p) = ty {
7739        if let Some(last) = p.path.segments.last() {
7740            return last.ident == "Option";
7741        }
7742    }
7743    false
7744}