Skip to main content

fse_schema/
model.rs

1//! The schema model — the single source of truth for what a `#[derive(Table)]`
2//! struct means in SQL. Everything is `serde`-serializable because the
3//! snapshot file is this model as JSON.
4
5use serde::{Deserialize, Serialize};
6
7/// A whole application schema: every `#[derive(Table)]` struct and
8/// `#[derive(DbEnum)]` enum found in the tables folder. Tables and enums are
9/// kept sorted by name so snapshots are deterministic regardless of file
10/// ordering.
11#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
12pub struct Schema {
13    pub tables: Vec<TableDef>,
14    pub enums: Vec<EnumDef>,
15}
16
17impl Schema {
18    pub fn table(&self, name: &str) -> Option<&TableDef> {
19        self.tables.iter().find(|t| t.name == name)
20    }
21}
22
23/// A `#[derive(DbEnum)]` enum: stored as TEXT, constrained with a CHECK.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct EnumDef {
26    /// Rust enum name, e.g. `ProductStatus`.
27    pub rust_name: String,
28    /// Stored values: snake_case of the variant names.
29    pub values: Vec<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct TableDef {
34    /// SQL table name (`products`).
35    pub name: String,
36    /// Rust struct name (`Product`).
37    pub struct_name: String,
38    pub columns: Vec<ColumnDef>,
39    /// Prisma-style relation fields: struct fields that are *not* database
40    /// columns but hold a related row (`Option<OtherTable>`), populated by an
41    /// eager join when a query asks for them via `include:`. Skipped by the
42    /// migration diff entirely — they carry no DDL.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub relations: Vec<RelationDef>,
45}
46
47impl TableDef {
48    pub fn column(&self, name: &str) -> Option<&ColumnDef> {
49        self.columns.iter().find(|c| c.name == name)
50    }
51
52    pub fn relation(&self, field: &str) -> Option<&RelationDef> {
53        self.relations.iter().find(|r| r.field == field)
54    }
55
56    pub fn primary_key(&self) -> Vec<&ColumnDef> {
57        self.columns.iter().filter(|c| c.primary_key).collect()
58    }
59
60    /// True when the pk is the conventional `id: i64` surrogate key, which
61    /// maps to `INTEGER PRIMARY KEY AUTOINCREMENT`.
62    pub fn auto_id(&self) -> bool {
63        let pk = self.primary_key();
64        pk.len() == 1 && pk[0].name == "id" && pk[0].ty == SqlType::Integer
65    }
66}
67
68/// A belongs-to relation field: `#[orm(relation = fk_column)] field:
69/// Option<Target>`. Traverses `local_column` (a foreign key on this table) to
70/// its referenced row. When the FK column is nullable the join is a LEFT JOIN
71/// and the field is `None` for rows with no parent.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct RelationDef {
74    /// Struct field name (`run`, `donor`).
75    pub field: String,
76    /// Target struct name (`Run`), taken from the field's `Option<Target>` type.
77    pub target_struct: String,
78    /// Target SQL table (`runs`); resolved from `local_column`'s foreign key by
79    /// [`crate::parse::parse_sources`]. Empty in single-struct (derive) contexts.
80    pub target_table: String,
81    /// The foreign-key column on this table the relation joins through.
82    pub local_column: String,
83    /// True when `local_column` is nullable → LEFT JOIN, `None` when absent.
84    pub nullable: bool,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct ColumnDef {
89    pub name: String,
90    /// The Rust type as written, with `Option` unwrapped (`NaiveDateTime`).
91    pub rust_type: String,
92    pub ty: SqlType,
93    pub nullable: bool,
94    pub primary_key: bool,
95    pub unique: bool,
96    /// Stored as TEXT through serde (`#[orm(json)]`).
97    pub json: bool,
98    /// Column holds a value stored as TEXT via `as_str()`/`FromStr`: a
99    /// `#[derive(DbEnum)]` (with a CHECK from `check_in`) or an
100    /// `#[orm(text)]` type (no CHECK).
101    pub is_enum: bool,
102    /// `#[orm(index)]` — a plain (non-unique) index on this column.
103    #[serde(default)]
104    pub index: bool,
105    pub default: Option<DefaultValue>,
106    pub references: Option<ForeignKey>,
107    /// Allowed values (from the enum) — rendered as a CHECK constraint.
108    pub check_in: Option<Vec<String>>,
109    /// One-shot rename marker: `#[orm(renamed_from = "old")]`. Remove the
110    /// attribute once the generated migration has been applied.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub renamed_from: Option<String>,
113}
114
115impl ColumnDef {
116    /// The column identity used for change detection — everything except the
117    /// transient rename marker and the index flag (index changes are plain
118    /// CREATE/DROP INDEX statements, never a rebuild by themselves).
119    pub fn signature(&self) -> ColumnDef {
120        ColumnDef {
121            renamed_from: None,
122            index: false,
123            ..self.clone()
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129pub enum SqlType {
130    Integer,
131    Real,
132    Text,
133    Blob,
134    Boolean,
135    Timestamp,
136}
137
138impl SqlType {
139    pub fn sql(self) -> &'static str {
140        match self {
141            SqlType::Integer => "INTEGER",
142            SqlType::Real => "REAL",
143            SqlType::Text => "TEXT",
144            SqlType::Blob => "BLOB",
145            SqlType::Boolean => "BOOLEAN",
146            SqlType::Timestamp => "TIMESTAMP",
147        }
148    }
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub enum DefaultValue {
153    /// `#[orm(default = now)]` → `DEFAULT CURRENT_TIMESTAMP`.
154    Now,
155    Int(i64),
156    Float(f64),
157    Text(String),
158    Bool(bool),
159}
160
161impl DefaultValue {
162    pub fn sql(&self) -> String {
163        match self {
164            DefaultValue::Now => "CURRENT_TIMESTAMP".into(),
165            DefaultValue::Int(i) => i.to_string(),
166            DefaultValue::Float(f) => f.to_string(),
167            DefaultValue::Text(s) => format!("'{}'", s.replace('\'', "''")),
168            DefaultValue::Bool(b) => if *b { "1" } else { "0" }.into(),
169        }
170    }
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct ForeignKey {
175    /// Target table. Holds the referenced *struct* name straight after
176    /// parsing a single struct; [`crate::parse::parse_sources`] resolves it
177    /// to the table name.
178    pub table: String,
179    pub column: String,
180    pub on_delete: Option<OnDelete>,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184pub enum OnDelete {
185    Cascade,
186    SetNull,
187    Restrict,
188}
189
190impl OnDelete {
191    pub fn sql(self) -> &'static str {
192        match self {
193            OnDelete::Cascade => "CASCADE",
194            OnDelete::SetNull => "SET NULL",
195            OnDelete::Restrict => "RESTRICT",
196        }
197    }
198}