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    for attr in item.attrs.iter().filter(|a| a.path().is_ident("orm")) {
142        attr.parse_nested_meta(|meta| {
143            if meta.path.is_ident("table") {
144                let lit: syn::LitStr = meta.value()?.parse()?;
145                table_name = Some(lit.value());
146                Ok(())
147            } else {
148                Err(meta.error("unknown #[orm(...)] key on a struct; expected `table = \"...\"`"))
149            }
150        })
151        .map_err(|e| Error::new(format!("{struct_name}: {e}")))?;
152    }
153    let name = table_name.unwrap_or_else(|| pluralize(&to_snake_case(&struct_name)));
154
155    let syn::Fields::Named(fields) = &item.fields else {
156        return Err(Error::new(format!("{struct_name}: a Table struct needs named fields")));
157    };
158
159    let mut columns = Vec::new();
160    let mut relations = Vec::new();
161    for field in &fields.named {
162        match field_from_field(&struct_name, field, enums)? {
163            ParsedField::Column(c) => columns.push(*c),
164            ParsedField::Relation(r) => relations.push(r),
165        }
166    }
167
168    // A relation joins through one of this table's own foreign-key columns, so
169    // resolve its LEFT/INNER-ness (nullable FK → LEFT) and validate the column.
170    for rel in &mut relations {
171        let Some(col) = columns.iter().find(|c| c.name == rel.local_column) else {
172            return Err(Error::new(format!(
173                "{struct_name}.{}: relation column `{}` is not a field on this struct",
174                rel.field, rel.local_column
175            )));
176        };
177        if col.references.is_none() {
178            return Err(Error::new(format!(
179                "{struct_name}.{}: relation column `{}` has no #[orm(references(...))] — a relation \
180                 must join through a foreign key",
181                rel.field, rel.local_column
182            )));
183        }
184        rel.nullable = col.nullable;
185    }
186
187    let table = TableDef { name, struct_name: struct_name.clone(), columns, relations };
188    if table.primary_key().is_empty() {
189        return Err(Error::new(format!(
190            "{struct_name}: no primary key — add an `id: i64` field or mark fields with #[orm(primary_key)]"
191        )));
192    }
193    Ok(table)
194}
195
196/// Classify a struct field: a relation field (`#[orm(relation = fk)]`) carries
197/// no DDL and joins to another table; anything else is a database column.
198fn field_from_field(
199    struct_name: &str,
200    field: &syn::Field,
201    enums: Option<&[EnumDef]>,
202) -> Result<ParsedField, Error> {
203    if let Some(rel) = relation_from_field(struct_name, field)? {
204        return Ok(ParsedField::Relation(rel));
205    }
206    Ok(ParsedField::Column(Box::new(column_from_field(struct_name, field, enums)?)))
207}
208
209/// A relation field is `#[orm(relation = fk_column)] name: Option<Target>`. It
210/// must be `Option` (unloaded relations are `None`) and carry no other orm
211/// keys. Returns `None` when the field is an ordinary column.
212///
213/// Parses each `#[orm(...)]` attribute's arguments as a plain
214/// `Punctuated<Meta, Comma>` (the generic form: bare `unique`, `name = value`,
215/// or `name(...)`) rather than probing key-by-key with `parse_nested_meta` —
216/// every field is scanned here before we know whether it is a relation or an
217/// ordinary column, so this must never partially consume a key's value (e.g.
218/// `references(Target, on_delete = cascade)`) only to abandon it; `Meta`
219/// parses each item fully regardless of which key it turns out to be.
220fn relation_from_field(
221    struct_name: &str,
222    field: &syn::Field,
223) -> Result<Option<RelationDef>, Error> {
224    let field_name = field.ident.as_ref().expect("named field").to_string();
225    let ctx = format!("{struct_name}.{field_name}");
226
227    let mut local_column: Option<String> = None;
228    let mut other_key = false;
229    for attr in field.attrs.iter().filter(|a| a.path().is_ident("orm")) {
230        let metas = attr
231            .parse_args_with(syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated)
232            .map_err(|e| Error::new(format!("{ctx}: {e}")))?;
233        for meta in &metas {
234            if meta.path().is_ident("relation") {
235                let syn::Meta::NameValue(nv) = meta else {
236                    return Err(Error::new(format!("{ctx}: expected `relation = fk_column`")));
237                };
238                let syn::Expr::Path(p) = &nv.value else {
239                    return Err(Error::new(format!("{ctx}: relation value must be a column name")));
240                };
241                let Some(ident) = p.path.get_ident() else {
242                    return Err(Error::new(format!("{ctx}: relation value must be a column name")));
243                };
244                local_column = Some(ident.to_string());
245            } else {
246                other_key = true;
247            }
248        }
249    }
250    let Some(local_column) = local_column else {
251        return Ok(None);
252    };
253    if other_key {
254        return Err(Error::new(format!(
255            "{ctx}: a #[orm(relation = ...)] field takes no other orm keys"
256        )));
257    }
258
259    let (nullable, inner) = unwrap_option(&field.ty);
260    if !nullable {
261        return Err(Error::new(format!(
262            "{ctx}: a relation field must be `Option<Target>` (it is `None` until loaded)"
263        )));
264    }
265    let Some(target_struct) = last_segment_ident(inner) else {
266        return Err(Error::new(format!("{ctx}: relation target must be a struct type")));
267    };
268
269    Ok(Some(RelationDef {
270        field: field_name,
271        target_struct,
272        // Resolved from local_column's foreign key in `parse_sources`.
273        target_table: String::new(),
274        local_column,
275        // Resolved from the FK column's nullability in `table_from_struct`.
276        nullable: false,
277    }))
278}
279
280fn column_from_field(
281    struct_name: &str,
282    field: &syn::Field,
283    enums: Option<&[EnumDef]>,
284) -> Result<ColumnDef, Error> {
285    let name = field
286        .ident
287        .as_ref()
288        .expect("named field")
289        .to_string();
290    let ctx = format!("{struct_name}.{name}");
291
292    let mut unique = false;
293    let mut json = false;
294    let mut text = false;
295    let mut index = false;
296    let mut explicit_pk = false;
297    let mut default: Option<DefaultValue> = None;
298    let mut references: Option<ForeignKey> = None;
299    let mut renamed_from: Option<String> = None;
300
301    for attr in field.attrs.iter().filter(|a| a.path().is_ident("orm")) {
302        attr.parse_nested_meta(|meta| {
303            if meta.path.is_ident("unique") {
304                unique = true;
305            } else if meta.path.is_ident("json") {
306                json = true;
307            } else if meta.path.is_ident("text") {
308                text = true;
309            } else if meta.path.is_ident("index") {
310                index = true;
311            } else if meta.path.is_ident("primary_key") {
312                explicit_pk = true;
313            } else if meta.path.is_ident("renamed_from") {
314                let lit: syn::LitStr = meta.value()?.parse()?;
315                renamed_from = Some(lit.value());
316            } else if meta.path.is_ident("default") {
317                let expr: syn::Expr = meta.value()?.parse()?;
318                default = Some(parse_default(&expr).map_err(|m| meta.error(m))?);
319            } else if meta.path.is_ident("references") {
320                let mut target: Option<String> = None;
321                let mut on_delete: Option<OnDelete> = None;
322                meta.parse_nested_meta(|m| {
323                    if m.path.is_ident("on_delete") {
324                        let ident: syn::Ident = m.value()?.parse()?;
325                        on_delete = Some(match ident.to_string().as_str() {
326                            "cascade" => OnDelete::Cascade,
327                            "set_null" => OnDelete::SetNull,
328                            "restrict" => OnDelete::Restrict,
329                            other => {
330                                return Err(m.error(format!(
331                                    "unknown on_delete `{other}`; expected cascade, set_null or restrict"
332                                )));
333                            }
334                        });
335                    } else if let Some(ident) = m.path.get_ident() {
336                        target = Some(ident.to_string());
337                    } else {
338                        return Err(m.error("expected a struct name, e.g. references(Event)"));
339                    }
340                    Ok(())
341                })?;
342                let Some(target) = target else {
343                    return Err(meta.error("references(...) needs a target, e.g. references(Event)"));
344                };
345                references = Some(ForeignKey { table: target, column: "id".into(), on_delete });
346            } else {
347                return Err(meta.error(
348                    "unknown #[orm(...)] key; expected unique, json, text, index, primary_key, default, references or renamed_from",
349                ));
350            }
351            Ok(())
352        })
353        .map_err(|e| Error::new(format!("{ctx}: {e}")))?;
354    }
355
356    let (nullable, inner) = unwrap_option(&field.ty);
357    let rust_type = inner.to_token_stream().to_string().replace(' ', "");
358
359    let (ty, is_enum, check_in) = if json {
360        (SqlType::Text, false, None)
361    } else if text {
362        // `#[orm(text)]`: stored TEXT via as_str()/FromStr, no CHECK — for
363        // types whose value set the schema layer cannot see (e.g. a role
364        // enum generated by an app macro).
365        if native_sql_type(inner).is_some() {
366            return Err(Error::new(format!(
367                "{ctx}: #[orm(text)] is for non-native types (this one maps natively already)"
368            )));
369        }
370        (SqlType::Text, true, None)
371    } else if let Some(t) = native_sql_type(inner) {
372        (t, false, None)
373    } else if let Some(enums) = enums {
374        let type_name = last_segment_ident(inner);
375        match enums.iter().find(|e| Some(e.rust_name.as_str()) == type_name.as_deref()) {
376            Some(e) => (SqlType::Text, true, Some(e.values.clone())),
377            None => {
378                return Err(Error::new(format!(
379                    "{ctx}: unsupported type `{rust_type}` — use a native type, derive DbEnum on it, or mark the field #[orm(json)]"
380                )));
381            }
382        }
383    } else {
384        // Single-item context (derive macro): assume DbEnum, DDL data absent.
385        (SqlType::Text, true, None)
386    };
387
388    let mut primary_key = explicit_pk;
389    if name == "id" && ty == SqlType::Integer && !nullable {
390        primary_key = true;
391    }
392    if primary_key && nullable {
393        return Err(Error::new(format!("{ctx}: a primary key cannot be Option")));
394    }
395
396    if let Some(d) = &default {
397        validate_default(&ctx, ty, d, check_in.as_deref())?;
398    }
399    if index && (unique || primary_key) {
400        return Err(Error::new(format!(
401            "{ctx}: #[orm(index)] is redundant — unique/primary key columns are already indexed"
402        )));
403    }
404
405    Ok(ColumnDef {
406        name,
407        rust_type,
408        ty,
409        nullable,
410        primary_key,
411        unique,
412        json,
413        is_enum,
414        index,
415        default,
416        references,
417        check_in,
418        renamed_from,
419    })
420}
421
422fn parse_default(expr: &syn::Expr) -> Result<DefaultValue, String> {
423    match expr {
424        syn::Expr::Path(p) if p.path.is_ident("now") => Ok(DefaultValue::Now),
425        syn::Expr::Lit(l) => match &l.lit {
426            syn::Lit::Int(i) => Ok(DefaultValue::Int(i.base10_parse().map_err(|e| e.to_string())?)),
427            syn::Lit::Float(f) => {
428                Ok(DefaultValue::Float(f.base10_parse().map_err(|e| e.to_string())?))
429            }
430            syn::Lit::Str(s) => Ok(DefaultValue::Text(s.value())),
431            syn::Lit::Bool(b) => Ok(DefaultValue::Bool(b.value)),
432            _ => Err("unsupported default literal".into()),
433        },
434        syn::Expr::Unary(u) if matches!(u.op, syn::UnOp::Neg(_)) => match parse_default(&u.expr)? {
435            DefaultValue::Int(i) => Ok(DefaultValue::Int(-i)),
436            DefaultValue::Float(f) => Ok(DefaultValue::Float(-f)),
437            _ => Err("cannot negate this default".into()),
438        },
439        _ => Err("expected a literal or `now`, e.g. default = 0 or default = now".into()),
440    }
441}
442
443fn validate_default(
444    ctx: &str,
445    ty: SqlType,
446    d: &DefaultValue,
447    check_in: Option<&[String]>,
448) -> Result<(), Error> {
449    let ok = matches!(
450        (ty, d),
451        (SqlType::Integer, DefaultValue::Int(_))
452            | (SqlType::Real, DefaultValue::Float(_))
453            | (SqlType::Real, DefaultValue::Int(_))
454            | (SqlType::Text, DefaultValue::Text(_))
455            | (SqlType::Boolean, DefaultValue::Bool(_))
456            | (SqlType::Timestamp, DefaultValue::Now)
457    );
458    if !ok {
459        return Err(Error::new(format!(
460            "{ctx}: default {d:?} does not fit column type {ty:?}"
461        )));
462    }
463    if let (Some(values), DefaultValue::Text(s)) = (check_in, d)
464        && !values.iter().any(|v| v == s)
465    {
466        return Err(Error::new(format!(
467            "{ctx}: default '{s}' is not one of the enum values {values:?}"
468        )));
469    }
470    Ok(())
471}
472
473/// Map a Rust type to its native SQLite storage type. Anything not listed
474/// here needs `#[orm(json)]` or a `DbEnum`.
475pub fn native_sql_type(ty: &syn::Type) -> Option<SqlType> {
476    let seg = last_segment(ty)?;
477    Some(match seg.ident.to_string().as_str() {
478        "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize" => {
479            SqlType::Integer
480        }
481        "f32" | "f64" => SqlType::Real,
482        "bool" => SqlType::Boolean,
483        "String" => SqlType::Text,
484        "Uuid" => SqlType::Text,
485        "NaiveDateTime" | "DateTime" => SqlType::Timestamp,
486        "NaiveDate" | "NaiveTime" => SqlType::Text,
487        "Vec" => {
488            if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
489                && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
490                && last_segment(inner).is_some_and(|s| s.ident == "u8")
491            {
492                SqlType::Blob
493            } else {
494                return None;
495            }
496        }
497        _ => return None,
498    })
499}
500
501fn unwrap_option(ty: &syn::Type) -> (bool, &syn::Type) {
502    if let Some(seg) = last_segment(ty)
503        && seg.ident == "Option"
504        && let syn::PathArguments::AngleBracketed(args) = &seg.arguments
505        && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
506    {
507        return (true, inner);
508    }
509    (false, ty)
510}
511
512fn last_segment(ty: &syn::Type) -> Option<&syn::PathSegment> {
513    if let syn::Type::Path(p) = ty {
514        p.path.segments.last()
515    } else {
516        None
517    }
518}
519
520fn last_segment_ident(ty: &syn::Type) -> Option<String> {
521    last_segment(ty).map(|s| s.ident.to_string())
522}
523
524/// Does `#[derive(...)]` on this item mention `name` (by last path segment,
525/// so `fse_orm::Table` matches too)?
526pub fn has_derive(attrs: &[syn::Attribute], name: &str) -> bool {
527    attrs
528        .iter()
529        .filter(|a| a.path().is_ident("derive"))
530        .any(|a| {
531            let mut found = false;
532            let _ = a.parse_nested_meta(|meta| {
533                if meta.path.segments.last().is_some_and(|s| s.ident == name) {
534                    found = true;
535                }
536                Ok(())
537            });
538            found
539        })
540}
541
542pub fn to_snake_case(s: &str) -> String {
543    let mut out = String::new();
544    for (i, ch) in s.chars().enumerate() {
545        if ch.is_uppercase() {
546            if i != 0 {
547                out.push('_');
548            }
549            out.extend(ch.to_lowercase());
550        } else {
551            out.push(ch);
552        }
553    }
554    out
555}
556
557/// Naive English pluralization for default table names — struct `Category`
558/// becomes table `categories`. Wrong for irregular nouns; override with
559/// `#[orm(table = "...")]`.
560pub fn pluralize(s: &str) -> String {
561    if let Some(stem) = s.strip_suffix('y')
562        && stem.chars().last().is_some_and(|c| !"aeiou".contains(c))
563    {
564        return format!("{stem}ies");
565    }
566    if s.ends_with('s')
567        || s.ends_with('x')
568        || s.ends_with('z')
569        || s.ends_with("ch")
570        || s.ends_with("sh")
571    {
572        return format!("{s}es");
573    }
574    format!("{s}s")
575}