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 /// Adds another schema's tables and enums — how a module's shipped
23 /// snapshot joins the app's schema before diffing. `source` names the
24 /// module for error messages.
25 ///
26 /// # Errors
27 ///
28 /// A table name that already exists, or an enum of the same name with
29 /// different values, is a hard conflict.
30 pub fn merge(&mut self, other: Schema, source: &str) -> Result<(), crate::Error> {
31 for table in other.tables {
32 if self.table(&table.name).is_some() {
33 return Err(crate::Error::new(format!(
34 "table `{}` from module `{source}` conflicts with an existing table",
35 table.name
36 )));
37 }
38 self.tables.push(table);
39 }
40 for enum_def in other.enums {
41 match self.enums.iter().find(|e| e.rust_name == enum_def.rust_name) {
42 Some(existing) if existing.values != enum_def.values => {
43 return Err(crate::Error::new(format!(
44 "enum `{}` from module `{source}` conflicts with an existing enum \
45 of the same name but different values",
46 enum_def.rust_name
47 )));
48 }
49 Some(_) => {}
50 None => self.enums.push(enum_def),
51 }
52 }
53 self.tables.sort_by(|a, b| a.name.cmp(&b.name));
54 self.enums.sort_by(|a, b| a.rust_name.cmp(&b.rust_name));
55 Ok(())
56 }
57}
58
59/// A `#[derive(DbEnum)]` enum: stored as TEXT, constrained with a CHECK.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct EnumDef {
62 /// Rust enum name, e.g. `ProductStatus`.
63 pub rust_name: String,
64 /// Stored values: snake_case of the variant names.
65 pub values: Vec<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct TableDef {
70 /// SQL table name (`products`).
71 pub name: String,
72 /// Rust struct name (`Product`).
73 pub struct_name: String,
74 pub columns: Vec<ColumnDef>,
75 /// Prisma-style relation fields: struct fields that are *not* database
76 /// columns but hold a related row (`Option<OtherTable>`), populated by an
77 /// eager join when a query asks for them via `include:`. Skipped by the
78 /// migration diff entirely — they carry no DDL.
79 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub relations: Vec<RelationDef>,
81 /// `#[orm(unique(col_a, col_b))]` on the struct — a composite UNIQUE
82 /// constraint, each entry an ordered column-name list. Rendered as a
83 /// `CREATE UNIQUE INDEX`, not an inline table constraint, so it can be
84 /// added/dropped without a full table rebuild (same as a plain index).
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub composite_uniques: Vec<Vec<String>>,
87 /// `#[orm(index(col_a, col_b))]` on the struct — a multi-column index, or
88 /// a single-column index on a column that's part of a composite primary
89 /// key (where field-level `#[orm(index)]` is rejected as redundant with
90 /// the PK's own index, even though a secondary single-column index there
91 /// is often genuinely useful — SQLite's PK index is ordered and doesn't
92 /// help a lookup on a non-leading PK column alone).
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub composite_indexes: Vec<Vec<String>>,
95}
96
97impl TableDef {
98 pub fn column(&self, name: &str) -> Option<&ColumnDef> {
99 self.columns.iter().find(|c| c.name == name)
100 }
101
102 pub fn relation(&self, field: &str) -> Option<&RelationDef> {
103 self.relations.iter().find(|r| r.field == field)
104 }
105
106 pub fn primary_key(&self) -> Vec<&ColumnDef> {
107 self.columns.iter().filter(|c| c.primary_key).collect()
108 }
109
110 /// True when the pk is the conventional `id: i64` surrogate key, which
111 /// maps to `INTEGER PRIMARY KEY AUTOINCREMENT`.
112 pub fn auto_id(&self) -> bool {
113 let pk = self.primary_key();
114 pk.len() == 1 && pk[0].name == "id" && pk[0].ty == SqlType::Integer
115 }
116}
117
118/// A belongs-to relation field: `#[orm(relation = fk_column)] field:
119/// Option<Target>`. Traverses `local_column` (a foreign key on this table) to
120/// its referenced row. When the FK column is nullable the join is a LEFT JOIN
121/// and the field is `None` for rows with no parent.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct RelationDef {
124 /// Struct field name (`run`, `donor`).
125 pub field: String,
126 /// Target struct name (`Run`), taken from the field's `Option<Target>` type.
127 pub target_struct: String,
128 /// Target SQL table (`runs`); resolved from `local_column`'s foreign key by
129 /// [`crate::parse::parse_sources`]. Empty in single-struct (derive) contexts.
130 pub target_table: String,
131 /// The foreign-key column on this table the relation joins through.
132 pub local_column: String,
133 /// True when `local_column` is nullable → LEFT JOIN, `None` when absent.
134 pub nullable: bool,
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct ColumnDef {
139 pub name: String,
140 /// The Rust type as written, with `Option` unwrapped (`NaiveDateTime`).
141 pub rust_type: String,
142 pub ty: SqlType,
143 pub nullable: bool,
144 pub primary_key: bool,
145 pub unique: bool,
146 /// Stored as TEXT through serde (`#[orm(json)]`).
147 pub json: bool,
148 /// Column holds a value stored as TEXT via `as_str()`/`FromStr`: a
149 /// `#[derive(DbEnum)]` (with a CHECK from `check_in`) or an
150 /// `#[orm(text)]` type (no CHECK).
151 pub is_enum: bool,
152 /// `#[orm(index)]` — a plain (non-unique) index on this column.
153 #[serde(default)]
154 pub index: bool,
155 pub default: Option<DefaultValue>,
156 pub references: Option<ForeignKey>,
157 /// Allowed values (from the enum) — rendered as a CHECK constraint.
158 pub check_in: Option<Vec<String>>,
159 /// One-shot rename marker: `#[orm(renamed_from = "old")]`. Remove the
160 /// attribute once the generated migration has been applied.
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub renamed_from: Option<String>,
163}
164
165impl ColumnDef {
166 /// The column identity used for change detection — everything except the
167 /// transient rename marker and the index flag (index changes are plain
168 /// CREATE/DROP INDEX statements, never a rebuild by themselves).
169 pub fn signature(&self) -> ColumnDef {
170 ColumnDef {
171 renamed_from: None,
172 index: false,
173 ..self.clone()
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179pub enum SqlType {
180 Integer,
181 Real,
182 Text,
183 Blob,
184 Boolean,
185 Timestamp,
186}
187
188impl SqlType {
189 pub fn sql(self) -> &'static str {
190 match self {
191 SqlType::Integer => "INTEGER",
192 SqlType::Real => "REAL",
193 SqlType::Text => "TEXT",
194 SqlType::Blob => "BLOB",
195 SqlType::Boolean => "BOOLEAN",
196 SqlType::Timestamp => "TIMESTAMP",
197 }
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub enum DefaultValue {
203 /// `#[orm(default = now)]` → `DEFAULT CURRENT_TIMESTAMP`.
204 Now,
205 Int(i64),
206 Float(f64),
207 Text(String),
208 Bool(bool),
209}
210
211impl DefaultValue {
212 pub fn sql(&self) -> String {
213 match self {
214 DefaultValue::Now => "CURRENT_TIMESTAMP".into(),
215 DefaultValue::Int(i) => i.to_string(),
216 DefaultValue::Float(f) => f.to_string(),
217 DefaultValue::Text(s) => format!("'{}'", s.replace('\'', "''")),
218 DefaultValue::Bool(b) => if *b { "1" } else { "0" }.into(),
219 }
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct ForeignKey {
225 /// Target table. Holds the referenced *struct* name straight after
226 /// parsing a single struct; [`crate::parse::parse_sources`] resolves it
227 /// to the table name.
228 pub table: String,
229 pub column: String,
230 pub on_delete: Option<OnDelete>,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234pub enum OnDelete {
235 Cascade,
236 SetNull,
237 Restrict,
238}
239
240impl OnDelete {
241 pub fn sql(self) -> &'static str {
242 match self {
243 OnDelete::Cascade => "CASCADE",
244 OnDelete::SetNull => "SET NULL",
245 OnDelete::Restrict => "RESTRICT",
246 }
247 }
248}