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