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 `rustango::forms::FormStruct` (slice 8.4B). Generates a
27/// `parse(&HashMap<String, String>) -> Result<Self, FormError>` impl
28/// that walks every named field and:
29///
30/// * Parses the string value into the field's Rust type (`String`,
31///   `i32`, `i64`, `f32`, `f64`, `bool`, plus `Option<T>` for the
32///   nullable case).
33/// * Applies any `#[form(min = ..)]` / `#[form(max = ..)]` /
34///   `#[form(min_length = ..)]` / `#[form(max_length = ..)]`
35///   validators in declaration order, returning `FormError::Parse`
36///   on the first failure.
37///
38/// Example:
39///
40/// ```ignore
41/// #[derive(Form)]
42/// pub struct CreateItemForm {
43///     #[form(min_length = 1, max_length = 64)]
44///     pub name: String,
45///     #[form(min = 0, max = 150)]
46///     pub age: i32,
47///     pub active: bool,
48///     pub email: Option<String>,
49/// }
50///
51/// let parsed = CreateItemForm::parse(&form_map)?;
52/// ```
53#[proc_macro_derive(Form, attributes(form))]
54pub fn derive_form(input: TokenStream) -> TokenStream {
55    let input = parse_macro_input!(input as DeriveInput);
56    expand_form(&input)
57        .unwrap_or_else(syn::Error::into_compile_error)
58        .into()
59}
60
61/// Bake every `*.json` migration file in a directory into the binary
62/// at compile time. Returns a `&'static [(&'static str, &'static str)]`
63/// of `(name, json_content)` pairs, lex-sorted by file stem.
64///
65/// Pair with `rustango::migrate::migrate_embedded` at runtime — same
66/// behaviour as `migrate(pool, dir)` but with no filesystem access.
67/// The path is interpreted relative to the user's `CARGO_MANIFEST_DIR`
68/// (i.e. the crate that invokes the macro). Default is
69/// `"./migrations"` if no argument is supplied.
70///
71/// ```ignore
72/// const EMBEDDED: &[(&str, &str)] = rustango::embed_migrations!();
73/// // or:
74/// const EMBEDDED: &[(&str, &str)] = rustango::embed_migrations!("./migrations");
75///
76/// rustango::migrate::migrate_embedded(&pool, EMBEDDED).await?;
77/// ```
78///
79/// **Compile-time guarantees** (rustango v0.4+, slice 5): every JSON
80/// file's `name` field must equal its file stem, every `prev`
81/// reference must point to another migration in the same directory,
82/// and the JSON must parse. A broken chain — orphan `prev`, missing
83/// predecessor, malformed file — fails at macro-expansion time with
84/// a clear `compile_error!`. *No other Django-shape Rust framework
85/// validates migration chains at compile time*: Cot's migrations are
86/// imperative Rust code (no static chain), Loco's are SeaORM
87/// up/down (same), Rwf's are raw SQL (no chain at all).
88///
89/// Each migration is included via `include_str!` so cargo's rebuild
90/// detection picks up file *content* changes. **Caveat:** cargo
91/// doesn't watch directory listings, so adding or removing a
92/// migration file inside the dir won't auto-trigger a rebuild — run
93/// `cargo clean` (or just bump any other source file) when you add
94/// new migrations during embedded development.
95#[proc_macro]
96pub fn embed_migrations(input: TokenStream) -> TokenStream {
97    expand_embed_migrations(input.into())
98        .unwrap_or_else(syn::Error::into_compile_error)
99        .into()
100}
101
102fn expand_embed_migrations(input: TokenStream2) -> syn::Result<TokenStream2> {
103    // Default to "./migrations" if invoked without args.
104    let path_str = if input.is_empty() {
105        "./migrations".to_string()
106    } else {
107        let lit: LitStr = syn::parse2(input)?;
108        lit.value()
109    };
110
111    let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
112        syn::Error::new(
113            proc_macro2::Span::call_site(),
114            "embed_migrations! must be invoked during a Cargo build (CARGO_MANIFEST_DIR not set)",
115        )
116    })?;
117    let abs = std::path::Path::new(&manifest).join(&path_str);
118
119    let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
120    if abs.is_dir() {
121        let read = std::fs::read_dir(&abs).map_err(|e| {
122            syn::Error::new(
123                proc_macro2::Span::call_site(),
124                format!("embed_migrations!: cannot read {}: {e}", abs.display()),
125            )
126        })?;
127        for entry in read.flatten() {
128            let path = entry.path();
129            if !path.is_file() {
130                continue;
131            }
132            if path.extension().and_then(|s| s.to_str()) != Some("json") {
133                continue;
134            }
135            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
136                continue;
137            };
138            entries.push((stem.to_owned(), path));
139        }
140    }
141    entries.sort_by(|a, b| a.0.cmp(&b.0));
142
143    // Compile-time chain validation: read each migration's JSON,
144    // pull `name` and `prev` (file-stem-keyed for the chain check),
145    // and verify every `prev` points to another migration in the
146    // slice. Mismatches between the file stem and the embedded
147    // `name` field — and broken `prev` chains — fail at MACRO
148    // EXPANSION time so a misshapen migration set never compiles.
149    //
150    // This is the v0.4 Slice 5 distinguisher: rustango's JSON
151    // migrations + a Rust proc-macro that reads them is the unique
152    // combo nothing else in the Django-shape Rust camp can match
153    // (Cot's are imperative Rust code, Loco's are SeaORM up/down,
154    // Rwf's are raw SQL — none have a static chain to validate).
155    let mut chain_names: Vec<String> = Vec::with_capacity(entries.len());
156    let mut prev_refs: Vec<(String, Option<String>)> = Vec::with_capacity(entries.len());
157    for (stem, path) in &entries {
158        let raw = std::fs::read_to_string(path).map_err(|e| {
159            syn::Error::new(
160                proc_macro2::Span::call_site(),
161                format!(
162                    "embed_migrations!: cannot read {} for chain validation: {e}",
163                    path.display()
164                ),
165            )
166        })?;
167        let json: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
168            syn::Error::new(
169                proc_macro2::Span::call_site(),
170                format!(
171                    "embed_migrations!: {} is not valid JSON: {e}",
172                    path.display()
173                ),
174            )
175        })?;
176        let name = json
177            .get("name")
178            .and_then(|v| v.as_str())
179            .ok_or_else(|| {
180                syn::Error::new(
181                    proc_macro2::Span::call_site(),
182                    format!(
183                        "embed_migrations!: {} is missing the `name` field",
184                        path.display()
185                    ),
186                )
187            })?
188            .to_owned();
189        if name != *stem {
190            return Err(syn::Error::new(
191                proc_macro2::Span::call_site(),
192                format!(
193                    "embed_migrations!: file stem `{stem}` does not match the migration's \
194                     `name` field `{name}` — rename the file or fix the JSON",
195                ),
196            ));
197        }
198        let prev = json
199            .get("prev")
200            .and_then(|v| v.as_str())
201            .map(str::to_owned);
202        chain_names.push(name.clone());
203        prev_refs.push((name, prev));
204    }
205
206    let name_set: std::collections::HashSet<&str> =
207        chain_names.iter().map(String::as_str).collect();
208    for (name, prev) in &prev_refs {
209        if let Some(p) = prev {
210            if !name_set.contains(p.as_str()) {
211                return Err(syn::Error::new(
212                    proc_macro2::Span::call_site(),
213                    format!(
214                        "embed_migrations!: broken migration chain — `{name}` declares \
215                         prev=`{p}` but no migration with that name exists in {}",
216                        abs.display()
217                    ),
218                ));
219            }
220        }
221    }
222
223    let pairs: Vec<TokenStream2> = entries
224        .iter()
225        .map(|(name, path)| {
226            let path_lit = path.display().to_string();
227            quote! { (#name, ::core::include_str!(#path_lit)) }
228        })
229        .collect();
230
231    Ok(quote! {
232        {
233            const __RUSTANGO_EMBEDDED: &[(&'static str, &'static str)] = &[#(#pairs),*];
234            __RUSTANGO_EMBEDDED
235        }
236    })
237}
238
239fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
240    let struct_name = &input.ident;
241
242    let Data::Struct(data) = &input.data else {
243        return Err(syn::Error::new_spanned(
244            struct_name,
245            "Model can only be derived on structs",
246        ));
247    };
248    let Fields::Named(named) = &data.fields else {
249        return Err(syn::Error::new_spanned(
250            struct_name,
251            "Model requires a struct with named fields",
252        ));
253    };
254
255    let container = parse_container_attrs(input)?;
256    let table = container
257        .table
258        .unwrap_or_else(|| to_snake_case(&struct_name.to_string()));
259    let model_name = struct_name.to_string();
260
261    let collected = collect_fields(named)?;
262
263    // Validate that #[rustango(display = "…")] names a real field.
264    if let Some((ref display, span)) = container.display {
265        if !collected.field_names.iter().any(|n| n == display) {
266            return Err(syn::Error::new(
267                span,
268                format!("`display = \"{display}\"` does not match any field on this struct"),
269            ));
270        }
271    }
272    let display = container.display.map(|(name, _)| name);
273
274    let model_impl = model_impl_tokens(
275        struct_name,
276        &model_name,
277        &table,
278        display.as_deref(),
279        &collected.field_schemas,
280    );
281    let module_ident = column_module_ident(struct_name);
282    let column_consts = column_const_tokens(&module_ident, &collected.column_entries);
283    let inherent_impl = inherent_impl_tokens(
284        struct_name,
285        &collected,
286        collected.primary_key.as_ref(),
287        &column_consts,
288    );
289    let column_module = column_module_tokens(&module_ident, struct_name, &collected.column_entries);
290    let from_row_impl = from_row_impl_tokens(struct_name, &collected.from_row_inits);
291
292    Ok(quote! {
293        #model_impl
294        #inherent_impl
295        #from_row_impl
296        #column_module
297
298        ::rustango::core::inventory::submit! {
299            ::rustango::core::ModelEntry {
300                schema: <#struct_name as ::rustango::core::Model>::SCHEMA,
301            }
302        }
303    })
304}
305
306struct ColumnEntry {
307    /// The struct field ident, used both for the inherent const name on
308    /// the model and for the inner column type's name.
309    ident: syn::Ident,
310    /// The struct's field type, used as `Column::Value`.
311    value_ty: Type,
312    /// Rust-side field name (e.g. `"id"`).
313    name: String,
314    /// SQL-side column name (e.g. `"user_id"`).
315    column: String,
316    /// `::rustango::core::FieldType::I64` etc.
317    field_type_tokens: TokenStream2,
318}
319
320struct CollectedFields {
321    field_schemas: Vec<TokenStream2>,
322    from_row_inits: Vec<TokenStream2>,
323    /// Static column-name list — used by the simple insert path
324    /// (no `Auto<T>` fields). Aligned with `insert_values`.
325    insert_columns: Vec<TokenStream2>,
326    /// Static `Into<SqlValue>` expressions, one per field. Aligned
327    /// with `insert_columns`. Used by the simple insert path only.
328    insert_values: Vec<TokenStream2>,
329    /// Per-field push expressions for the dynamic (Auto-aware)
330    /// insert path. Each statement either unconditionally pushes
331    /// `(column, value)` or, for an `Auto<T>` field, conditionally
332    /// pushes only when `Auto::Set(_)`. Built only when `has_auto`.
333    insert_pushes: Vec<TokenStream2>,
334    /// SQL columns for `RETURNING` — one per `Auto<T>` field. Empty
335    /// when `has_auto == false`.
336    returning_cols: Vec<TokenStream2>,
337    /// `self.<field> = Row::try_get(&row, "<col>")?;` for each Auto
338    /// field. Run after `insert_returning` to populate the model.
339    auto_assigns: Vec<TokenStream2>,
340    /// `(ident, column_literal)` pairs for every Auto field. Used by
341    /// the bulk_insert codegen to rebuild assigns against `_row_mut`
342    /// instead of `self`.
343    auto_field_idents: Vec<(syn::Ident, String)>,
344    /// Bulk-insert per-row pushes for **non-Auto fields only**. Used
345    /// by the all-Auto-Unset bulk path (Auto cols dropped from
346    /// `columns`).
347    bulk_pushes_no_auto: Vec<TokenStream2>,
348    /// Bulk-insert per-row pushes for **all fields including Auto**.
349    /// Used by the all-Auto-Set bulk path (Auto col included with the
350    /// caller-supplied value).
351    bulk_pushes_all: Vec<TokenStream2>,
352    /// Column-name literals for non-Auto fields only (paired with
353    /// `bulk_pushes_no_auto`).
354    bulk_columns_no_auto: Vec<TokenStream2>,
355    /// Column-name literals for every field including Auto (paired
356    /// with `bulk_pushes_all`).
357    bulk_columns_all: Vec<TokenStream2>,
358    /// `let _i_unset_<n> = matches!(rows[0].<auto_field>, Auto::Unset);`
359    /// + the loop that asserts every row matches. One pair per Auto
360    /// field. Empty when `has_auto == false`.
361    bulk_auto_uniformity: Vec<TokenStream2>,
362    /// Identifier of the first Auto field, used as the witness for
363    /// "all rows agree on Set vs Unset". Set only when `has_auto`.
364    first_auto_ident: Option<syn::Ident>,
365    /// `true` if any field on the struct is `Auto<T>`.
366    has_auto: bool,
367    /// `true` when the primary-key field's Rust type is `Auto<T>`.
368    /// Gates `save()` codegen — only Auto PKs let us infer
369    /// insert-vs-update from the in-memory value.
370    pk_is_auto: bool,
371    /// `Assignment` constructors for every non-PK column. Drives the
372    /// UPDATE branch of `save()`.
373    update_assignments: Vec<TokenStream2>,
374    primary_key: Option<(syn::Ident, String)>,
375    column_entries: Vec<ColumnEntry>,
376    /// Rust-side field names, in declaration order. Used to validate
377    /// container attributes like `display = "…"`.
378    field_names: Vec<String>,
379}
380
381fn collect_fields(named: &syn::FieldsNamed) -> syn::Result<CollectedFields> {
382    let cap = named.named.len();
383    let mut out = CollectedFields {
384        field_schemas: Vec::with_capacity(cap),
385        from_row_inits: Vec::with_capacity(cap),
386        insert_columns: Vec::with_capacity(cap),
387        insert_values: Vec::with_capacity(cap),
388        insert_pushes: Vec::with_capacity(cap),
389        returning_cols: Vec::new(),
390        auto_assigns: Vec::new(),
391        auto_field_idents: Vec::new(),
392        bulk_pushes_no_auto: Vec::with_capacity(cap),
393        bulk_pushes_all: Vec::with_capacity(cap),
394        bulk_columns_no_auto: Vec::with_capacity(cap),
395        bulk_columns_all: Vec::with_capacity(cap),
396        bulk_auto_uniformity: Vec::new(),
397        first_auto_ident: None,
398        has_auto: false,
399        pk_is_auto: false,
400        update_assignments: Vec::with_capacity(cap),
401        primary_key: None,
402        column_entries: Vec::with_capacity(cap),
403        field_names: Vec::with_capacity(cap),
404    };
405
406    for field in &named.named {
407        let info = process_field(field)?;
408        out.field_names.push(info.ident.to_string());
409        out.field_schemas.push(info.schema);
410        out.from_row_inits.push(info.from_row_init);
411        let column = info.column.as_str();
412        let ident = info.ident;
413        out.insert_columns.push(quote!(#column));
414        out.insert_values.push(quote! {
415            ::core::convert::Into::<::rustango::core::SqlValue>::into(
416                ::core::clone::Clone::clone(&self.#ident)
417            )
418        });
419        if info.auto {
420            out.has_auto = true;
421            if out.first_auto_ident.is_none() {
422                out.first_auto_ident = Some(ident.clone());
423            }
424            out.returning_cols.push(quote!(#column));
425            out.auto_field_idents
426                .push((ident.clone(), info.column.clone()));
427            out.auto_assigns.push(quote! {
428                self.#ident = ::rustango::sql::sqlx::Row::try_get(&_returning_row, #column)?;
429            });
430            out.insert_pushes.push(quote! {
431                if let ::rustango::sql::Auto::Set(_v) = &self.#ident {
432                    _columns.push(#column);
433                    _values.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
434                        ::core::clone::Clone::clone(_v)
435                    ));
436                }
437            });
438            // Bulk: Auto fields appear only in the all-Set path,
439            // never in the Unset path (we drop them from `columns`).
440            out.bulk_columns_all.push(quote!(#column));
441            out.bulk_pushes_all.push(quote! {
442                _row_vals.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
443                    ::core::clone::Clone::clone(&_row.#ident)
444                ));
445            });
446            // Uniformity check: every row's Auto state must match the
447            // first row's. Mixed Set/Unset within one bulk_insert is
448            // rejected here so the column list stays consistent.
449            let ident_clone = ident.clone();
450            out.bulk_auto_uniformity.push(quote! {
451                for _r in rows.iter().skip(1) {
452                    if matches!(_r.#ident_clone, ::rustango::sql::Auto::Unset) != _first_unset {
453                        return ::core::result::Result::Err(
454                            ::rustango::sql::ExecError::Sql(
455                                ::rustango::sql::SqlError::BulkAutoMixed
456                            )
457                        );
458                    }
459                }
460            });
461        } else {
462            out.insert_pushes.push(quote! {
463                _columns.push(#column);
464                _values.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
465                    ::core::clone::Clone::clone(&self.#ident)
466                ));
467            });
468            // Bulk: non-Auto fields appear in BOTH paths.
469            out.bulk_columns_no_auto.push(quote!(#column));
470            out.bulk_columns_all.push(quote!(#column));
471            let push_expr = quote! {
472                _row_vals.push(::core::convert::Into::<::rustango::core::SqlValue>::into(
473                    ::core::clone::Clone::clone(&_row.#ident)
474                ));
475            };
476            out.bulk_pushes_no_auto.push(push_expr.clone());
477            out.bulk_pushes_all.push(push_expr);
478        }
479        if info.primary_key {
480            if out.primary_key.is_some() {
481                return Err(syn::Error::new_spanned(
482                    field,
483                    "only one field may be marked `#[rustango(primary_key)]`",
484                ));
485            }
486            out.primary_key = Some((ident.clone(), info.column.clone()));
487            if info.auto {
488                out.pk_is_auto = true;
489            }
490        } else {
491            out.update_assignments.push(quote! {
492                ::rustango::core::Assignment {
493                    column: #column,
494                    value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
495                        ::core::clone::Clone::clone(&self.#ident)
496                    ),
497                }
498            });
499        }
500        out.column_entries.push(ColumnEntry {
501            ident: ident.clone(),
502            value_ty: info.value_ty.clone(),
503            name: ident.to_string(),
504            column: info.column.clone(),
505            field_type_tokens: info.field_type_tokens,
506        });
507    }
508    Ok(out)
509}
510
511fn model_impl_tokens(
512    struct_name: &syn::Ident,
513    model_name: &str,
514    table: &str,
515    display: Option<&str>,
516    field_schemas: &[TokenStream2],
517) -> TokenStream2 {
518    let display_tokens = if let Some(name) = display {
519        quote!(::core::option::Option::Some(#name))
520    } else {
521        quote!(::core::option::Option::None)
522    };
523    quote! {
524        impl ::rustango::core::Model for #struct_name {
525            const SCHEMA: &'static ::rustango::core::ModelSchema = &::rustango::core::ModelSchema {
526                name: #model_name,
527                table: #table,
528                fields: &[ #(#field_schemas),* ],
529                display: #display_tokens,
530            };
531        }
532    }
533}
534
535fn inherent_impl_tokens(
536    struct_name: &syn::Ident,
537    fields: &CollectedFields,
538    primary_key: Option<&(syn::Ident, String)>,
539    column_consts: &TokenStream2,
540) -> TokenStream2 {
541    let save_method = if fields.pk_is_auto {
542        let (pk_ident, pk_column) = primary_key
543            .expect("pk_is_auto implies primary_key is Some");
544        let pk_column_lit = pk_column.as_str();
545        let assignments = &fields.update_assignments;
546        Some(quote! {
547            /// Insert this row if its `Auto<T>` primary key is
548            /// `Unset`, otherwise update the existing row matching the
549            /// PK. Mirrors Django's `save()` — caller doesn't need to
550            /// pick `insert` vs the bulk-update path manually.
551            ///
552            /// On the insert branch, populates the PK from `RETURNING`
553            /// (same behavior as `insert`). On the update branch,
554            /// writes every non-PK column back; if no row matches the
555            /// PK, returns `Ok(())` silently.
556            ///
557            /// Only generated when the primary key is declared as
558            /// `Auto<T>`. Models with a manually-managed PK must use
559            /// `insert` or the QuerySet update builder.
560            ///
561            /// # Errors
562            /// Returns [`::rustango::sql::ExecError`] for SQL-writing
563            /// or driver failures.
564            pub async fn save(
565                &mut self,
566                pool: &::rustango::sql::sqlx::PgPool,
567            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
568                if matches!(self.#pk_ident, ::rustango::sql::Auto::Unset) {
569                    return self.insert(pool).await;
570                }
571                let _query = ::rustango::core::UpdateQuery {
572                    model: <Self as ::rustango::core::Model>::SCHEMA,
573                    set: ::std::vec![ #( #assignments ),* ],
574                    where_clause: ::rustango::core::WhereExpr::Predicate(
575                        ::rustango::core::Filter {
576                            column: #pk_column_lit,
577                            op: ::rustango::core::Op::Eq,
578                            value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
579                                ::core::clone::Clone::clone(&self.#pk_ident)
580                            ),
581                        }
582                    ),
583                };
584                let _ = ::rustango::sql::update(pool, &_query).await?;
585                ::core::result::Result::Ok(())
586            }
587        })
588    } else {
589        None
590    };
591
592    let pk_methods = primary_key.map(|(pk_ident, pk_column)| {
593        let pk_column_lit = pk_column.as_str();
594        quote! {
595            /// Delete the row identified by this instance's primary key.
596            ///
597            /// Returns the number of rows affected (0 or 1).
598            ///
599            /// # Errors
600            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
601            /// driver failures.
602            pub async fn delete(
603                &self,
604                pool: &::rustango::sql::sqlx::PgPool,
605            ) -> ::core::result::Result<u64, ::rustango::sql::ExecError> {
606                let query = ::rustango::core::DeleteQuery {
607                    model: <Self as ::rustango::core::Model>::SCHEMA,
608                    where_clause: ::rustango::core::WhereExpr::Predicate(
609                        ::rustango::core::Filter {
610                            column: #pk_column_lit,
611                            op: ::rustango::core::Op::Eq,
612                            value: ::core::convert::Into::<::rustango::core::SqlValue>::into(
613                                ::core::clone::Clone::clone(&self.#pk_ident)
614                            ),
615                        }
616                    ),
617                };
618                ::rustango::sql::delete(pool, &query).await
619            }
620        }
621    });
622
623    let insert_method = if fields.has_auto {
624        let pushes = &fields.insert_pushes;
625        let returning_cols = &fields.returning_cols;
626        let auto_assigns = &fields.auto_assigns;
627        quote! {
628            /// Insert this row into its table. Skips columns whose
629            /// `Auto<T>` value is `Unset` so Postgres' SERIAL/BIGSERIAL
630            /// sequence fills them in, then reads each `Auto` column
631            /// back via `RETURNING` and stores it on `self`.
632            ///
633            /// # Errors
634            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
635            /// driver failures.
636            pub async fn insert(
637                &mut self,
638                pool: &::rustango::sql::sqlx::PgPool,
639            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
640                let mut _columns: ::std::vec::Vec<&'static str> =
641                    ::std::vec::Vec::new();
642                let mut _values: ::std::vec::Vec<::rustango::core::SqlValue> =
643                    ::std::vec::Vec::new();
644                #( #pushes )*
645                let query = ::rustango::core::InsertQuery {
646                    model: <Self as ::rustango::core::Model>::SCHEMA,
647                    columns: _columns,
648                    values: _values,
649                    returning: ::std::vec![ #( #returning_cols ),* ],
650                };
651                let _returning_row = ::rustango::sql::insert_returning(pool, &query).await?;
652                #( #auto_assigns )*
653                ::core::result::Result::Ok(())
654            }
655        }
656    } else {
657        let insert_columns = &fields.insert_columns;
658        let insert_values = &fields.insert_values;
659        quote! {
660            /// Insert this row into its table.
661            ///
662            /// # Errors
663            /// Returns [`::rustango::sql::ExecError`] for SQL-writing or
664            /// driver failures.
665            pub async fn insert(
666                &self,
667                pool: &::rustango::sql::sqlx::PgPool,
668            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
669                let query = ::rustango::core::InsertQuery {
670                    model: <Self as ::rustango::core::Model>::SCHEMA,
671                    columns: ::std::vec![ #( #insert_columns ),* ],
672                    values: ::std::vec![ #( #insert_values ),* ],
673                    returning: ::std::vec::Vec::new(),
674                };
675                ::rustango::sql::insert(pool, &query).await
676            }
677        }
678    };
679
680    let bulk_insert_method = if fields.has_auto {
681        let cols_no_auto = &fields.bulk_columns_no_auto;
682        let cols_all = &fields.bulk_columns_all;
683        let pushes_no_auto = &fields.bulk_pushes_no_auto;
684        let pushes_all = &fields.bulk_pushes_all;
685        let returning_cols = &fields.returning_cols;
686        let auto_assigns_for_row = bulk_auto_assigns_for_row(fields);
687        let uniformity = &fields.bulk_auto_uniformity;
688        let first_auto_ident = fields
689            .first_auto_ident
690            .as_ref()
691            .expect("has_auto implies first_auto_ident is Some");
692        quote! {
693            /// Bulk-insert `rows` in a single round-trip. Every row's
694            /// `Auto<T>` PK fields must uniformly be `Auto::Unset`
695            /// (sequence fills them in) or uniformly `Auto::Set(_)`
696            /// (caller-supplied values). Mixed Set/Unset is rejected
697            /// — call `insert` per row for that case.
698            ///
699            /// Empty slice is a no-op. Each row's `Auto` fields are
700            /// populated from the `RETURNING` clause in input order
701            /// before this returns.
702            ///
703            /// # Errors
704            /// Returns [`::rustango::sql::ExecError`] for validation,
705            /// SQL-writing, mixed-Auto rejection, or driver failures.
706            pub async fn bulk_insert(
707                rows: &mut [Self],
708                pool: &::rustango::sql::sqlx::PgPool,
709            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
710                if rows.is_empty() {
711                    return ::core::result::Result::Ok(());
712                }
713                let _first_unset = matches!(
714                    rows[0].#first_auto_ident,
715                    ::rustango::sql::Auto::Unset
716                );
717                #( #uniformity )*
718
719                let mut _all_rows: ::std::vec::Vec<
720                    ::std::vec::Vec<::rustango::core::SqlValue>,
721                > = ::std::vec::Vec::with_capacity(rows.len());
722                let _columns: ::std::vec::Vec<&'static str> = if _first_unset {
723                    for _row in rows.iter() {
724                        let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
725                            ::std::vec::Vec::new();
726                        #( #pushes_no_auto )*
727                        _all_rows.push(_row_vals);
728                    }
729                    ::std::vec![ #( #cols_no_auto ),* ]
730                } else {
731                    for _row in rows.iter() {
732                        let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
733                            ::std::vec::Vec::new();
734                        #( #pushes_all )*
735                        _all_rows.push(_row_vals);
736                    }
737                    ::std::vec![ #( #cols_all ),* ]
738                };
739
740                let _query = ::rustango::core::BulkInsertQuery {
741                    model: <Self as ::rustango::core::Model>::SCHEMA,
742                    columns: _columns,
743                    rows: _all_rows,
744                    returning: ::std::vec![ #( #returning_cols ),* ],
745                };
746                let _returned = ::rustango::sql::bulk_insert(pool, &_query).await?;
747                if _returned.len() != rows.len() {
748                    return ::core::result::Result::Err(
749                        ::rustango::sql::ExecError::Sql(
750                            ::rustango::sql::SqlError::BulkInsertReturningMismatch {
751                                expected: rows.len(),
752                                actual: _returned.len(),
753                            }
754                        )
755                    );
756                }
757                for (_returning_row, _row_mut) in _returned.iter().zip(rows.iter_mut()) {
758                    #auto_assigns_for_row
759                }
760                ::core::result::Result::Ok(())
761            }
762        }
763    } else {
764        let cols_all = &fields.bulk_columns_all;
765        let pushes_all = &fields.bulk_pushes_all;
766        quote! {
767            /// Bulk-insert `rows` in a single round-trip. Every row's
768            /// fields are written verbatim — there are no `Auto<T>`
769            /// fields on this model.
770            ///
771            /// Empty slice is a no-op.
772            ///
773            /// # Errors
774            /// Returns [`::rustango::sql::ExecError`] for validation,
775            /// SQL-writing, or driver failures.
776            pub async fn bulk_insert(
777                rows: &[Self],
778                pool: &::rustango::sql::sqlx::PgPool,
779            ) -> ::core::result::Result<(), ::rustango::sql::ExecError> {
780                if rows.is_empty() {
781                    return ::core::result::Result::Ok(());
782                }
783                let mut _all_rows: ::std::vec::Vec<
784                    ::std::vec::Vec<::rustango::core::SqlValue>,
785                > = ::std::vec::Vec::with_capacity(rows.len());
786                for _row in rows.iter() {
787                    let mut _row_vals: ::std::vec::Vec<::rustango::core::SqlValue> =
788                        ::std::vec::Vec::new();
789                    #( #pushes_all )*
790                    _all_rows.push(_row_vals);
791                }
792                let _query = ::rustango::core::BulkInsertQuery {
793                    model: <Self as ::rustango::core::Model>::SCHEMA,
794                    columns: ::std::vec![ #( #cols_all ),* ],
795                    rows: _all_rows,
796                    returning: ::std::vec::Vec::new(),
797                };
798                let _ = ::rustango::sql::bulk_insert(pool, &_query).await?;
799                ::core::result::Result::Ok(())
800            }
801        }
802    };
803
804    quote! {
805        impl #struct_name {
806            /// Start a new `QuerySet` over this model.
807            #[must_use]
808            pub fn objects() -> ::rustango::query::QuerySet<#struct_name> {
809                ::rustango::query::QuerySet::new()
810            }
811
812            #insert_method
813
814            #bulk_insert_method
815
816            #save_method
817
818            #pk_methods
819
820            #column_consts
821        }
822    }
823}
824
825/// Per-row Auto-field assigns for `bulk_insert` — equivalent to
826/// `auto_assigns` but reading from `_returning_row` and writing to
827/// `_row_mut` instead of `self`.
828fn bulk_auto_assigns_for_row(fields: &CollectedFields) -> TokenStream2 {
829    let lines = fields.auto_field_idents.iter().map(|(ident, column)| {
830        let col_lit = column.as_str();
831        quote! {
832            _row_mut.#ident = ::rustango::sql::sqlx::Row::try_get(
833                _returning_row,
834                #col_lit,
835            )?;
836        }
837    });
838    quote! { #( #lines )* }
839}
840
841/// Emit `pub const id: …Id = …Id;` per field, inside the inherent impl.
842fn column_const_tokens(module_ident: &syn::Ident, entries: &[ColumnEntry]) -> TokenStream2 {
843    let lines = entries.iter().map(|e| {
844        let ident = &e.ident;
845        let col_ty = column_type_ident(ident);
846        quote! {
847            #[allow(non_upper_case_globals)]
848            pub const #ident: #module_ident::#col_ty = #module_ident::#col_ty;
849        }
850    });
851    quote! { #(#lines)* }
852}
853
854/// Emit a hidden per-model module carrying one zero-sized type per field,
855/// each with a `Column` impl pointing back at the model.
856fn column_module_tokens(
857    module_ident: &syn::Ident,
858    struct_name: &syn::Ident,
859    entries: &[ColumnEntry],
860) -> TokenStream2 {
861    let items = entries.iter().map(|e| {
862        let col_ty = column_type_ident(&e.ident);
863        let value_ty = &e.value_ty;
864        let name = &e.name;
865        let column = &e.column;
866        let field_type_tokens = &e.field_type_tokens;
867        quote! {
868            #[derive(::core::clone::Clone, ::core::marker::Copy)]
869            pub struct #col_ty;
870
871            impl ::rustango::core::Column for #col_ty {
872                type Model = super::#struct_name;
873                type Value = #value_ty;
874                const NAME: &'static str = #name;
875                const COLUMN: &'static str = #column;
876                const FIELD_TYPE: ::rustango::core::FieldType = #field_type_tokens;
877            }
878        }
879    });
880    quote! {
881        #[doc(hidden)]
882        #[allow(non_camel_case_types, non_snake_case)]
883        pub mod #module_ident {
884            // Re-import the parent scope so field types referencing
885            // sibling models (e.g. `ForeignKey<Author>`) resolve
886            // inside this submodule. Without this we'd hit
887            // `proc_macro_derive_resolution_fallback` warnings.
888            #[allow(unused_imports)]
889            use super::*;
890            #(#items)*
891        }
892    }
893}
894
895fn column_type_ident(field_ident: &syn::Ident) -> syn::Ident {
896    syn::Ident::new(&format!("{field_ident}_col"), field_ident.span())
897}
898
899fn column_module_ident(struct_name: &syn::Ident) -> syn::Ident {
900    syn::Ident::new(
901        &format!("__rustango_cols_{struct_name}"),
902        struct_name.span(),
903    )
904}
905
906fn from_row_impl_tokens(struct_name: &syn::Ident, from_row_inits: &[TokenStream2]) -> TokenStream2 {
907    quote! {
908        impl<'r> ::rustango::sql::sqlx::FromRow<'r, ::rustango::sql::sqlx::postgres::PgRow>
909            for #struct_name
910        {
911            fn from_row(
912                row: &'r ::rustango::sql::sqlx::postgres::PgRow,
913            ) -> ::core::result::Result<Self, ::rustango::sql::sqlx::Error> {
914                ::core::result::Result::Ok(Self {
915                    #( #from_row_inits ),*
916                })
917            }
918        }
919    }
920}
921
922struct ContainerAttrs {
923    table: Option<String>,
924    display: Option<(String, proc_macro2::Span)>,
925}
926
927fn parse_container_attrs(input: &DeriveInput) -> syn::Result<ContainerAttrs> {
928    let mut out = ContainerAttrs {
929        table: None,
930        display: None,
931    };
932    for attr in &input.attrs {
933        if !attr.path().is_ident("rustango") {
934            continue;
935        }
936        attr.parse_nested_meta(|meta| {
937            if meta.path.is_ident("table") {
938                let s: LitStr = meta.value()?.parse()?;
939                out.table = Some(s.value());
940                return Ok(());
941            }
942            if meta.path.is_ident("display") {
943                let s: LitStr = meta.value()?.parse()?;
944                out.display = Some((s.value(), s.span()));
945                return Ok(());
946            }
947            Err(meta.error("unknown rustango container attribute"))
948        })?;
949    }
950    Ok(out)
951}
952
953struct FieldAttrs {
954    column: Option<String>,
955    primary_key: bool,
956    fk: Option<String>,
957    o2o: Option<String>,
958    on: Option<String>,
959    max_length: Option<u32>,
960    min: Option<i64>,
961    max: Option<i64>,
962    default: Option<String>,
963}
964
965fn parse_field_attrs(field: &syn::Field) -> syn::Result<FieldAttrs> {
966    let mut out = FieldAttrs {
967        column: None,
968        primary_key: false,
969        fk: None,
970        o2o: None,
971        on: None,
972        max_length: None,
973        min: None,
974        max: None,
975        default: None,
976    };
977    for attr in &field.attrs {
978        if !attr.path().is_ident("rustango") {
979            continue;
980        }
981        attr.parse_nested_meta(|meta| {
982            if meta.path.is_ident("column") {
983                let s: LitStr = meta.value()?.parse()?;
984                out.column = Some(s.value());
985                return Ok(());
986            }
987            if meta.path.is_ident("primary_key") {
988                out.primary_key = true;
989                return Ok(());
990            }
991            if meta.path.is_ident("fk") {
992                let s: LitStr = meta.value()?.parse()?;
993                out.fk = Some(s.value());
994                return Ok(());
995            }
996            if meta.path.is_ident("o2o") {
997                let s: LitStr = meta.value()?.parse()?;
998                out.o2o = Some(s.value());
999                return Ok(());
1000            }
1001            if meta.path.is_ident("on") {
1002                let s: LitStr = meta.value()?.parse()?;
1003                out.on = Some(s.value());
1004                return Ok(());
1005            }
1006            if meta.path.is_ident("max_length") {
1007                let lit: syn::LitInt = meta.value()?.parse()?;
1008                out.max_length = Some(lit.base10_parse::<u32>()?);
1009                return Ok(());
1010            }
1011            if meta.path.is_ident("min") {
1012                out.min = Some(parse_signed_i64(&meta)?);
1013                return Ok(());
1014            }
1015            if meta.path.is_ident("max") {
1016                out.max = Some(parse_signed_i64(&meta)?);
1017                return Ok(());
1018            }
1019            if meta.path.is_ident("default") {
1020                let s: LitStr = meta.value()?.parse()?;
1021                out.default = Some(s.value());
1022                return Ok(());
1023            }
1024            Err(meta.error("unknown rustango field attribute"))
1025        })?;
1026    }
1027    Ok(out)
1028}
1029
1030/// Parse a signed integer literal, accepting optional leading `-`.
1031fn parse_signed_i64(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<i64> {
1032    let expr: syn::Expr = meta.value()?.parse()?;
1033    match expr {
1034        syn::Expr::Lit(syn::ExprLit {
1035            lit: syn::Lit::Int(lit),
1036            ..
1037        }) => lit.base10_parse::<i64>(),
1038        syn::Expr::Unary(syn::ExprUnary {
1039            op: syn::UnOp::Neg(_),
1040            expr,
1041            ..
1042        }) => {
1043            if let syn::Expr::Lit(syn::ExprLit {
1044                lit: syn::Lit::Int(lit),
1045                ..
1046            }) = *expr
1047            {
1048                let v: i64 = lit.base10_parse()?;
1049                Ok(-v)
1050            } else {
1051                Err(syn::Error::new_spanned(expr, "expected integer literal"))
1052            }
1053        }
1054        other => Err(syn::Error::new_spanned(
1055            other,
1056            "expected integer literal (signed)",
1057        )),
1058    }
1059}
1060
1061struct FieldInfo<'a> {
1062    ident: &'a syn::Ident,
1063    column: String,
1064    primary_key: bool,
1065    /// `true` when the Rust type was `Auto<T>` — the INSERT path will
1066    /// skip this column when `Auto::Unset` and emit it under
1067    /// `RETURNING` so Postgres' sequence DEFAULT fills in the value.
1068    auto: bool,
1069    /// The original field type, e.g. `i64` or `Option<String>`. Emitted as
1070    /// the `Column::Value` associated type for typed-column tokens.
1071    value_ty: &'a Type,
1072    /// `FieldType` variant tokens (`::rustango::core::FieldType::I64`).
1073    field_type_tokens: TokenStream2,
1074    schema: TokenStream2,
1075    from_row_init: TokenStream2,
1076}
1077
1078fn process_field(field: &syn::Field) -> syn::Result<FieldInfo<'_>> {
1079    let attrs = parse_field_attrs(field)?;
1080    let ident = field
1081        .ident
1082        .as_ref()
1083        .ok_or_else(|| syn::Error::new(field.span(), "tuple structs are not supported"))?;
1084    let name = ident.to_string();
1085    let column = attrs.column.clone().unwrap_or_else(|| name.clone());
1086    let primary_key = attrs.primary_key;
1087    let DetectedType {
1088        kind,
1089        nullable,
1090        auto,
1091        fk_inner,
1092    } = detect_type(&field.ty)?;
1093    check_bound_compatibility(field, &attrs, kind)?;
1094    if auto && !primary_key {
1095        return Err(syn::Error::new_spanned(
1096            field,
1097            "`Auto<T>` is only valid on a `#[rustango(primary_key)]` field",
1098        ));
1099    }
1100    if auto && attrs.default.is_some() {
1101        return Err(syn::Error::new_spanned(
1102            field,
1103            "`#[rustango(default = \"…\")]` is redundant on an `Auto<T>` field — \
1104             SERIAL / BIGSERIAL already supplies a default sequence.",
1105        ));
1106    }
1107    if fk_inner.is_some() && primary_key {
1108        return Err(syn::Error::new_spanned(
1109            field,
1110            "`ForeignKey<T>` is not allowed on a primary-key field — \
1111             a row's PK is its own identity, not a reference to a parent.",
1112        ));
1113    }
1114    let relation = relation_tokens(field, &attrs, fk_inner)?;
1115    let column_lit = column.as_str();
1116    let field_type_tokens = kind.variant_tokens();
1117    let max_length = optional_u32(attrs.max_length);
1118    let min = optional_i64(attrs.min);
1119    let max = optional_i64(attrs.max);
1120    let default = optional_str(attrs.default.as_deref());
1121
1122    let schema = quote! {
1123        ::rustango::core::FieldSchema {
1124            name: #name,
1125            column: #column_lit,
1126            ty: #field_type_tokens,
1127            nullable: #nullable,
1128            primary_key: #primary_key,
1129            relation: #relation,
1130            max_length: #max_length,
1131            min: #min,
1132            max: #max,
1133            default: #default,
1134            auto: #auto,
1135        }
1136    };
1137
1138    let from_row_init = quote! {
1139        #ident: ::rustango::sql::sqlx::Row::try_get(row, #column_lit)?
1140    };
1141
1142    Ok(FieldInfo {
1143        ident,
1144        column,
1145        primary_key,
1146        auto,
1147        value_ty: &field.ty,
1148        field_type_tokens,
1149        schema,
1150        from_row_init,
1151    })
1152}
1153
1154fn check_bound_compatibility(
1155    field: &syn::Field,
1156    attrs: &FieldAttrs,
1157    kind: DetectedKind,
1158) -> syn::Result<()> {
1159    if attrs.max_length.is_some() && kind != DetectedKind::String {
1160        return Err(syn::Error::new_spanned(
1161            field,
1162            "`max_length` is only valid on `String` fields (or `Option<String>`)",
1163        ));
1164    }
1165    if (attrs.min.is_some() || attrs.max.is_some()) && !kind.is_integer() {
1166        return Err(syn::Error::new_spanned(
1167            field,
1168            "`min` / `max` are only valid on integer fields (`i32`, `i64`, optionally Option-wrapped)",
1169        ));
1170    }
1171    if let (Some(min), Some(max)) = (attrs.min, attrs.max) {
1172        if min > max {
1173            return Err(syn::Error::new_spanned(
1174                field,
1175                format!("`min` ({min}) is greater than `max` ({max})"),
1176            ));
1177        }
1178    }
1179    Ok(())
1180}
1181
1182fn optional_u32(value: Option<u32>) -> TokenStream2 {
1183    if let Some(v) = value {
1184        quote!(::core::option::Option::Some(#v))
1185    } else {
1186        quote!(::core::option::Option::None)
1187    }
1188}
1189
1190fn optional_i64(value: Option<i64>) -> TokenStream2 {
1191    if let Some(v) = value {
1192        quote!(::core::option::Option::Some(#v))
1193    } else {
1194        quote!(::core::option::Option::None)
1195    }
1196}
1197
1198fn optional_str(value: Option<&str>) -> TokenStream2 {
1199    if let Some(v) = value {
1200        quote!(::core::option::Option::Some(#v))
1201    } else {
1202        quote!(::core::option::Option::None)
1203    }
1204}
1205
1206fn relation_tokens(
1207    field: &syn::Field,
1208    attrs: &FieldAttrs,
1209    fk_inner: Option<&syn::Type>,
1210) -> syn::Result<TokenStream2> {
1211    if let Some(inner) = fk_inner {
1212        if attrs.fk.is_some() || attrs.o2o.is_some() {
1213            return Err(syn::Error::new_spanned(
1214                field,
1215                "`ForeignKey<T>` already declares the FK target via the type parameter — \
1216                 remove the `fk = \"…\"` / `o2o = \"…\"` attribute.",
1217            ));
1218        }
1219        let on = attrs.on.as_deref().unwrap_or("id");
1220        return Ok(quote! {
1221            ::core::option::Option::Some(::rustango::core::Relation::Fk {
1222                to: <#inner as ::rustango::core::Model>::SCHEMA.table,
1223                on: #on,
1224            })
1225        });
1226    }
1227    match (&attrs.fk, &attrs.o2o) {
1228        (Some(_), Some(_)) => Err(syn::Error::new_spanned(
1229            field,
1230            "`fk` and `o2o` are mutually exclusive",
1231        )),
1232        (Some(to), None) => {
1233            let on = attrs.on.as_deref().unwrap_or("id");
1234            Ok(quote! {
1235                ::core::option::Option::Some(::rustango::core::Relation::Fk { to: #to, on: #on })
1236            })
1237        }
1238        (None, Some(to)) => {
1239            let on = attrs.on.as_deref().unwrap_or("id");
1240            Ok(quote! {
1241                ::core::option::Option::Some(::rustango::core::Relation::O2O { to: #to, on: #on })
1242            })
1243        }
1244        (None, None) => {
1245            if attrs.on.is_some() {
1246                return Err(syn::Error::new_spanned(
1247                    field,
1248                    "`on` requires `fk` or `o2o`",
1249                ));
1250            }
1251            Ok(quote!(::core::option::Option::None))
1252        }
1253    }
1254}
1255
1256/// Mirrors `rustango_core::FieldType`. Local copy so the macro can reason
1257/// about kinds without depending on `rustango-core` (which would require a
1258/// proc-macro/normal split it doesn't have today).
1259#[derive(Clone, Copy, PartialEq, Eq)]
1260enum DetectedKind {
1261    I32,
1262    I64,
1263    F32,
1264    F64,
1265    Bool,
1266    String,
1267    DateTime,
1268    Date,
1269    Uuid,
1270    Json,
1271}
1272
1273impl DetectedKind {
1274    fn variant_tokens(self) -> TokenStream2 {
1275        match self {
1276            Self::I32 => quote!(::rustango::core::FieldType::I32),
1277            Self::I64 => quote!(::rustango::core::FieldType::I64),
1278            Self::F32 => quote!(::rustango::core::FieldType::F32),
1279            Self::F64 => quote!(::rustango::core::FieldType::F64),
1280            Self::Bool => quote!(::rustango::core::FieldType::Bool),
1281            Self::String => quote!(::rustango::core::FieldType::String),
1282            Self::DateTime => quote!(::rustango::core::FieldType::DateTime),
1283            Self::Date => quote!(::rustango::core::FieldType::Date),
1284            Self::Uuid => quote!(::rustango::core::FieldType::Uuid),
1285            Self::Json => quote!(::rustango::core::FieldType::Json),
1286        }
1287    }
1288
1289    fn is_integer(self) -> bool {
1290        matches!(self, Self::I32 | Self::I64)
1291    }
1292}
1293
1294/// Result of walking a field's Rust type. `kind` is the underlying
1295/// `FieldType`; `nullable` is set by an outer `Option<T>`; `auto` is
1296/// set by an outer `Auto<T>` (server-assigned PK); `fk_inner` is
1297/// `Some(<T>)` when the field was `ForeignKey<T>` (or
1298/// `Option<ForeignKey<T>>`), letting the codegen reach `T::SCHEMA`.
1299#[derive(Clone, Copy)]
1300struct DetectedType<'a> {
1301    kind: DetectedKind,
1302    nullable: bool,
1303    auto: bool,
1304    fk_inner: Option<&'a syn::Type>,
1305}
1306
1307fn detect_type(ty: &syn::Type) -> syn::Result<DetectedType<'_>> {
1308    let Type::Path(TypePath { path, qself: None }) = ty else {
1309        return Err(syn::Error::new_spanned(ty, "unsupported field type"));
1310    };
1311    let last = path
1312        .segments
1313        .last()
1314        .ok_or_else(|| syn::Error::new_spanned(ty, "empty type path"))?;
1315
1316    if last.ident == "Option" {
1317        let inner = generic_inner(ty, &last.arguments, "Option")?;
1318        let inner_det = detect_type(inner)?;
1319        if inner_det.nullable {
1320            return Err(syn::Error::new_spanned(
1321                ty,
1322                "nested Option is not supported",
1323            ));
1324        }
1325        if inner_det.auto {
1326            return Err(syn::Error::new_spanned(
1327                ty,
1328                "`Option<Auto<T>>` is not supported — Auto fields are server-assigned and cannot be NULL",
1329            ));
1330        }
1331        return Ok(DetectedType {
1332            nullable: true,
1333            ..inner_det
1334        });
1335    }
1336
1337    if last.ident == "Auto" {
1338        let inner = generic_inner(ty, &last.arguments, "Auto")?;
1339        let inner_det = detect_type(inner)?;
1340        if inner_det.auto {
1341            return Err(syn::Error::new_spanned(
1342                ty,
1343                "nested Auto is not supported",
1344            ));
1345        }
1346        if inner_det.nullable {
1347            return Err(syn::Error::new_spanned(
1348                ty,
1349                "`Auto<Option<T>>` is not supported — Auto fields are server-assigned and cannot be NULL",
1350            ));
1351        }
1352        if inner_det.fk_inner.is_some() {
1353            return Err(syn::Error::new_spanned(
1354                ty,
1355                "`Auto<ForeignKey<T>>` is not supported — Auto is for server-assigned PKs, ForeignKey is for parent references",
1356            ));
1357        }
1358        if !matches!(inner_det.kind, DetectedKind::I32 | DetectedKind::I64) {
1359            return Err(syn::Error::new_spanned(
1360                ty,
1361                "`Auto<T>` only supports integer types (`i32` → SERIAL, `i64` → BIGSERIAL)",
1362            ));
1363        }
1364        return Ok(DetectedType {
1365            auto: true,
1366            ..inner_det
1367        });
1368    }
1369
1370    if last.ident == "ForeignKey" {
1371        let inner = generic_inner(ty, &last.arguments, "ForeignKey")?;
1372        // `ForeignKey<T>` is stored as BIGINT — same column shape as
1373        // the v0.1 `i64` + `#[rustango(fk = …)]` form. The macro does
1374        // not recurse into `T` because `T` is a Model struct, not a
1375        // primitive — its identity is opaque to schema detection.
1376        return Ok(DetectedType {
1377            kind: DetectedKind::I64,
1378            nullable: false,
1379            auto: false,
1380            fk_inner: Some(inner),
1381        });
1382    }
1383
1384    let kind = match last.ident.to_string().as_str() {
1385        "i32" => DetectedKind::I32,
1386        "i64" => DetectedKind::I64,
1387        "f32" => DetectedKind::F32,
1388        "f64" => DetectedKind::F64,
1389        "bool" => DetectedKind::Bool,
1390        "String" => DetectedKind::String,
1391        "DateTime" => DetectedKind::DateTime,
1392        "NaiveDate" => DetectedKind::Date,
1393        "Uuid" => DetectedKind::Uuid,
1394        "Value" => DetectedKind::Json,
1395        other => {
1396            return Err(syn::Error::new_spanned(
1397                ty,
1398                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)"),
1399            ));
1400        }
1401    };
1402    Ok(DetectedType {
1403        kind,
1404        nullable: false,
1405        auto: false,
1406        fk_inner: None,
1407    })
1408}
1409
1410fn generic_inner<'a>(
1411    ty: &'a Type,
1412    arguments: &'a PathArguments,
1413    wrapper: &str,
1414) -> syn::Result<&'a Type> {
1415    let PathArguments::AngleBracketed(args) = arguments else {
1416        return Err(syn::Error::new_spanned(
1417            ty,
1418            format!("{wrapper} requires a generic argument"),
1419        ));
1420    };
1421    args.args
1422        .iter()
1423        .find_map(|a| match a {
1424            GenericArgument::Type(t) => Some(t),
1425            _ => None,
1426        })
1427        .ok_or_else(|| {
1428            syn::Error::new_spanned(ty, format!("{wrapper}<T> requires a type argument"))
1429        })
1430}
1431
1432fn to_snake_case(s: &str) -> String {
1433    let mut out = String::with_capacity(s.len() + 4);
1434    for (i, ch) in s.chars().enumerate() {
1435        if ch.is_ascii_uppercase() {
1436            if i > 0 {
1437                out.push('_');
1438            }
1439            out.push(ch.to_ascii_lowercase());
1440        } else {
1441            out.push(ch);
1442        }
1443    }
1444    out
1445}
1446
1447// ============================================================
1448//  #[derive(Form)]  —  slice 8.4B
1449// ============================================================
1450
1451/// Per-field `#[form(...)]` attributes recognised by the derive.
1452#[derive(Default)]
1453struct FormFieldAttrs {
1454    min: Option<i64>,
1455    max: Option<i64>,
1456    min_length: Option<u32>,
1457    max_length: Option<u32>,
1458}
1459
1460/// Detected shape of a form field's Rust type.
1461#[derive(Clone, Copy)]
1462enum FormFieldKind {
1463    String,
1464    I32,
1465    I64,
1466    F32,
1467    F64,
1468    Bool,
1469}
1470
1471impl FormFieldKind {
1472    fn parse_method(self) -> &'static str {
1473        match self {
1474            Self::I32 => "i32",
1475            Self::I64 => "i64",
1476            Self::F32 => "f32",
1477            Self::F64 => "f64",
1478            // String + Bool don't go through `str::parse`; the codegen
1479            // handles them inline.
1480            Self::String | Self::Bool => "",
1481        }
1482    }
1483}
1484
1485fn expand_form(input: &DeriveInput) -> syn::Result<TokenStream2> {
1486    let struct_name = &input.ident;
1487
1488    let Data::Struct(data) = &input.data else {
1489        return Err(syn::Error::new_spanned(
1490            struct_name,
1491            "Form can only be derived on structs",
1492        ));
1493    };
1494    let Fields::Named(named) = &data.fields else {
1495        return Err(syn::Error::new_spanned(
1496            struct_name,
1497            "Form requires a struct with named fields",
1498        ));
1499    };
1500
1501    let mut field_blocks: Vec<TokenStream2> = Vec::with_capacity(named.named.len());
1502    let mut field_idents: Vec<&syn::Ident> = Vec::with_capacity(named.named.len());
1503
1504    for field in &named.named {
1505        let ident = field
1506            .ident
1507            .as_ref()
1508            .ok_or_else(|| syn::Error::new(field.span(), "tuple structs are not supported"))?;
1509        let attrs = parse_form_field_attrs(field)?;
1510        let (kind, nullable) = detect_form_field(&field.ty, field.span())?;
1511
1512        let name_lit = ident.to_string();
1513        let parse_block = render_form_field_parse(ident, &name_lit, kind, nullable, &attrs);
1514        field_blocks.push(parse_block);
1515        field_idents.push(ident);
1516    }
1517
1518    Ok(quote! {
1519        impl ::rustango::forms::FormStruct for #struct_name {
1520            fn parse(
1521                form: &::std::collections::HashMap<::std::string::String, ::std::string::String>,
1522            ) -> ::core::result::Result<Self, ::rustango::forms::FormError> {
1523                #( #field_blocks )*
1524                ::core::result::Result::Ok(Self {
1525                    #( #field_idents ),*
1526                })
1527            }
1528        }
1529    })
1530}
1531
1532fn parse_form_field_attrs(field: &syn::Field) -> syn::Result<FormFieldAttrs> {
1533    let mut out = FormFieldAttrs::default();
1534    for attr in &field.attrs {
1535        if !attr.path().is_ident("form") {
1536            continue;
1537        }
1538        attr.parse_nested_meta(|meta| {
1539            if meta.path.is_ident("min") {
1540                let lit: syn::LitInt = meta.value()?.parse()?;
1541                out.min = Some(lit.base10_parse::<i64>()?);
1542                return Ok(());
1543            }
1544            if meta.path.is_ident("max") {
1545                let lit: syn::LitInt = meta.value()?.parse()?;
1546                out.max = Some(lit.base10_parse::<i64>()?);
1547                return Ok(());
1548            }
1549            if meta.path.is_ident("min_length") {
1550                let lit: syn::LitInt = meta.value()?.parse()?;
1551                out.min_length = Some(lit.base10_parse::<u32>()?);
1552                return Ok(());
1553            }
1554            if meta.path.is_ident("max_length") {
1555                let lit: syn::LitInt = meta.value()?.parse()?;
1556                out.max_length = Some(lit.base10_parse::<u32>()?);
1557                return Ok(());
1558            }
1559            Err(meta.error(
1560                "unknown form attribute (supported: `min`, `max`, `min_length`, `max_length`)",
1561            ))
1562        })?;
1563    }
1564    Ok(out)
1565}
1566
1567fn detect_form_field(ty: &Type, span: proc_macro2::Span) -> syn::Result<(FormFieldKind, bool)> {
1568    let Type::Path(TypePath { path, qself: None }) = ty else {
1569        return Err(syn::Error::new(
1570            span,
1571            "Form field must be a simple typed path (e.g. `String`, `i32`, `Option<String>`)",
1572        ));
1573    };
1574    let last = path
1575        .segments
1576        .last()
1577        .ok_or_else(|| syn::Error::new(span, "empty type path"))?;
1578
1579    if last.ident == "Option" {
1580        let inner = generic_inner(ty, &last.arguments, "Option")?;
1581        let (kind, nested) = detect_form_field(inner, span)?;
1582        if nested {
1583            return Err(syn::Error::new(
1584                span,
1585                "nested Option in Form fields is not supported",
1586            ));
1587        }
1588        return Ok((kind, true));
1589    }
1590
1591    let kind = match last.ident.to_string().as_str() {
1592        "String" => FormFieldKind::String,
1593        "i32" => FormFieldKind::I32,
1594        "i64" => FormFieldKind::I64,
1595        "f32" => FormFieldKind::F32,
1596        "f64" => FormFieldKind::F64,
1597        "bool" => FormFieldKind::Bool,
1598        other => {
1599            return Err(syn::Error::new(
1600                span,
1601                format!(
1602                    "Form field type `{other}` is not supported in v0.8 — use String / \
1603                     i32 / i64 / f32 / f64 / bool, optionally wrapped in Option<…>"
1604                ),
1605            ));
1606        }
1607    };
1608    Ok((kind, false))
1609}
1610
1611#[allow(clippy::too_many_lines)]
1612fn render_form_field_parse(
1613    ident: &syn::Ident,
1614    name_lit: &str,
1615    kind: FormFieldKind,
1616    nullable: bool,
1617    attrs: &FormFieldAttrs,
1618) -> TokenStream2 {
1619    // Common helper: pull the raw &str out of the form. Bool's
1620    // checkbox semantics (absent = false) are handled inline; every
1621    // other type errors with `Missing` for absent required fields,
1622    // or yields `None` for absent nullable Option<T> fields.
1623    let lookup = quote! {
1624        let __raw: ::core::option::Option<&::std::string::String> = form.get(#name_lit);
1625    };
1626
1627    let parsed_value = match kind {
1628        FormFieldKind::Bool => quote! {
1629            // HTML checkbox: absent = false, anything-non-empty = true,
1630            // except literal "false"/"0"/"off"/"no".
1631            let __v: bool = match __raw {
1632                ::core::option::Option::None => false,
1633                ::core::option::Option::Some(__s) => !matches!(
1634                    __s.to_ascii_lowercase().as_str(),
1635                    "" | "false" | "0" | "off" | "no"
1636                ),
1637            };
1638        },
1639        FormFieldKind::String => {
1640            if nullable {
1641                quote! {
1642                    let __v: ::core::option::Option<::std::string::String> = match __raw {
1643                        ::core::option::Option::None => ::core::option::Option::None,
1644                        ::core::option::Option::Some(__s) if __s.is_empty() => {
1645                            ::core::option::Option::None
1646                        }
1647                        ::core::option::Option::Some(__s) => {
1648                            ::core::option::Option::Some(::core::clone::Clone::clone(__s))
1649                        }
1650                    };
1651                }
1652            } else {
1653                quote! {
1654                    let __v: ::std::string::String = match __raw {
1655                        ::core::option::Option::Some(__s) if !__s.is_empty() => {
1656                            ::core::clone::Clone::clone(__s)
1657                        }
1658                        _ => {
1659                            return ::core::result::Result::Err(
1660                                ::rustango::forms::FormError::Missing {
1661                                    field: ::std::string::String::from(#name_lit),
1662                                }
1663                            );
1664                        }
1665                    };
1666                }
1667            }
1668        }
1669        FormFieldKind::I32 | FormFieldKind::I64 | FormFieldKind::F32 | FormFieldKind::F64 => {
1670            let parse_ty = syn::Ident::new(kind.parse_method(), proc_macro2::Span::call_site());
1671            let ty_lit = kind.parse_method();
1672            let parse_expr = quote! {
1673                __s.parse::<#parse_ty>().map_err(|__e| {
1674                    ::rustango::forms::FormError::Parse {
1675                        field: ::std::string::String::from(#name_lit),
1676                        ty: #ty_lit,
1677                        value: ::core::clone::Clone::clone(__s),
1678                        detail: ::std::string::ToString::to_string(&__e),
1679                    }
1680                })
1681            };
1682            if nullable {
1683                quote! {
1684                    let __v: ::core::option::Option<#parse_ty> = match __raw {
1685                        ::core::option::Option::None => ::core::option::Option::None,
1686                        ::core::option::Option::Some(__s) if __s.is_empty() => {
1687                            ::core::option::Option::None
1688                        }
1689                        ::core::option::Option::Some(__s) => {
1690                            ::core::option::Option::Some(#parse_expr?)
1691                        }
1692                    };
1693                }
1694            } else {
1695                quote! {
1696                    let __v: #parse_ty = match __raw {
1697                        ::core::option::Option::Some(__s) if !__s.is_empty() => {
1698                            #parse_expr?
1699                        }
1700                        _ => {
1701                            return ::core::result::Result::Err(
1702                                ::rustango::forms::FormError::Missing {
1703                                    field: ::std::string::String::from(#name_lit),
1704                                }
1705                            );
1706                        }
1707                    };
1708                }
1709            }
1710        }
1711    };
1712
1713    // Validator emission. min / max only make sense on numeric kinds;
1714    // min_length / max_length only on String. We silently ignore
1715    // wrong-shape combinations for now (a future commit can validate
1716    // attribute-vs-type at macro time).
1717    let validators = render_form_validators(name_lit, kind, nullable, attrs);
1718
1719    quote! {
1720        let #ident = {
1721            #lookup
1722            #parsed_value
1723            #validators
1724            __v
1725        };
1726    }
1727}
1728
1729fn render_form_validators(
1730    name_lit: &str,
1731    kind: FormFieldKind,
1732    nullable: bool,
1733    attrs: &FormFieldAttrs,
1734) -> TokenStream2 {
1735    let mut checks: Vec<TokenStream2> = Vec::new();
1736
1737    let val_ref = if nullable {
1738        // Validate the inner value when Some; skip when None.
1739        quote! { __v.as_ref() }
1740    } else {
1741        quote! { ::core::option::Option::Some(&__v) }
1742    };
1743
1744    let is_string = matches!(kind, FormFieldKind::String);
1745    let is_numeric = matches!(
1746        kind,
1747        FormFieldKind::I32 | FormFieldKind::I64 | FormFieldKind::F32 | FormFieldKind::F64
1748    );
1749
1750    if is_string {
1751        if let Some(min_len) = attrs.min_length {
1752            let min_len_usize = min_len as usize;
1753            checks.push(quote! {
1754                if let ::core::option::Option::Some(__s) = #val_ref {
1755                    if __s.len() < #min_len_usize {
1756                        return ::core::result::Result::Err(
1757                            ::rustango::forms::FormError::Parse {
1758                                field: ::std::string::String::from(#name_lit),
1759                                ty: "String",
1760                                value: ::core::clone::Clone::clone(__s),
1761                                detail: ::std::format!(
1762                                    "shorter than min_length {}", #min_len_usize
1763                                ),
1764                            }
1765                        );
1766                    }
1767                }
1768            });
1769        }
1770        if let Some(max_len) = attrs.max_length {
1771            let max_len_usize = max_len as usize;
1772            checks.push(quote! {
1773                if let ::core::option::Option::Some(__s) = #val_ref {
1774                    if __s.len() > #max_len_usize {
1775                        return ::core::result::Result::Err(
1776                            ::rustango::forms::FormError::Parse {
1777                                field: ::std::string::String::from(#name_lit),
1778                                ty: "String",
1779                                value: ::core::clone::Clone::clone(__s),
1780                                detail: ::std::format!(
1781                                    "longer than max_length {}", #max_len_usize
1782                                ),
1783                            }
1784                        );
1785                    }
1786                }
1787            });
1788        }
1789    }
1790
1791    if is_numeric {
1792        if let Some(min) = attrs.min {
1793            checks.push(quote! {
1794                if let ::core::option::Option::Some(__n) = #val_ref {
1795                    let __nf = (*__n) as f64;
1796                    if __nf < (#min as f64) {
1797                        return ::core::result::Result::Err(
1798                            ::rustango::forms::FormError::Parse {
1799                                field: ::std::string::String::from(#name_lit),
1800                                ty: "numeric",
1801                                value: ::std::string::ToString::to_string(__n),
1802                                detail: ::std::format!("less than min {}", #min),
1803                            }
1804                        );
1805                    }
1806                }
1807            });
1808        }
1809        if let Some(max) = attrs.max {
1810            checks.push(quote! {
1811                if let ::core::option::Option::Some(__n) = #val_ref {
1812                    let __nf = (*__n) as f64;
1813                    if __nf > (#max as f64) {
1814                        return ::core::result::Result::Err(
1815                            ::rustango::forms::FormError::Parse {
1816                                field: ::std::string::String::from(#name_lit),
1817                                ty: "numeric",
1818                                value: ::std::string::ToString::to_string(__n),
1819                                detail: ::std::format!("greater than max {}", #max),
1820                            }
1821                        );
1822                    }
1823                }
1824            });
1825        }
1826    }
1827
1828    quote! { #( #checks )* }
1829}