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::FormStruct` (slice 8.4B). Generates a
67/// `parse(&HashMap<String, String>) -> Result<Self, FormError>` 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///
114/// The macro also emits a custom `impl serde::Serialize` — do **not** also `#[derive(Serialize)]`.
115#[proc_macro_derive(Serializer, attributes(serializer))]
116pub fn derive_serializer(input: TokenStream) -> TokenStream {
117    let input = parse_macro_input!(input as DeriveInput);
118    expand_serializer(&input)
119        .unwrap_or_else(syn::Error::into_compile_error)
120        .into()
121}
122
123/// Bake every `*.json` migration file in a directory into the binary
124/// at compile time. Returns a `&'static [(&'static str, &'static str)]`
125/// of `(name, json_content)` pairs, lex-sorted by file stem.
126///
127/// Pair with `rustango::migrate::migrate_embedded` at runtime — same
128/// behaviour as `migrate(pool, dir)` but with no filesystem access.
129/// The path is interpreted relative to the user's `CARGO_MANIFEST_DIR`
130/// (i.e. the crate that invokes the macro). Default is
131/// `"./migrations"` if no argument is supplied.
132///
133/// ```ignore
134/// const EMBEDDED: &[(&str, &str)] = rustango::embed_migrations!();
135/// // or:
136/// const EMBEDDED: &[(&str, &str)] = rustango::embed_migrations!("./migrations");
137///
138/// rustango::migrate::migrate_embedded(&pool, EMBEDDED).await?;
139/// ```
140///
141/// **Compile-time guarantees** (rustango v0.4+, slice 5): every JSON
142/// file's `name` field must equal its file stem, every `prev`
143/// reference must point to another migration in the same directory,
144/// and the JSON must parse. A broken chain — orphan `prev`, missing
145/// predecessor, malformed file — fails at macro-expansion time with
146/// a clear `compile_error!`. *No other Django-shape Rust framework
147/// validates migration chains at compile time*: Cot's migrations are
148/// imperative Rust code (no static chain), Loco's are SeaORM
149/// up/down (same), Rwf's are raw SQL (no chain at all).
150///
151/// Each migration is included via `include_str!` so cargo's rebuild
152/// detection picks up file *content* changes. **Caveat:** cargo
153/// doesn't watch directory listings, so adding or removing a
154/// migration file inside the dir won't auto-trigger a rebuild — run
155/// `cargo clean` (or just bump any other source file) when you add
156/// new migrations during embedded development.
157#[proc_macro]
158pub fn embed_migrations(input: TokenStream) -> TokenStream {
159    expand_embed_migrations(input.into())
160        .unwrap_or_else(syn::Error::into_compile_error)
161        .into()
162}
163
164/// `#[rustango::main]` — the Django-shape runserver entrypoint. Wraps
165/// `#[tokio::main]` and a default `tracing_subscriber` initialisation
166/// (env-filter, falling back to `info,sqlx=warn`) so user `main`
167/// functions are zero-boilerplate:
168///
169/// ```ignore
170/// #[rustango::main]
171/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
172///     rustango::server::Builder::from_env().await?
173///         .migrate("migrations").await?
174///         .api(my_app::urls::api())
175///         .seed_with(my_app::seed::run).await?
176///         .serve("0.0.0.0:8080").await
177/// }
178/// ```
179///
180/// Optional `flavor = "current_thread"` passes through to
181/// `#[tokio::main]`; default is the multi-threaded runtime.
182///
183/// Pulls `tracing-subscriber` into the rustango crate behind the
184/// `runtime` sub-feature (implied by `tenancy`), so apps that opt
185/// out get plain `#[tokio::main]` ergonomics without the dependency.
186#[proc_macro_attribute]
187pub fn main(args: TokenStream, item: TokenStream) -> TokenStream {
188    expand_main(args.into(), item.into())
189        .unwrap_or_else(syn::Error::into_compile_error)
190        .into()
191}
192
193fn expand_main(args: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
194    let mut input: syn::ItemFn = syn::parse2(item)?;
195    if input.sig.asyncness.is_none() {
196        return Err(syn::Error::new(
197            input.sig.ident.span(),
198            "`#[rustango::main]` must wrap an `async fn`",
199        ));
200    }
201
202    // v0.31.1 (#4): hand-roll the tokio runtime instead of delegating
203    // to `#[tokio::main]`. Tokio's proc-macro internally emits
204    // `::tokio::*` paths that resolve against the user crate's deps,
205    // so calling it through the rustango re-export still requires the
206    // user to add tokio to their own Cargo.toml. Building the
207    // runtime ourselves keeps the dep transitive through the
208    // `runtime` feature on rustango.
209    //
210    // Parse optional `flavor = "current_thread"` / `flavor =
211    // "multi_thread"` from the attribute args. Unknown args are
212    // tolerated (forward-compat with tokio's own arg surface).
213    let flavor = parse_flavor(&args);
214    let builder_call = match flavor {
215        Flavor::CurrentThread => quote! {
216            ::rustango::__private_runtime::tokio::runtime::Builder::new_current_thread()
217        },
218        Flavor::MultiThread => quote! {
219            ::rustango::__private_runtime::tokio::runtime::Builder::new_multi_thread()
220        },
221    };
222
223    // Detach the user body and rewrite `main` as a sync fn that
224    // builds the runtime and blocks on the async body.
225    let user_body = input.block.clone();
226    input.sig.asyncness = None;
227    input.block = syn::parse2(quote! {{
228        {
229            use ::rustango::__private_runtime::tracing_subscriber::{self, EnvFilter};
230            // `try_init` so duplicate installers (e.g. tests already
231            // holding a subscriber) don't panic.
232            let _ = tracing_subscriber::fmt()
233                .with_env_filter(
234                    EnvFilter::try_from_default_env()
235                        .unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn")),
236                )
237                .try_init();
238        }
239        let __rt = #builder_call
240            .enable_all()
241            .build()
242            .expect("failed to build tokio runtime");
243        __rt.block_on(async move #user_body)
244    }})?;
245
246    Ok(quote! {
247        #input
248    })
249}
250
251enum Flavor {
252    MultiThread,
253    CurrentThread,
254}
255
256fn parse_flavor(args: &TokenStream2) -> Flavor {
257    // Cheap parser: look for the literal token sequence
258    // `flavor = "current_thread"`. Everything else (including
259    // bare `multi_thread` or no args) defaults to multi-thread.
260    let s = args.to_string();
261    if s.contains("current_thread") {
262        Flavor::CurrentThread
263    } else {
264        Flavor::MultiThread
265    }
266}
267
268fn expand_embed_migrations(input: TokenStream2) -> syn::Result<TokenStream2> {
269    // Default to "./migrations" if invoked without args.
270    let path_str = if input.is_empty() {
271        "./migrations".to_string()
272    } else {
273        let lit: LitStr = syn::parse2(input)?;
274        lit.value()
275    };
276
277    let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
278        syn::Error::new(
279            proc_macro2::Span::call_site(),
280            "embed_migrations! must be invoked during a Cargo build (CARGO_MANIFEST_DIR not set)",
281        )
282    })?;
283    let abs = std::path::Path::new(&manifest).join(&path_str);
284
285    let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
286    if abs.is_dir() {
287        let read = std::fs::read_dir(&abs).map_err(|e| {
288            syn::Error::new(
289                proc_macro2::Span::call_site(),
290                format!("embed_migrations!: cannot read {}: {e}", abs.display()),
291            )
292        })?;
293        for entry in read.flatten() {
294            let path = entry.path();
295            if !path.is_file() {
296                continue;
297            }
298            if path.extension().and_then(|s| s.to_str()) != Some("json") {
299                continue;
300            }
301            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
302                continue;
303            };
304            entries.push((stem.to_owned(), path));
305        }
306    }
307    entries.sort_by(|a, b| a.0.cmp(&b.0));
308
309    // Compile-time chain validation: read each migration's JSON,
310    // pull `name` and `prev` (file-stem-keyed for the chain check),
311    // and verify every `prev` points to another migration in the
312    // slice. Mismatches between the file stem and the embedded
313    // `name` field — and broken `prev` chains — fail at MACRO
314    // EXPANSION time so a misshapen migration set never compiles.
315    //
316    // This is the v0.4 Slice 5 distinguisher: rustango's JSON
317    // migrations + a Rust proc-macro that reads them is the unique
318    // combo nothing else in the Django-shape Rust camp can match
319    // (Cot's are imperative Rust code, Loco's are SeaORM up/down,
320    // Rwf's are raw SQL — none have a static chain to validate).
321    let mut chain_names: Vec<String> = Vec::with_capacity(entries.len());
322    let mut prev_refs: Vec<(String, Option<String>)> = Vec::with_capacity(entries.len());
323    for (stem, path) in &entries {
324        let raw = std::fs::read_to_string(path).map_err(|e| {
325            syn::Error::new(
326                proc_macro2::Span::call_site(),
327                format!(
328                    "embed_migrations!: cannot read {} for chain validation: {e}",
329                    path.display()
330                ),
331            )
332        })?;
333        let json: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
334            syn::Error::new(
335                proc_macro2::Span::call_site(),
336                format!(
337                    "embed_migrations!: {} is not valid JSON: {e}",
338                    path.display()
339                ),
340            )
341        })?;
342        let name = json
343            .get("name")
344            .and_then(|v| v.as_str())
345            .ok_or_else(|| {
346                syn::Error::new(
347                    proc_macro2::Span::call_site(),
348                    format!(
349                        "embed_migrations!: {} is missing the `name` field",
350                        path.display()
351                    ),
352                )
353            })?
354            .to_owned();
355        if name != *stem {
356            return Err(syn::Error::new(
357                proc_macro2::Span::call_site(),
358                format!(
359                    "embed_migrations!: file stem `{stem}` does not match the migration's \
360                     `name` field `{name}` — rename the file or fix the JSON",
361                ),
362            ));
363        }
364        let prev = json.get("prev").and_then(|v| v.as_str()).map(str::to_owned);
365        chain_names.push(name.clone());
366        prev_refs.push((name, prev));
367    }
368
369    let name_set: std::collections::HashSet<&str> =
370        chain_names.iter().map(String::as_str).collect();
371    for (name, prev) in &prev_refs {
372        if let Some(p) = prev {
373            if !name_set.contains(p.as_str()) {
374                return Err(syn::Error::new(
375                    proc_macro2::Span::call_site(),
376                    format!(
377                        "embed_migrations!: broken migration chain — `{name}` declares \
378                         prev=`{p}` but no migration with that name exists in {}",
379                        abs.display()
380                    ),
381                ));
382            }
383        }
384    }
385
386    let pairs: Vec<TokenStream2> = entries
387        .iter()
388        .map(|(name, path)| {
389            let path_lit = path.display().to_string();
390            quote! { (#name, ::core::include_str!(#path_lit)) }
391        })
392        .collect();
393
394    Ok(quote! {
395        {
396            const __RUSTANGO_EMBEDDED: &[(&'static str, &'static str)] = &[#(#pairs),*];
397            __RUSTANGO_EMBEDDED
398        }
399    })
400}
401
402fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
403    let struct_name = &input.ident;
404
405    let Data::Struct(data) = &input.data else {
406        return Err(syn::Error::new_spanned(
407            struct_name,
408            "Model can only be derived on structs",
409        ));
410    };
411    let Fields::Named(named) = &data.fields else {
412        return Err(syn::Error::new_spanned(
413            struct_name,
414            "Model requires a struct with named fields",
415        ));
416    };
417
418    let container = parse_container_attrs(input)?;
419    let table = container
420        .table
421        .unwrap_or_else(|| to_snake_case(&struct_name.to_string()));
422    let model_name = struct_name.to_string();
423
424    let collected = collect_fields(named, &table)?;
425
426    // Validate that #[rustango(display = "…")] names a real field.
427    if let Some((ref display, span)) = container.display {
428        if !collected.field_names.iter().any(|n| n == display) {
429            return Err(syn::Error::new(
430                span,
431                format!("`display = \"{display}\"` does not match any field on this struct"),
432            ));
433        }
434    }
435    let display = container.display.map(|(name, _)| name);
436    let app_label = container.app.clone();
437
438    // Validate admin field-name lists against declared field names.
439    if let Some(admin) = &container.admin {
440        for (label, list) in [
441            ("list_display", &admin.list_display),
442            ("search_fields", &admin.search_fields),
443            ("readonly_fields", &admin.readonly_fields),
444            ("list_filter", &admin.list_filter),
445        ] {
446            if let Some((names, span)) = list {
447                for name in names {
448                    if !collected.field_names.iter().any(|n| n == name) {
449                        return Err(syn::Error::new(
450                            *span,
451                            format!(
452                                "`{label} = \"{name}\"`: \"{name}\" is not a declared field on this struct"
453                            ),
454                        ));
455                    }
456                }
457            }
458        }
459        if let Some((pairs, span)) = &admin.ordering {
460            for (name, _) in pairs {
461                if !collected.field_names.iter().any(|n| n == name) {
462                    return Err(syn::Error::new(
463                        *span,
464                        format!(
465                            "`ordering = \"{name}\"`: \"{name}\" is not a declared field on this struct"
466                        ),
467                    ));
468                }
469            }
470        }
471        if let Some((groups, span)) = &admin.fieldsets {
472            for (_, fields) in groups {
473                for name in fields {
474                    if !collected.field_names.iter().any(|n| n == name) {
475                        return Err(syn::Error::new(
476                            *span,
477                            format!(
478                                "`fieldsets`: \"{name}\" is not a declared field on this struct"
479                            ),
480                        ));
481                    }
482                }
483            }
484        }
485    }
486    if let Some(audit) = &container.audit {
487        if let Some((names, span)) = &audit.track {
488            for name in names {
489                if !collected.field_names.iter().any(|n| n == name) {
490                    return Err(syn::Error::new(
491                        *span,
492                        format!(
493                            "`audit(track = \"{name}\")`: \"{name}\" is not a declared field on this struct"
494                        ),
495                    ));
496                }
497            }
498        }
499    }
500
501    // Build the audit_track list for ModelSchema: None when no audit attr,
502    // Some(empty) when audit present without track, Some(names) when explicit.
503    let audit_track_names: Option<Vec<String>> = container.audit.as_ref().map(|audit| {
504        audit
505            .track
506            .as_ref()
507            .map(|(names, _)| names.clone())
508            .unwrap_or_default()
509    });
510
511    // Merge field-level indexes into the container's index list.
512    let mut all_indexes: Vec<IndexAttr> = container.indexes;
513    for field in &named.named {
514        let ident = field.ident.as_ref().expect("named");
515        let col = to_snake_case(&ident.to_string()); // column name fallback
516                                                     // Re-parse field attrs to check for index flag
517        if let Ok(fa) = parse_field_attrs(field) {
518            if fa.index {
519                let col_name = fa.column.clone().unwrap_or_else(|| col.clone());
520                let auto_name = if fa.index_unique {
521                    format!("{table}_{col_name}_uq_idx")
522                } else {
523                    format!("{table}_{col_name}_idx")
524                };
525                all_indexes.push(IndexAttr {
526                    name: fa.index_name.or(Some(auto_name)),
527                    columns: vec![col_name],
528                    unique: fa.index_unique,
529                });
530            }
531        }
532    }
533
534    let model_impl = model_impl_tokens(
535        struct_name,
536        &model_name,
537        &table,
538        display.as_deref(),
539        app_label.as_deref(),
540        container.admin.as_ref(),
541        &collected.field_schemas,
542        collected.soft_delete_column.as_deref(),
543        container.permissions,
544        audit_track_names.as_deref(),
545        &container.m2m,
546        &all_indexes,
547        &container.checks,
548        &container.composite_fks,
549        &container.generic_fks,
550        container.scope.as_deref(),
551    );
552    let module_ident = column_module_ident(struct_name);
553    let column_consts = column_const_tokens(&module_ident, &collected.column_entries);
554    let audited_fields: Option<Vec<&ColumnEntry>> = container.audit.as_ref().map(|audit| {
555        let track_set: Option<std::collections::HashSet<&str>> = audit
556            .track
557            .as_ref()
558            .map(|(names, _)| names.iter().map(String::as_str).collect());
559        collected
560            .column_entries
561            .iter()
562            .filter(|c| {
563                track_set
564                    .as_ref()
565                    .map_or(true, |s| s.contains(c.name.as_str()))
566            })
567            .collect()
568    });
569    let inherent_impl = inherent_impl_tokens(
570        struct_name,
571        &collected,
572        collected.primary_key.as_ref(),
573        &column_consts,
574        audited_fields.as_deref(),
575        &all_indexes,
576    );
577    let column_module = column_module_tokens(&module_ident, struct_name, &collected.column_entries);
578    let from_row_impl = from_row_impl_tokens(struct_name, &collected.from_row_inits);
579    let reverse_helpers = reverse_helper_tokens(struct_name, &collected.fk_relations);
580    let m2m_accessors = m2m_accessor_tokens(struct_name, &container.m2m);
581
582    Ok(quote! {
583        #model_impl
584        #inherent_impl
585        #from_row_impl
586        #column_module
587        #reverse_helpers
588        #m2m_accessors
589
590        ::rustango::core::inventory::submit! {
591            ::rustango::core::ModelEntry {
592                schema: <#struct_name as ::rustango::core::Model>::SCHEMA,
593                // `module_path!()` evaluates at the registration site,
594                // so a Model declared in `crate::blog::models` records
595                // `"<crate>::blog::models"` and `resolved_app_label()`
596                // can infer "blog" without an explicit attribute.
597                module_path: ::core::module_path!(),
598            }
599        }
600    })
601}
602
603/// Emit `impl LoadRelated for #StructName` — slice 9.0d. Pattern-
604/// matches `field_name` against the model's FK fields and, for a
605/// match, decodes the FK target via the parent's macro-generated
606/// `__rustango_from_aliased_row`, reads the parent's PK, and stores
607/// `ForeignKey::Loaded` on `self`.
608///
609/// Always emitted (with empty arms for FK-less models, which
610/// return `Ok(false)` for any field name) so the `T: LoadRelated`
611/// trait bound on `fetch_on` is universally satisfied — users
612/// never have to think about implementing it.
613fn load_related_impl_tokens(struct_name: &syn::Ident, fk_relations: &[FkRelation]) -> TokenStream2 {
614    let arms = fk_relations.iter().map(|rel| {
615        let parent_ty = &rel.parent_type;
616        let fk_col = rel.fk_column.as_str();
617        // FK field's Rust ident matches its SQL column name in v0.8
618        // (no `column = "..."` rename ships on FK fields).
619        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
620        let (variant_ident, default_expr) = rel.pk_kind.sqlvalue_match_arm();
621        let assign = if rel.nullable {
622            quote! {
623                self.#field_ident = ::core::option::Option::Some(
624                    ::rustango::sql::ForeignKey::loaded(_pk, _parent),
625                );
626            }
627        } else {
628            quote! {
629                self.#field_ident = ::rustango::sql::ForeignKey::loaded(_pk, _parent);
630            }
631        };
632        quote! {
633            #fk_col => {
634                let _parent: #parent_ty = <#parent_ty>::__rustango_from_aliased_row(row, alias)?;
635                // Loud-in-debug, default-in-release: a divergence
636                // between the FK field's declared `K` (drives the
637                // expected `SqlValue::<Variant>`) and the parent's
638                // `__rustango_pk_value` output is a macro-internal
639                // invariant break — surfacing the panic in dev
640                // catches it before users hit silent PK=0 corruption.
641                let _pk = match <#parent_ty>::__rustango_pk_value(&_parent) {
642                    ::rustango::core::SqlValue::#variant_ident(v) => v,
643                    _other => {
644                        ::core::debug_assert!(
645                            false,
646                            "rustango macro bug: load_related on FK `{}` expected \
647                             SqlValue::{} from parent's __rustango_pk_value but got \
648                             {:?} — file a bug at https://github.com/ujeenet/rustango",
649                            #fk_col,
650                            ::core::stringify!(#variant_ident),
651                            _other,
652                        );
653                        #default_expr
654                    }
655                };
656                #assign
657                ::core::result::Result::Ok(true)
658            }
659        }
660    });
661    quote! {
662        impl ::rustango::sql::LoadRelated for #struct_name {
663            #[allow(unused_variables)]
664            fn __rustango_load_related(
665                &mut self,
666                row: &::rustango::sql::sqlx::postgres::PgRow,
667                field_name: &str,
668                alias: &str,
669            ) -> ::core::result::Result<bool, ::rustango::sql::sqlx::Error> {
670                match field_name {
671                    #( #arms )*
672                    _ => ::core::result::Result::Ok(false),
673                }
674            }
675        }
676    }
677}
678
679/// MySQL counterpart of [`load_related_impl_tokens`] — v0.23.0-batch8.
680/// Emits a call to the cfg-gated `__impl_my_load_related!` macro_rules,
681/// which expands to a `LoadRelatedMy` impl when rustango is built with
682/// the `mysql` feature, and to nothing otherwise. The decoded parent
683/// is read via `__rustango_from_aliased_my_row` (the MySQL aliased
684/// decoder, also batch8) so the dual emission is symmetric across
685/// backends.
686fn load_related_impl_my_tokens(
687    struct_name: &syn::Ident,
688    fk_relations: &[FkRelation],
689) -> TokenStream2 {
690    let arms = fk_relations.iter().map(|rel| {
691        let parent_ty = &rel.parent_type;
692        let fk_col = rel.fk_column.as_str();
693        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
694        let (variant_ident, default_expr) = rel.pk_kind.sqlvalue_match_arm();
695        let assign = if rel.nullable {
696            quote! {
697                __self.#field_ident = ::core::option::Option::Some(
698                    ::rustango::sql::ForeignKey::loaded(_pk, _parent),
699                );
700            }
701        } else {
702            quote! {
703                __self.#field_ident = ::rustango::sql::ForeignKey::loaded(_pk, _parent);
704            }
705        };
706        // `self` IS hygiene-tracked through macro_rules — emitted from
707        // a different context than the `&mut self` parameter inside
708        // the macro_rules-expanded fn. Pass it through as `__self`
709        // and let the macro_rules rebind it to the receiver.
710        quote! {
711            #fk_col => {
712                let _parent: #parent_ty =
713                    <#parent_ty>::__rustango_from_aliased_my_row(row, alias)?;
714                // See note in `load_related_impl_tokens` (PG twin) —
715                // the same loud-in-debug invariant guard.
716                let _pk = match <#parent_ty>::__rustango_pk_value(&_parent) {
717                    ::rustango::core::SqlValue::#variant_ident(v) => v,
718                    _other => {
719                        ::core::debug_assert!(
720                            false,
721                            "rustango macro bug: load_related on FK `{}` expected \
722                             SqlValue::{} from parent's __rustango_pk_value but got \
723                             {:?} — file a bug at https://github.com/ujeenet/rustango",
724                            #fk_col,
725                            ::core::stringify!(#variant_ident),
726                            _other,
727                        );
728                        #default_expr
729                    }
730                };
731                #assign
732                ::core::result::Result::Ok(true)
733            }
734        }
735    });
736    quote! {
737        ::rustango::__impl_my_load_related!(#struct_name, |__self, row, field_name, alias| {
738            #( #arms )*
739        });
740    }
741}
742
743/// Same shape as [`load_related_impl_my_tokens`] but for SQLite.
744/// Emits a call to `__impl_sqlite_load_related!` which expands to a
745/// `LoadRelatedSqlite` impl when the `sqlite` feature is on.
746fn load_related_impl_sqlite_tokens(
747    struct_name: &syn::Ident,
748    fk_relations: &[FkRelation],
749) -> TokenStream2 {
750    let arms = fk_relations.iter().map(|rel| {
751        let parent_ty = &rel.parent_type;
752        let fk_col = rel.fk_column.as_str();
753        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
754        let (variant_ident, default_expr) = rel.pk_kind.sqlvalue_match_arm();
755        let assign = if rel.nullable {
756            quote! {
757                __self.#field_ident = ::core::option::Option::Some(
758                    ::rustango::sql::ForeignKey::loaded(_pk, _parent),
759                );
760            }
761        } else {
762            quote! {
763                __self.#field_ident = ::rustango::sql::ForeignKey::loaded(_pk, _parent);
764            }
765        };
766        quote! {
767            #fk_col => {
768                let _parent: #parent_ty =
769                    <#parent_ty>::__rustango_from_aliased_sqlite_row(row, alias)?;
770                let _pk = match <#parent_ty>::__rustango_pk_value(&_parent) {
771                    ::rustango::core::SqlValue::#variant_ident(v) => v,
772                    _other => {
773                        ::core::debug_assert!(
774                            false,
775                            "rustango macro bug: load_related on FK `{}` expected \
776                             SqlValue::{} from parent's __rustango_pk_value but got \
777                             {:?} — file a bug at https://github.com/ujeenet/rustango",
778                            #fk_col,
779                            ::core::stringify!(#variant_ident),
780                            _other,
781                        );
782                        #default_expr
783                    }
784                };
785                #assign
786                ::core::result::Result::Ok(true)
787            }
788        }
789    });
790    quote! {
791        ::rustango::__impl_sqlite_load_related!(#struct_name, |__self, row, field_name, alias| {
792            #( #arms )*
793        });
794    }
795}
796
797/// Emit `impl FkPkAccess for #StructName` — slice 9.0e. Pattern-
798/// matches `field_name` against the model's FK fields and returns
799/// the FK's stored PK as `i64`. Used by `fetch_with_prefetch` to
800/// group children by parent PK.
801///
802/// Always emitted (with `_ => None` for FK-less models) so the
803/// trait bound on `fetch_with_prefetch` is universally satisfied.
804fn fk_pk_access_impl_tokens(struct_name: &syn::Ident, fk_relations: &[FkRelation]) -> TokenStream2 {
805    let arms = fk_relations.iter().map(|rel| {
806        let fk_col = rel.fk_column.as_str();
807        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
808        if rel.pk_kind == DetectedKind::I64 {
809            // i64 FK — return the stored PK so prefetch_related can
810            // group children by it. Nullable variant unwraps via
811            // `as_ref().map(...)`: an unset (NULL) FK column yields
812            // `None` and that child sits out of the grouping (correct
813            // semantics — it has no parent to attach to).
814            if rel.nullable {
815                quote! {
816                    #fk_col => self.#field_ident
817                        .as_ref()
818                        .map(|fk| ::rustango::sql::ForeignKey::pk(fk)),
819                }
820            } else {
821                quote! {
822                    #fk_col => ::core::option::Option::Some(self.#field_ident.pk()),
823                }
824            }
825        } else {
826            // Non-i64 FK PKs (e.g. `ForeignKey<T, String>`,
827            // `ForeignKey<T, Uuid>`) opt out of `prefetch_related`'s
828            // i64-keyed grouping path — the trait signature is
829            // `Option<i64>` and a non-i64 PK can't lower into it.
830            // The FK still works for everything else (CRUD, lazy
831            // load via `.get()`, select_related JOINs); only the
832            // bulk prefetch grouper needs the integer key.
833            quote! {
834                #fk_col => ::core::option::Option::None,
835            }
836        }
837    });
838    // PK-type-agnostic version: every FK arm emits an
839    // `Option<SqlValue>` so `fetch_with_prefetch` can group by any
840    // PK type (i64, i32, String, Uuid). Models with non-i64 FK PKs
841    // opt OUT of the legacy i64 method (it returns None) but opt IN
842    // here.
843    let value_arms = fk_relations.iter().map(|rel| {
844        let fk_col = rel.fk_column.as_str();
845        let field_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site());
846        if rel.nullable {
847            quote! {
848                #fk_col => self.#field_ident
849                    .as_ref()
850                    .map(|fk| ::core::convert::Into::<::rustango::core::SqlValue>::into(
851                        ::rustango::sql::ForeignKey::pk(fk)
852                    )),
853            }
854        } else {
855            quote! {
856                #fk_col => ::core::option::Option::Some(
857                    ::core::convert::Into::<::rustango::core::SqlValue>::into(
858                        self.#field_ident.pk()
859                    )
860                ),
861            }
862        }
863    });
864    quote! {
865        impl ::rustango::sql::FkPkAccess for #struct_name {
866            #[allow(unused_variables)]
867            fn __rustango_fk_pk(&self, field_name: &str) -> ::core::option::Option<i64> {
868                match field_name {
869                    #( #arms )*
870                    _ => ::core::option::Option::None,
871                }
872            }
873            #[allow(unused_variables)]
874            fn __rustango_fk_pk_value(
875                &self,
876                field_name: &str,
877            ) -> ::core::option::Option<::rustango::core::SqlValue> {
878                match field_name {
879                    #( #value_arms )*
880                    _ => ::core::option::Option::None,
881                }
882            }
883        }
884    }
885}
886
887/// For every `ForeignKey<Parent>` field on `Child`, emit
888/// `impl Parent { pub async fn <child_table>_set(&self, executor) -> Vec<Child> }`.
889/// Reads the parent's PK via the macro-generated `__rustango_pk_value`
890/// and runs a single `SELECT … FROM <child_table> WHERE <fk_column> = $1`
891/// — the canonical reverse-FK fetch. One round trip, no N+1.
892fn reverse_helper_tokens(child_ident: &syn::Ident, fk_relations: &[FkRelation]) -> TokenStream2 {
893    if fk_relations.is_empty() {
894        return TokenStream2::new();
895    }
896    // Snake-case the child struct name to derive the method suffix —
897    // `Post` → `post_set`, `BlogComment` → `blog_comment_set`. Avoids
898    // English-plural edge cases (Django's `<child>_set` convention).
899    let suffix = format!("{}_set", to_snake_case(&child_ident.to_string()));
900    let method_ident = syn::Ident::new(&suffix, child_ident.span());
901    let impls = fk_relations.iter().map(|rel| {
902        let parent_ty = &rel.parent_type;
903        let fk_col = rel.fk_column.as_str();
904        let doc = format!(
905            "Fetch every `{child_ident}` whose `{fk_col}` foreign key points at this row. \
906             Single SQL query — `SELECT … FROM <{child_ident} table> WHERE {fk_col} = $1` — \
907             generated from the FK declaration on `{child_ident}::{fk_col}`. Composes with \
908             further `{child_ident}::objects()` filters via direct queryset use."
909        );
910        quote! {
911            impl #parent_ty {
912                #[doc = #doc]
913                ///
914                /// # Errors
915                /// Returns [`::rustango::sql::ExecError`] for SQL-writing
916                /// or driver failures.
917                pub async fn #method_ident<'_c, _E>(
918                    &self,
919                    _executor: _E,
920                ) -> ::core::result::Result<
921                    ::std::vec::Vec<#child_ident>,
922                    ::rustango::sql::ExecError,
923                >
924                where
925                    _E: ::rustango::sql::sqlx::Executor<
926                        '_c,
927                        Database = ::rustango::sql::sqlx::Postgres,
928                    >,
929                {
930                    let _pk: ::rustango::core::SqlValue = self.__rustango_pk_value();
931                    ::rustango::query::QuerySet::<#child_ident>::new()
932                        .filter(#fk_col, ::rustango::core::Op::Eq, _pk)
933                        .fetch_on(_executor)
934                        .await
935                }
936            }
937        }
938    });
939    quote! { #( #impls )* }
940}
941
942/// Emit `<name>_m2m(&self) -> M2MManager` inherent methods for every M2M
943/// relation declared on the model.
944fn m2m_accessor_tokens(struct_name: &syn::Ident, m2m_relations: &[M2MAttr]) -> TokenStream2 {
945    if m2m_relations.is_empty() {
946        return TokenStream2::new();
947    }
948    let methods = m2m_relations.iter().map(|rel| {
949        let method_name = format!("{}_m2m", rel.name);
950        let method_ident = syn::Ident::new(&method_name, struct_name.span());
951        let through = rel.through.as_str();
952        let src_col = rel.src.as_str();
953        let dst_col = rel.dst.as_str();
954        quote! {
955            pub fn #method_ident(&self) -> ::rustango::sql::M2MManager {
956                ::rustango::sql::M2MManager {
957                    src_pk: self.__rustango_pk_value(),
958                    through: #through,
959                    src_col: #src_col,
960                    dst_col: #dst_col,
961                }
962            }
963        }
964    });
965    quote! {
966        impl #struct_name {
967            #( #methods )*
968        }
969    }
970}
971
972struct ColumnEntry {
973    /// The struct field ident, used both for the inherent const name on
974    /// the model and for the inner column type's name.
975    ident: syn::Ident,
976    /// The struct's field type, used as `Column::Value`.
977    value_ty: Type,
978    /// Rust-side field name (e.g. `"id"`).
979    name: String,
980    /// SQL-side column name (e.g. `"user_id"`).
981    column: String,
982    /// `::rustango::core::FieldType::I64` etc.
983    field_type_tokens: TokenStream2,
984}
985
986struct CollectedFields {
987    field_schemas: Vec<TokenStream2>,
988    from_row_inits: Vec<TokenStream2>,
989    /// Aliased counterparts of `from_row_inits` — read columns via
990    /// `format!("{prefix}__{col}")` aliases so a Model can be
991    /// decoded from a JOINed row's projected target columns.
992    from_aliased_row_inits: Vec<TokenStream2>,
993    /// Static column-name list — used by the simple insert path
994    /// (no `Auto<T>` fields). Aligned with `insert_values`.
995    insert_columns: Vec<TokenStream2>,
996    /// Static `Into<SqlValue>` expressions, one per field. Aligned
997    /// with `insert_columns`. Used by the simple insert path only.
998    insert_values: Vec<TokenStream2>,
999    /// Per-field push expressions for the dynamic (Auto-aware)
1000    /// insert path. Each statement either unconditionally pushes
1001    /// `(column, value)` or, for an `Auto<T>` field, conditionally
1002    /// pushes only when `Auto::Set(_)`. Built only when `has_auto`.
1003    insert_pushes: Vec<TokenStream2>,
1004    /// SQL columns for `RETURNING` — one per `Auto<T>` field. Empty
1005    /// when `has_auto == false`.
1006    returning_cols: Vec<TokenStream2>,
1007    /// `self.<field> = Row::try_get(&row, "<col>")?;` for each Auto
1008    /// field. Run after `insert_returning` to populate the model.
1009    auto_assigns: Vec<TokenStream2>,
1010    /// `(ident, column_literal)` pairs for every Auto field. Used by
1011    /// the bulk_insert codegen to rebuild assigns against `_row_mut`
1012    /// instead of `self`.
1013    auto_field_idents: Vec<(syn::Ident, String)>,
1014    /// Inner `T` of the first `Auto<T>` field, for the MySQL
1015    /// `LAST_INSERT_ID()` assignment in `AssignAutoPkPool`.
1016    first_auto_value_ty: Option<Type>,
1017    /// Bulk-insert per-row pushes for **non-Auto fields only**. Used
1018    /// by the all-Auto-Unset bulk path (Auto cols dropped from
1019    /// `columns`).
1020    bulk_pushes_no_auto: Vec<TokenStream2>,
1021    /// Bulk-insert per-row pushes for **all fields including Auto**.
1022    /// Used by the all-Auto-Set bulk path (Auto col included with the
1023    /// caller-supplied value).
1024    bulk_pushes_all: Vec<TokenStream2>,
1025    /// Column-name literals for non-Auto fields only (paired with
1026    /// `bulk_pushes_no_auto`).
1027    bulk_columns_no_auto: Vec<TokenStream2>,
1028    /// Column-name literals for every field including Auto (paired
1029    /// with `bulk_pushes_all`).
1030    bulk_columns_all: Vec<TokenStream2>,
1031    /// `let _i_unset_<n> = matches!(rows[0].<auto_field>, Auto::Unset);`
1032    /// + the loop that asserts every row matches. One pair per Auto
1033    /// field. Empty when `has_auto == false`.
1034    bulk_auto_uniformity: Vec<TokenStream2>,
1035    /// Identifier of the first Auto field, used as the witness for
1036    /// "all rows agree on Set vs Unset". Set only when `has_auto`.
1037    first_auto_ident: Option<syn::Ident>,
1038    /// `true` if any field on the struct is `Auto<T>`.
1039    has_auto: bool,
1040    /// `true` when the primary-key field's Rust type is `Auto<T>`.
1041    /// Gates `save()` codegen — only Auto PKs let us infer
1042    /// insert-vs-update from the in-memory value.
1043    pk_is_auto: bool,
1044    /// `Assignment` constructors for every non-PK column. Drives the
1045    /// UPDATE branch of `save()`.
1046    update_assignments: Vec<TokenStream2>,
1047    /// Column name literals (`"col"`) for every non-PK, non-auto_now_add column.
1048    /// Drives the `ON CONFLICT ... DO UPDATE SET` clause in `upsert_on`.
1049    upsert_update_columns: Vec<TokenStream2>,
1050    primary_key: Option<(syn::Ident, String)>,
1051    column_entries: Vec<ColumnEntry>,
1052    /// Rust-side field names, in declaration order. Used to validate
1053    /// container attributes like `display = "…"`.
1054    field_names: Vec<String>,
1055    /// FK fields on this child model. Drives the reverse-relation
1056    /// helper emit — for each FK, the macro adds an inherent
1057    /// `<parent>::<child_table>_set(&self, executor) -> Vec<Self>`
1058    /// method on the parent type.
1059    fk_relations: Vec<FkRelation>,
1060    /// SQL column name of the `#[rustango(soft_delete)]` field, if
1061    /// the model has one. Drives emission of the `soft_delete_on` /
1062    /// `restore_on` inherent methods. At most one such column per
1063    /// model is allowed; collect_fields rejects duplicates.
1064    soft_delete_column: Option<String>,
1065}
1066
1067#[derive(Clone)]
1068struct FkRelation {
1069    /// Inner type of `ForeignKey<T, K>` — the parent model. The reverse
1070    /// helper is emitted as `impl <ParentType> { … }`.
1071    parent_type: Type,
1072    /// SQL column name on the child table for this FK (e.g. `"author"`).
1073    /// Used in the generated `WHERE <fk_column> = $1` clause.
1074    fk_column: String,
1075    /// `K`'s underlying scalar kind — drives the `match SqlValue { … }`
1076    /// arm emitted by [`load_related_impl_tokens`]. `I64` for the
1077    /// default `ForeignKey<T>` (no explicit K); other kinds when the
1078    /// user wrote `ForeignKey<T, String>`, `ForeignKey<T, Uuid>`, etc.
1079    pk_kind: DetectedKind,
1080    /// `true` when the field is `Option<ForeignKey<T, K>>` (nullable
1081    /// FK column). Drives the `Some(...)` wrapping in load_related
1082    /// assignment and `.as_ref().map(...)` in the FK PK accessor so
1083    /// the codegen matches the field's declared shape.
1084    nullable: bool,
1085}
1086
1087fn collect_fields(named: &syn::FieldsNamed, table: &str) -> syn::Result<CollectedFields> {
1088    let cap = named.named.len();
1089    let mut out = CollectedFields {
1090        field_schemas: Vec::with_capacity(cap),
1091        from_row_inits: Vec::with_capacity(cap),
1092        from_aliased_row_inits: Vec::with_capacity(cap),
1093        insert_columns: Vec::with_capacity(cap),
1094        insert_values: Vec::with_capacity(cap),
1095        insert_pushes: Vec::with_capacity(cap),
1096        returning_cols: Vec::new(),
1097        auto_assigns: Vec::new(),
1098        auto_field_idents: Vec::new(),
1099        first_auto_value_ty: None,
1100        bulk_pushes_no_auto: Vec::with_capacity(cap),
1101        bulk_pushes_all: Vec::with_capacity(cap),
1102        bulk_columns_no_auto: Vec::with_capacity(cap),
1103        bulk_columns_all: Vec::with_capacity(cap),
1104        bulk_auto_uniformity: Vec::new(),
1105        first_auto_ident: None,
1106        has_auto: false,
1107        pk_is_auto: false,
1108        update_assignments: Vec::with_capacity(cap),
1109        upsert_update_columns: Vec::with_capacity(cap),
1110        primary_key: None,
1111        column_entries: Vec::with_capacity(cap),
1112        field_names: Vec::with_capacity(cap),
1113        fk_relations: Vec::new(),
1114        soft_delete_column: None,
1115    };
1116
1117    for field in &named.named {
1118        let info = process_field(field, table)?;
1119        out.field_names.push(info.ident.to_string());
1120        out.field_schemas.push(info.schema);
1121        out.from_row_inits.push(info.from_row_init);
1122        out.from_aliased_row_inits.push(info.from_aliased_row_init);
1123        if let Some(parent_ty) = info.fk_inner.clone() {
1124            out.fk_relations.push(FkRelation {
1125                parent_type: parent_ty,
1126                fk_column: info.column.clone(),
1127                pk_kind: info.fk_pk_kind,
1128                nullable: info.nullable,
1129            });
1130        }
1131        if info.soft_delete {
1132            if out.soft_delete_column.is_some() {
1133                return Err(syn::Error::new_spanned(
1134                    field,
1135                    "only one field may be marked `#[rustango(soft_delete)]`",
1136                ));
1137            }
1138            out.soft_delete_column = Some(info.column.clone());
1139        }
1140        let column = info.column.as_str();
1141        let ident = info.ident;
1142        // Generated columns (`#[rustango(generated_as = "EXPR")]`)
1143        // skip every write path — Postgres recomputes the value
1144        // from EXPR. Push only the column-entry record (so typed
1145        // column constants still exist for filtering / projection)
1146        // and the schema literal (already pushed above) and move
1147        // on. No insert_columns/values, no insert_pushes, no
1148        // bulk_*, no update_assignments, no upsert_update_columns,
1149        // no returning_cols.
1150        if info.generated_as.is_some() {
1151            out.column_entries.push(ColumnEntry {
1152                ident: ident.clone(),
1153                value_ty: info.value_ty.clone(),
1154                name: ident.to_string(),
1155                column: info.column.clone(),
1156                field_type_tokens: info.field_type_tokens,
1157            });
1158            continue;
1159        }
1160        out.insert_columns.push(quote!(#column));
1161        out.insert_values.push(quote! {
1162            ::core::convert::Into::<::rustango::core::SqlValue>::into(
1163                ::core::clone::Clone::clone(&self.#ident)
1164            )
1165        });
1166        if info.auto {
1167            out.has_auto = true;
1168            if out.first_auto_ident.is_none() {
1169                out.first_auto_ident = Some(ident.clone());
1170                out.first_auto_value_ty = auto_inner_type(info.value_ty).cloned();
1171            }
1172            out.returning_cols.push(quote!(#column));
1173            out.auto_field_idents
1174                .push((ident.clone(), info.column.clone()));
1175            out.auto_assigns.push(quote! {
1176                self.#ident = ::rustango::sql::try_get_returning(_returning_row, #column)?;
1177            });
1178            out.insert_pushes.push(quote! {
1179                if let ::rustango::sql::Auto::Set(_v) = &self.#ident {
1180                    _columns.push(#column);
1181                    _values.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
1182                        ::core::clone::Clone::clone(_v)
1183                    ));
1184                }
1185            });
1186            // Bulk: Auto fields appear only in the all-Set path,
1187            // never in the Unset path (we drop them from `columns`).
1188            out.bulk_columns_all.push(quote!(#column));
1189            out.bulk_pushes_all.push(quote! {
1190                _row_vals.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
1191                    ::core::clone::Clone::clone(&_row.#ident)
1192                ));
1193            });
1194            // Uniformity check: every row's Auto state must match the
1195            // first row's. Mixed Set/Unset within one bulk_insert is
1196            // rejected here so the column list stays consistent.
1197            let ident_clone = ident.clone();
1198            out.bulk_auto_uniformity.push(quote! {
1199                for _r in rows.iter().skip(1) {
1200                    if matches!(_r.#ident_clone, ::rustango::sql::Auto::Unset) != _first_unset {
1201                        return ::core::result::Result::Err(
1202                            ::rustango::sql::ExecError::Sql(
1203                                ::rustango::sql::SqlError::BulkAutoMixed
1204                            )
1205                        );
1206                    }
1207                }
1208            });
1209        } else {
1210            out.insert_pushes.push(quote! {
1211                _columns.push(#column);
1212                _values.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
1213                    ::core::clone::Clone::clone(&self.#ident)
1214                ));
1215            });
1216            // Bulk: non-Auto fields appear in BOTH paths.
1217            out.bulk_columns_no_auto.push(quote!(#column));
1218            out.bulk_columns_all.push(quote!(#column));
1219            let push_expr = quote! {
1220                _row_vals.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
1221                    ::core::clone::Clone::clone(&_row.#ident)
1222                ));
1223            };
1224            out.bulk_pushes_no_auto.push(push_expr.clone());
1225            out.bulk_pushes_all.push(push_expr);
1226        }
1227        if info.primary_key {
1228            if out.primary_key.is_some() {
1229                return Err(syn::Error::new_spanned(
1230                    field,
1231                    "only one field may be marked `#[rustango(primary_key)]`",
1232                ));
1233            }
1234            out.primary_key = Some((ident.clone(), info.column.clone()));
1235            if info.auto {
1236                out.pk_is_auto = true;
1237            }
1238        } else if info.auto_now_add {
1239            // Immutable post-insert: skip from UPDATE entirely.
1240        } else if info.auto_now {
1241            // `auto_now` columns: bind `chrono::Utc::now()` on every
1242            // UPDATE so the column is always overridden with the
1243            // wall-clock at write time, regardless of what value the
1244            // user left in the struct field.
1245            out.update_assignments.push(quote! {
1246                ::rustango::core::Assignment {
1247                    column: #column,
1248                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
1249                        ::chrono::Utc::now()
1250                    ),
1251                }
1252            });
1253            out.upsert_update_columns.push(quote!(#column));
1254        } else {
1255            out.update_assignments.push(quote! {
1256                ::rustango::core::Assignment {
1257                    column: #column,
1258                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
1259                        ::core::clone::Clone::clone(&self.#ident)
1260                    ),
1261                }
1262            });
1263            out.upsert_update_columns.push(quote!(#column));
1264        }
1265        out.column_entries.push(ColumnEntry {
1266            ident: ident.clone(),
1267            value_ty: info.value_ty.clone(),
1268            name: ident.to_string(),
1269            column: info.column.clone(),
1270            field_type_tokens: info.field_type_tokens,
1271        });
1272    }
1273    Ok(out)
1274}
1275
1276fn model_impl_tokens(
1277    struct_name: &syn::Ident,
1278    model_name: &str,
1279    table: &str,
1280    display: Option<&str>,
1281    app_label: Option<&str>,
1282    admin: Option<&AdminAttrs>,
1283    field_schemas: &[TokenStream2],
1284    soft_delete_column: Option<&str>,
1285    permissions: bool,
1286    audit_track: Option<&[String]>,
1287    m2m_relations: &[M2MAttr],
1288    indexes: &[IndexAttr],
1289    checks: &[CheckAttr],
1290    composite_fks: &[CompositeFkAttr],
1291    generic_fks: &[GenericFkAttr],
1292    scope: Option<&str>,
1293) -> TokenStream2 {
1294    let display_tokens = if let Some(name) = display {
1295        quote!(::core::option::Option::Some(#name))
1296    } else {
1297        quote!(::core::option::Option::None)
1298    };
1299    let app_label_tokens = if let Some(name) = app_label {
1300        quote!(::core::option::Option::Some(#name))
1301    } else {
1302        quote!(::core::option::Option::None)
1303    };
1304    let soft_delete_tokens = if let Some(col) = soft_delete_column {
1305        quote!(::core::option::Option::Some(#col))
1306    } else {
1307        quote!(::core::option::Option::None)
1308    };
1309    let audit_track_tokens = match audit_track {
1310        None => quote!(::core::option::Option::None),
1311        Some(names) => {
1312            let lits = names.iter().map(|n| n.as_str());
1313            quote!(::core::option::Option::Some(&[ #(#lits),* ]))
1314        }
1315    };
1316    let admin_tokens = admin_config_tokens(admin);
1317    // Default `tenant` so single-tenant projects (no `scope` attr
1318    // anywhere) keep the v0.24.x behavior. Container-attr parser
1319    // already validated the value is "registry" or "tenant".
1320    let scope_tokens = match scope.map(|s| s.to_ascii_lowercase()).as_deref() {
1321        Some("registry") => quote!(::rustango::core::ModelScope::Registry),
1322        _ => quote!(::rustango::core::ModelScope::Tenant),
1323    };
1324    let indexes_tokens = indexes.iter().map(|idx| {
1325        let name = idx.name.as_deref().unwrap_or("unnamed_index");
1326        let cols: Vec<&str> = idx.columns.iter().map(String::as_str).collect();
1327        let unique = idx.unique;
1328        quote! {
1329            ::rustango::core::IndexSchema {
1330                name: #name,
1331                columns: &[ #(#cols),* ],
1332                unique: #unique,
1333            }
1334        }
1335    });
1336    let checks_tokens = checks.iter().map(|c| {
1337        let name = c.name.as_str();
1338        let expr = c.expr.as_str();
1339        quote! {
1340            ::rustango::core::CheckConstraint {
1341                name: #name,
1342                expr: #expr,
1343            }
1344        }
1345    });
1346    let composite_fk_tokens = composite_fks.iter().map(|rel| {
1347        let name = rel.name.as_str();
1348        let to = rel.to.as_str();
1349        let from_cols: Vec<&str> = rel.from.iter().map(String::as_str).collect();
1350        let on_cols: Vec<&str> = rel.on.iter().map(String::as_str).collect();
1351        quote! {
1352            ::rustango::core::CompositeFkRelation {
1353                name: #name,
1354                to: #to,
1355                from: &[ #(#from_cols),* ],
1356                on: &[ #(#on_cols),* ],
1357            }
1358        }
1359    });
1360    let generic_fk_tokens = generic_fks.iter().map(|rel| {
1361        let name = rel.name.as_str();
1362        let ct_col = rel.ct_column.as_str();
1363        let pk_col = rel.pk_column.as_str();
1364        quote! {
1365            ::rustango::core::GenericRelation {
1366                name: #name,
1367                ct_column: #ct_col,
1368                pk_column: #pk_col,
1369            }
1370        }
1371    });
1372    let m2m_tokens = m2m_relations.iter().map(|rel| {
1373        let name = rel.name.as_str();
1374        let to = rel.to.as_str();
1375        let through = rel.through.as_str();
1376        let src = rel.src.as_str();
1377        let dst = rel.dst.as_str();
1378        quote! {
1379            ::rustango::core::M2MRelation {
1380                name: #name,
1381                to: #to,
1382                through: #through,
1383                src_col: #src,
1384                dst_col: #dst,
1385            }
1386        }
1387    });
1388    quote! {
1389        impl ::rustango::core::Model for #struct_name {
1390            const SCHEMA: &'static ::rustango::core::ModelSchema = &::rustango::core::ModelSchema {
1391                name: #model_name,
1392                table: #table,
1393                fields: &[ #(#field_schemas),* ],
1394                display: #display_tokens,
1395                app_label: #app_label_tokens,
1396                admin: #admin_tokens,
1397                soft_delete_column: #soft_delete_tokens,
1398                permissions: #permissions,
1399                audit_track: #audit_track_tokens,
1400                m2m: &[ #(#m2m_tokens),* ],
1401                indexes: &[ #(#indexes_tokens),* ],
1402                check_constraints: &[ #(#checks_tokens),* ],
1403                composite_relations: &[ #(#composite_fk_tokens),* ],
1404                generic_relations: &[ #(#generic_fk_tokens),* ],
1405                scope: #scope_tokens,
1406            };
1407        }
1408    }
1409}
1410
1411/// Emit the `admin: Option<&'static AdminConfig>` field for the model
1412/// schema. `None` when the user wrote no `#[rustango(admin(...))]`;
1413/// otherwise a static reference to a populated `AdminConfig`.
1414fn admin_config_tokens(admin: Option<&AdminAttrs>) -> TokenStream2 {
1415    let Some(admin) = admin else {
1416        return quote!(::core::option::Option::None);
1417    };
1418
1419    let list_display = admin
1420        .list_display
1421        .as_ref()
1422        .map(|(v, _)| v.as_slice())
1423        .unwrap_or(&[]);
1424    let list_display_lits = list_display.iter().map(|s| s.as_str());
1425
1426    let search_fields = admin
1427        .search_fields
1428        .as_ref()
1429        .map(|(v, _)| v.as_slice())
1430        .unwrap_or(&[]);
1431    let search_fields_lits = search_fields.iter().map(|s| s.as_str());
1432
1433    let readonly_fields = admin
1434        .readonly_fields
1435        .as_ref()
1436        .map(|(v, _)| v.as_slice())
1437        .unwrap_or(&[]);
1438    let readonly_fields_lits = readonly_fields.iter().map(|s| s.as_str());
1439
1440    let list_filter = admin
1441        .list_filter
1442        .as_ref()
1443        .map(|(v, _)| v.as_slice())
1444        .unwrap_or(&[]);
1445    let list_filter_lits = list_filter.iter().map(|s| s.as_str());
1446
1447    let actions = admin
1448        .actions
1449        .as_ref()
1450        .map(|(v, _)| v.as_slice())
1451        .unwrap_or(&[]);
1452    let actions_lits = actions.iter().map(|s| s.as_str());
1453
1454    let fieldsets = admin
1455        .fieldsets
1456        .as_ref()
1457        .map(|(v, _)| v.as_slice())
1458        .unwrap_or(&[]);
1459    let fieldset_tokens = fieldsets.iter().map(|(title, fields)| {
1460        let title = title.as_str();
1461        let field_lits = fields.iter().map(|s| s.as_str());
1462        quote!(::rustango::core::Fieldset {
1463            title: #title,
1464            fields: &[ #( #field_lits ),* ],
1465        })
1466    });
1467
1468    let list_per_page = admin.list_per_page.unwrap_or(0);
1469
1470    let ordering_pairs = admin
1471        .ordering
1472        .as_ref()
1473        .map(|(v, _)| v.as_slice())
1474        .unwrap_or(&[]);
1475    let ordering_tokens = ordering_pairs.iter().map(|(name, desc)| {
1476        let name = name.as_str();
1477        let desc = *desc;
1478        quote!((#name, #desc))
1479    });
1480
1481    quote! {
1482        ::core::option::Option::Some(&::rustango::core::AdminConfig {
1483            list_display: &[ #( #list_display_lits ),* ],
1484            search_fields: &[ #( #search_fields_lits ),* ],
1485            list_per_page: #list_per_page,
1486            ordering: &[ #( #ordering_tokens ),* ],
1487            readonly_fields: &[ #( #readonly_fields_lits ),* ],
1488            list_filter: &[ #( #list_filter_lits ),* ],
1489            actions: &[ #( #actions_lits ),* ],
1490            fieldsets: &[ #( #fieldset_tokens ),* ],
1491        })
1492    }
1493}
1494
1495fn inherent_impl_tokens(
1496    struct_name: &syn::Ident,
1497    fields: &CollectedFields,
1498    primary_key: Option<&(syn::Ident, String)>,
1499    column_consts: &TokenStream2,
1500    audited_fields: Option<&[&ColumnEntry]>,
1501    indexes: &[IndexAttr],
1502) -> TokenStream2 {
1503    // Audit-emit fragments threaded into write paths. Non-empty only
1504    // when the model carries `#[rustango(audit(...))]`. They reborrow
1505    // `_executor` (a `&mut PgConnection` for audited models — the
1506    // macro switches the signature below) so the data write and the
1507    // audit INSERT both run on the same caller-supplied connection.
1508    let executor_passes_to_data_write = if audited_fields.is_some() {
1509        quote!(&mut *_executor)
1510    } else {
1511        quote!(_executor)
1512    };
1513    let executor_param = if audited_fields.is_some() {
1514        quote!(_executor: &mut ::rustango::sql::sqlx::PgConnection)
1515    } else {
1516        quote!(_executor: _E)
1517    };
1518    let executor_generics = if audited_fields.is_some() {
1519        quote!()
1520    } else {
1521        quote!(<'_c, _E>)
1522    };
1523    let executor_where = if audited_fields.is_some() {
1524        quote!()
1525    } else {
1526        quote! {
1527            where
1528                _E: ::rustango::sql::sqlx::Executor<'_c, Database = ::rustango::sql::sqlx::Postgres>,
1529        }
1530    };
1531    // For audited models the `_on` methods take `&mut PgConnection`, so
1532    // the &PgPool convenience wrappers (`save`, `insert`, `delete`)
1533    // must acquire a connection first. Non-audited models keep the
1534    // direct delegation since `&PgPool` IS an Executor.
1535    let pool_to_save_on = if audited_fields.is_some() {
1536        quote! {
1537            let mut _conn = pool.acquire().await?;
1538            self.save_on(&mut *_conn).await
1539        }
1540    } else {
1541        quote!(self.save_on(pool).await)
1542    };
1543    let pool_to_insert_on = if audited_fields.is_some() {
1544        quote! {
1545            let mut _conn = pool.acquire().await?;
1546            self.insert_on(&mut *_conn).await
1547        }
1548    } else {
1549        quote!(self.insert_on(pool).await)
1550    };
1551    let pool_to_delete_on = if audited_fields.is_some() {
1552        quote! {
1553            let mut _conn = pool.acquire().await?;
1554            self.delete_on(&mut *_conn).await
1555        }
1556    } else {
1557        quote!(self.delete_on(pool).await)
1558    };
1559    let pool_to_bulk_insert_on = if audited_fields.is_some() {
1560        quote! {
1561            let mut _conn = pool.acquire().await?;
1562            Self::bulk_insert_on(rows, &mut *_conn).await
1563        }
1564    } else {
1565        quote!(Self::bulk_insert_on(rows, pool).await)
1566    };
1567    // Pre-existing bug surfaced by batch 22's first audited Auto<T>
1568    // PK test model: `upsert(&PgPool)` body called `self.upsert_on(pool)`
1569    // directly, but `upsert_on` for audited models takes
1570    // `&mut PgConnection` (the audit emit needs a real connection).
1571    // Add the missing acquire shim to keep audited Auto-PK upsert
1572    // compiling.
1573    let pool_to_upsert_on = if audited_fields.is_some() {
1574        quote! {
1575            let mut _conn = pool.acquire().await?;
1576            self.upsert_on(&mut *_conn).await
1577        }
1578    } else {
1579        quote!(self.upsert_on(pool).await)
1580    };
1581
1582    // `insert_pool(&Pool)` — v0.23.0-batch9. Non-audited models only
1583    // (audit-on-connection over &Pool needs a bi-dialect transaction
1584    // helper, deferred). Two body shapes:
1585    // - has_auto: build InsertQuery skipping Auto::Unset columns,
1586    //   request Auto cols in `returning`, dispatch via
1587    //   `insert_returning_pool`, then on the returned `PgRow` /
1588    //   `MySqlAutoId(id)` enum — pull each Auto field from the PG
1589    //   row OR drop the single i64 into the first Auto field on MySQL
1590    //   (multi-Auto models on MySQL error at runtime since
1591    //   `LAST_INSERT_ID()` only reports one)
1592    // - non-Auto: build InsertQuery with explicit columns/values and
1593    //   call `insert_pool` (no returning needed)
1594    // pool_insert_method body for the audited Auto-PK case is moved
1595    // to after audit_pair_tokens / audit_pk_to_string (they live
1596    // ~150 lines below). This block keeps the non-audited and
1597    // non-Auto branches in place — the audited Auto-PK arm is
1598    // computed below and merged via the dispatch helper variable.
1599    let pool_insert_method = if audited_fields.is_some() && !fields.has_auto {
1600        // Audited models with explicit (non-Auto) PKs go through
1601        // the non-Auto insert path below — the audit emit is one
1602        // round-trip after the INSERT inside the same tx via
1603        // audit::save_one_with_audit_pool? No, INSERT semantics
1604        // differ. For non-Auto PK + audited, route through a
1605        // dedicated insert + audit emit on the same tx, but defer
1606        // the macro emission to the audit-bundle-aware block below
1607        // — this `quote!()` placeholder gets overwritten there.
1608        quote!()
1609    } else if audited_fields.is_some() && fields.has_auto {
1610        // Audited Auto-PK insert_pool — assembled after the audit
1611        // bundles. Placeholder; real emission below.
1612        quote!()
1613    } else if fields.has_auto {
1614        let pushes = &fields.insert_pushes;
1615        let returning_cols = &fields.returning_cols;
1616        quote! {
1617            /// Insert this row against either backend, populating any
1618            /// `Auto<T>` PK from the auto-assigned value.
1619            ///
1620            /// # Errors
1621            /// As [`Self::insert`].
1622            pub async fn insert_pool(
1623                &mut self,
1624                pool: &::rustango::sql::Pool,
1625            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
1626                let mut _columns: ::std::vec::Vec<&'static str> =
1627                    ::std::vec::Vec::new();
1628                let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
1629                    ::std::vec::Vec::new();
1630                #( #pushes )*
1631                let _query = ::rustango::core::InsertQuery {
1632                    model: <Self as ::rustango::core::Model>::SCHEMA,
1633                    columns: _columns,
1634                    values: _values,
1635                    returning: ::std::vec![ #( #returning_cols ),* ],
1636                    on_conflict: ::core::option::Option::None,
1637                };
1638                let _result = ::rustango::sql::insert_returning_pool(
1639                    pool, &_query,
1640                ).await?;
1641                ::rustango::sql::apply_auto_pk_pool(_result, self)
1642            }
1643        }
1644    } else {
1645        let insert_columns = &fields.insert_columns;
1646        let insert_values = &fields.insert_values;
1647        quote! {
1648            /// Insert this row into its table against either backend.
1649            /// Equivalent to [`Self::insert`] but takes
1650            /// [`::rustango::sql::Pool`].
1651            ///
1652            /// # Errors
1653            /// As [`Self::insert`].
1654            pub async fn insert_pool(
1655                &self,
1656                pool: &::rustango::sql::Pool,
1657            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
1658                let _query = ::rustango::core::InsertQuery {
1659                    model: <Self as ::rustango::core::Model>::SCHEMA,
1660                    columns: ::std::vec![ #( #insert_columns ),* ],
1661                    values: ::std::vec![ #( #insert_values ),* ],
1662                    returning: ::std::vec::Vec::new(),
1663                    on_conflict: ::core::option::Option::None,
1664                };
1665                ::rustango::sql::insert_pool(pool, &_query).await
1666            }
1667        }
1668    };
1669
1670    // pool_save_method moved to after audit_pair_tokens /
1671    // audit_pk_to_string (they live ~70 lines below) — needed for
1672    // the audited branch which builds an UpdateQuery + PendingEntry
1673    // and dispatches via audit::save_one_with_audit_pool.
1674
1675    // pool_delete_method moved to after audit_pair_tokens / audit_pk_to_string
1676    // are computed (they live ~80 lines below).
1677
1678    // Build the (column, JSON value) pair list used by every
1679    // snapshot-style audit emission. Reused across delete_on,
1680    // soft_delete_on, restore_on, and (later) bulk paths. Empty
1681    // when the model isn't audited.
1682    let audit_pair_tokens: Vec<TokenStream2> = audited_fields
1683        .map(|tracked| {
1684            tracked
1685                .iter()
1686                .map(|c| {
1687                    let column_lit = c.column.as_str();
1688                    let ident = &c.ident;
1689                    quote! {
1690                        (
1691                            #column_lit,
1692                            ::serde_json::to_value(&self.#ident)
1693                                .unwrap_or(::serde_json::Value::Null),
1694                        )
1695                    }
1696                })
1697                .collect()
1698        })
1699        .unwrap_or_default();
1700    let audit_pk_to_string = if let Some((pk_ident, _)) = primary_key {
1701        if fields.pk_is_auto {
1702            quote!(self.#pk_ident.get().map(|v| ::std::format!("{}", v)).unwrap_or_default())
1703        } else {
1704            quote!(::std::format!("{}", &self.#pk_ident))
1705        }
1706    } else {
1707        quote!(::std::string::String::new())
1708    };
1709    let make_op_emit = |op_path: TokenStream2| -> TokenStream2 {
1710        if audited_fields.is_some() {
1711            let pairs = audit_pair_tokens.iter();
1712            let pk_str = audit_pk_to_string.clone();
1713            quote! {
1714                let _audit_entry = ::rustango::audit::PendingEntry {
1715                    entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
1716                    entity_pk: #pk_str,
1717                    operation: #op_path,
1718                    source: ::rustango::audit::current_source(),
1719                    changes: ::rustango::audit::snapshot_changes(&[
1720                        #( #pairs ),*
1721                    ]),
1722                };
1723                ::rustango::audit::emit_one(&mut *_executor, &_audit_entry).await?;
1724            }
1725        } else {
1726            quote!()
1727        }
1728    };
1729    let audit_insert_emit = make_op_emit(quote!(::rustango::audit::AuditOp::Create));
1730    let audit_delete_emit = make_op_emit(quote!(::rustango::audit::AuditOp::Delete));
1731    let audit_softdelete_emit = make_op_emit(quote!(::rustango::audit::AuditOp::SoftDelete));
1732    let audit_restore_emit = make_op_emit(quote!(::rustango::audit::AuditOp::Restore));
1733
1734    // `save_pool(&Pool)` — emitted for every model with a PK.
1735    // Audited Auto-PK models are deferred (the Auto::Unset →
1736    // insert_pool path needs the audited-insert flow from a future
1737    // batch). Three body shapes:
1738    // - non-audited, plain PK: build UpdateQuery + dispatch through
1739    //   sql::update_pool
1740    // - non-audited, Auto-PK: same, but Auto::Unset routes to
1741    //   self.insert_pool which already handles RETURNING / LAST_INSERT_ID
1742    // - audited, plain PK: build UpdateQuery + PendingEntry, dispatch
1743    //   through audit::save_one_with_audit_pool (per-backend tx wraps
1744    //   UPDATE + audit emit atomically). Snapshot-style audit (post-
1745    //   write field values) — diff-style audit (with pre-UPDATE
1746    //   SELECT for `before` values) needs per-tracked-column codegen
1747    //   that doesn't fit the runtime-helper pattern; legacy &PgPool
1748    //   `save` keeps the diff for now.
1749    let pool_save_method = if let Some((pk_ident, pk_col)) = primary_key {
1750        let pk_column_lit = pk_col.as_str();
1751        let assignments = &fields.update_assignments;
1752        if audited_fields.is_some() {
1753            if fields.pk_is_auto {
1754                // Auto-PK + audited: defer. The Auto::Unset insert
1755                // path needs a transactional INSERT + LAST_INSERT_ID
1756                // + audit emit flow — that's a follow-up batch.
1757                quote!()
1758            } else {
1759                let pairs = audit_pair_tokens.iter();
1760                let pk_str = audit_pk_to_string.clone();
1761                quote! {
1762                    /// Save (UPDATE) this row against either backend
1763                    /// with audit emission inside the same transaction.
1764                    /// Bi-dialect counterpart of [`Self::save`] for
1765                    /// audited models with non-`Auto<T>` PKs.
1766                    ///
1767                    /// Captures **post-write** field state (snapshot
1768                    /// audit). The legacy &PgPool [`Self::save`]
1769                    /// captures BEFORE+AFTER for true diff audit;
1770                    /// porting that to the &Pool path needs runtime
1771                    /// per-tracked-column decoding and is deferred.
1772                    ///
1773                    /// # Errors
1774                    /// As [`Self::save`].
1775                    pub async fn save_pool(
1776                        &mut self,
1777                        pool: &::rustango::sql::Pool,
1778                    ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
1779                        let _query = ::rustango::core::UpdateQuery {
1780                            model: <Self as ::rustango::core::Model>::SCHEMA,
1781                            set: ::std::vec![ #( #assignments ),* ],
1782                            where_clause: ::rustango::core::WhereExpr::Predicate(
1783                                ::rustango::core::Filter {
1784                                    column: #pk_column_lit,
1785                                    op: ::rustango::core::Op::Eq,
1786                                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
1787                                        ::core::clone::Clone::clone(&self.#pk_ident)
1788                                    ),
1789                                }
1790                            ),
1791                        };
1792                        let _audit_entry = ::rustango::audit::PendingEntry {
1793                            entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
1794                            entity_pk: #pk_str,
1795                            operation: ::rustango::audit::AuditOp::Update,
1796                            source: ::rustango::audit::current_source(),
1797                            changes: ::rustango::audit::snapshot_changes(&[
1798                                #( #pairs ),*
1799                            ]),
1800                        };
1801                        let _ = ::rustango::audit::save_one_with_audit_pool(
1802                            pool, &_query, &_audit_entry,
1803                        ).await?;
1804                        ::core::result::Result::Ok(())
1805                    }
1806                }
1807            }
1808        } else {
1809            let dispatch_unset = if fields.pk_is_auto {
1810                quote! {
1811                    if matches!(self.#pk_ident, ::rustango::sql::Auto::Unset) {
1812                        return self.insert_pool(pool).await;
1813                    }
1814                }
1815            } else {
1816                quote!()
1817            };
1818            quote! {
1819                /// Save this row to its table against either backend.
1820                /// `INSERT` when the `Auto<T>` PK is `Unset`, else
1821                /// `UPDATE` keyed on the PK.
1822                ///
1823                /// # Errors
1824                /// As [`Self::save`].
1825                pub async fn save_pool(
1826                    &mut self,
1827                    pool: &::rustango::sql::Pool,
1828                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
1829                    #dispatch_unset
1830                    let _query = ::rustango::core::UpdateQuery {
1831                        model: <Self as ::rustango::core::Model>::SCHEMA,
1832                        set: ::std::vec![ #( #assignments ),* ],
1833                        where_clause: ::rustango::core::WhereExpr::Predicate(
1834                            ::rustango::core::Filter {
1835                                column: #pk_column_lit,
1836                                op: ::rustango::core::Op::Eq,
1837                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
1838                                    ::core::clone::Clone::clone(&self.#pk_ident)
1839                                ),
1840                            }
1841                        ),
1842                    };
1843                    let _ = ::rustango::sql::update_pool(pool, &_query).await?;
1844                    ::core::result::Result::Ok(())
1845                }
1846            }
1847        }
1848    } else {
1849        quote!()
1850    };
1851
1852    // Audited `insert_pool` (overrides the placeholder set higher up
1853    // in the function). v0.23.0-batch22 — both Auto-PK and non-Auto-PK
1854    // audited models get insert_pool routing through
1855    // audit::insert_one_with_audit_pool (per-backend tx wraps INSERT
1856    // + auto-PK readback + audit emit). Snapshot-style audit (the
1857    // PendingEntry's `changes` carries post-write field values).
1858    let pool_insert_method = if audited_fields.is_some() {
1859        if let Some(_) = primary_key {
1860            let pushes = if fields.has_auto {
1861                fields.insert_pushes.clone()
1862            } else {
1863                // For non-Auto-PK models, the macro normally builds
1864                // {columns, values} from fields.insert_columns +
1865                // fields.insert_values rather than insert_pushes.
1866                // Map those into the pushes shape.
1867                fields
1868                    .insert_columns
1869                    .iter()
1870                    .zip(&fields.insert_values)
1871                    .map(|(col, val)| {
1872                        quote! {
1873                            _columns.push(#col);
1874                            _values.push(#val);
1875                        }
1876                    })
1877                    .collect()
1878            };
1879            let returning_cols: Vec<proc_macro2::TokenStream> = if fields.has_auto {
1880                fields.returning_cols.clone()
1881            } else {
1882                // Non-Auto-PK: still need RETURNING something for the
1883                // audit helper's contract (it errors on empty
1884                // returning). Return the PK column so the audit row
1885                // can carry the assigned PK back. Some non-Auto PKs
1886                // are server-side-default (e.g. UUIDv4 default), so
1887                // RETURNING is genuinely useful.
1888                primary_key
1889                    .map(|(_, col)| {
1890                        let lit = col.as_str();
1891                        vec![quote!(#lit)]
1892                    })
1893                    .unwrap_or_default()
1894            };
1895            let pairs = audit_pair_tokens.iter();
1896            let pk_str = audit_pk_to_string.clone();
1897            quote! {
1898                /// Insert this row against either backend with audit
1899                /// emission inside the same transaction. Bi-dialect
1900                /// counterpart of [`Self::insert`] for audited models.
1901                ///
1902                /// Snapshot-style audit (post-write field values).
1903                ///
1904                /// # Errors
1905                /// As [`Self::insert`].
1906                pub async fn insert_pool(
1907                    &mut self,
1908                    pool: &::rustango::sql::Pool,
1909                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
1910                    let mut _columns: ::std::vec::Vec<&'static str> =
1911                        ::std::vec::Vec::new();
1912                    let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
1913                        ::std::vec::Vec::new();
1914                    #( #pushes )*
1915                    let _query = ::rustango::core::InsertQuery {
1916                        model: <Self as ::rustango::core::Model>::SCHEMA,
1917                        columns: _columns,
1918                        values: _values,
1919                        returning: ::std::vec![ #( #returning_cols ),* ],
1920                        on_conflict: ::core::option::Option::None,
1921                    };
1922                    let _audit_entry = ::rustango::audit::PendingEntry {
1923                        entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
1924                        entity_pk: #pk_str,
1925                        operation: ::rustango::audit::AuditOp::Create,
1926                        source: ::rustango::audit::current_source(),
1927                        changes: ::rustango::audit::snapshot_changes(&[
1928                            #( #pairs ),*
1929                        ]),
1930                    };
1931                    let _result = ::rustango::audit::insert_one_with_audit_pool(
1932                        pool, &_query, &_audit_entry,
1933                    ).await?;
1934                    ::rustango::sql::apply_auto_pk_pool(_result, self)
1935                }
1936            }
1937        } else {
1938            quote!()
1939        }
1940    } else {
1941        // Keep the non-audited pool_insert_method we built earlier.
1942        pool_insert_method
1943    };
1944
1945    // Update audited save_pool: now that insert_pool is wired for
1946    // audited Auto-PK models, save_pool can dispatch Auto::Unset →
1947    // insert_pool. Non-audited save_pool already does this.
1948    // v0.23.0-batch25 — diff-style audit on the audited save_pool path.
1949    // Replaces the snapshot-only emission with a per-backend transaction
1950    // body that:
1951    //  1. SELECTs the tracked columns by PK (typed Row::try_get per
1952    //     column), capturing BEFORE values
1953    //  2. compiles the UPDATE via pool.dialect() and runs it on the tx
1954    //  3. builds AFTER pairs from &self
1955    //  4. diffs BEFORE/AFTER, emits one PendingEntry with
1956    //     AuditOp::Update + diff_changes(...) on the same tx connection
1957    //  5. commits
1958    //
1959    // Per-backend arms inline the SQL string + placeholder shape, then
1960    // share the `audit_before_pair_tokens` decoder block (Row::try_get
1961    // is polymorphic over Row type — the same tokens work against
1962    // PgRow and MySqlRow as long as the field's Rust type implements
1963    // both Decode<Postgres> and Decode<MySql>, which Auto<T> +
1964    // primitives + chrono/uuid/serde_json::Value all do).
1965    let pool_save_method = if let Some(tracked) = audited_fields {
1966        if let Some((pk_ident, pk_col)) = primary_key {
1967            let pk_column_lit = pk_col.as_str();
1968            // Two iterators — quote!'s `#(#var)*` consumes the
1969            // iterator, and we need to splice the same after-pairs
1970            // sequence into both per-backend arms.
1971            let after_pairs_pg = audit_pair_tokens.iter().collect::<Vec<_>>();
1972            let pk_str = audit_pk_to_string.clone();
1973            // Per-tracked-column BEFORE-pair token list. Each entry
1974            // is `(col_lit, try_get_returning<value_ty>(row, col_lit) → Json)`.
1975            // The Row alias resolves to PgRow / MySqlRow per call site,
1976            // so the same template generates both the PG and MySQL bodies.
1977            let mk_before_pairs =
1978                |getter: proc_macro2::TokenStream| -> Vec<proc_macro2::TokenStream> {
1979                    tracked
1980                        .iter()
1981                        .map(|c| {
1982                            let column_lit = c.column.as_str();
1983                            let value_ty = &c.value_ty;
1984                            quote! {
1985                                (
1986                                    #column_lit,
1987                                    match #getter::<#value_ty>(
1988                                        _audit_before_row, #column_lit,
1989                                    ) {
1990                                        ::core::result::Result::Ok(v) => {
1991                                            ::serde_json::to_value(&v)
1992                                                .unwrap_or(::serde_json::Value::Null)
1993                                        }
1994                                        ::core::result::Result::Err(_) => ::serde_json::Value::Null,
1995                                    },
1996                                )
1997                            }
1998                        })
1999                        .collect()
2000                };
2001            let before_pairs_pg: Vec<proc_macro2::TokenStream> =
2002                mk_before_pairs(quote!(::rustango::sql::try_get_returning));
2003            let before_pairs_my: Vec<proc_macro2::TokenStream> =
2004                mk_before_pairs(quote!(::rustango::sql::try_get_returning_my));
2005            let before_pairs_sqlite: Vec<proc_macro2::TokenStream> =
2006                mk_before_pairs(quote!(::rustango::sql::try_get_returning_sqlite));
2007            let pg_select_cols: String = tracked
2008                .iter()
2009                .map(|c| format!("\"{}\"", c.column.replace('"', "\"\"")))
2010                .collect::<Vec<_>>()
2011                .join(", ");
2012            let my_select_cols: String = tracked
2013                .iter()
2014                .map(|c| format!("`{}`", c.column.replace('`', "``")))
2015                .collect::<Vec<_>>()
2016                .join(", ");
2017            // SQLite uses double-quote identifier quoting (same as
2018            // Postgres in default config), so the column-list shape
2019            // matches PG.
2020            let sqlite_select_cols: String = pg_select_cols.clone();
2021            let pk_value_for_bind = if fields.pk_is_auto {
2022                quote!(self.#pk_ident.get().copied().unwrap_or_default())
2023            } else {
2024                quote!(::core::clone::Clone::clone(&self.#pk_ident))
2025            };
2026            let assignments = &fields.update_assignments;
2027            let unset_dispatch = if fields.has_auto {
2028                quote! {
2029                    if matches!(self.#pk_ident, ::rustango::sql::Auto::Unset) {
2030                        return self.insert_pool(pool).await;
2031                    }
2032                }
2033            } else {
2034                quote!()
2035            };
2036            quote! {
2037                /// Save this row against either backend with audit
2038                /// emission (diff-style: BEFORE+AFTER) inside the
2039                /// same transaction. Auto::Unset PK routes to
2040                /// insert_pool. Bi-dialect counterpart of
2041                /// [`Self::save`] for audited models.
2042                ///
2043                /// The audit row's `changes` JSON contains one
2044                /// `{ "field": { "before": …, "after": … } }` entry
2045                /// per tracked column whose value actually changed
2046                /// — same shape as the existing &PgPool save() emits.
2047                ///
2048                /// # Errors
2049                /// As [`Self::save`].
2050                pub async fn save_pool(
2051                    &mut self,
2052                    pool: &::rustango::sql::Pool,
2053                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2054                    #unset_dispatch
2055                    let _query = ::rustango::core::UpdateQuery {
2056                        model: <Self as ::rustango::core::Model>::SCHEMA,
2057                        set: ::std::vec![ #( #assignments ),* ],
2058                        where_clause: ::rustango::core::WhereExpr::Predicate(
2059                            ::rustango::core::Filter {
2060                                column: #pk_column_lit,
2061                                op: ::rustango::core::Op::Eq,
2062                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2063                                    ::core::clone::Clone::clone(&self.#pk_ident)
2064                                ),
2065                            }
2066                        ),
2067                    };
2068                    let _after_pairs: ::std::vec::Vec<(&'static str, ::serde_json::Value)> =
2069                        ::std::vec![ #( #after_pairs_pg ),* ];
2070                    ::rustango::audit::save_one_with_diff_pool(
2071                        pool,
2072                        &_query,
2073                        #pk_column_lit,
2074                        ::core::convert::Into::<::rustango::core::SqlValue>::into(
2075                            #pk_value_for_bind,
2076                        ),
2077                        <Self as ::rustango::core::Model>::SCHEMA.table,
2078                        #pk_str,
2079                        _after_pairs,
2080                        #pg_select_cols,
2081                        #my_select_cols,
2082                        #sqlite_select_cols,
2083                        |_audit_before_row| ::std::vec![ #( #before_pairs_pg ),* ],
2084                        |_audit_before_row| ::std::vec![ #( #before_pairs_my ),* ],
2085                        |_audit_before_row| ::std::vec![ #( #before_pairs_sqlite ),* ],
2086                    ).await
2087                }
2088            }
2089        } else {
2090            quote!()
2091        }
2092    } else {
2093        pool_save_method
2094    };
2095
2096    // `delete_pool(&Pool)` — emitted for every model with a PK. Two
2097    // body shapes:
2098    // - non-audited: simple dispatch through `sql::delete_pool`
2099    // - audited: routes through `audit::delete_one_with_audit_pool`,
2100    //   which opens a per-backend transaction wrapping DELETE +
2101    //   audit emit so the data write and audit row commit atomically.
2102    let pool_delete_method = {
2103        let pk_column_lit = primary_key.map(|(_, col)| col.as_str()).unwrap_or("id");
2104        let pk_ident_for_pool = primary_key.map(|(ident, _)| ident);
2105        if let Some(pk_ident) = pk_ident_for_pool {
2106            if audited_fields.is_some() {
2107                let pairs = audit_pair_tokens.iter();
2108                let pk_str = audit_pk_to_string.clone();
2109                quote! {
2110                    /// Delete this row against either backend with audit
2111                    /// emission inside the same transaction. Bi-dialect
2112                    /// counterpart of [`Self::delete`] for audited models.
2113                    ///
2114                    /// # Errors
2115                    /// As [`Self::delete`].
2116                    pub async fn delete_pool(
2117                        &self,
2118                        pool: &::rustango::sql::Pool,
2119                    ) -> ::core::result::Result<u64, ::rustango::sql::ExecError> {
2120                        let _query = ::rustango::core::DeleteQuery {
2121                            model: <Self as ::rustango::core::Model>::SCHEMA,
2122                            where_clause: ::rustango::core::WhereExpr::Predicate(
2123                                ::rustango::core::Filter {
2124                                    column: #pk_column_lit,
2125                                    op: ::rustango::core::Op::Eq,
2126                                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2127                                        ::core::clone::Clone::clone(&self.#pk_ident)
2128                                    ),
2129                                }
2130                            ),
2131                        };
2132                        let _audit_entry = ::rustango::audit::PendingEntry {
2133                            entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
2134                            entity_pk: #pk_str,
2135                            operation: ::rustango::audit::AuditOp::Delete,
2136                            source: ::rustango::audit::current_source(),
2137                            changes: ::rustango::audit::snapshot_changes(&[
2138                                #( #pairs ),*
2139                            ]),
2140                        };
2141                        ::rustango::audit::delete_one_with_audit_pool(
2142                            pool, &_query, &_audit_entry,
2143                        ).await
2144                    }
2145                }
2146            } else {
2147                quote! {
2148                    /// Delete the row identified by this instance's primary key
2149                    /// against either backend. Equivalent to [`Self::delete`] but
2150                    /// takes [`::rustango::sql::Pool`] and dispatches per backend.
2151                    ///
2152                    /// # Errors
2153                    /// As [`Self::delete`].
2154                    pub async fn delete_pool(
2155                        &self,
2156                        pool: &::rustango::sql::Pool,
2157                    ) -> ::core::result::Result<u64, ::rustango::sql::ExecError> {
2158                        let _query = ::rustango::core::DeleteQuery {
2159                            model: <Self as ::rustango::core::Model>::SCHEMA,
2160                            where_clause: ::rustango::core::WhereExpr::Predicate(
2161                                ::rustango::core::Filter {
2162                                    column: #pk_column_lit,
2163                                    op: ::rustango::core::Op::Eq,
2164                                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2165                                        ::core::clone::Clone::clone(&self.#pk_ident)
2166                                    ),
2167                                }
2168                            ),
2169                        };
2170                        ::rustango::sql::delete_pool(pool, &_query).await
2171                    }
2172                }
2173            }
2174        } else {
2175            quote!()
2176        }
2177    };
2178
2179    // Update emission captures both BEFORE and AFTER state — runs an
2180    // extra SELECT against `_executor` BEFORE the UPDATE, captures
2181    // each tracked field's prior value, then after the UPDATE diffs
2182    // against the in-memory `&self`. `diff_changes` drops unchanged
2183    // columns so the JSON only contains the actual delta.
2184    //
2185    // Two-fragment shape: `audit_update_pre` runs before the UPDATE
2186    // and binds `_audit_before_pairs`; `audit_update_post` runs
2187    // after the UPDATE and emits the PendingEntry.
2188    let (audit_update_pre, audit_update_post): (TokenStream2, TokenStream2) = if let Some(tracked) =
2189        audited_fields
2190    {
2191        if tracked.is_empty() {
2192            (quote!(), quote!())
2193        } else {
2194            let select_cols: String = tracked
2195                .iter()
2196                .map(|c| format!("\"{}\"", c.column.replace('"', "\"\"")))
2197                .collect::<Vec<_>>()
2198                .join(", ");
2199            let pk_column_for_select = primary_key.map(|(_, col)| col.clone()).unwrap_or_default();
2200            let select_cols_lit = select_cols;
2201            let pk_column_lit_for_select = pk_column_for_select;
2202            let pk_value_for_bind = if let Some((pk_ident, _)) = primary_key {
2203                if fields.pk_is_auto {
2204                    quote!(self.#pk_ident.get().copied().unwrap_or_default())
2205                } else {
2206                    quote!(::core::clone::Clone::clone(&self.#pk_ident))
2207                }
2208            } else {
2209                quote!(0_i64)
2210            };
2211            let before_pairs = tracked.iter().map(|c| {
2212                let column_lit = c.column.as_str();
2213                let value_ty = &c.value_ty;
2214                quote! {
2215                    (
2216                        #column_lit,
2217                        match ::rustango::sql::sqlx::Row::try_get::<#value_ty, _>(
2218                            &_audit_before_row, #column_lit,
2219                        ) {
2220                            ::core::result::Result::Ok(v) => {
2221                                ::serde_json::to_value(&v)
2222                                    .unwrap_or(::serde_json::Value::Null)
2223                            }
2224                            ::core::result::Result::Err(_) => ::serde_json::Value::Null,
2225                        },
2226                    )
2227                }
2228            });
2229            let after_pairs = tracked.iter().map(|c| {
2230                let column_lit = c.column.as_str();
2231                let ident = &c.ident;
2232                quote! {
2233                    (
2234                        #column_lit,
2235                        ::serde_json::to_value(&self.#ident)
2236                            .unwrap_or(::serde_json::Value::Null),
2237                    )
2238                }
2239            });
2240            let pk_str = audit_pk_to_string.clone();
2241            let pre = quote! {
2242                let _audit_select_sql = ::std::format!(
2243                    r#"SELECT {} FROM "{}" WHERE "{}" = $1"#,
2244                    #select_cols_lit,
2245                    <Self as ::rustango::core::Model>::SCHEMA.table,
2246                    #pk_column_lit_for_select,
2247                );
2248                let _audit_before_pairs:
2249                    ::std::option::Option<::std::vec::Vec<(&'static str, ::serde_json::Value)>> =
2250                    match ::rustango::sql::sqlx::query(&_audit_select_sql)
2251                        .bind(#pk_value_for_bind)
2252                        .fetch_optional(&mut *_executor)
2253                        .await
2254                    {
2255                        ::core::result::Result::Ok(::core::option::Option::Some(_audit_before_row)) => {
2256                            ::core::option::Option::Some(::std::vec![ #( #before_pairs ),* ])
2257                        }
2258                        _ => ::core::option::Option::None,
2259                    };
2260            };
2261            let post = quote! {
2262                if let ::core::option::Option::Some(_audit_before) = _audit_before_pairs {
2263                    let _audit_after:
2264                        ::std::vec::Vec<(&'static str, ::serde_json::Value)> =
2265                        ::std::vec![ #( #after_pairs ),* ];
2266                    let _audit_entry = ::rustango::audit::PendingEntry {
2267                        entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
2268                        entity_pk: #pk_str,
2269                        operation: ::rustango::audit::AuditOp::Update,
2270                        source: ::rustango::audit::current_source(),
2271                        changes: ::rustango::audit::diff_changes(
2272                            &_audit_before,
2273                            &_audit_after,
2274                        ),
2275                    };
2276                    ::rustango::audit::emit_one(&mut *_executor, &_audit_entry).await?;
2277                }
2278            };
2279            (pre, post)
2280        }
2281    } else {
2282        (quote!(), quote!())
2283    };
2284
2285    // Bulk-insert audit: capture every row's tracked fields after the
2286    // RETURNING populates each PK, then push one batched INSERT INTO
2287    // audit_log via `emit_many`. One round-trip regardless of N rows.
2288    let audit_bulk_insert_emit: TokenStream2 = if audited_fields.is_some() {
2289        let row_pk_str = if let Some((pk_ident, _)) = primary_key {
2290            if fields.pk_is_auto {
2291                quote!(_row.#pk_ident.get().map(|v| ::std::format!("{}", v)).unwrap_or_default())
2292            } else {
2293                quote!(::std::format!("{}", &_row.#pk_ident))
2294            }
2295        } else {
2296            quote!(::std::string::String::new())
2297        };
2298        let row_pairs = audited_fields.unwrap_or(&[]).iter().map(|c| {
2299            let column_lit = c.column.as_str();
2300            let ident = &c.ident;
2301            quote! {
2302                (
2303                    #column_lit,
2304                    ::serde_json::to_value(&_row.#ident)
2305                        .unwrap_or(::serde_json::Value::Null),
2306                )
2307            }
2308        });
2309        quote! {
2310            let _audit_source = ::rustango::audit::current_source();
2311            let mut _audit_entries:
2312                ::std::vec::Vec<::rustango::audit::PendingEntry> =
2313                    ::std::vec::Vec::with_capacity(rows.len());
2314            for _row in rows.iter() {
2315                _audit_entries.push(::rustango::audit::PendingEntry {
2316                    entity_table: <Self as ::rustango::core::Model>::SCHEMA.table,
2317                    entity_pk: #row_pk_str,
2318                    operation: ::rustango::audit::AuditOp::Create,
2319                    source: _audit_source.clone(),
2320                    changes: ::rustango::audit::snapshot_changes(&[
2321                        #( #row_pairs ),*
2322                    ]),
2323                });
2324            }
2325            ::rustango::audit::emit_many(&mut *_executor, &_audit_entries).await?;
2326        }
2327    } else {
2328        quote!()
2329    };
2330
2331    let save_method = if fields.pk_is_auto {
2332        let (pk_ident, pk_column) = primary_key.expect("pk_is_auto implies primary_key is Some");
2333        let pk_column_lit = pk_column.as_str();
2334        let assignments = &fields.update_assignments;
2335        let upsert_cols = &fields.upsert_update_columns;
2336        let upsert_pushes = &fields.insert_pushes;
2337        let upsert_returning = &fields.returning_cols;
2338        let upsert_auto_assigns = &fields.auto_assigns;
2339        // Conflict target: prefer the first declared `unique_together`
2340        // when it exists. Plain `Auto<T>` PKs are server-assigned via
2341        // `BIGSERIAL` and never collide on insert, so a PK-only target
2342        // would silently turn `upsert()` into "always-insert" for
2343        // surrogate-PK models with composite UNIQUE constraints — see
2344        // `RolePermission` / `UserRole` / `UserPermission` in the
2345        // tenancy permission engine. When no `unique_together` is
2346        // declared we keep the PK target (the original behaviour).
2347        let upsert_target_columns: Vec<String> = indexes
2348            .iter()
2349            .find(|i| i.unique && !i.columns.is_empty())
2350            .map(|i| i.columns.clone())
2351            .unwrap_or_else(|| vec![pk_column.clone()]);
2352        let upsert_target_lits = upsert_target_columns
2353            .iter()
2354            .map(String::as_str)
2355            .collect::<Vec<_>>();
2356        let conflict_clause = if fields.upsert_update_columns.is_empty() {
2357            quote!(::rustango::core::ConflictClause::DoNothing)
2358        } else {
2359            quote!(::rustango::core::ConflictClause::DoUpdate {
2360                target: ::std::vec![ #( #upsert_target_lits ),* ],
2361                update_columns: ::std::vec![ #( #upsert_cols ),* ],
2362            })
2363        };
2364        Some(quote! {
2365            /// Insert this row if its `Auto<T>` primary key is
2366            /// `Unset`, otherwise update the existing row matching the
2367            /// PK. Mirrors Django's `save()` — caller doesn't need to
2368            /// pick `insert` vs the bulk-update path manually.
2369            ///
2370            /// On the insert branch, populates the PK from `RETURNING`
2371            /// (same behavior as `insert`). On the update branch,
2372            /// writes every non-PK column back; if no row matches the
2373            /// PK, returns `Ok(())` silently.
2374            ///
2375            /// Only generated when the primary key is declared as
2376            /// `Auto<T>`. Models with a manually-managed PK must use
2377            /// `insert` or the QuerySet update builder.
2378            ///
2379            /// # Errors
2380            /// Returns [`::rustango::sql::ExecError`] for SQL-writing
2381            /// or driver failures.
2382            pub async fn save(
2383                &mut self,
2384                pool: &::rustango::sql::sqlx::PgPool,
2385            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2386                #pool_to_save_on
2387            }
2388
2389            /// Like [`Self::save`] but accepts any sqlx executor —
2390            /// `&PgPool`, `&mut PgConnection`, or a transaction. The
2391            /// escape hatch for tenant-scoped writes: schema-mode
2392            /// tenants share the registry pool but rely on a per-
2393            /// checkout `SET search_path`, so passing `&PgPool` would
2394            /// silently hit the wrong schema. Acquire a connection
2395            /// via `TenantPools::acquire(&org)` and pass `&mut *conn`.
2396            ///
2397            /// # Errors
2398            /// As [`Self::save`].
2399            pub async fn save_on #executor_generics (
2400                &mut self,
2401                #executor_param,
2402            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
2403            #executor_where
2404            {
2405                if matches!(self.#pk_ident, ::rustango::sql::Auto::Unset) {
2406                    return self.insert_on(#executor_passes_to_data_write).await;
2407                }
2408                #audit_update_pre
2409                let _query = ::rustango::core::UpdateQuery {
2410                    model: <Self as ::rustango::core::Model>::SCHEMA,
2411                    set: ::std::vec![ #( #assignments ),* ],
2412                    where_clause: ::rustango::core::WhereExpr::Predicate(
2413                        ::rustango::core::Filter {
2414                            column: #pk_column_lit,
2415                            op: ::rustango::core::Op::Eq,
2416                            value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2417                                ::core::clone::Clone::clone(&self.#pk_ident)
2418                            ),
2419                        }
2420                    ),
2421                };
2422                let _ = ::rustango::sql::update_on(
2423                    #executor_passes_to_data_write,
2424                    &_query,
2425                ).await?;
2426                #audit_update_post
2427                ::core::result::Result::Ok(())
2428            }
2429
2430            /// Per-call override for the audit source. Runs
2431            /// [`Self::save_on`] inside an [`::rustango::audit::with_source`]
2432            /// scope so the resulting audit entry records `source`
2433            /// instead of the task-local default. Useful for seed
2434            /// scripts and one-off CLI tools that don't sit inside an
2435            /// admin handler. The override applies only to this call;
2436            /// no global state changes.
2437            ///
2438            /// # Errors
2439            /// As [`Self::save_on`].
2440            pub async fn save_on_with #executor_generics (
2441                &mut self,
2442                #executor_param,
2443                source: ::rustango::audit::AuditSource,
2444            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
2445            #executor_where
2446            {
2447                ::rustango::audit::with_source(source, self.save_on(_executor)).await
2448            }
2449
2450            /// Insert this row or update it in-place if the primary key already
2451            /// exists — single round-trip via `INSERT … ON CONFLICT (pk) DO UPDATE`.
2452            ///
2453            /// With `Auto::Unset` PK the server assigns a new key and no conflict
2454            /// can occur (equivalent to `insert`). With `Auto::Set` PK the row is
2455            /// inserted if absent or all non-PK columns are overwritten if present.
2456            ///
2457            /// # Errors
2458            /// As [`Self::insert_on`].
2459            pub async fn upsert(
2460                &mut self,
2461                pool: &::rustango::sql::sqlx::PgPool,
2462            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2463                #pool_to_upsert_on
2464            }
2465
2466            /// Like [`Self::upsert`] but accepts any sqlx executor.
2467            /// See [`Self::save_on`] for tenancy-scoped rationale.
2468            ///
2469            /// # Errors
2470            /// As [`Self::upsert`].
2471            pub async fn upsert_on #executor_generics (
2472                &mut self,
2473                #executor_param,
2474            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
2475            #executor_where
2476            {
2477                let mut _columns: ::std::vec::Vec<&'static str> =
2478                    ::std::vec::Vec::new();
2479                let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
2480                    ::std::vec::Vec::new();
2481                #( #upsert_pushes )*
2482                let query = ::rustango::core::InsertQuery {
2483                    model: <Self as ::rustango::core::Model>::SCHEMA,
2484                    columns: _columns,
2485                    values: _values,
2486                    returning: ::std::vec![ #( #upsert_returning ),* ],
2487                    on_conflict: ::core::option::Option::Some(#conflict_clause),
2488                };
2489                let _returning_row_v = ::rustango::sql::insert_returning_on(
2490                    #executor_passes_to_data_write,
2491                    &query,
2492                ).await?;
2493                let _returning_row = &_returning_row_v;
2494                #( #upsert_auto_assigns )*
2495                ::core::result::Result::Ok(())
2496            }
2497        })
2498    } else {
2499        None
2500    };
2501
2502    let pk_methods = primary_key.map(|(pk_ident, pk_column)| {
2503        let pk_column_lit = pk_column.as_str();
2504        // Optional `soft_delete_on` / `restore_on` companions when the
2505        // model has a `#[rustango(soft_delete)]` column. They land
2506        // alongside the regular `delete_on` so callers have both
2507        // options — a hard delete (audit-tracked as a real DELETE) and
2508        // a logical delete (audit-tracked as an UPDATE setting the
2509        // deleted_at column to NOW()).
2510        let soft_delete_methods = if let Some(col) = fields.soft_delete_column.as_deref() {
2511            let col_lit = col;
2512            quote! {
2513                /// Soft-delete this row by setting its
2514                /// `#[rustango(soft_delete)]` column to `NOW()`.
2515                /// Mirrors Django's `SoftDeleteModel.delete()` shape:
2516                /// the row stays in the table; query helpers can
2517                /// filter it out by checking the column for `IS NOT
2518                /// NULL`.
2519                ///
2520                /// # Errors
2521                /// As [`Self::delete`].
2522                pub async fn soft_delete_on #executor_generics (
2523                    &self,
2524                    #executor_param,
2525                ) -> ::core::result::Result<u64, ::rustango::sql::ExecError>
2526                #executor_where
2527                {
2528                    let _query = ::rustango::core::UpdateQuery {
2529                        model: <Self as ::rustango::core::Model>::SCHEMA,
2530                        set: ::std::vec![
2531                            ::rustango::core::Assignment {
2532                                column: #col_lit,
2533                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2534                                    ::chrono::Utc::now()
2535                                ),
2536                            },
2537                        ],
2538                        where_clause: ::rustango::core::WhereExpr::Predicate(
2539                            ::rustango::core::Filter {
2540                                column: #pk_column_lit,
2541                                op: ::rustango::core::Op::Eq,
2542                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2543                                    ::core::clone::Clone::clone(&self.#pk_ident)
2544                                ),
2545                            }
2546                        ),
2547                    };
2548                    let _affected = ::rustango::sql::update_on(
2549                        #executor_passes_to_data_write,
2550                        &_query,
2551                    ).await?;
2552                    #audit_softdelete_emit
2553                    ::core::result::Result::Ok(_affected)
2554                }
2555
2556                /// Inverse of [`Self::soft_delete_on`] — clears the
2557                /// soft-delete column back to NULL so the row is
2558                /// considered live again.
2559                ///
2560                /// # Errors
2561                /// As [`Self::delete`].
2562                pub async fn restore_on #executor_generics (
2563                    &self,
2564                    #executor_param,
2565                ) -> ::core::result::Result<u64, ::rustango::sql::ExecError>
2566                #executor_where
2567                {
2568                    let _query = ::rustango::core::UpdateQuery {
2569                        model: <Self as ::rustango::core::Model>::SCHEMA,
2570                        set: ::std::vec![
2571                            ::rustango::core::Assignment {
2572                                column: #col_lit,
2573                                value: ::rustango::core::SqlValue::Null,
2574                            },
2575                        ],
2576                        where_clause: ::rustango::core::WhereExpr::Predicate(
2577                            ::rustango::core::Filter {
2578                                column: #pk_column_lit,
2579                                op: ::rustango::core::Op::Eq,
2580                                value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2581                                    ::core::clone::Clone::clone(&self.#pk_ident)
2582                                ),
2583                            }
2584                        ),
2585                    };
2586                    let _affected = ::rustango::sql::update_on(
2587                        #executor_passes_to_data_write,
2588                        &_query,
2589                    ).await?;
2590                    #audit_restore_emit
2591                    ::core::result::Result::Ok(_affected)
2592                }
2593            }
2594        } else {
2595            quote!()
2596        };
2597        quote! {
2598            /// Delete the row identified by this instance's primary key.
2599            ///
2600            /// Returns the number of rows affected (0 or 1).
2601            ///
2602            /// # Errors
2603            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
2604            /// driver failures.
2605            pub async fn delete(
2606                &self,
2607                pool: &::rustango::sql::sqlx::PgPool,
2608            ) -> ::core::result::Result<u64, ::rustango::sql::ExecError> {
2609                #pool_to_delete_on
2610            }
2611
2612            /// Like [`Self::delete`] but accepts any sqlx executor —
2613            /// for tenant-scoped deletes against an explicitly-acquired
2614            /// connection. See [`Self::save_on`] for the rationale.
2615            ///
2616            /// # Errors
2617            /// As [`Self::delete`].
2618            pub async fn delete_on #executor_generics (
2619                &self,
2620                #executor_param,
2621            ) -> ::core::result::Result<u64, ::rustango::sql::ExecError>
2622            #executor_where
2623            {
2624                let query = ::rustango::core::DeleteQuery {
2625                    model: <Self as ::rustango::core::Model>::SCHEMA,
2626                    where_clause: ::rustango::core::WhereExpr::Predicate(
2627                        ::rustango::core::Filter {
2628                            column: #pk_column_lit,
2629                            op: ::rustango::core::Op::Eq,
2630                            value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
2631                                ::core::clone::Clone::clone(&self.#pk_ident)
2632                            ),
2633                        }
2634                    ),
2635                };
2636                let _affected = ::rustango::sql::delete_on(
2637                    #executor_passes_to_data_write,
2638                    &query,
2639                ).await?;
2640                #audit_delete_emit
2641                ::core::result::Result::Ok(_affected)
2642            }
2643
2644            /// Per-call audit-source override for [`Self::delete_on`].
2645            /// See [`Self::save_on_with`] for shape rationale.
2646            ///
2647            /// # Errors
2648            /// As [`Self::delete_on`].
2649            pub async fn delete_on_with #executor_generics (
2650                &self,
2651                #executor_param,
2652                source: ::rustango::audit::AuditSource,
2653            ) -> ::core::result::Result<u64, ::rustango::sql::ExecError>
2654            #executor_where
2655            {
2656                ::rustango::audit::with_source(source, self.delete_on(_executor)).await
2657            }
2658            #pool_delete_method
2659            #pool_insert_method
2660            #pool_save_method
2661            #soft_delete_methods
2662        }
2663    });
2664
2665    let insert_method = if fields.has_auto {
2666        let pushes = &fields.insert_pushes;
2667        let returning_cols = &fields.returning_cols;
2668        let auto_assigns = &fields.auto_assigns;
2669        quote! {
2670            /// Insert this row into its table. Skips columns whose
2671            /// `Auto<T>` value is `Unset` so Postgres' SERIAL/BIGSERIAL
2672            /// sequence fills them in, then reads each `Auto` column
2673            /// back via `RETURNING` and stores it on `self`.
2674            ///
2675            /// # Errors
2676            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
2677            /// driver failures.
2678            pub async fn insert(
2679                &mut self,
2680                pool: &::rustango::sql::sqlx::PgPool,
2681            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2682                #pool_to_insert_on
2683            }
2684
2685            /// Like [`Self::insert`] but accepts any sqlx executor.
2686            /// See [`Self::save_on`] for tenancy-scoped rationale.
2687            ///
2688            /// # Errors
2689            /// As [`Self::insert`].
2690            pub async fn insert_on #executor_generics (
2691                &mut self,
2692                #executor_param,
2693            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
2694            #executor_where
2695            {
2696                let mut _columns: ::std::vec::Vec<&'static str> =
2697                    ::std::vec::Vec::new();
2698                let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
2699                    ::std::vec::Vec::new();
2700                #( #pushes )*
2701                let query = ::rustango::core::InsertQuery {
2702                    model: <Self as ::rustango::core::Model>::SCHEMA,
2703                    columns: _columns,
2704                    values: _values,
2705                    returning: ::std::vec![ #( #returning_cols ),* ],
2706                    on_conflict: ::core::option::Option::None,
2707                };
2708                let _returning_row_v = ::rustango::sql::insert_returning_on(
2709                    #executor_passes_to_data_write,
2710                    &query,
2711                ).await?;
2712                let _returning_row = &_returning_row_v;
2713                #( #auto_assigns )*
2714                #audit_insert_emit
2715                ::core::result::Result::Ok(())
2716            }
2717
2718            /// Per-call audit-source override for [`Self::insert_on`].
2719            /// See [`Self::save_on_with`] for shape rationale.
2720            ///
2721            /// # Errors
2722            /// As [`Self::insert_on`].
2723            pub async fn insert_on_with #executor_generics (
2724                &mut self,
2725                #executor_param,
2726                source: ::rustango::audit::AuditSource,
2727            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
2728            #executor_where
2729            {
2730                ::rustango::audit::with_source(source, self.insert_on(_executor)).await
2731            }
2732        }
2733    } else {
2734        let insert_columns = &fields.insert_columns;
2735        let insert_values = &fields.insert_values;
2736        quote! {
2737            /// Insert this row into its table.
2738            ///
2739            /// # Errors
2740            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
2741            /// driver failures.
2742            pub async fn insert(
2743                &self,
2744                pool: &::rustango::sql::sqlx::PgPool,
2745            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2746                self.insert_on(pool).await
2747            }
2748
2749            /// Like [`Self::insert`] but accepts any sqlx executor.
2750            /// See [`Self::save_on`] for tenancy-scoped rationale.
2751            ///
2752            /// # Errors
2753            /// As [`Self::insert`].
2754            pub async fn insert_on<'_c, _E>(
2755                &self,
2756                _executor: _E,
2757            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
2758            where
2759                _E: ::rustango::sql::sqlx::Executor<'_c, Database = ::rustango::sql::sqlx::Postgres>,
2760            {
2761                let query = ::rustango::core::InsertQuery {
2762                    model: <Self as ::rustango::core::Model>::SCHEMA,
2763                    columns: ::std::vec![ #( #insert_columns ),* ],
2764                    values: ::std::vec![ #( #insert_values ),* ],
2765                    returning: ::std::vec::Vec::new(),
2766                    on_conflict: ::core::option::Option::None,
2767                };
2768                ::rustango::sql::insert_on(_executor, &query).await
2769            }
2770        }
2771    };
2772
2773    let bulk_insert_method = if fields.has_auto {
2774        let cols_no_auto = &fields.bulk_columns_no_auto;
2775        let cols_all = &fields.bulk_columns_all;
2776        let pushes_no_auto = &fields.bulk_pushes_no_auto;
2777        let pushes_all = &fields.bulk_pushes_all;
2778        let returning_cols = &fields.returning_cols;
2779        let auto_assigns_for_row = bulk_auto_assigns_for_row(fields);
2780        let uniformity = &fields.bulk_auto_uniformity;
2781        let first_auto_ident = fields
2782            .first_auto_ident
2783            .as_ref()
2784            .expect("has_auto implies first_auto_ident is Some");
2785        quote! {
2786            /// Bulk-insert `rows` in a single round-trip. Every row's
2787            /// `Auto<T>` PK fields must uniformly be `Auto::Unset`
2788            /// (sequence fills them in) or uniformly `Auto::Set(_)`
2789            /// (caller-supplied values). Mixed Set/Unset is rejected
2790            /// — call `insert` per row for that case.
2791            ///
2792            /// Empty slice is a no-op. Each row's `Auto` fields are
2793            /// populated from the `RETURNING` clause in input order
2794            /// before this returns.
2795            ///
2796            /// # Errors
2797            /// Returns [`::rustango::sql::ExecError`] for validation,
2798            /// SQL-writing, mixed-Auto rejection, or driver failures.
2799            pub async fn bulk_insert(
2800                rows: &mut [Self],
2801                pool: &::rustango::sql::sqlx::PgPool,
2802            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2803                #pool_to_bulk_insert_on
2804            }
2805
2806            /// Like [`Self::bulk_insert`] but accepts any sqlx executor.
2807            /// See [`Self::save_on`] for tenancy-scoped rationale.
2808            ///
2809            /// # Errors
2810            /// As [`Self::bulk_insert`].
2811            pub async fn bulk_insert_on #executor_generics (
2812                rows: &mut [Self],
2813                #executor_param,
2814            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
2815            #executor_where
2816            {
2817                if rows.is_empty() {
2818                    return ::core::result::Result::Ok(());
2819                }
2820                let _first_unset = matches!(
2821                    rows[0].#first_auto_ident,
2822                    ::rustango::sql::Auto::Unset
2823                );
2824                #( #uniformity )*
2825
2826                let mut _all_rows: ::std::vec::Vec<
2827                    ::std::vec::Vec<::rustango::core::SqlValue>,
2828                > = ::std::vec::Vec::with_capacity(rows.len());
2829                let _columns: ::std::vec::Vec<&'static str> = if _first_unset {
2830                    for _row in rows.iter() {
2831                        let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
2832                            ::std::vec::Vec::new();
2833                        #( #pushes_no_auto )*
2834                        _all_rows.push(_row_vals);
2835                    }
2836                    ::std::vec![ #( #cols_no_auto ),* ]
2837                } else {
2838                    for _row in rows.iter() {
2839                        let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
2840                            ::std::vec::Vec::new();
2841                        #( #pushes_all )*
2842                        _all_rows.push(_row_vals);
2843                    }
2844                    ::std::vec![ #( #cols_all ),* ]
2845                };
2846
2847                let _query = ::rustango::core::BulkInsertQuery {
2848                    model: <Self as ::rustango::core::Model>::SCHEMA,
2849                    columns: _columns,
2850                    rows: _all_rows,
2851                    returning: ::std::vec![ #( #returning_cols ),* ],
2852                    on_conflict: ::core::option::Option::None,
2853                };
2854                let _returned = ::rustango::sql::bulk_insert_on(
2855                    #executor_passes_to_data_write,
2856                    &_query,
2857                ).await?;
2858                if _returned.len() != rows.len() {
2859                    return ::core::result::Result::Err(
2860                        ::rustango::sql::ExecError::Sql(
2861                            ::rustango::sql::SqlError::BulkInsertReturningMismatch {
2862                                expected: rows.len(),
2863                                actual: _returned.len(),
2864                            }
2865                        )
2866                    );
2867                }
2868                for (_returning_row, _row_mut) in _returned.iter().zip(rows.iter_mut()) {
2869                    #auto_assigns_for_row
2870                }
2871                #audit_bulk_insert_emit
2872                ::core::result::Result::Ok(())
2873            }
2874        }
2875    } else {
2876        let cols_all = &fields.bulk_columns_all;
2877        let pushes_all = &fields.bulk_pushes_all;
2878        quote! {
2879            /// Bulk-insert `rows` in a single round-trip. Every row's
2880            /// fields are written verbatim — there are no `Auto<T>`
2881            /// fields on this model.
2882            ///
2883            /// Empty slice is a no-op.
2884            ///
2885            /// # Errors
2886            /// Returns [`::rustango::sql::ExecError`] for validation,
2887            /// SQL-writing, or driver failures.
2888            pub async fn bulk_insert(
2889                rows: &[Self],
2890                pool: &::rustango::sql::sqlx::PgPool,
2891            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
2892                Self::bulk_insert_on(rows, pool).await
2893            }
2894
2895            /// Like [`Self::bulk_insert`] but accepts any sqlx executor.
2896            /// See [`Self::save_on`] for tenancy-scoped rationale.
2897            ///
2898            /// # Errors
2899            /// As [`Self::bulk_insert`].
2900            pub async fn bulk_insert_on<'_c, _E>(
2901                rows: &[Self],
2902                _executor: _E,
2903            ) -> ::core::result::Result<(), ::rustango::sql::ExecError>
2904            where
2905                _E: ::rustango::sql::sqlx::Executor<'_c, Database = ::rustango::sql::sqlx::Postgres>,
2906            {
2907                if rows.is_empty() {
2908                    return ::core::result::Result::Ok(());
2909                }
2910                let mut _all_rows: ::std::vec::Vec<
2911                    ::std::vec::Vec<::rustango::core::SqlValue>,
2912                > = ::std::vec::Vec::with_capacity(rows.len());
2913                for _row in rows.iter() {
2914                    let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
2915                        ::std::vec::Vec::new();
2916                    #( #pushes_all )*
2917                    _all_rows.push(_row_vals);
2918                }
2919                let _query = ::rustango::core::BulkInsertQuery {
2920                    model: <Self as ::rustango::core::Model>::SCHEMA,
2921                    columns: ::std::vec![ #( #cols_all ),* ],
2922                    rows: _all_rows,
2923                    returning: ::std::vec::Vec::new(),
2924                    on_conflict: ::core::option::Option::None,
2925                };
2926                let _ = ::rustango::sql::bulk_insert_on(_executor, &_query).await?;
2927                ::core::result::Result::Ok(())
2928            }
2929        }
2930    };
2931
2932    let pk_value_helper = primary_key.map(|(pk_ident, _)| {
2933        quote! {
2934            /// Hidden runtime accessor for the primary-key value as a
2935            /// [`SqlValue`]. Used by reverse-relation helpers
2936            /// (`<parent>::<child>_set`) emitted from sibling models'
2937            /// FK fields. Not part of the public API.
2938            #[doc(hidden)]
2939            pub fn __rustango_pk_value(&self) -> ::rustango::core::SqlValue {
2940                ::core::convert::Into::<::rustango::core::SqlValue>::into(
2941                    ::core::clone::Clone::clone(&self.#pk_ident)
2942                )
2943            }
2944        }
2945    });
2946
2947    let has_pk_value_impl = primary_key.map(|(pk_ident, _)| {
2948        quote! {
2949            impl ::rustango::sql::HasPkValue for #struct_name {
2950                fn __rustango_pk_value_impl(&self) -> ::rustango::core::SqlValue {
2951                    ::core::convert::Into::<::rustango::core::SqlValue>::into(
2952                        ::core::clone::Clone::clone(&self.#pk_ident)
2953                    )
2954                }
2955            }
2956        }
2957    });
2958
2959    let fk_pk_access_impl = fk_pk_access_impl_tokens(struct_name, &fields.fk_relations);
2960
2961    // Slice 17.1 — `AssignAutoPkPool` impl lets `apply_auto_pk_pool`
2962    // dispatch to the right per-backend body without the macro emitting
2963    // any `#[cfg(feature = …)]` arm into consumer code. Always emitted
2964    // so audited models with non-Auto PKs (which still go through
2965    // `insert_one_with_audit_pool` → `apply_auto_pk_pool`) link.
2966    let assign_auto_pk_pool_impl = {
2967        let auto_assigns = &fields.auto_assigns;
2968        // SQLite ≥ 3.35 supports the same RETURNING shape as Postgres,
2969        // so the body is structurally identical to `auto_assigns` —
2970        // only the helper name swaps from `try_get_returning` to
2971        // `try_get_returning_sqlite` so the closure typechecks against
2972        // a `SqliteRow` instead of a `PgRow`.
2973        let auto_assigns_sqlite: Vec<TokenStream2> = fields
2974            .auto_field_idents
2975            .iter()
2976            .map(|(ident, column)| {
2977                quote! {
2978                    self.#ident = ::rustango::sql::try_get_returning_sqlite(
2979                        _returning_row, #column
2980                    )?;
2981                }
2982            })
2983            .collect();
2984        let mysql_body = if let Some(first) = fields.first_auto_ident.as_ref() {
2985            // The MySQL `LAST_INSERT_ID()` is always i64. Route through
2986            // `MysqlAutoIdSet` so Auto<i32> narrows safely and
2987            // Auto<Uuid>/etc. fail to link against MySQL (intended —
2988            // those models can't use AUTO_INCREMENT). The trait is only
2989            // touched on the MySQL arm at runtime, so PG-only consumers
2990            // never see the bound failure.
2991            //
2992            // Pre-v0.20: models with multiple `Auto<T>` fields (e.g.
2993            // Auto<i64> PK + auto_now_add timestamp) errored hard at
2994            // runtime with "multi-column RETURNING". MySQL has no
2995            // multi-column RETURNING semantic and a follow-up SELECT
2996            // would need cross-trait plumbing. Pragmatic shape: succeed
2997            // with the FIRST Auto field populated from LAST_INSERT_ID();
2998            // any other Auto fields stay `Auto::Unset`. Callers that
2999            // need the DB-defaulted timestamp / UUID can re-fetch the
3000            // row by PK after `save_pool`. Fixes the cookbook chapter
3001            // 12 dialect divergence.
3002            let value_ty = fields
3003                .first_auto_value_ty
3004                .as_ref()
3005                .expect("first_auto_value_ty set whenever first_auto_ident is");
3006            quote! {
3007                let _converted = <#value_ty as ::rustango::sql::MysqlAutoIdSet>
3008                    ::rustango_from_mysql_auto_id(_id)?;
3009                self.#first = ::rustango::sql::Auto::Set(_converted);
3010                ::core::result::Result::Ok(())
3011            }
3012        } else {
3013            quote! {
3014                let _ = _id;
3015                ::core::result::Result::Ok(())
3016            }
3017        };
3018        quote! {
3019            impl ::rustango::sql::AssignAutoPkPool for #struct_name {
3020                fn __rustango_assign_from_pg_row(
3021                    &mut self,
3022                    _returning_row: &::rustango::sql::PgReturningRow,
3023                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3024                    #( #auto_assigns )*
3025                    ::core::result::Result::Ok(())
3026                }
3027                fn __rustango_assign_from_mysql_id(
3028                    &mut self,
3029                    _id: i64,
3030                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3031                    #mysql_body
3032                }
3033                fn __rustango_assign_from_sqlite_row(
3034                    &mut self,
3035                    _returning_row: &::rustango::sql::SqliteReturningRow,
3036                ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
3037                    #( #auto_assigns_sqlite )*
3038                    ::core::result::Result::Ok(())
3039                }
3040            }
3041        }
3042    };
3043
3044    let from_aliased_row_inits = &fields.from_aliased_row_inits;
3045    let aliased_row_helper = quote! {
3046        /// Decode a row's aliased target columns (produced by
3047        /// `select_related`'s LEFT JOIN) into a fresh instance of
3048        /// this model. Reads each column via
3049        /// `format!("{prefix}__{col}")`, matching the alias the
3050        /// SELECT writer emitted. Slice 9.0d.
3051        #[doc(hidden)]
3052        pub fn __rustango_from_aliased_row(
3053            row: &::rustango::sql::sqlx::postgres::PgRow,
3054            prefix: &str,
3055        ) -> ::core::result::Result<Self, ::rustango::sql::sqlx::Error> {
3056            ::core::result::Result::Ok(Self {
3057                #( #from_aliased_row_inits ),*
3058            })
3059        }
3060    };
3061    // v0.23.0-batch8 — MySQL counterpart, gated through the
3062    // cfg-aware macro_rules so PG-only builds expand to nothing.
3063    let aliased_row_helper_my = quote! {
3064        ::rustango::__impl_my_aliased_row_decoder!(#struct_name, |row, prefix| {
3065            #( #from_aliased_row_inits ),*
3066        });
3067    };
3068
3069    // v0.27 Phase 3 — SQLite counterpart, same hygiene-aware closure
3070    // pattern + cfg gate on the `sqlite` feature.
3071    let aliased_row_helper_sqlite = quote! {
3072        ::rustango::__impl_sqlite_aliased_row_decoder!(#struct_name, |row, prefix| {
3073            #( #from_aliased_row_inits ),*
3074        });
3075    };
3076
3077    let load_related_impl = load_related_impl_tokens(struct_name, &fields.fk_relations);
3078    let load_related_impl_my = load_related_impl_my_tokens(struct_name, &fields.fk_relations);
3079    let load_related_impl_sqlite =
3080        load_related_impl_sqlite_tokens(struct_name, &fields.fk_relations);
3081
3082    quote! {
3083        impl #struct_name {
3084            /// Start a new `QuerySet` over this model.
3085            #[must_use]
3086            pub fn objects() -> ::rustango::query::QuerySet<#struct_name> {
3087                ::rustango::query::QuerySet::new()
3088            }
3089
3090            #insert_method
3091
3092            #bulk_insert_method
3093
3094            #save_method
3095
3096            #pk_methods
3097
3098            #pk_value_helper
3099
3100            #aliased_row_helper
3101
3102            #column_consts
3103        }
3104
3105        #aliased_row_helper_my
3106
3107        #aliased_row_helper_sqlite
3108
3109        #load_related_impl
3110
3111        #load_related_impl_my
3112
3113        #load_related_impl_sqlite
3114
3115        #has_pk_value_impl
3116
3117        #fk_pk_access_impl
3118
3119        #assign_auto_pk_pool_impl
3120    }
3121}
3122
3123/// Per-row Auto-field assigns for `bulk_insert` — equivalent to
3124/// `auto_assigns` but reading from `_returning_row` and writing to
3125/// `_row_mut` instead of `self`.
3126fn bulk_auto_assigns_for_row(fields: &CollectedFields) -> TokenStream2 {
3127    let lines = fields.auto_field_idents.iter().map(|(ident, column)| {
3128        let col_lit = column.as_str();
3129        quote! {
3130            _row_mut.#ident = ::rustango::sql::sqlx::Row::try_get(
3131                _returning_row,
3132                #col_lit,
3133            )?;
3134        }
3135    });
3136    quote! { #( #lines )* }
3137}
3138
3139/// Emit `pub const id: …Id = …Id;` per field, inside the inherent impl.
3140fn column_const_tokens(module_ident: &syn::Ident, entries: &[ColumnEntry]) -> TokenStream2 {
3141    let lines = entries.iter().map(|e| {
3142        let ident = &e.ident;
3143        let col_ty = column_type_ident(ident);
3144        quote! {
3145            #[allow(non_upper_case_globals)]
3146            pub const #ident: #module_ident::#col_ty = #module_ident::#col_ty;
3147        }
3148    });
3149    quote! { #(#lines)* }
3150}
3151
3152/// Emit a hidden per-model module carrying one zero-sized type per field,
3153/// each with a `Column` impl pointing back at the model.
3154fn column_module_tokens(
3155    module_ident: &syn::Ident,
3156    struct_name: &syn::Ident,
3157    entries: &[ColumnEntry],
3158) -> TokenStream2 {
3159    let items = entries.iter().map(|e| {
3160        let col_ty = column_type_ident(&e.ident);
3161        let value_ty = &e.value_ty;
3162        let name = &e.name;
3163        let column = &e.column;
3164        let field_type_tokens = &e.field_type_tokens;
3165        quote! {
3166            #[derive(::core::clone::Clone, ::core::marker::Copy)]
3167            pub struct #col_ty;
3168
3169            impl ::rustango::core::Column for #col_ty {
3170                type Model = super::#struct_name;
3171                type Value = #value_ty;
3172                const NAME: &'static str = #name;
3173                const COLUMN: &'static str = #column;
3174                const FIELD_TYPE: ::rustango::core::FieldType = #field_type_tokens;
3175            }
3176        }
3177    });
3178    quote! {
3179        #[doc(hidden)]
3180        #[allow(non_camel_case_types, non_snake_case)]
3181        pub mod #module_ident {
3182            // Re-import the parent scope so field types referencing
3183            // sibling models (e.g. `ForeignKey<Author>`) resolve
3184            // inside this submodule. Without this we'd hit
3185            // `proc_macro_derive_resolution_fallback` warnings.
3186            #[allow(unused_imports)]
3187            use super::*;
3188            #(#items)*
3189        }
3190    }
3191}
3192
3193fn column_type_ident(field_ident: &syn::Ident) -> syn::Ident {
3194    syn::Ident::new(&format!("{field_ident}_col"), field_ident.span())
3195}
3196
3197fn column_module_ident(struct_name: &syn::Ident) -> syn::Ident {
3198    syn::Ident::new(
3199        &format!("__rustango_cols_{struct_name}"),
3200        struct_name.span(),
3201    )
3202}
3203
3204fn from_row_impl_tokens(struct_name: &syn::Ident, from_row_inits: &[TokenStream2]) -> TokenStream2 {
3205    // The Postgres impl is always emitted — every rustango build pulls in
3206    // sqlx-postgres via the default `postgres` feature. The MySQL impl is
3207    // routed through `::rustango::__impl_my_from_row!`, a cfg-gated
3208    // macro_rules whose body collapses to nothing when rustango is built
3209    // without the `mysql` feature. No user-facing feature shim required.
3210    //
3211    // The macro_rules pattern expects `[ field: expr, … ]` — we need to
3212    // re-shape `from_row_inits` (each token is `field: row.try_get(...)`)
3213    // back into a comma-separated list inside square brackets. Since each
3214    // entry is already in `field: expr` shape, the existing tokens slot in.
3215    quote! {
3216        impl<'r> ::rustango::sql::sqlx::FromRow<'r, ::rustango::sql::sqlx::postgres::PgRow>
3217            for #struct_name
3218        {
3219            fn from_row(
3220                row: &'r ::rustango::sql::sqlx::postgres::PgRow,
3221            ) -> ::core::result::Result<Self, ::rustango::sql::sqlx::Error> {
3222                ::core::result::Result::Ok(Self {
3223                    #( #from_row_inits ),*
3224                })
3225            }
3226        }
3227
3228        ::rustango::__impl_my_from_row!(#struct_name, |row| {
3229            #( #from_row_inits ),*
3230        });
3231
3232        ::rustango::__impl_sqlite_from_row!(#struct_name, |row| {
3233            #( #from_row_inits ),*
3234        });
3235    }
3236}
3237
3238struct ContainerAttrs {
3239    table: Option<String>,
3240    display: Option<(String, proc_macro2::Span)>,
3241    /// Explicit Django-style app label from `#[rustango(app = "blog")]`.
3242    /// Recorded on the emitted `ModelSchema.app_label`. When unset,
3243    /// `ModelEntry::resolved_app_label()` infers from `module_path!()`
3244    /// at runtime — this attribute is the override for cases where
3245    /// the inference is wrong (e.g. a model that conceptually belongs
3246    /// to one app but is physically in another module).
3247    app: Option<String>,
3248    /// Django ModelAdmin-shape per-model knobs from
3249    /// `#[rustango(admin(...))]`. `None` when the user didn't write the
3250    /// attribute — the emitted `ModelSchema.admin` becomes `None` and
3251    /// admin code falls back to `AdminConfig::DEFAULT`.
3252    admin: Option<AdminAttrs>,
3253    /// Per-model audit configuration from `#[rustango(audit(...))]`.
3254    /// `None` when the model isn't audited — write paths emit no
3255    /// audit entries. When present, single-row writes capture
3256    /// before/after for the listed fields and bulk writes batch
3257    /// snapshots into one INSERT into `rustango_audit_log`.
3258    audit: Option<AuditAttrs>,
3259    /// `true` when `#[rustango(permissions)]` is present. Signals that
3260    /// `auto_create_permissions` should seed the four CRUD codenames for
3261    /// this model.
3262    permissions: bool,
3263    /// Many-to-many relations declared via
3264    /// `#[rustango(m2m(name = "tags", to = "app_tags", through = "post_tags",
3265    ///                 src = "post_id", dst = "tag_id"))]`.
3266    m2m: Vec<M2MAttr>,
3267    /// Composite indexes declared via
3268    /// `#[rustango(index("col1, col2"))]` or
3269    /// `#[rustango(index("col1, col2", unique, name = "my_idx"))]`.
3270    /// Single-column indexes from `#[rustango(index)]` on fields are
3271    /// accumulated here during field collection.
3272    indexes: Vec<IndexAttr>,
3273    /// Table-level CHECK constraints declared via
3274    /// `#[rustango(check(name = "…", expr = "…"))]`.
3275    checks: Vec<CheckAttr>,
3276    /// Composite (multi-column) FKs declared via
3277    /// `#[rustango(fk_composite(name = "…", to = "…", on = (…), from = (…)))]`.
3278    /// Sub-slice F.2 of the v0.15.0 ContentType plan.
3279    composite_fks: Vec<CompositeFkAttr>,
3280    /// Generic ("any model") FKs declared via
3281    /// `#[rustango(generic_fk(name = "…", ct_column = "…", pk_column = "…"))]`.
3282    /// Sub-slice F.4 of the v0.15.0 ContentType plan.
3283    generic_fks: Vec<GenericFkAttr>,
3284    /// Where this model lives in a tenancy deployment, declared via
3285    /// `#[rustango(scope = "registry")]` or `#[rustango(scope = "tenant")]`.
3286    /// Defaults to `"tenant"` when unset; `makemigrations` uses this
3287    /// to partition diff output between registry-scoped and
3288    /// tenant-scoped migration files.
3289    scope: Option<String>,
3290}
3291
3292/// Parsed form of one index declaration (field-level or container-level).
3293struct IndexAttr {
3294    /// Index name; auto-derived when `None` at parse time.
3295    name: Option<String>,
3296    /// Column names in the index.
3297    columns: Vec<String>,
3298    /// `true` for `CREATE UNIQUE INDEX`.
3299    unique: bool,
3300}
3301
3302/// Parsed form of one `#[rustango(check(name = "…", expr = "…"))]` declaration.
3303struct CheckAttr {
3304    name: String,
3305    expr: String,
3306}
3307
3308/// Parsed form of one `#[rustango(fk_composite(name = "audit_target",
3309/// to = "rustango_audit_log", on = ("entity_table", "entity_pk"),
3310/// from = ("table_name", "row_pk")))]` declaration. Sub-slice F.2 of
3311/// the v0.15.0 ContentType plan — multi-column foreign keys live on
3312/// the model, not the field.
3313struct CompositeFkAttr {
3314    /// Logical relation name (free-form Rust identifier).
3315    name: String,
3316    /// SQL table name of the target.
3317    to: String,
3318    /// Source-side column names, in declaration order.
3319    from: Vec<String>,
3320    /// Target-side column names, same length / order as `from`.
3321    on: Vec<String>,
3322}
3323
3324/// Parsed form of one `#[rustango(generic_fk(name = "target",
3325/// ct_column = "content_type_id", pk_column = "object_pk"))]`
3326/// declaration. Sub-slice F.4 of the v0.15.0 ContentType plan —
3327/// generic ("any model") FKs live on the model, not the field.
3328struct GenericFkAttr {
3329    /// Logical relation name (free-form Rust identifier).
3330    name: String,
3331    /// Source-side column carrying the `content_type_id` value.
3332    ct_column: String,
3333    /// Source-side column carrying the target row's primary key.
3334    pk_column: String,
3335}
3336
3337/// Parsed form of one `#[rustango(m2m(...))]` declaration.
3338struct M2MAttr {
3339    /// Accessor suffix: `tags` → generates `tags_m2m()`.
3340    name: String,
3341    /// Target table (e.g. `"app_tags"`).
3342    to: String,
3343    /// Junction table (e.g. `"post_tags"`).
3344    through: String,
3345    /// Source FK column in the junction table (e.g. `"post_id"`).
3346    src: String,
3347    /// Destination FK column in the junction table (e.g. `"tag_id"`).
3348    dst: String,
3349}
3350
3351/// Parsed shape of `#[rustango(audit(track = "name, body", source =
3352/// "user"))]`. `track` is a comma-separated list of field names whose
3353/// before/after values land in the JSONB `changes` column. `source`
3354/// is informational only — it pins a default source when the model
3355/// is written outside any `audit::with_source(...)` scope (rare).
3356#[derive(Default)]
3357struct AuditAttrs {
3358    /// Field names to capture in the `changes` JSONB. Validated
3359    /// against declared scalar fields at compile time. Empty means
3360    /// "track every scalar field" — Django's audit-everything default.
3361    track: Option<(Vec<String>, proc_macro2::Span)>,
3362}
3363
3364/// Parsed shape of `#[rustango(admin(list_display = "…", search_fields =
3365/// "…", list_per_page = N, ordering = "…"))]`. Field-name lists are
3366/// comma-separated strings; we validate each ident against the model's
3367/// declared fields at compile time.
3368#[derive(Default)]
3369struct AdminAttrs {
3370    list_display: Option<(Vec<String>, proc_macro2::Span)>,
3371    search_fields: Option<(Vec<String>, proc_macro2::Span)>,
3372    list_per_page: Option<usize>,
3373    ordering: Option<(Vec<(String, bool)>, proc_macro2::Span)>,
3374    readonly_fields: Option<(Vec<String>, proc_macro2::Span)>,
3375    list_filter: Option<(Vec<String>, proc_macro2::Span)>,
3376    /// Bulk action names. No field-validation against model fields —
3377    /// these are action handlers, not column references.
3378    actions: Option<(Vec<String>, proc_macro2::Span)>,
3379    /// Form fieldsets — `Vec<(title, [field_names])>`. Pipe-separated
3380    /// sections, comma-separated fields per section, optional
3381    /// `Title:` prefix. Empty title omits the `<legend>`.
3382    fieldsets: Option<(Vec<(String, Vec<String>)>, proc_macro2::Span)>,
3383}
3384
3385fn parse_container_attrs(input: &DeriveInput) -> syn::Result<ContainerAttrs> {
3386    let mut out = ContainerAttrs {
3387        table: None,
3388        display: None,
3389        app: None,
3390        admin: None,
3391        audit: None,
3392        // Default `permissions = true` so every `#[derive(Model)]`
3393        // gets the four CRUD codenames seeded by `auto_create_permissions`
3394        // and is visible to non-superusers in the tenant admin without
3395        // manual per-model annotation. Models that intentionally don't
3396        // want permission rows (registry-internal types, framework
3397        // tables operators shouldn't manage directly) opt out via
3398        // `#[rustango(permissions = false)]`. v0.27.2 — fixes the
3399        // out-of-the-box admin invisibility regression (#62).
3400        permissions: true,
3401        m2m: Vec::new(),
3402        indexes: Vec::new(),
3403        checks: Vec::new(),
3404        composite_fks: Vec::new(),
3405        generic_fks: Vec::new(),
3406        scope: None,
3407    };
3408    for attr in &input.attrs {
3409        if !attr.path().is_ident("rustango") {
3410            continue;
3411        }
3412        attr.parse_nested_meta(|meta| {
3413            if meta.path.is_ident("table") {
3414                let s: LitStr = meta.value()?.parse()?;
3415                let name = s.value();
3416                // v0.27.3 (#65) — macro-time guard against table names
3417                // that compile but break SQL downstream. Hyphens are
3418                // the common footgun: PostgreSQL accepts a quoted
3419                // `"intermediate-region"` in CREATE TABLE, but the
3420                // FK / index name derivation in `migrate::ddl`
3421                // emits `intermediate-region_field_fkey` unquoted,
3422                // which then fails the SQL parser. Same shape rule
3423                // as Postgres regular identifiers so the safe path
3424                // is the only path.
3425                validate_table_name(&name, s.span())?;
3426                out.table = Some(name);
3427                return Ok(());
3428            }
3429            if meta.path.is_ident("display") {
3430                let s: LitStr = meta.value()?.parse()?;
3431                out.display = Some((s.value(), s.span()));
3432                return Ok(());
3433            }
3434            if meta.path.is_ident("app") {
3435                let s: LitStr = meta.value()?.parse()?;
3436                out.app = Some(s.value());
3437                return Ok(());
3438            }
3439            if meta.path.is_ident("scope") {
3440                let s: LitStr = meta.value()?.parse()?;
3441                let val = s.value();
3442                if !matches!(val.to_ascii_lowercase().as_str(), "registry" | "tenant") {
3443                    return Err(meta.error(format!(
3444                        "`scope` must be \"registry\" or \"tenant\", got {val:?}"
3445                    )));
3446                }
3447                out.scope = Some(val);
3448                return Ok(());
3449            }
3450            if meta.path.is_ident("admin") {
3451                let mut admin = AdminAttrs::default();
3452                meta.parse_nested_meta(|inner| {
3453                    if inner.path.is_ident("list_display") {
3454                        let s: LitStr = inner.value()?.parse()?;
3455                        admin.list_display =
3456                            Some((split_field_list(&s.value()), s.span()));
3457                        return Ok(());
3458                    }
3459                    if inner.path.is_ident("search_fields") {
3460                        let s: LitStr = inner.value()?.parse()?;
3461                        admin.search_fields =
3462                            Some((split_field_list(&s.value()), s.span()));
3463                        return Ok(());
3464                    }
3465                    if inner.path.is_ident("readonly_fields") {
3466                        let s: LitStr = inner.value()?.parse()?;
3467                        admin.readonly_fields =
3468                            Some((split_field_list(&s.value()), s.span()));
3469                        return Ok(());
3470                    }
3471                    if inner.path.is_ident("list_per_page") {
3472                        let lit: syn::LitInt = inner.value()?.parse()?;
3473                        admin.list_per_page = Some(lit.base10_parse::<usize>()?);
3474                        return Ok(());
3475                    }
3476                    if inner.path.is_ident("ordering") {
3477                        let s: LitStr = inner.value()?.parse()?;
3478                        admin.ordering = Some((
3479                            parse_ordering_list(&s.value()),
3480                            s.span(),
3481                        ));
3482                        return Ok(());
3483                    }
3484                    if inner.path.is_ident("list_filter") {
3485                        let s: LitStr = inner.value()?.parse()?;
3486                        admin.list_filter =
3487                            Some((split_field_list(&s.value()), s.span()));
3488                        return Ok(());
3489                    }
3490                    if inner.path.is_ident("actions") {
3491                        let s: LitStr = inner.value()?.parse()?;
3492                        admin.actions =
3493                            Some((split_field_list(&s.value()), s.span()));
3494                        return Ok(());
3495                    }
3496                    if inner.path.is_ident("fieldsets") {
3497                        let s: LitStr = inner.value()?.parse()?;
3498                        admin.fieldsets =
3499                            Some((parse_fieldset_list(&s.value()), s.span()));
3500                        return Ok(());
3501                    }
3502                    Err(inner.error(
3503                        "unknown admin attribute (supported: \
3504                         `list_display`, `search_fields`, `readonly_fields`, \
3505                         `list_filter`, `list_per_page`, `ordering`, `actions`, \
3506                         `fieldsets`)",
3507                    ))
3508                })?;
3509                out.admin = Some(admin);
3510                return Ok(());
3511            }
3512            if meta.path.is_ident("audit") {
3513                let mut audit = AuditAttrs::default();
3514                meta.parse_nested_meta(|inner| {
3515                    if inner.path.is_ident("track") {
3516                        let s: LitStr = inner.value()?.parse()?;
3517                        audit.track =
3518                            Some((split_field_list(&s.value()), s.span()));
3519                        return Ok(());
3520                    }
3521                    Err(inner.error(
3522                        "unknown audit attribute (supported: `track`)",
3523                    ))
3524                })?;
3525                out.audit = Some(audit);
3526                return Ok(());
3527            }
3528            if meta.path.is_ident("permissions") {
3529                // Two forms accepted:
3530                //   #[rustango(permissions)]          — flag form, true
3531                //   #[rustango(permissions = false)]  — explicit opt-out
3532                //   #[rustango(permissions = true)]   — explicit opt-in
3533                if let Ok(v) = meta.value() {
3534                    let lit: syn::LitBool = v.parse()?;
3535                    out.permissions = lit.value;
3536                } else {
3537                    out.permissions = true;
3538                }
3539                return Ok(());
3540            }
3541            if meta.path.is_ident("unique_together") {
3542                // Django-shape composite UNIQUE index. Two syntaxes:
3543                //
3544                //   #[rustango(unique_together = "org_id, user_id")]                       — auto-derived name
3545                //   #[rustango(unique_together(columns = "org_id, user_id", name = "x"))]  — explicit name
3546                //
3547                // Both produce `CREATE UNIQUE INDEX <name> ON <table>
3548                // (col1, col2)`, where <name> defaults to
3549                // `<table>_<col1>_<col2>_uq` when not supplied.
3550                let (columns, name) = parse_together_attr(&meta, "unique_together")?;
3551                out.indexes.push(IndexAttr { name, columns, unique: true });
3552                return Ok(());
3553            }
3554            if meta.path.is_ident("index_together") {
3555                // Django-shape composite (non-unique) index. Two syntaxes
3556                // mirroring `unique_together`.
3557                //
3558                //   #[rustango(index_together = "created_at, status")]
3559                //   #[rustango(index_together(columns = "created_at, status", name = "x"))]
3560                let (columns, name) = parse_together_attr(&meta, "index_together")?;
3561                out.indexes.push(IndexAttr { name, columns, unique: false });
3562                return Ok(());
3563            }
3564            if meta.path.is_ident("index") {
3565                // Container-level composite index — legacy entry that
3566                // was advertised with a trailing `, unique, name = ...`
3567                // flag block which doesn't actually compose under
3568                // `parse_nested_meta`. Prefer `unique_together` /
3569                // `index_together` (above) for new code. The bare
3570                // `index = "..."` form is kept for back-compat: it
3571                // emits a non-unique composite index.
3572                let cols_lit: LitStr = meta.value()?.parse()?;
3573                let columns = split_field_list(&cols_lit.value());
3574                out.indexes.push(IndexAttr { name: None, columns, unique: false });
3575                return Ok(());
3576            }
3577            if meta.path.is_ident("check") {
3578                // #[rustango(check(name = "…", expr = "…"))]
3579                let mut name: Option<String> = None;
3580                let mut expr: Option<String> = None;
3581                meta.parse_nested_meta(|inner| {
3582                    if inner.path.is_ident("name") {
3583                        let s: LitStr = inner.value()?.parse()?;
3584                        name = Some(s.value());
3585                        return Ok(());
3586                    }
3587                    if inner.path.is_ident("expr") {
3588                        let s: LitStr = inner.value()?.parse()?;
3589                        expr = Some(s.value());
3590                        return Ok(());
3591                    }
3592                    Err(inner.error("unknown check attribute (supported: `name`, `expr`)"))
3593                })?;
3594                let name = name.ok_or_else(|| meta.error("check requires `name = \"...\"`"))?;
3595                let expr = expr.ok_or_else(|| meta.error("check requires `expr = \"...\"`"))?;
3596                out.checks.push(CheckAttr { name, expr });
3597                return Ok(());
3598            }
3599            if meta.path.is_ident("generic_fk") {
3600                let mut gfk = GenericFkAttr {
3601                    name: String::new(),
3602                    ct_column: String::new(),
3603                    pk_column: String::new(),
3604                };
3605                meta.parse_nested_meta(|inner| {
3606                    if inner.path.is_ident("name") {
3607                        let s: LitStr = inner.value()?.parse()?;
3608                        gfk.name = s.value();
3609                        return Ok(());
3610                    }
3611                    if inner.path.is_ident("ct_column") {
3612                        let s: LitStr = inner.value()?.parse()?;
3613                        gfk.ct_column = s.value();
3614                        return Ok(());
3615                    }
3616                    if inner.path.is_ident("pk_column") {
3617                        let s: LitStr = inner.value()?.parse()?;
3618                        gfk.pk_column = s.value();
3619                        return Ok(());
3620                    }
3621                    Err(inner.error(
3622                        "unknown generic_fk attribute (supported: `name`, `ct_column`, `pk_column`)",
3623                    ))
3624                })?;
3625                if gfk.name.is_empty() {
3626                    return Err(meta.error("generic_fk requires `name = \"...\"`"));
3627                }
3628                if gfk.ct_column.is_empty() {
3629                    return Err(meta.error("generic_fk requires `ct_column = \"...\"`"));
3630                }
3631                if gfk.pk_column.is_empty() {
3632                    return Err(meta.error("generic_fk requires `pk_column = \"...\"`"));
3633                }
3634                out.generic_fks.push(gfk);
3635                return Ok(());
3636            }
3637            if meta.path.is_ident("fk_composite") {
3638                let mut fk = CompositeFkAttr {
3639                    name: String::new(),
3640                    to: String::new(),
3641                    from: Vec::new(),
3642                    on: Vec::new(),
3643                };
3644                meta.parse_nested_meta(|inner| {
3645                    if inner.path.is_ident("name") {
3646                        let s: LitStr = inner.value()?.parse()?;
3647                        fk.name = s.value();
3648                        return Ok(());
3649                    }
3650                    if inner.path.is_ident("to") {
3651                        let s: LitStr = inner.value()?.parse()?;
3652                        fk.to = s.value();
3653                        return Ok(());
3654                    }
3655                    // `on = ("col1", "col2", ...)` — parse a parenthesised
3656                    // comma-list of string literals.
3657                    if inner.path.is_ident("on") || inner.path.is_ident("from") {
3658                        let value = inner.value()?;
3659                        let content;
3660                        syn::parenthesized!(content in value);
3661                        let lits: syn::punctuated::Punctuated<syn::LitStr, syn::Token![,]> =
3662                            content.parse_terminated(
3663                                |p| p.parse::<syn::LitStr>(),
3664                                syn::Token![,],
3665                            )?;
3666                        let cols: Vec<String> = lits.iter().map(syn::LitStr::value).collect();
3667                        if inner.path.is_ident("on") {
3668                            fk.on = cols;
3669                        } else {
3670                            fk.from = cols;
3671                        }
3672                        return Ok(());
3673                    }
3674                    Err(inner.error(
3675                        "unknown fk_composite attribute (supported: `name`, `to`, `on`, `from`)",
3676                    ))
3677                })?;
3678                if fk.name.is_empty() {
3679                    return Err(meta.error("fk_composite requires `name = \"...\"`"));
3680                }
3681                if fk.to.is_empty() {
3682                    return Err(meta.error("fk_composite requires `to = \"...\"`"));
3683                }
3684                if fk.from.is_empty() || fk.on.is_empty() {
3685                    return Err(meta.error(
3686                        "fk_composite requires non-empty `from = (...)` and `on = (...)` tuples",
3687                    ));
3688                }
3689                if fk.from.len() != fk.on.len() {
3690                    return Err(meta.error(format!(
3691                        "fk_composite `from` ({} cols) and `on` ({} cols) must be the same length",
3692                        fk.from.len(),
3693                        fk.on.len(),
3694                    )));
3695                }
3696                out.composite_fks.push(fk);
3697                return Ok(());
3698            }
3699            if meta.path.is_ident("m2m") {
3700                let mut m2m = M2MAttr {
3701                    name: String::new(),
3702                    to: String::new(),
3703                    through: String::new(),
3704                    src: String::new(),
3705                    dst: String::new(),
3706                };
3707                meta.parse_nested_meta(|inner| {
3708                    if inner.path.is_ident("name") {
3709                        let s: LitStr = inner.value()?.parse()?;
3710                        m2m.name = s.value();
3711                        return Ok(());
3712                    }
3713                    if inner.path.is_ident("to") {
3714                        let s: LitStr = inner.value()?.parse()?;
3715                        m2m.to = s.value();
3716                        return Ok(());
3717                    }
3718                    if inner.path.is_ident("through") {
3719                        let s: LitStr = inner.value()?.parse()?;
3720                        m2m.through = s.value();
3721                        return Ok(());
3722                    }
3723                    if inner.path.is_ident("src") {
3724                        let s: LitStr = inner.value()?.parse()?;
3725                        m2m.src = s.value();
3726                        return Ok(());
3727                    }
3728                    if inner.path.is_ident("dst") {
3729                        let s: LitStr = inner.value()?.parse()?;
3730                        m2m.dst = s.value();
3731                        return Ok(());
3732                    }
3733                    Err(inner.error("unknown m2m attribute (supported: `name`, `to`, `through`, `src`, `dst`)"))
3734                })?;
3735                if m2m.name.is_empty() {
3736                    return Err(meta.error("m2m requires `name = \"...\"`"));
3737                }
3738                if m2m.to.is_empty() {
3739                    return Err(meta.error("m2m requires `to = \"...\"`"));
3740                }
3741                if m2m.through.is_empty() {
3742                    return Err(meta.error("m2m requires `through = \"...\"`"));
3743                }
3744                if m2m.src.is_empty() {
3745                    return Err(meta.error("m2m requires `src = \"...\"`"));
3746                }
3747                if m2m.dst.is_empty() {
3748                    return Err(meta.error("m2m requires `dst = \"...\"`"));
3749                }
3750                out.m2m.push(m2m);
3751                return Ok(());
3752            }
3753            Err(meta.error("unknown rustango container attribute"))
3754        })?;
3755    }
3756    Ok(out)
3757}
3758
3759/// Split a comma-separated field-name list (e.g. `"name, office"`) into
3760/// owned field names, trimming whitespace and skipping empty entries.
3761/// Field-name validation against the model is done by the caller.
3762fn split_field_list(raw: &str) -> Vec<String> {
3763    raw.split(',')
3764        .map(str::trim)
3765        .filter(|s| !s.is_empty())
3766        .map(str::to_owned)
3767        .collect()
3768}
3769
3770/// Shared parser for `unique_together` and `index_together` container
3771/// attrs. Accepts both shapes:
3772///
3773///   * `attr = "col1, col2"`              — auto-derived index name.
3774///   * `attr(columns = "col1, col2", name = "...")` — explicit name.
3775///
3776/// Returns `(columns, name)`.
3777fn parse_together_attr(
3778    meta: &syn::meta::ParseNestedMeta<'_>,
3779    attr: &str,
3780) -> syn::Result<(Vec<String>, Option<String>)> {
3781    // Disambiguate by whether the next token is `=` (key-value) or
3782    // `(` (parenthesized).
3783    if meta.input.peek(syn::Token![=]) {
3784        let cols_lit: LitStr = meta.value()?.parse()?;
3785        let columns = split_field_list(&cols_lit.value());
3786        check_together_columns(meta, attr, &columns)?;
3787        return Ok((columns, None));
3788    }
3789    let mut columns: Option<Vec<String>> = None;
3790    let mut name: Option<String> = None;
3791    meta.parse_nested_meta(|inner| {
3792        if inner.path.is_ident("columns") {
3793            let s: LitStr = inner.value()?.parse()?;
3794            columns = Some(split_field_list(&s.value()));
3795            return Ok(());
3796        }
3797        if inner.path.is_ident("name") {
3798            let s: LitStr = inner.value()?.parse()?;
3799            name = Some(s.value());
3800            return Ok(());
3801        }
3802        Err(inner.error("unknown sub-attribute (supported: `columns`, `name`)"))
3803    })?;
3804    let columns = columns.ok_or_else(|| {
3805        meta.error(format!(
3806            "{attr}(...) requires a `columns = \"col1, col2\"` argument",
3807        ))
3808    })?;
3809    check_together_columns(meta, attr, &columns)?;
3810    Ok((columns, name))
3811}
3812
3813fn check_together_columns(
3814    meta: &syn::meta::ParseNestedMeta<'_>,
3815    attr: &str,
3816    columns: &[String],
3817) -> syn::Result<()> {
3818    if columns.len() < 2 {
3819        let single = if attr == "unique_together" {
3820            "#[rustango(unique)] on the field"
3821        } else {
3822            "#[rustango(index)] on the field"
3823        };
3824        return Err(meta.error(format!(
3825            "{attr} expects two or more columns; for a single-column equivalent use {single}",
3826        )));
3827    }
3828    Ok(())
3829}
3830
3831/// Parse the fieldsets DSL: pipe-separated sections, optional
3832/// `"Title:"` prefix on each, comma-separated field names after.
3833/// Examples:
3834/// * `"name, office"` → one untitled section with two fields
3835/// * `"Identity: name, office | Metadata: created_at"` → two titled
3836///   sections
3837///
3838/// Returns `(title, fields)` pairs. Title is `""` when no prefix.
3839fn parse_fieldset_list(raw: &str) -> Vec<(String, Vec<String>)> {
3840    raw.split('|')
3841        .map(str::trim)
3842        .filter(|s| !s.is_empty())
3843        .map(|section| {
3844            // Split off an optional `Title:` prefix (first colon).
3845            let (title, rest) = match section.split_once(':') {
3846                Some((title, rest)) if !title.contains(',') => (title.trim().to_owned(), rest),
3847                _ => (String::new(), section),
3848            };
3849            let fields = split_field_list(rest);
3850            (title, fields)
3851        })
3852        .collect()
3853}
3854
3855/// Parse Django-shape ordering — `"name"` is ASC, `"-name"` is DESC.
3856/// Returns `(field_name, desc)` pairs in the same order as the input.
3857fn parse_ordering_list(raw: &str) -> Vec<(String, bool)> {
3858    raw.split(',')
3859        .map(str::trim)
3860        .filter(|s| !s.is_empty())
3861        .map(|spec| {
3862            spec.strip_prefix('-')
3863                .map_or((spec.to_owned(), false), |rest| {
3864                    (rest.trim().to_owned(), true)
3865                })
3866        })
3867        .collect()
3868}
3869
3870struct FieldAttrs {
3871    column: Option<String>,
3872    primary_key: bool,
3873    fk: Option<String>,
3874    o2o: Option<String>,
3875    on: Option<String>,
3876    max_length: Option<u32>,
3877    min: Option<i64>,
3878    max: Option<i64>,
3879    default: Option<String>,
3880    /// `#[rustango(auto_uuid)]` — UUID PK generated by Postgres
3881    /// `gen_random_uuid()`. Implies `auto + primary_key + default =
3882    /// "gen_random_uuid()"`. The Rust field type must be
3883    /// `uuid::Uuid` (or `Auto<Uuid>`); the column is excluded from
3884    /// INSERTs so the DB DEFAULT fires.
3885    auto_uuid: bool,
3886    /// `#[rustango(auto_now_add)]` — `created_at`-shape column.
3887    /// Server-set on insert, immutable from app code afterwards.
3888    /// Implies `auto + default = "now()"`. Field type must be
3889    /// `DateTime<Utc>`.
3890    auto_now_add: bool,
3891    /// `#[rustango(auto_now)]` — `updated_at`-shape column. Set on
3892    /// every insert AND every update. Implies `auto + default =
3893    /// "now()"`; the macro additionally rewrites `update_on` /
3894    /// `save_on` to bind `chrono::Utc::now()` instead of the user's
3895    /// field value.
3896    auto_now: bool,
3897    /// `#[rustango(soft_delete)]` — `deleted_at`-shape column. Type
3898    /// must be `Option<DateTime<Utc>>`. Triggers macro emission of
3899    /// `soft_delete_on(executor)` and `restore_on(executor)`
3900    /// methods on the model.
3901    soft_delete: bool,
3902    /// `#[rustango(unique)]` — adds a `UNIQUE` constraint inline on
3903    /// the column in the generated DDL.
3904    unique: bool,
3905    /// `#[rustango(index)]` or `#[rustango(index(name = "…", unique))]` —
3906    /// generates a `CREATE INDEX` for this column. `unique` here means
3907    /// `CREATE UNIQUE INDEX` (distinct from the `unique` constraint above).
3908    index: bool,
3909    index_unique: bool,
3910    index_name: Option<String>,
3911    /// `#[rustango(generated_as = "EXPR")]` — emit `GENERATED ALWAYS
3912    /// AS (EXPR) STORED` in the column DDL. Read-only from app code:
3913    /// the macro skips this column from every INSERT and UPDATE
3914    /// path, so the database always recomputes the value from
3915    /// `EXPR`. Backlog item #35.
3916    generated_as: Option<String>,
3917}
3918
3919fn parse_field_attrs(field: &syn::Field) -> syn::Result<FieldAttrs> {
3920    let mut out = FieldAttrs {
3921        column: None,
3922        primary_key: false,
3923        fk: None,
3924        o2o: None,
3925        on: None,
3926        max_length: None,
3927        min: None,
3928        max: None,
3929        default: None,
3930        auto_uuid: false,
3931        auto_now_add: false,
3932        auto_now: false,
3933        soft_delete: false,
3934        unique: false,
3935        index: false,
3936        index_unique: false,
3937        index_name: None,
3938        generated_as: None,
3939    };
3940    for attr in &field.attrs {
3941        if !attr.path().is_ident("rustango") {
3942            continue;
3943        }
3944        attr.parse_nested_meta(|meta| {
3945            if meta.path.is_ident("column") {
3946                let s: LitStr = meta.value()?.parse()?;
3947                let name = s.value();
3948                validate_sql_identifier(&name, "column", s.span())?;
3949                out.column = Some(name);
3950                return Ok(());
3951            }
3952            if meta.path.is_ident("primary_key") {
3953                out.primary_key = true;
3954                return Ok(());
3955            }
3956            if meta.path.is_ident("fk") {
3957                let s: LitStr = meta.value()?.parse()?;
3958                out.fk = Some(s.value());
3959                return Ok(());
3960            }
3961            if meta.path.is_ident("o2o") {
3962                let s: LitStr = meta.value()?.parse()?;
3963                out.o2o = Some(s.value());
3964                return Ok(());
3965            }
3966            if meta.path.is_ident("on") {
3967                let s: LitStr = meta.value()?.parse()?;
3968                out.on = Some(s.value());
3969                return Ok(());
3970            }
3971            if meta.path.is_ident("max_length") {
3972                let lit: syn::LitInt = meta.value()?.parse()?;
3973                out.max_length = Some(lit.base10_parse::<u32>()?);
3974                return Ok(());
3975            }
3976            if meta.path.is_ident("min") {
3977                out.min = Some(parse_signed_i64(&meta)?);
3978                return Ok(());
3979            }
3980            if meta.path.is_ident("max") {
3981                out.max = Some(parse_signed_i64(&meta)?);
3982                return Ok(());
3983            }
3984            if meta.path.is_ident("default") {
3985                let s: LitStr = meta.value()?.parse()?;
3986                out.default = Some(s.value());
3987                return Ok(());
3988            }
3989            if meta.path.is_ident("generated_as") {
3990                let s: LitStr = meta.value()?.parse()?;
3991                out.generated_as = Some(s.value());
3992                return Ok(());
3993            }
3994            if meta.path.is_ident("auto_uuid") {
3995                out.auto_uuid = true;
3996                // Implied: PK + auto + DEFAULT gen_random_uuid().
3997                // Each is also explicitly settable; the explicit
3998                // value wins if conflicting.
3999                out.primary_key = true;
4000                if out.default.is_none() {
4001                    out.default = Some("gen_random_uuid()".into());
4002                }
4003                return Ok(());
4004            }
4005            if meta.path.is_ident("auto_now_add") {
4006                out.auto_now_add = true;
4007                if out.default.is_none() {
4008                    out.default = Some("now()".into());
4009                }
4010                return Ok(());
4011            }
4012            if meta.path.is_ident("auto_now") {
4013                out.auto_now = true;
4014                if out.default.is_none() {
4015                    out.default = Some("now()".into());
4016                }
4017                return Ok(());
4018            }
4019            if meta.path.is_ident("soft_delete") {
4020                out.soft_delete = true;
4021                return Ok(());
4022            }
4023            if meta.path.is_ident("unique") {
4024                out.unique = true;
4025                return Ok(());
4026            }
4027            if meta.path.is_ident("index") {
4028                out.index = true;
4029                // Optional sub-attrs: #[rustango(index(unique, name = "…"))]
4030                if meta.input.peek(syn::token::Paren) {
4031                    meta.parse_nested_meta(|inner| {
4032                        if inner.path.is_ident("unique") {
4033                            out.index_unique = true;
4034                            return Ok(());
4035                        }
4036                        if inner.path.is_ident("name") {
4037                            let s: LitStr = inner.value()?.parse()?;
4038                            out.index_name = Some(s.value());
4039                            return Ok(());
4040                        }
4041                        Err(inner
4042                            .error("unknown index sub-attribute (supported: `unique`, `name`)"))
4043                    })?;
4044                }
4045                return Ok(());
4046            }
4047            Err(meta.error("unknown rustango field attribute"))
4048        })?;
4049    }
4050    Ok(out)
4051}
4052
4053/// Parse a signed integer literal, accepting optional leading `-`.
4054fn parse_signed_i64(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<i64> {
4055    let expr: syn::Expr = meta.value()?.parse()?;
4056    match expr {
4057        syn::Expr::Lit(syn::ExprLit {
4058            lit: syn::Lit::Int(lit),
4059            ..
4060        }) => lit.base10_parse::<i64>(),
4061        syn::Expr::Unary(syn::ExprUnary {
4062            op: syn::UnOp::Neg(_),
4063            expr,
4064            ..
4065        }) => {
4066            if let syn::Expr::Lit(syn::ExprLit {
4067                lit: syn::Lit::Int(lit),
4068                ..
4069            }) = *expr
4070            {
4071                let v: i64 = lit.base10_parse()?;
4072                Ok(-v)
4073            } else {
4074                Err(syn::Error::new_spanned(expr, "expected integer literal"))
4075            }
4076        }
4077        other => Err(syn::Error::new_spanned(
4078            other,
4079            "expected integer literal (signed)",
4080        )),
4081    }
4082}
4083
4084struct FieldInfo<'a> {
4085    ident: &'a syn::Ident,
4086    column: String,
4087    primary_key: bool,
4088    /// `true` when the Rust type was `Auto<T>` — the INSERT path will
4089    /// skip this column when `Auto::Unset` and emit it under
4090    /// `RETURNING` so Postgres' sequence DEFAULT fills in the value.
4091    auto: bool,
4092    /// The original field type, e.g. `i64` or `Option<String>`. Emitted as
4093    /// the `Column::Value` associated type for typed-column tokens.
4094    value_ty: &'a Type,
4095    /// `FieldType` variant tokens (`::rustango::core::FieldType::I64`).
4096    field_type_tokens: TokenStream2,
4097    schema: TokenStream2,
4098    from_row_init: TokenStream2,
4099    /// Variant of [`Self::from_row_init`] that reads the column via
4100    /// `format!("{prefix}__{col}")` so a model can be decoded out of
4101    /// the aliased columns of a JOINed row. Drives slice 9.0d's
4102    /// `Self::__rustango_from_aliased_row(row, prefix)` per-Model
4103    /// helper that `select_related` calls when stitching loaded FKs.
4104    from_aliased_row_init: TokenStream2,
4105    /// Inner type from a `ForeignKey<T, K>` field, if any. The reverse-
4106    /// relation helper emit (`Author::<child>_set`) needs to know `T`
4107    /// to point the generated method at the right child model.
4108    fk_inner: Option<Type>,
4109    /// `K`'s scalar kind for a `ForeignKey<T, K>` field. Mirrors
4110    /// `kind` (since ForeignKey detection sets `kind` to K's
4111    /// underlying type) but stored separately for clarity at the
4112    /// `FkRelation` construction site, which only sees the FK's
4113    /// surface fields.
4114    fk_pk_kind: DetectedKind,
4115    /// `true` when the field is `Option<ForeignKey<T, K>>` rather than
4116    /// the bare `ForeignKey<T, K>`. Routes the load_related and
4117    /// fk_pk_access emitters to wrap assignments / accessors in
4118    /// `Some(...)` / `as_ref().map(...)` respectively, so a nullable
4119    /// FK column compiles end-to-end. The DDL writer reads this off
4120    /// the field schema (`nullable` flag); the macro just needs to
4121    /// keep the Rust-side codegen consistent.
4122    nullable: bool,
4123    /// `true` when this column was marked `#[rustango(auto_now)]` —
4124    /// `update_on` / `save_on` bind `chrono::Utc::now()` for this
4125    /// column instead of the user-supplied value, so `updated_at`
4126    /// always reflects the latest write without the caller having
4127    /// to remember to set it.
4128    auto_now: bool,
4129    /// `true` when this column was marked `#[rustango(auto_now_add)]`
4130    /// — the column is server-set on INSERT (DB DEFAULT) and
4131    /// **immutable** afterwards. `update_on` / `save_on` skip the
4132    /// column entirely so a stale `created_at` value in memory never
4133    /// rewrites the persisted timestamp.
4134    auto_now_add: bool,
4135    /// `true` when this column was marked `#[rustango(soft_delete)]`.
4136    /// Triggers emission of `soft_delete_on(executor)` and
4137    /// `restore_on(executor)` on the model's inherent impl. There is
4138    /// at most one such column per model — emission asserts this.
4139    soft_delete: bool,
4140    /// `Some` when this column was marked
4141    /// `#[rustango(generated_as = "EXPR")]`. The macro skips it from
4142    /// every INSERT and UPDATE path; the database recomputes the
4143    /// value from `EXPR`. Backlog item #35.
4144    generated_as: Option<String>,
4145}
4146
4147/// Reject table names that won't survive SQL identifier
4148/// derivation downstream. Postgres' regular-identifier rule
4149/// (`[a-zA-Z_][a-zA-Z0-9_]*`) is the safe shape: it round-trips
4150/// through the framework's unquoted FK / index / constraint name
4151/// emission without surprises. We also disallow leading-digit and
4152/// the empty string for clarity.
4153///
4154/// Reserved-word collisions (`select`, `from`, …) aren't flagged
4155/// here — those produce a runtime error from the SQL parser,
4156/// which is loud enough; statically enumerating reserved words
4157/// across the three supported dialects is more friction than help.
4158///
4159/// Backlog item #65.
4160fn validate_table_name(name: &str, span: proc_macro2::Span) -> syn::Result<()> {
4161    validate_sql_identifier(name, "table", span)
4162}
4163
4164/// Reject SQL identifiers that compile but break downstream SQL
4165/// generation. Same rule for tables and columns: `[a-zA-Z_][a-zA-Z0-9_]*`.
4166/// `kind` is "table" / "column" — used for the error message so users
4167/// see which attribute caused the failure.
4168fn validate_sql_identifier(name: &str, kind: &str, span: proc_macro2::Span) -> syn::Result<()> {
4169    if name.is_empty() {
4170        return Err(syn::Error::new(
4171            span,
4172            format!("`{kind} = \"\"` is not a valid SQL identifier"),
4173        ));
4174    }
4175    let mut chars = name.chars();
4176    let first = chars.next().unwrap();
4177    if !(first.is_ascii_alphabetic() || first == '_') {
4178        return Err(syn::Error::new(
4179            span,
4180            format!("{kind} name `{name}` must start with a letter or underscore (got {first:?})"),
4181        ));
4182    }
4183    for c in chars {
4184        if !(c.is_ascii_alphanumeric() || c == '_') {
4185            return Err(syn::Error::new(
4186                span,
4187                format!(
4188                    "{kind} name `{name}` contains invalid character {c:?} — \
4189                     SQL identifiers must match `[a-zA-Z_][a-zA-Z0-9_]*`. \
4190                     Hyphens in particular break FK / index name derivation \
4191                     downstream; use underscores instead (e.g. `{}`)",
4192                    name.replace(|x: char| !x.is_ascii_alphanumeric() && x != '_', "_"),
4193                ),
4194            ));
4195        }
4196    }
4197    Ok(())
4198}
4199
4200fn process_field<'a>(field: &'a syn::Field, table: &str) -> syn::Result<FieldInfo<'a>> {
4201    let attrs = parse_field_attrs(field)?;
4202    let ident = field
4203        .ident
4204        .as_ref()
4205        .ok_or_else(|| syn::Error::new(field.span(), "tuple structs are not supported"))?;
4206    let name = ident.to_string();
4207    let column = attrs.column.clone().unwrap_or_else(|| name.clone());
4208    let primary_key = attrs.primary_key;
4209    let DetectedType {
4210        kind,
4211        nullable,
4212        auto: detected_auto,
4213        fk_inner,
4214    } = detect_type(&field.ty)?;
4215    check_bound_compatibility(field, &attrs, kind)?;
4216    let auto = detected_auto;
4217    // Mixin attributes piggyback on the existing `Auto<T>` skip-on-
4218    // INSERT path: the user must wrap the field in `Auto<T>`, which
4219    // marks the column as DB-default-supplied. The mixin attrs then
4220    // layer in the SQL default (`now()` / `gen_random_uuid()`) and,
4221    // for `auto_now`, force the value on UPDATE too.
4222    if attrs.auto_uuid {
4223        if kind != DetectedKind::Uuid {
4224            return Err(syn::Error::new_spanned(
4225                field,
4226                "`#[rustango(auto_uuid)]` requires the field type to be \
4227                 `Auto<uuid::Uuid>`",
4228            ));
4229        }
4230        if !detected_auto {
4231            return Err(syn::Error::new_spanned(
4232                field,
4233                "`#[rustango(auto_uuid)]` requires the field type to be \
4234                 wrapped in `Auto<...>` so the macro skips the column on \
4235                 INSERT and the DB DEFAULT (`gen_random_uuid()`) fires",
4236            ));
4237        }
4238    }
4239    if attrs.auto_now_add || attrs.auto_now {
4240        if kind != DetectedKind::DateTime {
4241            return Err(syn::Error::new_spanned(
4242                field,
4243                "`#[rustango(auto_now_add)]` / `#[rustango(auto_now)]` require \
4244                 the field type to be `Auto<chrono::DateTime<chrono::Utc>>`",
4245            ));
4246        }
4247        if !detected_auto {
4248            return Err(syn::Error::new_spanned(
4249                field,
4250                "`#[rustango(auto_now_add)]` / `#[rustango(auto_now)]` require \
4251                 the field type to be wrapped in `Auto<...>` so the macro skips \
4252                 the column on INSERT and the DB DEFAULT (`now()`) fires",
4253            ));
4254        }
4255    }
4256    if attrs.soft_delete && !(kind == DetectedKind::DateTime && nullable) {
4257        return Err(syn::Error::new_spanned(
4258            field,
4259            "`#[rustango(soft_delete)]` requires the field type to be \
4260             `Option<chrono::DateTime<chrono::Utc>>`",
4261        ));
4262    }
4263    let is_mixin_auto = attrs.auto_uuid || attrs.auto_now_add || attrs.auto_now;
4264    if detected_auto && !primary_key && !is_mixin_auto {
4265        return Err(syn::Error::new_spanned(
4266            field,
4267            "`Auto<T>` is only valid on a `#[rustango(primary_key)]` field, \
4268             or on a field carrying one of `auto_uuid`, `auto_now_add`, or \
4269             `auto_now`",
4270        ));
4271    }
4272    if detected_auto && attrs.default.is_some() && !is_mixin_auto {
4273        return Err(syn::Error::new_spanned(
4274            field,
4275            "`#[rustango(default = \"…\")]` is redundant on an `Auto<T>` field — \
4276             SERIAL / BIGSERIAL already supplies a default sequence.",
4277        ));
4278    }
4279    if fk_inner.is_some() && primary_key {
4280        return Err(syn::Error::new_spanned(
4281            field,
4282            "`ForeignKey<T>` is not allowed on a primary-key field — \
4283             a row's PK is its own identity, not a reference to a parent.",
4284        ));
4285    }
4286    if attrs.generated_as.is_some() {
4287        if primary_key {
4288            return Err(syn::Error::new_spanned(
4289                field,
4290                "`#[rustango(generated_as = \"…\")]` is not allowed on a \
4291                 primary-key field — a PK must be writable so the row \
4292                 has an identity at INSERT time.",
4293            ));
4294        }
4295        if attrs.default.is_some() {
4296            return Err(syn::Error::new_spanned(
4297                field,
4298                "`#[rustango(generated_as = \"…\")]` cannot combine with \
4299                 `default = \"…\"` — Postgres rejects DEFAULT on \
4300                 generated columns. The expression IS the default.",
4301            ));
4302        }
4303        if detected_auto {
4304            return Err(syn::Error::new_spanned(
4305                field,
4306                "`#[rustango(generated_as = \"…\")]` is not allowed on \
4307                 an `Auto<T>` field — generated columns are computed \
4308                 by the DB, not server-assigned via a sequence. Use a \
4309                 plain Rust type (e.g. `f64`).",
4310            ));
4311        }
4312        if fk_inner.is_some() {
4313            return Err(syn::Error::new_spanned(
4314                field,
4315                "`#[rustango(generated_as = \"…\")]` is not allowed on a \
4316                 ForeignKey field.",
4317            ));
4318        }
4319    }
4320    let relation = relation_tokens(field, &attrs, fk_inner, table)?;
4321    let column_lit = column.as_str();
4322    let field_type_tokens = kind.variant_tokens();
4323    let max_length = optional_u32(attrs.max_length);
4324    let min = optional_i64(attrs.min);
4325    let max = optional_i64(attrs.max);
4326    let default = optional_str(attrs.default.as_deref());
4327
4328    let unique = attrs.unique;
4329    let generated_as = optional_str(attrs.generated_as.as_deref());
4330    let schema = quote! {
4331        ::rustango::core::FieldSchema {
4332            name: #name,
4333            column: #column_lit,
4334            ty: #field_type_tokens,
4335            nullable: #nullable,
4336            primary_key: #primary_key,
4337            relation: #relation,
4338            max_length: #max_length,
4339            min: #min,
4340            max: #max,
4341            default: #default,
4342            auto: #auto,
4343            unique: #unique,
4344            generated_as: #generated_as,
4345        }
4346    };
4347
4348    let from_row_init = quote! {
4349        #ident: ::rustango::sql::sqlx::Row::try_get(row, #column_lit)?
4350    };
4351    let from_aliased_row_init = quote! {
4352        #ident: ::rustango::sql::sqlx::Row::try_get(
4353            row,
4354            ::std::format!("{}__{}", prefix, #column_lit).as_str(),
4355        )?
4356    };
4357
4358    Ok(FieldInfo {
4359        ident,
4360        column,
4361        primary_key,
4362        auto,
4363        value_ty: &field.ty,
4364        field_type_tokens,
4365        schema,
4366        from_row_init,
4367        from_aliased_row_init,
4368        fk_inner: fk_inner.cloned(),
4369        fk_pk_kind: kind,
4370        nullable,
4371        auto_now: attrs.auto_now,
4372        auto_now_add: attrs.auto_now_add,
4373        soft_delete: attrs.soft_delete,
4374        generated_as: attrs.generated_as.clone(),
4375    })
4376}
4377
4378fn check_bound_compatibility(
4379    field: &syn::Field,
4380    attrs: &FieldAttrs,
4381    kind: DetectedKind,
4382) -> syn::Result<()> {
4383    if attrs.max_length.is_some() && kind != DetectedKind::String {
4384        return Err(syn::Error::new_spanned(
4385            field,
4386            "`max_length` is only valid on `String` fields (or `Option<String>`)",
4387        ));
4388    }
4389    if (attrs.min.is_some() || attrs.max.is_some()) && !kind.is_integer() {
4390        return Err(syn::Error::new_spanned(
4391            field,
4392            "`min` / `max` are only valid on integer fields (`i32`, `i64`, optionally Option-wrapped)",
4393        ));
4394    }
4395    if let (Some(min), Some(max)) = (attrs.min, attrs.max) {
4396        if min > max {
4397            return Err(syn::Error::new_spanned(
4398                field,
4399                format!("`min` ({min}) is greater than `max` ({max})"),
4400            ));
4401        }
4402    }
4403    Ok(())
4404}
4405
4406fn optional_u32(value: Option<u32>) -> TokenStream2 {
4407    if let Some(v) = value {
4408        quote!(::core::option::Option::Some(#v))
4409    } else {
4410        quote!(::core::option::Option::None)
4411    }
4412}
4413
4414fn optional_i64(value: Option<i64>) -> TokenStream2 {
4415    if let Some(v) = value {
4416        quote!(::core::option::Option::Some(#v))
4417    } else {
4418        quote!(::core::option::Option::None)
4419    }
4420}
4421
4422fn optional_str(value: Option<&str>) -> TokenStream2 {
4423    if let Some(v) = value {
4424        quote!(::core::option::Option::Some(#v))
4425    } else {
4426        quote!(::core::option::Option::None)
4427    }
4428}
4429
4430fn relation_tokens(
4431    field: &syn::Field,
4432    attrs: &FieldAttrs,
4433    fk_inner: Option<&syn::Type>,
4434    table: &str,
4435) -> syn::Result<TokenStream2> {
4436    if let Some(inner) = fk_inner {
4437        if attrs.fk.is_some() || attrs.o2o.is_some() {
4438            return Err(syn::Error::new_spanned(
4439                field,
4440                "`ForeignKey<T>` already declares the FK target via the type parameter — \
4441                 remove the `fk = \"…\"` / `o2o = \"…\"` attribute.",
4442            ));
4443        }
4444        let on = attrs.on.as_deref().unwrap_or("id");
4445        return Ok(quote! {
4446            ::core::option::Option::Some(::rustango::core::Relation::Fk {
4447                to: <#inner as ::rustango::core::Model>::SCHEMA.table,
4448                on: #on,
4449            })
4450        });
4451    }
4452    match (&attrs.fk, &attrs.o2o) {
4453        (Some(_), Some(_)) => Err(syn::Error::new_spanned(
4454            field,
4455            "`fk` and `o2o` are mutually exclusive",
4456        )),
4457        (Some(to), None) => {
4458            let on = attrs.on.as_deref().unwrap_or("id");
4459            // Self-FK sentinel — `#[rustango(fk = "self")]` resolves to
4460            // the model's own table. Threaded as a literal string at
4461            // macro-expansion time to sidestep the const-eval cycle
4462            // that `Self::SCHEMA.table` would create when referenced
4463            // inside Self::SCHEMA's own initializer.
4464            let resolved = if to == "self" { table } else { to };
4465            Ok(quote! {
4466                ::core::option::Option::Some(::rustango::core::Relation::Fk { to: #resolved, on: #on })
4467            })
4468        }
4469        (None, Some(to)) => {
4470            let on = attrs.on.as_deref().unwrap_or("id");
4471            let resolved = if to == "self" { table } else { to };
4472            Ok(quote! {
4473                ::core::option::Option::Some(::rustango::core::Relation::O2O { to: #resolved, on: #on })
4474            })
4475        }
4476        (None, None) => {
4477            if attrs.on.is_some() {
4478                return Err(syn::Error::new_spanned(
4479                    field,
4480                    "`on` requires `fk` or `o2o`",
4481                ));
4482            }
4483            Ok(quote!(::core::option::Option::None))
4484        }
4485    }
4486}
4487
4488/// Mirrors `rustango_core::FieldType`. Local copy so the macro can reason
4489/// about kinds without depending on `rustango-core` (which would require a
4490/// proc-macro/normal split it doesn't have today).
4491#[derive(Clone, Copy, PartialEq, Eq)]
4492enum DetectedKind {
4493    I16,
4494    I32,
4495    I64,
4496    F32,
4497    F64,
4498    Bool,
4499    String,
4500    DateTime,
4501    Date,
4502    Uuid,
4503    Json,
4504}
4505
4506impl DetectedKind {
4507    fn variant_tokens(self) -> TokenStream2 {
4508        match self {
4509            Self::I16 => quote!(::rustango::core::FieldType::I16),
4510            Self::I32 => quote!(::rustango::core::FieldType::I32),
4511            Self::I64 => quote!(::rustango::core::FieldType::I64),
4512            Self::F32 => quote!(::rustango::core::FieldType::F32),
4513            Self::F64 => quote!(::rustango::core::FieldType::F64),
4514            Self::Bool => quote!(::rustango::core::FieldType::Bool),
4515            Self::String => quote!(::rustango::core::FieldType::String),
4516            Self::DateTime => quote!(::rustango::core::FieldType::DateTime),
4517            Self::Date => quote!(::rustango::core::FieldType::Date),
4518            Self::Uuid => quote!(::rustango::core::FieldType::Uuid),
4519            Self::Json => quote!(::rustango::core::FieldType::Json),
4520        }
4521    }
4522
4523    fn is_integer(self) -> bool {
4524        matches!(self, Self::I16 | Self::I32 | Self::I64)
4525    }
4526
4527    /// `(SqlValue::<Variant>, default expr)` for emitting the
4528    /// `match SqlValue { … }` arm in `LoadRelated::__rustango_load_related`
4529    /// for a `ForeignKey<T, K>` FK whose K maps to `self`. The default
4530    /// fires only when the parent's `__rustango_pk_value` returns a
4531    /// different variant than expected, which is a compile-time bug —
4532    /// but we still need a value-typed fallback to keep the match
4533    /// total.
4534    fn sqlvalue_match_arm(self) -> (TokenStream2, TokenStream2) {
4535        match self {
4536            Self::I16 => (quote!(I16), quote!(0i16)),
4537            Self::I32 => (quote!(I32), quote!(0i32)),
4538            Self::I64 => (quote!(I64), quote!(0i64)),
4539            Self::F32 => (quote!(F32), quote!(0f32)),
4540            Self::F64 => (quote!(F64), quote!(0f64)),
4541            Self::Bool => (quote!(Bool), quote!(false)),
4542            Self::String => (quote!(String), quote!(::std::string::String::new())),
4543            Self::DateTime => (
4544                quote!(DateTime),
4545                quote!(<::chrono::DateTime<::chrono::Utc> as ::std::default::Default>::default()),
4546            ),
4547            Self::Date => (
4548                quote!(Date),
4549                quote!(<::chrono::NaiveDate as ::std::default::Default>::default()),
4550            ),
4551            Self::Uuid => (quote!(Uuid), quote!(::uuid::Uuid::nil())),
4552            Self::Json => (quote!(Json), quote!(::serde_json::Value::Null)),
4553        }
4554    }
4555}
4556
4557/// Result of walking a field's Rust type. `kind` is the underlying
4558/// `FieldType`; `nullable` is set by an outer `Option<T>`; `auto` is
4559/// set by an outer `Auto<T>` (server-assigned PK); `fk_inner` is
4560/// `Some(<T>)` when the field was `ForeignKey<T>` (or
4561/// `Option<ForeignKey<T>>`), letting the codegen reach `T::SCHEMA`.
4562#[derive(Clone, Copy)]
4563struct DetectedType<'a> {
4564    kind: DetectedKind,
4565    nullable: bool,
4566    auto: bool,
4567    fk_inner: Option<&'a syn::Type>,
4568}
4569
4570/// Extract the `T` from a `…::Auto<T>` field type. Returns `None` for
4571/// non-`Auto` types — the caller should already have routed Auto-only
4572/// codegen through this helper, so a `None` indicates a macro-internal
4573/// invariant break.
4574fn auto_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
4575    let Type::Path(TypePath { path, qself: None }) = ty else {
4576        return None;
4577    };
4578    let last = path.segments.last()?;
4579    if last.ident != "Auto" {
4580        return None;
4581    }
4582    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
4583        return None;
4584    };
4585    args.args.iter().find_map(|a| match a {
4586        syn::GenericArgument::Type(t) => Some(t),
4587        _ => None,
4588    })
4589}
4590
4591fn detect_type(ty: &syn::Type) -> syn::Result<DetectedType<'_>> {
4592    let Type::Path(TypePath { path, qself: None }) = ty else {
4593        return Err(syn::Error::new_spanned(ty, "unsupported field type"));
4594    };
4595    let last = path
4596        .segments
4597        .last()
4598        .ok_or_else(|| syn::Error::new_spanned(ty, "empty type path"))?;
4599
4600    if last.ident == "Option" {
4601        let inner = generic_inner(ty, &last.arguments, "Option")?;
4602        let inner_det = detect_type(inner)?;
4603        if inner_det.nullable {
4604            return Err(syn::Error::new_spanned(
4605                ty,
4606                "nested Option is not supported",
4607            ));
4608        }
4609        if inner_det.auto {
4610            return Err(syn::Error::new_spanned(
4611                ty,
4612                "`Option<Auto<T>>` is not supported — Auto fields are server-assigned and cannot be NULL",
4613            ));
4614        }
4615        return Ok(DetectedType {
4616            nullable: true,
4617            ..inner_det
4618        });
4619    }
4620
4621    if last.ident == "Auto" {
4622        let inner = generic_inner(ty, &last.arguments, "Auto")?;
4623        let inner_det = detect_type(inner)?;
4624        if inner_det.auto {
4625            return Err(syn::Error::new_spanned(ty, "nested Auto is not supported"));
4626        }
4627        if inner_det.nullable {
4628            return Err(syn::Error::new_spanned(
4629                ty,
4630                "`Auto<Option<T>>` is not supported — Auto fields are server-assigned and cannot be NULL",
4631            ));
4632        }
4633        if inner_det.fk_inner.is_some() {
4634            return Err(syn::Error::new_spanned(
4635                ty,
4636                "`Auto<ForeignKey<T>>` is not supported — Auto is for server-assigned PKs, ForeignKey is for parent references",
4637            ));
4638        }
4639        if !matches!(
4640            inner_det.kind,
4641            DetectedKind::I32 | DetectedKind::I64 | DetectedKind::Uuid | DetectedKind::DateTime
4642        ) {
4643            return Err(syn::Error::new_spanned(
4644                ty,
4645                "`Auto<T>` only supports integers (`i32` → SERIAL, `i64` → BIGSERIAL), \
4646                 `uuid::Uuid` (DEFAULT gen_random_uuid()), or `chrono::DateTime<chrono::Utc>` \
4647                 (DEFAULT now())",
4648            ));
4649        }
4650        return Ok(DetectedType {
4651            auto: true,
4652            ..inner_det
4653        });
4654    }
4655
4656    if last.ident == "ForeignKey" {
4657        let (inner, key_ty) = generic_pair(ty, &last.arguments, "ForeignKey")?;
4658        // Resolve the FK column's underlying SQL type from `K`. When the
4659        // user wrote `ForeignKey<T>` without a key parameter, the type
4660        // alias defaults to `i64` and we keep the v0.7 BIGINT shape.
4661        // When the user wrote `ForeignKey<T, K>` with an explicit `K`,
4662        // recurse into K so the column DDL emits the right SQL type
4663        // (VARCHAR for String, UUID for Uuid, …) and the load_related
4664        // emitter knows which `SqlValue` variant to match.
4665        let kind = match key_ty {
4666            Some(k) => detect_type(k)?.kind,
4667            None => DetectedKind::I64,
4668        };
4669        return Ok(DetectedType {
4670            kind,
4671            nullable: false,
4672            auto: false,
4673            fk_inner: Some(inner),
4674        });
4675    }
4676
4677    let kind = match last.ident.to_string().as_str() {
4678        "i16" => DetectedKind::I16,
4679        "i32" => DetectedKind::I32,
4680        "i64" => DetectedKind::I64,
4681        "f32" => DetectedKind::F32,
4682        "f64" => DetectedKind::F64,
4683        "bool" => DetectedKind::Bool,
4684        "String" => DetectedKind::String,
4685        "DateTime" => DetectedKind::DateTime,
4686        "NaiveDate" => DetectedKind::Date,
4687        "Uuid" => DetectedKind::Uuid,
4688        "Value" => DetectedKind::Json,
4689        other => {
4690            return Err(syn::Error::new_spanned(
4691                ty,
4692                format!("unsupported field type `{other}`; v0.1 supports i32/i64/f32/f64/bool/String/DateTime/NaiveDate/Uuid/serde_json::Value, optionally wrapped in Option or Auto (Auto only on integers)"),
4693            ));
4694        }
4695    };
4696    Ok(DetectedType {
4697        kind,
4698        nullable: false,
4699        auto: false,
4700        fk_inner: None,
4701    })
4702}
4703
4704fn generic_inner<'a>(
4705    ty: &'a Type,
4706    arguments: &'a PathArguments,
4707    wrapper: &str,
4708) -> syn::Result<&'a Type> {
4709    let PathArguments::AngleBracketed(args) = arguments else {
4710        return Err(syn::Error::new_spanned(
4711            ty,
4712            format!("{wrapper} requires a generic argument"),
4713        ));
4714    };
4715    args.args
4716        .iter()
4717        .find_map(|a| match a {
4718            GenericArgument::Type(t) => Some(t),
4719            _ => None,
4720        })
4721        .ok_or_else(|| {
4722            syn::Error::new_spanned(ty, format!("{wrapper}<T> requires a type argument"))
4723        })
4724}
4725
4726/// Like [`generic_inner`] but pulls *two* type args — the first is
4727/// required, the second is optional. Used by the `ForeignKey<T, K>`
4728/// detection where K defaults to `i64` when omitted.
4729fn generic_pair<'a>(
4730    ty: &'a Type,
4731    arguments: &'a PathArguments,
4732    wrapper: &str,
4733) -> syn::Result<(&'a Type, Option<&'a Type>)> {
4734    let PathArguments::AngleBracketed(args) = arguments else {
4735        return Err(syn::Error::new_spanned(
4736            ty,
4737            format!("{wrapper} requires a generic argument"),
4738        ));
4739    };
4740    let mut types = args.args.iter().filter_map(|a| match a {
4741        GenericArgument::Type(t) => Some(t),
4742        _ => None,
4743    });
4744    let first = types.next().ok_or_else(|| {
4745        syn::Error::new_spanned(ty, format!("{wrapper}<T> requires a type argument"))
4746    })?;
4747    let second = types.next();
4748    Ok((first, second))
4749}
4750
4751fn to_snake_case(s: &str) -> String {
4752    let mut out = String::with_capacity(s.len() + 4);
4753    for (i, ch) in s.chars().enumerate() {
4754        if ch.is_ascii_uppercase() {
4755            if i > 0 {
4756                out.push('_');
4757            }
4758            out.push(ch.to_ascii_lowercase());
4759        } else {
4760            out.push(ch);
4761        }
4762    }
4763    out
4764}
4765
4766// ============================================================
4767//  #[derive(Form)]  —  slice 8.4B
4768// ============================================================
4769
4770/// Per-field `#[form(...)]` attributes recognised by the derive.
4771#[derive(Default)]
4772struct FormFieldAttrs {
4773    min: Option<i64>,
4774    max: Option<i64>,
4775    min_length: Option<u32>,
4776    max_length: Option<u32>,
4777}
4778
4779/// Detected shape of a form field's Rust type.
4780#[derive(Clone, Copy)]
4781enum FormFieldKind {
4782    String,
4783    I16,
4784    I32,
4785    I64,
4786    F32,
4787    F64,
4788    Bool,
4789}
4790
4791impl FormFieldKind {
4792    fn parse_method(self) -> &'static str {
4793        match self {
4794            Self::I16 => "i16",
4795            Self::I32 => "i32",
4796            Self::I64 => "i64",
4797            Self::F32 => "f32",
4798            Self::F64 => "f64",
4799            // String + Bool don't go through `str::parse`; the codegen
4800            // handles them inline.
4801            Self::String | Self::Bool => "",
4802        }
4803    }
4804}
4805
4806fn expand_form(input: &DeriveInput) -> syn::Result<TokenStream2> {
4807    let struct_name = &input.ident;
4808
4809    let Data::Struct(data) = &input.data else {
4810        return Err(syn::Error::new_spanned(
4811            struct_name,
4812            "Form can only be derived on structs",
4813        ));
4814    };
4815    let Fields::Named(named) = &data.fields else {
4816        return Err(syn::Error::new_spanned(
4817            struct_name,
4818            "Form requires a struct with named fields",
4819        ));
4820    };
4821
4822    let mut field_blocks: Vec<TokenStream2> = Vec::with_capacity(named.named.len());
4823    let mut field_idents: Vec<&syn::Ident> = Vec::with_capacity(named.named.len());
4824
4825    for field in &named.named {
4826        let ident = field
4827            .ident
4828            .as_ref()
4829            .ok_or_else(|| syn::Error::new(field.span(), "tuple structs are not supported"))?;
4830        let attrs = parse_form_field_attrs(field)?;
4831        let (kind, nullable) = detect_form_field(&field.ty, field.span())?;
4832
4833        let name_lit = ident.to_string();
4834        let parse_block = render_form_field_parse(ident, &name_lit, kind, nullable, &attrs);
4835        field_blocks.push(parse_block);
4836        field_idents.push(ident);
4837    }
4838
4839    Ok(quote! {
4840        impl ::rustango::forms::Form for #struct_name {
4841            fn parse(
4842                data: &::std::collections::HashMap<::std::string::String, ::std::string::String>,
4843            ) -> ::core::result::Result<Self, ::rustango::forms::FormErrors> {
4844                let mut __errors = ::rustango::forms::FormErrors::default();
4845                #( #field_blocks )*
4846                if !__errors.is_empty() {
4847                    return ::core::result::Result::Err(__errors);
4848                }
4849                ::core::result::Result::Ok(Self {
4850                    #( #field_idents ),*
4851                })
4852            }
4853        }
4854    })
4855}
4856
4857fn parse_form_field_attrs(field: &syn::Field) -> syn::Result<FormFieldAttrs> {
4858    let mut out = FormFieldAttrs::default();
4859    for attr in &field.attrs {
4860        if !attr.path().is_ident("form") {
4861            continue;
4862        }
4863        attr.parse_nested_meta(|meta| {
4864            if meta.path.is_ident("min") {
4865                let lit: syn::LitInt = meta.value()?.parse()?;
4866                out.min = Some(lit.base10_parse::<i64>()?);
4867                return Ok(());
4868            }
4869            if meta.path.is_ident("max") {
4870                let lit: syn::LitInt = meta.value()?.parse()?;
4871                out.max = Some(lit.base10_parse::<i64>()?);
4872                return Ok(());
4873            }
4874            if meta.path.is_ident("min_length") {
4875                let lit: syn::LitInt = meta.value()?.parse()?;
4876                out.min_length = Some(lit.base10_parse::<u32>()?);
4877                return Ok(());
4878            }
4879            if meta.path.is_ident("max_length") {
4880                let lit: syn::LitInt = meta.value()?.parse()?;
4881                out.max_length = Some(lit.base10_parse::<u32>()?);
4882                return Ok(());
4883            }
4884            Err(meta.error(
4885                "unknown form attribute (supported: `min`, `max`, `min_length`, `max_length`)",
4886            ))
4887        })?;
4888    }
4889    Ok(out)
4890}
4891
4892fn detect_form_field(ty: &Type, span: proc_macro2::Span) -> syn::Result<(FormFieldKind, bool)> {
4893    let Type::Path(TypePath { path, qself: None }) = ty else {
4894        return Err(syn::Error::new(
4895            span,
4896            "Form field must be a simple typed path (e.g. `String`, `i32`, `Option<String>`)",
4897        ));
4898    };
4899    let last = path
4900        .segments
4901        .last()
4902        .ok_or_else(|| syn::Error::new(span, "empty type path"))?;
4903
4904    if last.ident == "Option" {
4905        let inner = generic_inner(ty, &last.arguments, "Option")?;
4906        let (kind, nested) = detect_form_field(inner, span)?;
4907        if nested {
4908            return Err(syn::Error::new(
4909                span,
4910                "nested Option in Form fields is not supported",
4911            ));
4912        }
4913        return Ok((kind, true));
4914    }
4915
4916    let kind = match last.ident.to_string().as_str() {
4917        "String" => FormFieldKind::String,
4918        "i16" => FormFieldKind::I16,
4919        "i32" => FormFieldKind::I32,
4920        "i64" => FormFieldKind::I64,
4921        "f32" => FormFieldKind::F32,
4922        "f64" => FormFieldKind::F64,
4923        "bool" => FormFieldKind::Bool,
4924        other => {
4925            return Err(syn::Error::new(
4926                span,
4927                format!(
4928                    "Form field type `{other}` is not supported in v0.8 — use String / \
4929                     i16 / i32 / i64 / f32 / f64 / bool, optionally wrapped in Option<…>"
4930                ),
4931            ));
4932        }
4933    };
4934    Ok((kind, false))
4935}
4936
4937#[allow(clippy::too_many_lines)]
4938fn render_form_field_parse(
4939    ident: &syn::Ident,
4940    name_lit: &str,
4941    kind: FormFieldKind,
4942    nullable: bool,
4943    attrs: &FormFieldAttrs,
4944) -> TokenStream2 {
4945    // Pull the raw &str from the payload. Uses variable name `data` to
4946    // match the new `Form::parse(data: &HashMap<…>)` signature.
4947    let lookup = quote! {
4948        let __raw: ::core::option::Option<&::std::string::String> = data.get(#name_lit);
4949    };
4950
4951    let parsed_value = match kind {
4952        FormFieldKind::Bool => quote! {
4953            let __v: bool = match __raw {
4954                ::core::option::Option::None => false,
4955                ::core::option::Option::Some(__s) => !matches!(
4956                    __s.to_ascii_lowercase().as_str(),
4957                    "" | "false" | "0" | "off" | "no"
4958                ),
4959            };
4960        },
4961        FormFieldKind::String => {
4962            if nullable {
4963                quote! {
4964                    let __v: ::core::option::Option<::std::string::String> = match __raw {
4965                        ::core::option::Option::None => ::core::option::Option::None,
4966                        ::core::option::Option::Some(__s) if __s.is_empty() => {
4967                            ::core::option::Option::None
4968                        }
4969                        ::core::option::Option::Some(__s) => {
4970                            ::core::option::Option::Some(::core::clone::Clone::clone(__s))
4971                        }
4972                    };
4973                }
4974            } else {
4975                quote! {
4976                    let __v: ::std::string::String = match __raw {
4977                        ::core::option::Option::Some(__s) if !__s.is_empty() => {
4978                            ::core::clone::Clone::clone(__s)
4979                        }
4980                        _ => {
4981                            __errors.add(#name_lit, "This field is required.");
4982                            ::std::string::String::new()
4983                        }
4984                    };
4985                }
4986            }
4987        }
4988        FormFieldKind::I16
4989        | FormFieldKind::I32
4990        | FormFieldKind::I64
4991        | FormFieldKind::F32
4992        | FormFieldKind::F64 => {
4993            let parse_ty = syn::Ident::new(kind.parse_method(), proc_macro2::Span::call_site());
4994            let ty_lit = kind.parse_method();
4995            let default_val = match kind {
4996                FormFieldKind::I16 => quote! { 0i16 },
4997                FormFieldKind::I32 => quote! { 0i32 },
4998                FormFieldKind::I64 => quote! { 0i64 },
4999                FormFieldKind::F32 => quote! { 0f32 },
5000                FormFieldKind::F64 => quote! { 0f64 },
5001                _ => quote! { Default::default() },
5002            };
5003            if nullable {
5004                quote! {
5005                    let __v: ::core::option::Option<#parse_ty> = match __raw {
5006                        ::core::option::Option::None => ::core::option::Option::None,
5007                        ::core::option::Option::Some(__s) if __s.is_empty() => {
5008                            ::core::option::Option::None
5009                        }
5010                        ::core::option::Option::Some(__s) => {
5011                            match __s.parse::<#parse_ty>() {
5012                                ::core::result::Result::Ok(__n) => {
5013                                    ::core::option::Option::Some(__n)
5014                                }
5015                                ::core::result::Result::Err(__e) => {
5016                                    __errors.add(
5017                                        #name_lit,
5018                                        ::std::format!("Enter a valid {} value: {}", #ty_lit, __e),
5019                                    );
5020                                    ::core::option::Option::None
5021                                }
5022                            }
5023                        }
5024                    };
5025                }
5026            } else {
5027                quote! {
5028                    let __v: #parse_ty = match __raw {
5029                        ::core::option::Option::Some(__s) if !__s.is_empty() => {
5030                            match __s.parse::<#parse_ty>() {
5031                                ::core::result::Result::Ok(__n) => __n,
5032                                ::core::result::Result::Err(__e) => {
5033                                    __errors.add(
5034                                        #name_lit,
5035                                        ::std::format!("Enter a valid {} value: {}", #ty_lit, __e),
5036                                    );
5037                                    #default_val
5038                                }
5039                            }
5040                        }
5041                        _ => {
5042                            __errors.add(#name_lit, "This field is required.");
5043                            #default_val
5044                        }
5045                    };
5046                }
5047            }
5048        }
5049    };
5050
5051    let validators = render_form_validators(name_lit, kind, nullable, attrs);
5052
5053    quote! {
5054        let #ident = {
5055            #lookup
5056            #parsed_value
5057            #validators
5058            __v
5059        };
5060    }
5061}
5062
5063fn render_form_validators(
5064    name_lit: &str,
5065    kind: FormFieldKind,
5066    nullable: bool,
5067    attrs: &FormFieldAttrs,
5068) -> TokenStream2 {
5069    let mut checks: Vec<TokenStream2> = Vec::new();
5070
5071    let val_ref = if nullable {
5072        quote! { __v.as_ref() }
5073    } else {
5074        quote! { ::core::option::Option::Some(&__v) }
5075    };
5076
5077    let is_string = matches!(kind, FormFieldKind::String);
5078    let is_numeric = matches!(
5079        kind,
5080        FormFieldKind::I16
5081            | FormFieldKind::I32
5082            | FormFieldKind::I64
5083            | FormFieldKind::F32
5084            | FormFieldKind::F64
5085    );
5086
5087    if is_string {
5088        if let Some(min_len) = attrs.min_length {
5089            let min_len_usize = min_len as usize;
5090            checks.push(quote! {
5091                if let ::core::option::Option::Some(__s) = #val_ref {
5092                    if __s.len() < #min_len_usize {
5093                        __errors.add(
5094                            #name_lit,
5095                            ::std::format!("Ensure this value has at least {} characters.", #min_len_usize),
5096                        );
5097                    }
5098                }
5099            });
5100        }
5101        if let Some(max_len) = attrs.max_length {
5102            let max_len_usize = max_len as usize;
5103            checks.push(quote! {
5104                if let ::core::option::Option::Some(__s) = #val_ref {
5105                    if __s.len() > #max_len_usize {
5106                        __errors.add(
5107                            #name_lit,
5108                            ::std::format!("Ensure this value has at most {} characters.", #max_len_usize),
5109                        );
5110                    }
5111                }
5112            });
5113        }
5114    }
5115
5116    if is_numeric {
5117        if let Some(min) = attrs.min {
5118            checks.push(quote! {
5119                if let ::core::option::Option::Some(__n) = #val_ref {
5120                    if (*__n as f64) < (#min as f64) {
5121                        __errors.add(
5122                            #name_lit,
5123                            ::std::format!("Ensure this value is greater than or equal to {}.", #min),
5124                        );
5125                    }
5126                }
5127            });
5128        }
5129        if let Some(max) = attrs.max {
5130            checks.push(quote! {
5131                if let ::core::option::Option::Some(__n) = #val_ref {
5132                    if (*__n as f64) > (#max as f64) {
5133                        __errors.add(
5134                            #name_lit,
5135                            ::std::format!("Ensure this value is less than or equal to {}.", #max),
5136                        );
5137                    }
5138                }
5139            });
5140        }
5141    }
5142
5143    quote! { #( #checks )* }
5144}
5145
5146// ============================================================
5147//  #[derive(ViewSet)]
5148// ============================================================
5149
5150struct ViewSetAttrs {
5151    model: syn::Path,
5152    fields: Option<Vec<String>>,
5153    filter_fields: Vec<String>,
5154    search_fields: Vec<String>,
5155    /// (field_name, desc)
5156    ordering: Vec<(String, bool)>,
5157    page_size: Option<usize>,
5158    read_only: bool,
5159    perms: ViewSetPermsAttrs,
5160}
5161
5162#[derive(Default)]
5163struct ViewSetPermsAttrs {
5164    list: Vec<String>,
5165    retrieve: Vec<String>,
5166    create: Vec<String>,
5167    update: Vec<String>,
5168    destroy: Vec<String>,
5169}
5170
5171fn expand_viewset(input: &DeriveInput) -> syn::Result<TokenStream2> {
5172    let struct_name = &input.ident;
5173
5174    // Must be a unit struct or an empty named struct.
5175    match &input.data {
5176        Data::Struct(s) => match &s.fields {
5177            Fields::Unit | Fields::Named(_) => {}
5178            Fields::Unnamed(_) => {
5179                return Err(syn::Error::new_spanned(
5180                    struct_name,
5181                    "ViewSet can only be derived on a unit struct or an empty named struct",
5182                ));
5183            }
5184        },
5185        _ => {
5186            return Err(syn::Error::new_spanned(
5187                struct_name,
5188                "ViewSet can only be derived on a struct",
5189            ));
5190        }
5191    }
5192
5193    let attrs = parse_viewset_attrs(input)?;
5194    let model_path = &attrs.model;
5195
5196    // `.fields(&[...])` call — None means skip (use all scalar fields).
5197    let fields_call = if let Some(ref fields) = attrs.fields {
5198        let lits = fields.iter().map(|f| f.as_str());
5199        quote!(.fields(&[ #(#lits),* ]))
5200    } else {
5201        quote!()
5202    };
5203
5204    let filter_fields_call = if attrs.filter_fields.is_empty() {
5205        quote!()
5206    } else {
5207        let lits = attrs.filter_fields.iter().map(|f| f.as_str());
5208        quote!(.filter_fields(&[ #(#lits),* ]))
5209    };
5210
5211    let search_fields_call = if attrs.search_fields.is_empty() {
5212        quote!()
5213    } else {
5214        let lits = attrs.search_fields.iter().map(|f| f.as_str());
5215        quote!(.search_fields(&[ #(#lits),* ]))
5216    };
5217
5218    let ordering_call = if attrs.ordering.is_empty() {
5219        quote!()
5220    } else {
5221        let pairs = attrs.ordering.iter().map(|(f, desc)| {
5222            let f = f.as_str();
5223            quote!((#f, #desc))
5224        });
5225        quote!(.ordering(&[ #(#pairs),* ]))
5226    };
5227
5228    let page_size_call = if let Some(n) = attrs.page_size {
5229        quote!(.page_size(#n))
5230    } else {
5231        quote!()
5232    };
5233
5234    let read_only_call = if attrs.read_only {
5235        quote!(.read_only())
5236    } else {
5237        quote!()
5238    };
5239
5240    let perms = &attrs.perms;
5241    let perms_call = if perms.list.is_empty()
5242        && perms.retrieve.is_empty()
5243        && perms.create.is_empty()
5244        && perms.update.is_empty()
5245        && perms.destroy.is_empty()
5246    {
5247        quote!()
5248    } else {
5249        let list_lits = perms.list.iter().map(|s| s.as_str());
5250        let retrieve_lits = perms.retrieve.iter().map(|s| s.as_str());
5251        let create_lits = perms.create.iter().map(|s| s.as_str());
5252        let update_lits = perms.update.iter().map(|s| s.as_str());
5253        let destroy_lits = perms.destroy.iter().map(|s| s.as_str());
5254        quote! {
5255            .permissions(::rustango::viewset::ViewSetPerms {
5256                list:     ::std::vec![ #(#list_lits.to_owned()),* ],
5257                retrieve: ::std::vec![ #(#retrieve_lits.to_owned()),* ],
5258                create:   ::std::vec![ #(#create_lits.to_owned()),* ],
5259                update:   ::std::vec![ #(#update_lits.to_owned()),* ],
5260                destroy:  ::std::vec![ #(#destroy_lits.to_owned()),* ],
5261            })
5262        }
5263    };
5264
5265    Ok(quote! {
5266        impl #struct_name {
5267            /// Build an `axum::Router` with the six standard REST endpoints
5268            /// for this ViewSet, mounted at `prefix`.
5269            pub fn router(prefix: &str, pool: ::rustango::sql::sqlx::PgPool) -> ::axum::Router {
5270                ::rustango::viewset::ViewSet::for_model(
5271                    <#model_path as ::rustango::core::Model>::SCHEMA
5272                )
5273                    #fields_call
5274                    #filter_fields_call
5275                    #search_fields_call
5276                    #ordering_call
5277                    #page_size_call
5278                    #perms_call
5279                    #read_only_call
5280                    .router(prefix, pool)
5281            }
5282        }
5283    })
5284}
5285
5286fn parse_viewset_attrs(input: &DeriveInput) -> syn::Result<ViewSetAttrs> {
5287    let mut model: Option<syn::Path> = None;
5288    let mut fields: Option<Vec<String>> = None;
5289    let mut filter_fields: Vec<String> = Vec::new();
5290    let mut search_fields: Vec<String> = Vec::new();
5291    let mut ordering: Vec<(String, bool)> = Vec::new();
5292    let mut page_size: Option<usize> = None;
5293    let mut read_only = false;
5294    let mut perms = ViewSetPermsAttrs::default();
5295
5296    for attr in &input.attrs {
5297        if !attr.path().is_ident("viewset") {
5298            continue;
5299        }
5300        attr.parse_nested_meta(|meta| {
5301            if meta.path.is_ident("model") {
5302                let path: syn::Path = meta.value()?.parse()?;
5303                model = Some(path);
5304                return Ok(());
5305            }
5306            if meta.path.is_ident("fields") {
5307                let s: LitStr = meta.value()?.parse()?;
5308                fields = Some(split_field_list(&s.value()));
5309                return Ok(());
5310            }
5311            if meta.path.is_ident("filter_fields") {
5312                let s: LitStr = meta.value()?.parse()?;
5313                filter_fields = split_field_list(&s.value());
5314                return Ok(());
5315            }
5316            if meta.path.is_ident("search_fields") {
5317                let s: LitStr = meta.value()?.parse()?;
5318                search_fields = split_field_list(&s.value());
5319                return Ok(());
5320            }
5321            if meta.path.is_ident("ordering") {
5322                let s: LitStr = meta.value()?.parse()?;
5323                ordering = parse_ordering_list(&s.value());
5324                return Ok(());
5325            }
5326            if meta.path.is_ident("page_size") {
5327                let lit: syn::LitInt = meta.value()?.parse()?;
5328                page_size = Some(lit.base10_parse::<usize>()?);
5329                return Ok(());
5330            }
5331            if meta.path.is_ident("read_only") {
5332                read_only = true;
5333                return Ok(());
5334            }
5335            if meta.path.is_ident("permissions") {
5336                meta.parse_nested_meta(|inner| {
5337                    let parse_codenames = |inner: &syn::meta::ParseNestedMeta| -> syn::Result<Vec<String>> {
5338                        let s: LitStr = inner.value()?.parse()?;
5339                        Ok(split_field_list(&s.value()))
5340                    };
5341                    if inner.path.is_ident("list") {
5342                        perms.list = parse_codenames(&inner)?;
5343                    } else if inner.path.is_ident("retrieve") {
5344                        perms.retrieve = parse_codenames(&inner)?;
5345                    } else if inner.path.is_ident("create") {
5346                        perms.create = parse_codenames(&inner)?;
5347                    } else if inner.path.is_ident("update") {
5348                        perms.update = parse_codenames(&inner)?;
5349                    } else if inner.path.is_ident("destroy") {
5350                        perms.destroy = parse_codenames(&inner)?;
5351                    } else {
5352                        return Err(inner.error(
5353                            "unknown permissions key (supported: list, retrieve, create, update, destroy)",
5354                        ));
5355                    }
5356                    Ok(())
5357                })?;
5358                return Ok(());
5359            }
5360            Err(meta.error(
5361                "unknown viewset attribute (supported: model, fields, filter_fields, \
5362                 search_fields, ordering, page_size, read_only, permissions(...))",
5363            ))
5364        })?;
5365    }
5366
5367    let model = model.ok_or_else(|| {
5368        syn::Error::new_spanned(&input.ident, "`#[viewset(model = SomeModel)]` is required")
5369    })?;
5370
5371    Ok(ViewSetAttrs {
5372        model,
5373        fields,
5374        filter_fields,
5375        search_fields,
5376        ordering,
5377        page_size,
5378        read_only,
5379        perms,
5380    })
5381}
5382
5383// ============================================================ #[derive(Serializer)]
5384
5385struct SerializerContainerAttrs {
5386    model: syn::Path,
5387}
5388
5389#[derive(Default)]
5390struct SerializerFieldAttrs {
5391    read_only: bool,
5392    write_only: bool,
5393    source: Option<String>,
5394    skip: bool,
5395    /// `#[serializer(method = "fn_name")]` — DRF SerializerMethodField
5396    /// analog. The macro emits `from_model` initializer that calls
5397    /// `Self::fn_name(&model)` and stores the return value.
5398    method: Option<String>,
5399    /// `#[serializer(validate = "fn_name")]` — per-field validator
5400    /// callable run by `Self::validate(&self)`. Must return
5401    /// `Result<(), String>`. Errors land in `FormErrors` keyed by
5402    /// the field name.
5403    validate: Option<String>,
5404    /// `#[serializer(nested)]` on a field whose type is another
5405    /// `Serializer` — the macro emits `from_model` initializer that
5406    /// reads the parent via `model.<source>.value()` then calls the
5407    /// child serializer's `from_model(parent)`. When the FK is
5408    /// unloaded the field falls back to `Default::default()` (does
5409    /// NOT panic) so a missing prefetch in prod degrades gracefully.
5410    /// Source field on the model defaults to the field name; override
5411    /// with `source = "..."`. Combine with `strict` to keep the v0.18.1
5412    /// panic-on-unloaded behavior for tests.
5413    nested: bool,
5414    /// `#[serializer(nested, strict)]` — opt back into the v0.18.1
5415    /// strict behavior: panic when the FK isn't loaded. Useful in
5416    /// test code where forgetting select_related must trip a hard
5417    /// failure rather than render a blank nested object.
5418    nested_strict: bool,
5419    /// `#[serializer(many = TagSerializer)]` — declare the field as
5420    /// a list of nested serializers. Field type must be `Vec<S>`
5421    /// where `S` is the inner serializer. The macro initializes the
5422    /// field to `Vec::new()` in `from_model` and emits a typed
5423    /// `set_<field>(&mut self, models: &[<S::Model>])` helper that
5424    /// maps each model row through `S::from_model`. Auto-load isn't
5425    /// possible (the M2M / one-to-many accessor is async); callers
5426    /// fetch the children + call the setter post-from_model.
5427    many: Option<syn::Type>,
5428}
5429
5430fn parse_serializer_container_attrs(input: &DeriveInput) -> syn::Result<SerializerContainerAttrs> {
5431    let mut model: Option<syn::Path> = None;
5432    for attr in &input.attrs {
5433        if !attr.path().is_ident("serializer") {
5434            continue;
5435        }
5436        attr.parse_nested_meta(|meta| {
5437            if meta.path.is_ident("model") {
5438                let _eq: syn::Token![=] = meta.input.parse()?;
5439                model = Some(meta.input.parse()?);
5440                return Ok(());
5441            }
5442            Err(meta.error("unknown serializer container attribute (supported: `model`)"))
5443        })?;
5444    }
5445    let model = model.ok_or_else(|| {
5446        syn::Error::new_spanned(
5447            &input.ident,
5448            "`#[serializer(model = SomeModel)]` is required",
5449        )
5450    })?;
5451    Ok(SerializerContainerAttrs { model })
5452}
5453
5454fn parse_serializer_field_attrs(field: &syn::Field) -> syn::Result<SerializerFieldAttrs> {
5455    let mut out = SerializerFieldAttrs::default();
5456    for attr in &field.attrs {
5457        if !attr.path().is_ident("serializer") {
5458            continue;
5459        }
5460        attr.parse_nested_meta(|meta| {
5461            if meta.path.is_ident("read_only") {
5462                out.read_only = true;
5463                return Ok(());
5464            }
5465            if meta.path.is_ident("write_only") {
5466                out.write_only = true;
5467                return Ok(());
5468            }
5469            if meta.path.is_ident("skip") {
5470                out.skip = true;
5471                return Ok(());
5472            }
5473            if meta.path.is_ident("source") {
5474                let s: LitStr = meta.value()?.parse()?;
5475                out.source = Some(s.value());
5476                return Ok(());
5477            }
5478            if meta.path.is_ident("method") {
5479                let s: LitStr = meta.value()?.parse()?;
5480                out.method = Some(s.value());
5481                return Ok(());
5482            }
5483            if meta.path.is_ident("validate") {
5484                let s: LitStr = meta.value()?.parse()?;
5485                out.validate = Some(s.value());
5486                return Ok(());
5487            }
5488            if meta.path.is_ident("many") {
5489                let _eq: syn::Token![=] = meta.input.parse()?;
5490                out.many = Some(meta.input.parse()?);
5491                return Ok(());
5492            }
5493            if meta.path.is_ident("nested") {
5494                out.nested = true;
5495                // Optional strict flag inside parentheses:
5496                //   #[serializer(nested(strict))]
5497                if meta.input.peek(syn::token::Paren) {
5498                    meta.parse_nested_meta(|inner| {
5499                        if inner.path.is_ident("strict") {
5500                            out.nested_strict = true;
5501                            return Ok(());
5502                        }
5503                        Err(inner.error("unknown nested sub-attribute (supported: `strict`)"))
5504                    })?;
5505                }
5506                return Ok(());
5507            }
5508            Err(meta.error(
5509                "unknown serializer field attribute (supported: \
5510                 `read_only`, `write_only`, `source`, `skip`, `method`, `validate`, `nested`)",
5511            ))
5512        })?;
5513    }
5514    // Validate: read_only + write_only is nonsensical
5515    if out.read_only && out.write_only {
5516        return Err(syn::Error::new_spanned(
5517            field,
5518            "a field cannot be both `read_only` and `write_only`",
5519        ));
5520    }
5521    if out.method.is_some() && out.source.is_some() {
5522        return Err(syn::Error::new_spanned(
5523            field,
5524            "`method` and `source` are mutually exclusive — `method` computes \
5525             the value from a method, `source` reads it from a different model field",
5526        ));
5527    }
5528    Ok(out)
5529}
5530
5531fn expand_serializer(input: &DeriveInput) -> syn::Result<TokenStream2> {
5532    let struct_name = &input.ident;
5533    let struct_name_lit = struct_name.to_string();
5534
5535    let Data::Struct(data) = &input.data else {
5536        return Err(syn::Error::new_spanned(
5537            struct_name,
5538            "Serializer can only be derived on structs",
5539        ));
5540    };
5541    let Fields::Named(named) = &data.fields else {
5542        return Err(syn::Error::new_spanned(
5543            struct_name,
5544            "Serializer requires a struct with named fields",
5545        ));
5546    };
5547
5548    let container = parse_serializer_container_attrs(input)?;
5549    let model_path = &container.model;
5550
5551    // Classify each field. `ty` is only consumed by the
5552    // `#[cfg(feature = "openapi")]` block below, but we always
5553    // capture it to keep the field-info build a single pass.
5554    #[allow(dead_code)]
5555    struct FieldInfo {
5556        ident: syn::Ident,
5557        ty: syn::Type,
5558        attrs: SerializerFieldAttrs,
5559    }
5560    let mut fields_info: Vec<FieldInfo> = Vec::new();
5561    for field in &named.named {
5562        let ident = field.ident.clone().expect("named field has ident");
5563        let attrs = parse_serializer_field_attrs(field)?;
5564        fields_info.push(FieldInfo {
5565            ident,
5566            ty: field.ty.clone(),
5567            attrs,
5568        });
5569    }
5570
5571    // Generate from_model body: struct literal with each field assigned.
5572    let from_model_fields = fields_info.iter().map(|fi| {
5573        let ident = &fi.ident;
5574        let ty = &fi.ty;
5575        if let Some(_inner) = &fi.attrs.many {
5576            // Many — collection field. Initialize empty; caller
5577            // populates via the macro-emitted set_<field> helper
5578            // after fetching the M2M children.
5579            quote! { #ident: ::std::vec::Vec::new() }
5580        } else if let Some(method) = &fi.attrs.method {
5581            // SerializerMethodField: call Self::<method>(&model) to
5582            // compute the value. Method signature must be
5583            // `fn <method>(model: &T) -> <field type>`.
5584            let method_ident = syn::Ident::new(method, ident.span());
5585            quote! { #ident: Self::#method_ident(model) }
5586        } else if fi.attrs.nested {
5587            // Nested serializer. Source defaults to the field name on
5588            // this struct; override via `source = "..."`. The source
5589            // field on the model is expected to be a `ForeignKey<T>`
5590            // whose `.value()` returns `Option<&T>` after lazy-load.
5591            //
5592            // Behavior matrix (tweakable per-field):
5593            //   * FK loaded   → nested object materializes via
5594            //                   ChildSerializer::from_model(parent).
5595            //   * FK unloaded → fall back to ChildSerializer::default()
5596            //                   (so prod doesn't crash on a missing
5597            //                   prefetch — just renders a blank nested
5598            //                   object). Add `#[serializer(nested,
5599            //                   strict)]` to keep the v0.18.1
5600            //                   panic-on-unloaded behavior for tests
5601            //                   that want hard guardrails.
5602            let src_name = fi.attrs.source.as_deref().unwrap_or(&fi.ident.to_string()).to_owned();
5603            let src_ident = syn::Ident::new(&src_name, ident.span());
5604            if fi.attrs.nested_strict {
5605                let panic_msg = format!(
5606                    "nested(strict) serializer for `{ident}` requires `model.{src_name}` to be loaded — \
5607                     call .get(&pool).await? or .select_related(\"{src_name}\") on the model first",
5608                );
5609                quote! {
5610                    #ident: <#ty as ::rustango::serializer::ModelSerializer>::from_model(
5611                        model.#src_ident.value().expect(#panic_msg),
5612                    )
5613                }
5614            } else {
5615                quote! {
5616                    #ident: match model.#src_ident.value() {
5617                        ::core::option::Option::Some(__loaded) =>
5618                            <#ty as ::rustango::serializer::ModelSerializer>::from_model(__loaded),
5619                        ::core::option::Option::None =>
5620                            ::core::default::Default::default(),
5621                    }
5622                }
5623            }
5624        } else if fi.attrs.write_only || fi.attrs.skip {
5625            // Not read from model — use default
5626            quote! { #ident: ::core::default::Default::default() }
5627        } else if let Some(src) = &fi.attrs.source {
5628            let src_ident = syn::Ident::new(src, ident.span());
5629            quote! { #ident: ::core::clone::Clone::clone(&model.#src_ident) }
5630        } else {
5631            quote! { #ident: ::core::clone::Clone::clone(&model.#ident) }
5632        }
5633    });
5634
5635    // Per-field validators (DRF-shape `validators=[...]`). Emit a
5636    // `validate(&self)` method that runs each user-defined validator
5637    // and aggregates errors into `FormErrors`.
5638    let validator_calls: Vec<_> = fields_info
5639        .iter()
5640        .filter_map(|fi| {
5641            let ident = &fi.ident;
5642            let name_lit = ident.to_string();
5643            let method = fi.attrs.validate.as_ref()?;
5644            let method_ident = syn::Ident::new(method, ident.span());
5645            Some(quote! {
5646                if let ::core::result::Result::Err(__e) = Self::#method_ident(&self.#ident) {
5647                    __errors.add(#name_lit.to_owned(), __e);
5648                }
5649            })
5650        })
5651        .collect();
5652    let validate_method = if validator_calls.is_empty() {
5653        quote! {}
5654    } else {
5655        quote! {
5656            impl #struct_name {
5657                /// Run every `#[serializer(validate = "...")]` per-field
5658                /// validator. Aggregates errors into `FormErrors` keyed
5659                /// by the field name. Returns `Ok(())` when all pass.
5660                pub fn validate(&self) -> ::core::result::Result<(), ::rustango::forms::FormErrors> {
5661                    let mut __errors = ::rustango::forms::FormErrors::default();
5662                    #( #validator_calls )*
5663                    if __errors.is_empty() {
5664                        ::core::result::Result::Ok(())
5665                    } else {
5666                        ::core::result::Result::Err(__errors)
5667                    }
5668                }
5669            }
5670        }
5671    };
5672
5673    // For every `#[serializer(many = S)]` field, emit a
5674    // `pub fn set_<field>(&mut self, models: &[<S::Model>]) -> &mut Self`
5675    // helper that maps the parents through `S::from_model`.
5676    let many_setters: Vec<_> = fields_info
5677        .iter()
5678        .filter_map(|fi| {
5679            let many_ty = fi.attrs.many.as_ref()?;
5680            let ident = &fi.ident;
5681            let setter = syn::Ident::new(&format!("set_{ident}"), ident.span());
5682            Some(quote! {
5683                /// Populate this `many` field by mapping each parent model
5684                /// through the inner serializer's `from_model`. Call after
5685                /// fetching the M2M / one-to-many children since
5686                /// `from_model` itself can't await an SQL query.
5687                pub fn #setter(
5688                    &mut self,
5689                    models: &[<#many_ty as ::rustango::serializer::ModelSerializer>::Model],
5690                ) -> &mut Self {
5691                    self.#ident = models.iter()
5692                        .map(<#many_ty as ::rustango::serializer::ModelSerializer>::from_model)
5693                        .collect();
5694                    self
5695                }
5696            })
5697        })
5698        .collect();
5699    let many_setters_impl = if many_setters.is_empty() {
5700        quote! {}
5701    } else {
5702        quote! {
5703            impl #struct_name {
5704                #( #many_setters )*
5705            }
5706        }
5707    };
5708
5709    // Generate custom Serialize: skip write_only fields
5710    let output_fields: Vec<_> = fields_info
5711        .iter()
5712        .filter(|fi| !fi.attrs.write_only)
5713        .collect();
5714    let output_field_count = output_fields.len();
5715    let serialize_fields = output_fields.iter().map(|fi| {
5716        let ident = &fi.ident;
5717        let name_lit = ident.to_string();
5718        quote! { __state.serialize_field(#name_lit, &self.#ident)?; }
5719    });
5720
5721    // writable_fields: normal + write_only (not read_only, not skip)
5722    let writable_lits: Vec<_> = fields_info
5723        .iter()
5724        .filter(|fi| !fi.attrs.read_only && !fi.attrs.skip)
5725        .map(|fi| fi.ident.to_string())
5726        .collect();
5727
5728    // OpenAPI: emit `impl OpenApiSchema` when our `openapi` feature is on.
5729    // Only includes fields shown in JSON output (skips write_only). For each
5730    // `Option<T>` field, omit from `required` and add `.nullable()`.
5731    let openapi_impl = {
5732        #[cfg(feature = "openapi")]
5733        {
5734            let property_calls = output_fields.iter().map(|fi| {
5735                let ident = &fi.ident;
5736                let name_lit = ident.to_string();
5737                let ty = &fi.ty;
5738                let nullable_call = if is_option(ty) {
5739                    quote! { .nullable() }
5740                } else {
5741                    quote! {}
5742                };
5743                quote! {
5744                    .property(
5745                        #name_lit,
5746                        <#ty as ::rustango::openapi::OpenApiSchema>::openapi_schema()
5747                            #nullable_call,
5748                    )
5749                }
5750            });
5751            let required_lits: Vec<_> = output_fields
5752                .iter()
5753                .filter(|fi| !is_option(&fi.ty))
5754                .map(|fi| fi.ident.to_string())
5755                .collect();
5756            quote! {
5757                impl ::rustango::openapi::OpenApiSchema for #struct_name {
5758                    fn openapi_schema() -> ::rustango::openapi::Schema {
5759                        ::rustango::openapi::Schema::object()
5760                            #( #property_calls )*
5761                            .required([ #( #required_lits ),* ])
5762                    }
5763                }
5764            }
5765        }
5766        #[cfg(not(feature = "openapi"))]
5767        {
5768            quote! {}
5769        }
5770    };
5771
5772    Ok(quote! {
5773        impl ::rustango::serializer::ModelSerializer for #struct_name {
5774            type Model = #model_path;
5775
5776            fn from_model(model: &Self::Model) -> Self {
5777                Self {
5778                    #( #from_model_fields ),*
5779                }
5780            }
5781
5782            fn writable_fields() -> &'static [&'static str] {
5783                &[ #( #writable_lits ),* ]
5784            }
5785        }
5786
5787        impl ::serde::Serialize for #struct_name {
5788            fn serialize<S>(&self, serializer: S)
5789                -> ::core::result::Result<S::Ok, S::Error>
5790            where
5791                S: ::serde::Serializer,
5792            {
5793                use ::serde::ser::SerializeStruct;
5794                let mut __state = serializer.serialize_struct(
5795                    #struct_name_lit,
5796                    #output_field_count,
5797                )?;
5798                #( #serialize_fields )*
5799                __state.end()
5800            }
5801        }
5802
5803        #openapi_impl
5804
5805        #validate_method
5806
5807        #many_setters_impl
5808    })
5809}
5810
5811/// Returns true if `ty` looks like `Option<T>` (any path ending in `Option`).
5812/// Only used by the `openapi`-gated emission of `OpenApiSchema`; muted
5813/// when the feature is off.
5814#[cfg_attr(not(feature = "openapi"), allow(dead_code))]
5815fn is_option(ty: &syn::Type) -> bool {
5816    if let syn::Type::Path(p) = ty {
5817        if let Some(last) = p.path.segments.last() {
5818            return last.ident == "Option";
5819        }
5820    }
5821    false
5822}