Skip to main content

fse_schema/
parse.rs

1//! syn-based parsing of `#[derive(Table)]` structs and `#[derive(DbEnum)]`
2//! enums into the schema model. This is the *only* place `#[orm(...)]`
3//! attribute semantics are defined — the derive macro and the CLI both call
4//! into here, so they can never drift apart.
5
6use quote::ToTokens;
7
8use crate::error::Error;
9use crate::model::{
10    ColumnDef, DefaultValue, EnumDef, ForeignKey, OnDelete, RelationDef, Schema, SqlType, TableDef,
11};
12
13/// A parsed struct field is either a database column or a Prisma-style
14/// relation field (`#[orm(relation = fk)]`), which carries no DDL.
15enum ParsedField {
16    Column(Box<ColumnDef>),
17    Relation(RelationDef),
18}
19
20/// Parse a whole tables folder: `sources` is `(file name, file content)`,
21/// where the file name is only used in error messages. Two passes — enums
22/// first so struct fields can resolve them — then foreign keys are resolved
23/// from struct names to table names.
24pub fn parse_sources(sources: &[(String, String)]) -> Result<Schema, Error> {
25    parse_sources_with_external(sources, &[])
26}
27
28/// Like [`parse_sources`], but with additional already-known tables (module
29/// snapshots) available for foreign-key/relation resolution — so an app
30/// table can `references(SomeModuleStruct)` a table it doesn't define. The
31/// external tables are *not* part of the returned schema.
32pub fn parse_sources_with_external(
33    sources: &[(String, String)],
34    external: &[TableDef],
35) -> Result<Schema, Error> {
36    let mut files = Vec::new();
37    for (name, code) in sources {
38        let file = syn::parse_file(code).map_err(|e| Error::new(format!("{name}: {e}")))?;
39        files.push((name, file));
40    }
41
42    let mut enums: Vec<EnumDef> = Vec::new();
43    for (name, file) in &files {
44        for item in &file.items {
45            if let syn::Item::Enum(e) = item
46                && has_derive(&e.attrs, "DbEnum")
47            {
48                let def = enum_from_item(e).map_err(|e| Error::new(format!("{name}: {e}")))?;
49                if enums.iter().any(|x| x.rust_name == def.rust_name) {
50                    return Err(Error::new(format!(
51                        "{name}: duplicate DbEnum {}",
52                        def.rust_name
53                    )));
54                }
55                enums.push(def);
56            }
57        }
58    }
59    enums.sort_by(|a, b| a.rust_name.cmp(&b.rust_name));
60
61    let mut tables: Vec<TableDef> = Vec::new();
62    for (name, file) in &files {
63        for item in &file.items {
64            if let syn::Item::Struct(s) = item
65                && (has_derive(&s.attrs, "Table") || has_model_attr(&s.attrs))
66            {
67                let table = table_from_struct(s, Some(&enums))
68                    .map_err(|e| Error::new(format!("{name}: {e}")))?;
69                if tables.iter().any(|t| t.name == table.name) {
70                    return Err(Error::new(format!(
71                        "{name}: duplicate table {}",
72                        table.name
73                    )));
74                }
75                if external.iter().any(|t| t.name == table.name) {
76                    return Err(Error::new(format!(
77                        "{name}: table {} is already defined by a module — rename the \
78                         struct or set #[orm(table = \"...\")]",
79                        table.name
80                    )));
81                }
82                tables.push(table);
83            }
84        }
85    }
86    tables.sort_by(|a, b| a.name.cmp(&b.name));
87
88    // Resolve foreign-key targets: `references(Event)` names a struct; turn
89    // it into the table name. A name that already matches a table (e.g. from
90    // `fse init` introspection) passes through unchanged. External (module)
91    // tables participate in resolution like local ones.
92    let by_struct: Vec<(String, String)> = tables
93        .iter()
94        .chain(external)
95        .map(|t| (t.struct_name.clone(), t.name.clone()))
96        .collect();
97    let table_names: Vec<String> = tables
98        .iter()
99        .chain(external)
100        .map(|t| t.name.clone())
101        .collect();
102    for table in &mut tables {
103        for col in &mut table.columns {
104            if let Some(fk) = &mut col.references {
105                if let Some((_, tn)) = by_struct.iter().find(|(s, _)| *s == fk.table) {
106                    fk.table = tn.clone();
107                } else if !table_names.contains(&fk.table) {
108                    return Err(Error::new(format!(
109                        "{}.{}: references unknown table/struct `{}`",
110                        table.struct_name, col.name, fk.table
111                    )));
112                }
113            }
114        }
115    }
116
117    // Resolve each relation's target table from the table it joins through: the
118    // FK column's (now table-resolved) reference. Also verify the relation's
119    // declared target struct matches that foreign key's target.
120    for table in &mut tables {
121        let fk_targets: Vec<(String, String)> = table
122            .columns
123            .iter()
124            .filter_map(|c| {
125                c.references
126                    .as_ref()
127                    .map(|fk| (c.name.clone(), fk.table.clone()))
128            })
129            .collect();
130        for rel in &mut table.relations {
131            if let Some((_, target)) = fk_targets
132                .iter()
133                .find(|(name, _)| *name == rel.local_column)
134            {
135                rel.target_table = target.clone();
136            }
137        }
138    }
139
140    Ok(Schema { tables, enums })
141}
142
143/// Parse one `#[derive(DbEnum)]` enum. Variants must be unit variants; the
144/// stored value is the snake_case variant name.
145pub fn enum_from_item(item: &syn::ItemEnum) -> Result<EnumDef, Error> {
146    let rust_name = item.ident.to_string();
147    let mut values = Vec::new();
148    for v in &item.variants {
149        if !matches!(v.fields, syn::Fields::Unit) {
150            return Err(Error::new(format!(
151                "{rust_name}::{}: DbEnum variants must be unit variants",
152                v.ident
153            )));
154        }
155        values.push(to_snake_case(&v.ident.to_string()));
156    }
157    if values.is_empty() {
158        return Err(Error::new(format!(
159            "{rust_name}: DbEnum needs at least one variant"
160        )));
161    }
162    Ok(EnumDef { rust_name, values })
163}
164
165/// Parse one `#[derive(Table)]` struct.
166///
167/// `enums` is the full set of known `DbEnum`s when parsing a whole folder
168/// (CLI). Pass `None` in single-item contexts (the derive macro, which cannot
169/// see other items): any unknown non-`json` type is then assumed to be a
170/// `DbEnum` and DDL-only data (`check_in`) is left empty.
171pub fn table_from_struct(
172    item: &syn::ItemStruct,
173    enums: Option<&[EnumDef]>,
174) -> Result<TableDef, Error> {
175    let struct_name = item.ident.to_string();
176
177    let mut table_name: Option<String> = None;
178    let mut composite_uniques: Vec<Vec<String>> = Vec::new();
179    let mut composite_indexes: Vec<Vec<String>> = Vec::new();
180    for attr in item.attrs.iter().filter(|a| a.path().is_ident("orm")) {
181        attr.parse_nested_meta(|meta| {
182            if meta.path.is_ident("table") {
183                let lit: syn::LitStr = meta.value()?.parse()?;
184                table_name = Some(lit.value());
185                Ok(())
186            } else if meta.path.is_ident("unique") {
187                composite_uniques.push(parse_struct_column_list(&meta, "unique")?);
188                Ok(())
189            } else if meta.path.is_ident("index") {
190                composite_indexes.push(parse_struct_column_list(&meta, "index")?);
191                Ok(())
192            } else {
193                Err(meta.error(
194                    "unknown #[orm(...)] key on a struct; expected `table = \"...\"`, \
195                     `unique(col, ...)` or `index(col, ...)`",
196                ))
197            }
198        })
199        .map_err(|e| Error::new(format!("{struct_name}: {e}")))?;
200    }
201    let name = table_name.unwrap_or_else(|| pluralize(&to_snake_case(&struct_name)));
202
203    let syn::Fields::Named(fields) = &item.fields else {
204        return Err(Error::new(format!(
205            "{struct_name}: a Table struct needs named fields"
206        )));
207    };
208
209    let mut columns = Vec::new();
210    let mut relations = Vec::new();
211    for field in &fields.named {
212        match field_from_field(&struct_name, field, enums)? {
213            ParsedField::Column(c) => columns.push(*c),
214            ParsedField::Relation(r) => relations.push(r),
215        }
216    }
217
218    // A relation joins through one of this table's own foreign-key columns, so
219    // resolve its LEFT/INNER-ness (nullable FK → LEFT) and validate the column.
220    for rel in &mut relations {
221        let Some(col) = columns.iter().find(|c| c.name == rel.local_column) else {
222            return Err(Error::new(format!(
223                "{struct_name}.{}: relation column `{}` is not a field on this struct",
224                rel.field, rel.local_column
225            )));
226        };
227        if col.references.is_none() {
228            return Err(Error::new(format!(
229                "{struct_name}.{}: relation column `{}` has no #[orm(references(...))] — a relation \
230                 must join through a foreign key",
231                rel.field, rel.local_column
232            )));
233        }
234        rel.nullable = col.nullable;
235    }
236
237    // Composite unique/index column lists must name real columns on this
238    // table — checked here, once every field has been parsed.
239    for cols in composite_uniques.iter().chain(composite_indexes.iter()) {
240        for col in cols {
241            if columns.iter().all(|c| &c.name != col) {
242                return Err(Error::new(format!(
243                    "{struct_name}: unique(...)/index(...) references unknown column `{col}`"
244                )));
245            }
246        }
247    }
248
249    let table = TableDef {
250        name,
251        struct_name: struct_name.clone(),
252        columns,
253        relations,
254        composite_uniques,
255        composite_indexes,
256    };
257    if table.primary_key().is_empty() {
258        return Err(Error::new(format!(
259            "{struct_name}: no primary key — add an `id: i64` field or mark fields with #[orm(primary_key)]"
260        )));
261    }
262    Ok(table)
263}
264
265/// Parses the parenthesized column list following a struct-level `unique`/
266/// `index` key, e.g. the `(user_id, run_id)` in `#[orm(unique(user_id, run_id))]`.
267fn parse_struct_column_list(
268    meta: &syn::meta::ParseNestedMeta,
269    key: &str,
270) -> syn::Result<Vec<String>> {
271    let mut cols = Vec::new();
272    meta.parse_nested_meta(|m| {
273        let Some(ident) = m.path.get_ident() else {
274            return Err(m.error("expected a column name"));
275        };
276        cols.push(ident.to_string());
277        Ok(())
278    })?;
279    if cols.is_empty() {
280        return Err(meta.error(format!(
281            "{key}(...) needs at least one column, e.g. {key}(a, b)"
282        )));
283    }
284    Ok(cols)
285}
286
287/// Classify a struct field: a relation field (`#[orm(relation = fk)]`) carries
288/// no DDL and joins to another table; anything else is a database column.
289fn field_from_field(
290    struct_name: &str,
291    field: &syn::Field,
292    enums: Option<&[EnumDef]>,
293) -> Result<ParsedField, Error> {
294    if let Some(rel) = relation_from_field(struct_name, field)? {
295        return Ok(ParsedField::Relation(rel));
296    }
297    Ok(ParsedField::Column(Box::new(column_from_field(
298        struct_name,
299        field,
300        enums,
301    )?)))
302}
303
304/// A relation field is `#[orm(relation = fk_column)] name: Option<Target>`. It
305/// must be `Option` (unloaded relations are `None`) and carry no other orm
306/// keys. Returns `None` when the field is an ordinary column.
307///
308/// Parses each `#[orm(...)]` attribute's arguments as a plain
309/// `Punctuated<Meta, Comma>` (the generic form: bare `unique`, `name = value`,
310/// or `name(...)`) rather than probing key-by-key with `parse_nested_meta` —
311/// every field is scanned here before we know whether it is a relation or an
312/// ordinary column, so this must never partially consume a key's value (e.g.
313/// `references(Target, on_delete = cascade)`) only to abandon it; `Meta`
314/// parses each item fully regardless of which key it turns out to be.
315fn relation_from_field(
316    struct_name: &str,
317    field: &syn::Field,
318) -> Result<Option<RelationDef>, Error> {
319    let field_name = field.ident.as_ref().expect("named field").to_string();
320    let ctx = format!("{struct_name}.{field_name}");
321
322    let mut local_column: Option<String> = None;
323    let mut other_key = false;
324    for attr in field.attrs.iter().filter(|a| a.path().is_ident("orm")) {
325        let metas = attr
326            .parse_args_with(
327                syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated,
328            )
329            .map_err(|e| Error::new(format!("{ctx}: {e}")))?;
330        for meta in &metas {
331            if meta.path().is_ident("relation") {
332                let syn::Meta::NameValue(nv) = meta else {
333                    return Err(Error::new(format!(
334                        "{ctx}: expected `relation = fk_column`"
335                    )));
336                };
337                let syn::Expr::Path(p) = &nv.value else {
338                    return Err(Error::new(format!(
339                        "{ctx}: relation value must be a column name"
340                    )));
341                };
342                let Some(ident) = p.path.get_ident() else {
343                    return Err(Error::new(format!(
344                        "{ctx}: relation value must be a column name"
345                    )));
346                };
347                local_column = Some(ident.to_string());
348            } else {
349                other_key = true;
350            }
351        }
352    }
353    let Some(local_column) = local_column else {
354        return Ok(None);
355    };
356    if other_key {
357        return Err(Error::new(format!(
358            "{ctx}: a #[orm(relation = ...)] field takes no other orm keys"
359        )));
360    }
361
362    let (nullable, inner) = unwrap_option(&field.ty);
363    if !nullable {
364        return Err(Error::new(format!(
365            "{ctx}: a relation field must be `Option<Target>` (it is `None` until loaded)"
366        )));
367    }
368    let Some(target_struct) = last_segment_ident(inner) else {
369        return Err(Error::new(format!(
370            "{ctx}: relation target must be a struct type"
371        )));
372    };
373
374    Ok(Some(RelationDef {
375        field: field_name,
376        target_struct,
377        // Resolved from local_column's foreign key in `parse_sources`.
378        target_table: String::new(),
379        local_column,
380        // Resolved from the FK column's nullability in `table_from_struct`.
381        nullable: false,
382    }))
383}
384
385fn column_from_field(
386    struct_name: &str,
387    field: &syn::Field,
388    enums: Option<&[EnumDef]>,
389) -> Result<ColumnDef, Error> {
390    let name = field.ident.as_ref().expect("named field").to_string();
391    let ctx = format!("{struct_name}.{name}");
392
393    let mut unique = false;
394    let mut json = false;
395    let mut text = false;
396    let mut index = false;
397    let mut explicit_pk = false;
398    let mut default: Option<DefaultValue> = None;
399    let mut references: Option<ForeignKey> = None;
400    let mut renamed_from: Option<String> = None;
401
402    for attr in field.attrs.iter().filter(|a| a.path().is_ident("orm")) {
403        attr.parse_nested_meta(|meta| {
404            if meta.path.is_ident("unique") {
405                unique = true;
406            } else if meta.path.is_ident("json") {
407                json = true;
408            } else if meta.path.is_ident("text") {
409                text = true;
410            } else if meta.path.is_ident("index") {
411                index = true;
412            } else if meta.path.is_ident("primary_key") {
413                explicit_pk = true;
414            } else if meta.path.is_ident("renamed_from") {
415                let lit: syn::LitStr = meta.value()?.parse()?;
416                renamed_from = Some(lit.value());
417            } else if meta.path.is_ident("default") {
418                let expr: syn::Expr = meta.value()?.parse()?;
419                default = Some(parse_default(&expr).map_err(|m| meta.error(m))?);
420            } else if meta.path.is_ident("references") {
421                let mut target: Option<String> = None;
422                let mut on_delete: Option<OnDelete> = None;
423                meta.parse_nested_meta(|m| {
424                    if m.path.is_ident("on_delete") {
425                        let ident: syn::Ident = m.value()?.parse()?;
426                        on_delete = Some(match ident.to_string().as_str() {
427                            "cascade" => OnDelete::Cascade,
428                            "set_null" => OnDelete::SetNull,
429                            "restrict" => OnDelete::Restrict,
430                            other => {
431                                return Err(m.error(format!(
432                                    "unknown on_delete `{other}`; expected cascade, set_null or restrict"
433                                )));
434                            }
435                        });
436                    } else if let Some(ident) = m.path.get_ident() {
437                        target = Some(ident.to_string());
438                    } else {
439                        return Err(m.error("expected a struct name, e.g. references(Event)"));
440                    }
441                    Ok(())
442                })?;
443                let Some(target) = target else {
444                    return Err(meta.error("references(...) needs a target, e.g. references(Event)"));
445                };
446                references = Some(ForeignKey { table: target, column: "id".into(), on_delete });
447            } else {
448                return Err(meta.error(
449                    "unknown #[orm(...)] key; expected unique, json, text, index, primary_key, default, references or renamed_from",
450                ));
451            }
452            Ok(())
453        })
454        .map_err(|e| Error::new(format!("{ctx}: {e}")))?;
455    }
456
457    let (nullable, inner) = unwrap_option(&field.ty);
458    let rust_type = inner.to_token_stream().to_string().replace(' ', "");
459
460    let (ty, is_enum, check_in) = if json {
461        (SqlType::Text, false, None)
462    } else if matches!(rust_type.as_str(), "u64" | "usize" | "isize") {
463        // SQLite INTEGER is i64 and sqlx-sqlite has no Encode impl for these
464        // — without this check the failure would be a cryptic trait-bound
465        // error deep inside generated code.
466        return Err(Error::new(format!(
467            "{ctx}: `{rust_type}` cannot be stored in SQLite (INTEGER is i64) — use i64"
468        )));
469    } else if text {
470        // `#[orm(text)]`: stored TEXT via as_str()/FromStr, no CHECK — for
471        // types whose value set the schema layer cannot see (e.g. a role
472        // enum generated by an app macro).
473        if native_sql_type(inner).is_some() {
474            return Err(Error::new(format!(
475                "{ctx}: #[orm(text)] is for non-native types (this one maps natively already)"
476            )));
477        }
478        (SqlType::Text, true, None)
479    } else if let Some(t) = native_sql_type(inner) {
480        (t, false, None)
481    } else if let Some(enums) = enums {
482        let type_name = last_segment_ident(inner);
483        match enums
484            .iter()
485            .find(|e| Some(e.rust_name.as_str()) == type_name.as_deref())
486        {
487            Some(e) => (SqlType::Text, true, Some(e.values.clone())),
488            None => {
489                return Err(Error::new(format!(
490                    "{ctx}: unsupported type `{rust_type}` — use a native type, derive DbEnum on it, or mark the field #[orm(json)]"
491                )));
492            }
493        }
494    } else {
495        // Single-item context (derive macro): assume DbEnum, DDL data absent.
496        (SqlType::Text, true, None)
497    };
498
499    let mut primary_key = explicit_pk;
500    if name == "id" && ty == SqlType::Integer && !nullable {
501        primary_key = true;
502    }
503    if primary_key && nullable {
504        return Err(Error::new(format!("{ctx}: a primary key cannot be Option")));
505    }
506
507    if let Some(d) = &default {
508        validate_default(&ctx, ty, d, check_in.as_deref())?;
509    }
510    if index && (unique || primary_key) {
511        return Err(Error::new(format!(
512            "{ctx}: #[orm(index)] is redundant — unique/primary key columns are already indexed"
513        )));
514    }
515
516    Ok(ColumnDef {
517        name,
518        rust_type,
519        ty,
520        nullable,
521        primary_key,
522        unique,
523        json,
524        is_enum,
525        index,
526        default,
527        references,
528        check_in,
529        renamed_from,
530    })
531}
532
533fn parse_default(expr: &syn::Expr) -> Result<DefaultValue, String> {
534    match expr {
535        syn::Expr::Path(p) if p.path.is_ident("now") => Ok(DefaultValue::Now),
536        syn::Expr::Lit(l) => match &l.lit {
537            syn::Lit::Int(i) => Ok(DefaultValue::Int(
538                i.base10_parse().map_err(|e| e.to_string())?,
539            )),
540            syn::Lit::Float(f) => Ok(DefaultValue::Float(
541                f.base10_parse().map_err(|e| e.to_string())?,
542            )),
543            syn::Lit::Str(s) => Ok(DefaultValue::Text(s.value())),
544            syn::Lit::Bool(b) => Ok(DefaultValue::Bool(b.value)),
545            _ => Err("unsupported default literal".into()),
546        },
547        syn::Expr::Unary(u) if matches!(u.op, syn::UnOp::Neg(_)) => match parse_default(&u.expr)? {
548            DefaultValue::Int(i) => Ok(DefaultValue::Int(-i)),
549            DefaultValue::Float(f) => Ok(DefaultValue::Float(-f)),
550            _ => Err("cannot negate this default".into()),
551        },
552        _ => Err("expected a literal or `now`, e.g. default = 0 or default = now".into()),
553    }
554}
555
556fn validate_default(
557    ctx: &str,
558    ty: SqlType,
559    d: &DefaultValue,
560    check_in: Option<&[String]>,
561) -> Result<(), Error> {
562    let ok = matches!(
563        (ty, d),
564        (SqlType::Integer, DefaultValue::Int(_))
565            | (SqlType::Real, DefaultValue::Float(_))
566            | (SqlType::Real, DefaultValue::Int(_))
567            | (SqlType::Text, DefaultValue::Text(_))
568            | (SqlType::Boolean, DefaultValue::Bool(_))
569            | (SqlType::Timestamp, DefaultValue::Now)
570    );
571    if !ok {
572        return Err(Error::new(format!(
573            "{ctx}: default {d:?} does not fit column type {ty:?}"
574        )));
575    }
576    if let (Some(values), DefaultValue::Text(s)) = (check_in, d)
577        && !values.iter().any(|v| v == s)
578    {
579        return Err(Error::new(format!(
580            "{ctx}: default '{s}' is not one of the enum values {values:?}"
581        )));
582    }
583    Ok(())
584}
585
586/// Map a Rust type to its native SQLite storage type. Anything not listed
587/// here needs `#[orm(json)]` or a `DbEnum`.
588pub fn native_sql_type(ty: &syn::Type) -> Option<SqlType> {
589    let seg = last_segment(ty)?;
590    Some(match seg.ident.to_string().as_str() {
591        // u64/usize/isize are rejected with a dedicated error in
592        // `column_from_field` — sqlx-sqlite cannot encode them.
593        "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" => SqlType::Integer,
594        "f32" | "f64" => SqlType::Real,
595        "bool" => SqlType::Boolean,
596        "String" => SqlType::Text,
597        "Uuid" => SqlType::Text,
598        "NaiveDateTime" | "DateTime" => SqlType::Timestamp,
599        "NaiveDate" | "NaiveTime" => SqlType::Text,
600        "Vec" => {
601            if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
602                && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
603                && last_segment(inner).is_some_and(|s| s.ident == "u8")
604            {
605                SqlType::Blob
606            } else {
607                return None;
608            }
609        }
610        _ => return None,
611    })
612}
613
614fn unwrap_option(ty: &syn::Type) -> (bool, &syn::Type) {
615    if let Some(seg) = last_segment(ty)
616        && seg.ident == "Option"
617        && let syn::PathArguments::AngleBracketed(args) = &seg.arguments
618        && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
619    {
620        return (true, inner);
621    }
622    (false, ty)
623}
624
625fn last_segment(ty: &syn::Type) -> Option<&syn::PathSegment> {
626    if let syn::Type::Path(p) = ty {
627        p.path.segments.last()
628    } else {
629        None
630    }
631}
632
633fn last_segment_ident(ty: &syn::Type) -> Option<String> {
634    last_segment(ty).map(|s| s.ident.to_string())
635}
636
637/// Does the item carry the framework's `#[model(...)]` attribute macro? Such
638/// a struct expands to `#[derive(Table)]` plus app metadata, so the schema
639/// layer treats it exactly like a hand-derived table. (The framework crates
640/// are not a dependency here — this is a purely syntactic check, in the same
641/// spirit as [`has_derive`].)
642pub fn has_model_attr(attrs: &[syn::Attribute]) -> bool {
643    attrs.iter().any(|a| {
644        a.path()
645            .segments
646            .last()
647            .is_some_and(|s| s.ident == "model")
648    })
649}
650
651/// Does `#[derive(...)]` on this item mention `name` (by last path segment,
652/// so `fse_orm::Table` matches too)?
653pub fn has_derive(attrs: &[syn::Attribute], name: &str) -> bool {
654    attrs
655        .iter()
656        .filter(|a| a.path().is_ident("derive"))
657        .any(|a| {
658            let mut found = false;
659            let _ = a.parse_nested_meta(|meta| {
660                if meta.path.segments.last().is_some_and(|s| s.ident == name) {
661                    found = true;
662                }
663                Ok(())
664            });
665            found
666        })
667}
668
669pub fn to_snake_case(s: &str) -> String {
670    let mut out = String::new();
671    for (i, ch) in s.chars().enumerate() {
672        if ch.is_uppercase() {
673            if i != 0 {
674                out.push('_');
675            }
676            out.extend(ch.to_lowercase());
677        } else {
678            out.push(ch);
679        }
680    }
681    out
682}
683
684/// Naive English pluralization for default table names — struct `Category`
685/// becomes table `categories`. Wrong for irregular nouns; override with
686/// `#[orm(table = "...")]`.
687pub fn pluralize(s: &str) -> String {
688    if let Some(stem) = s.strip_suffix('y')
689        && stem.chars().last().is_some_and(|c| !"aeiou".contains(c))
690    {
691        return format!("{stem}ies");
692    }
693    if s.ends_with('s')
694        || s.ends_with('x')
695        || s.ends_with('z')
696        || s.ends_with("ch")
697        || s.ends_with("sh")
698    {
699        return format!("{s}es");
700    }
701    format!("{s}s")
702}