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 /// `#[orm(unique(col_a, col_b))]` on the struct — a composite UNIQUE
46 /// constraint, each entry an ordered column-name list. Rendered as a
47 /// `CREATE UNIQUE INDEX`, not an inline table constraint, so it can be
48 /// added/dropped without a full table rebuild (same as a plain index).
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub composite_uniques: Vec<Vec<String>>,
51 /// `#[orm(index(col_a, col_b))]` on the struct — a multi-column index, or
52 /// a single-column index on a column that's part of a composite primary
53 /// key (where field-level `#[orm(index)]` is rejected as redundant with
54 /// the PK's own index, even though a secondary single-column index there
55 /// is often genuinely useful — SQLite's PK index is ordered and doesn't
56 /// help a lookup on a non-leading PK column alone).
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub composite_indexes: Vec<Vec<String>>,
59}
60
61impl TableDef {
62 pub fn column(&self, name: &str) -> Option<&ColumnDef> {
63 self.columns.iter().find(|c| c.name == name)
64 }
65
66 pub fn relation(&self, field: &str) -> Option<&RelationDef> {
67 self.relations.iter().find(|r| r.field == field)
68 }
69
70 pub fn primary_key(&self) -> Vec<&ColumnDef> {
71 self.columns.iter().filter(|c| c.primary_key).collect()
72 }
73
74 /// True when the pk is the conventional `id: i64` surrogate key, which
75 /// maps to `INTEGER PRIMARY KEY AUTOINCREMENT`.
76 pub fn auto_id(&self) -> bool {
77 let pk = self.primary_key();
78 pk.len() == 1 && pk[0].name == "id" && pk[0].ty == SqlType::Integer
79 }
80}
81
82/// A belongs-to relation field: `#[orm(relation = fk_column)] field:
83/// Option<Target>`. Traverses `local_column` (a foreign key on this table) to
84/// its referenced row. When the FK column is nullable the join is a LEFT JOIN
85/// and the field is `None` for rows with no parent.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct RelationDef {
88 /// Struct field name (`run`, `donor`).
89 pub field: String,
90 /// Target struct name (`Run`), taken from the field's `Option<Target>` type.
91 pub target_struct: String,
92 /// Target SQL table (`runs`); resolved from `local_column`'s foreign key by
93 /// [`crate::parse::parse_sources`]. Empty in single-struct (derive) contexts.
94 pub target_table: String,
95 /// The foreign-key column on this table the relation joins through.
96 pub local_column: String,
97 /// True when `local_column` is nullable → LEFT JOIN, `None` when absent.
98 pub nullable: bool,
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct ColumnDef {
103 pub name: String,
104 /// The Rust type as written, with `Option` unwrapped (`NaiveDateTime`).
105 pub rust_type: String,
106 pub ty: SqlType,
107 pub nullable: bool,
108 pub primary_key: bool,
109 pub unique: bool,
110 /// Stored as TEXT through serde (`#[orm(json)]`).
111 pub json: bool,
112 /// Column holds a value stored as TEXT via `as_str()`/`FromStr`: a
113 /// `#[derive(DbEnum)]` (with a CHECK from `check_in`) or an
114 /// `#[orm(text)]` type (no CHECK).
115 pub is_enum: bool,
116 /// `#[orm(index)]` — a plain (non-unique) index on this column.
117 #[serde(default)]
118 pub index: bool,
119 pub default: Option<DefaultValue>,
120 pub references: Option<ForeignKey>,
121 /// Allowed values (from the enum) — rendered as a CHECK constraint.
122 pub check_in: Option<Vec<String>>,
123 /// One-shot rename marker: `#[orm(renamed_from = "old")]`. Remove the
124 /// attribute once the generated migration has been applied.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub renamed_from: Option<String>,
127}
128
129impl ColumnDef {
130 /// The column identity used for change detection — everything except the
131 /// transient rename marker and the index flag (index changes are plain
132 /// CREATE/DROP INDEX statements, never a rebuild by themselves).
133 pub fn signature(&self) -> ColumnDef {
134 ColumnDef {
135 renamed_from: None,
136 index: false,
137 ..self.clone()
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143pub enum SqlType {
144 Integer,
145 Real,
146 Text,
147 Blob,
148 Boolean,
149 Timestamp,
150}
151
152impl SqlType {
153 pub fn sql(self) -> &'static str {
154 match self {
155 SqlType::Integer => "INTEGER",
156 SqlType::Real => "REAL",
157 SqlType::Text => "TEXT",
158 SqlType::Blob => "BLOB",
159 SqlType::Boolean => "BOOLEAN",
160 SqlType::Timestamp => "TIMESTAMP",
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub enum DefaultValue {
167 /// `#[orm(default = now)]` → `DEFAULT CURRENT_TIMESTAMP`.
168 Now,
169 Int(i64),
170 Float(f64),
171 Text(String),
172 Bool(bool),
173}
174
175impl DefaultValue {
176 pub fn sql(&self) -> String {
177 match self {
178 DefaultValue::Now => "CURRENT_TIMESTAMP".into(),
179 DefaultValue::Int(i) => i.to_string(),
180 DefaultValue::Float(f) => f.to_string(),
181 DefaultValue::Text(s) => format!("'{}'", s.replace('\'', "''")),
182 DefaultValue::Bool(b) => if *b { "1" } else { "0" }.into(),
183 }
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188pub struct ForeignKey {
189 /// Target table. Holds the referenced *struct* name straight after
190 /// parsing a single struct; [`crate::parse::parse_sources`] resolves it
191 /// to the table name.
192 pub table: String,
193 pub column: String,
194 pub on_delete: Option<OnDelete>,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198pub enum OnDelete {
199 Cascade,
200 SetNull,
201 Restrict,
202}
203
204impl OnDelete {
205 pub fn sql(self) -> &'static str {
206 match self {
207 OnDelete::Cascade => "CASCADE",
208 OnDelete::SetNull => "SET NULL",
209 OnDelete::Restrict => "RESTRICT",
210 }
211 }
212}