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