Skip to main content

fse_schema/
diff.rs

1//! Schema diffing: old snapshot + new structs → one migration file.
2//!
3//! SQLite only supports ADD COLUMN, DROP COLUMN and RENAME COLUMN directly —
4//! and even those with restrictions. Anything else (type change, adding
5//! NOT NULL/UNIQUE, changing a default, FK or CHECK) is emitted as the
6//! standard rebuild dance: create the new shape under a temporary name, copy
7//! the rows across, drop the old table, rename.
8
9use crate::error::Error;
10use crate::model::{ColumnDef, DefaultValue, Schema, TableDef};
11use crate::sql::{column_sql, create_table_sql, index_name};
12
13#[derive(Debug, Clone)]
14pub struct Migration {
15    /// The full migration file body (plain SQL, sqlx-compatible).
16    pub sql: String,
17    /// Human-readable one-liner, e.g. `products: add archived_at`.
18    pub summary: String,
19    /// True when data is lost (dropped tables or columns).
20    pub destructive: bool,
21    /// True when the generated SQL contains a TODO the user must edit before
22    /// applying (a new NOT NULL column without a default needs a backfill).
23    pub needs_manual_edit: bool,
24}
25
26impl Migration {
27    /// `summary` reduced to a safe migration-file name fragment.
28    pub fn filename_slug(&self) -> String {
29        let mut slug = String::new();
30        for ch in self.summary.chars() {
31            if ch.is_ascii_alphanumeric() {
32                slug.push(ch.to_ascii_lowercase());
33            } else if !slug.ends_with('_') && !slug.is_empty() {
34                slug.push('_');
35            }
36        }
37        let slug = slug.trim_matches('_').to_string();
38        slug.chars().take(48).collect()
39    }
40}
41
42/// Diff two schemas. Returns `None` when they are identical.
43pub fn diff_schemas(old: &Schema, new: &Schema) -> Result<Option<Migration>, Error> {
44    let mut stmts: Vec<String> = Vec::new();
45    let mut summary: Vec<String> = Vec::new();
46    let mut destructive = false;
47    let mut needs_manual_edit = false;
48
49    for t in &new.tables {
50        if old.table(&t.name).is_none() {
51            stmts.push(create_table_sql(t));
52            stmts.extend(crate::sql::index_sqls(t));
53            summary.push(format!("create {}", t.name));
54        }
55    }
56    for t in &old.tables {
57        if new.table(&t.name).is_none() {
58            stmts.push(format!("DROP TABLE {};", t.name));
59            summary.push(format!("drop {}", t.name));
60            destructive = true;
61        }
62    }
63    for t in &new.tables {
64        if let Some(old_t) = old.table(&t.name) {
65            diff_table(
66                old_t,
67                t,
68                &mut stmts,
69                &mut summary,
70                &mut destructive,
71                &mut needs_manual_edit,
72            )?;
73        }
74    }
75
76    if stmts.is_empty() {
77        return Ok(None);
78    }
79    Ok(Some(Migration {
80        sql: stmts.join("\n\n") + "\n",
81        summary: summary.join("; "),
82        destructive,
83        needs_manual_edit,
84    }))
85}
86
87fn diff_table(
88    old: &TableDef,
89    new: &TableDef,
90    stmts: &mut Vec<String>,
91    summary: &mut Vec<String>,
92    destructive: &mut bool,
93    needs_manual_edit: &mut bool,
94) -> Result<(), Error> {
95    // Apply pending renames to a copy of the old table so the rest of the
96    // diff compares like-for-like. A rename marker whose source column no
97    // longer exists (attribute left in place after the migration ran) is
98    // ignored.
99    let mut eff = old.clone();
100    let mut renames: Vec<(String, String)> = Vec::new();
101    for c in &new.columns {
102        if let Some(from) = &c.renamed_from
103            && eff.column(&c.name).is_none()
104            && let Some(oc) = eff.columns.iter_mut().find(|oc| oc.name == *from)
105        {
106            oc.name = c.name.clone();
107            renames.push((from.clone(), c.name.clone()));
108        }
109    }
110
111    let added: Vec<&ColumnDef> = new
112        .columns
113        .iter()
114        .filter(|c| eff.column(&c.name).is_none())
115        .collect();
116    let dropped: Vec<ColumnDef> = eff
117        .columns
118        .iter()
119        .filter(|c| new.column(&c.name).is_none())
120        .cloned()
121        .collect();
122    let changed: Vec<String> = new
123        .columns
124        .iter()
125        .filter(|c| {
126            eff.column(&c.name)
127                .is_some_and(|oc| oc.signature() != c.signature())
128        })
129        .map(|c| c.name.clone())
130        .collect();
131    // Index toggles on otherwise-unchanged columns: plain CREATE/DROP INDEX.
132    let index_changes: Vec<(&ColumnDef, bool)> = new
133        .columns
134        .iter()
135        .filter_map(|c| {
136            let old_c = eff.column(&c.name)?;
137            (old_c.index != c.index && old_c.signature() == c.signature())
138                .then_some((c, c.index))
139        })
140        .collect();
141
142    if renames.is_empty()
143        && added.is_empty()
144        && dropped.is_empty()
145        && changed.is_empty()
146        && index_changes.is_empty()
147    {
148        return Ok(());
149    }
150
151    let mut bits: Vec<String> = Vec::new();
152    bits.extend(renames.iter().map(|(f, t)| format!("rename {f} -> {t}")));
153    bits.extend(added.iter().map(|c| format!("add {}", c.name)));
154    bits.extend(dropped.iter().map(|c| format!("drop {}", c.name)));
155    bits.extend(changed.iter().map(|c| format!("change {c}")));
156    bits.extend(
157        index_changes
158            .iter()
159            .map(|(c, on)| format!("{} {}", if *on { "index" } else { "unindex" }, c.name)),
160    );
161
162    let rebuild = !changed.is_empty()
163        || added.iter().any(|c| !can_add_column(c))
164        || dropped.iter().any(|c| !can_drop_column(c));
165
166    if rebuild {
167        // The rebuild drops the old table (and its indexes) and recreates
168        // everything, so index changes need no separate statements.
169        stmts.push(rebuild_table_sql(old, new, needs_manual_edit));
170        stmts.extend(crate::sql::index_sqls(new));
171        summary.push(format!("rebuild {} ({})", new.name, bits.join(", ")));
172    } else {
173        for (from, to) in &renames {
174            // SQLite keeps an index working across a column rename but keeps
175            // its old name; recreate it under the conventional name.
176            if let Some(c) = new.column(to)
177                && c.index
178            {
179                stmts.push(format!("DROP INDEX IF EXISTS {};", index_name(&new.name, from)));
180            }
181            stmts.push(format!("ALTER TABLE {} RENAME COLUMN {from} TO {to};", new.name));
182            if let Some(c) = new.column(to)
183                && c.index
184            {
185                stmts.push(create_index_sql(&new.name, &c.name));
186            }
187        }
188        for c in &added {
189            stmts.push(format!("ALTER TABLE {} ADD COLUMN {};", new.name, column_sql(c, false)));
190            if c.index {
191                stmts.push(create_index_sql(&new.name, &c.name));
192            }
193        }
194        for c in &dropped {
195            stmts.push(format!("ALTER TABLE {} DROP COLUMN {};", new.name, c.name));
196        }
197        for (c, on) in &index_changes {
198            stmts.push(if *on {
199                create_index_sql(&new.name, &c.name)
200            } else {
201                format!("DROP INDEX IF EXISTS {};", index_name(&new.name, &c.name))
202            });
203        }
204        summary.push(format!("{}: {}", new.name, bits.join(", ")));
205    }
206    if !dropped.is_empty() {
207        *destructive = true;
208    }
209    Ok(())
210}
211
212fn create_index_sql(table: &str, column: &str) -> String {
213    format!("CREATE INDEX {} ON {table} ({column});", index_name(table, column))
214}
215
216/// SQLite `ALTER TABLE ... ADD COLUMN` restrictions: no PRIMARY KEY or
217/// UNIQUE, NOT NULL needs a constant default, `CURRENT_TIMESTAMP` is not
218/// allowed as the default, and a REFERENCES clause needs a NULL default.
219fn can_add_column(c: &ColumnDef) -> bool {
220    let constant_default = matches!(&c.default, Some(d) if !matches!(d, DefaultValue::Now));
221    if c.primary_key || c.unique {
222        return false;
223    }
224    if c.references.is_some() {
225        return c.nullable && c.default.is_none();
226    }
227    c.nullable && !matches!(c.default, Some(DefaultValue::Now)) || constant_default
228}
229
230/// `DROP COLUMN` is refused by SQLite for pk/unique/indexed columns.
231fn can_drop_column(c: &ColumnDef) -> bool {
232    !c.primary_key && !c.unique && !c.index
233}
234
235fn rebuild_table_sql(old: &TableDef, new: &TableDef, needs_manual_edit: &mut bool) -> String {
236    let table = &new.name;
237    let tmp_name = format!("{table}_new");
238    let tmp = TableDef {
239        name: tmp_name.clone(),
240        ..new.clone()
241    };
242    let create = create_table_sql(&tmp);
243
244    let cols: Vec<String> = new.columns.iter().map(|c| c.name.clone()).collect();
245    let exprs: Vec<String> = new
246        .columns
247        .iter()
248        .map(|c| {
249            if let Some(from) = &c.renamed_from
250                && old.column(from).is_some()
251            {
252                return from.clone();
253            }
254            if old.column(&c.name).is_some() {
255                return c.name.clone();
256            }
257            if let Some(d) = &c.default {
258                return d.sql();
259            }
260            if c.nullable {
261                return "NULL".into();
262            }
263            *needs_manual_edit = true;
264            format!("NULL /* TODO: backfill NOT NULL column {} */", c.name)
265        })
266        .collect();
267
268    format!(
269        "-- {table}: SQLite cannot express this change with ALTER TABLE, so the\n\
270         -- table is rebuilt and its rows copied over.\n\
271         {create}\n\n\
272         INSERT INTO {tmp_name} ({})\nSELECT {}\nFROM {table};\n\n\
273         DROP TABLE {table};\n\n\
274         ALTER TABLE {tmp_name} RENAME TO {table};",
275        cols.join(", "),
276        exprs.join(", "),
277    )
278}