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, composite_index_name, 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            stmts.extend(crate::sql::composite_index_sqls(t));
54            summary.push(format!("create {}", t.name));
55        }
56    }
57    for t in &old.tables {
58        if new.table(&t.name).is_none() {
59            stmts.push(format!("DROP TABLE {};", t.name));
60            summary.push(format!("drop {}", t.name));
61            destructive = true;
62        }
63    }
64    for t in &new.tables {
65        if let Some(old_t) = old.table(&t.name) {
66            diff_table(
67                old_t,
68                t,
69                &mut stmts,
70                &mut summary,
71                &mut destructive,
72                &mut needs_manual_edit,
73            )?;
74        }
75    }
76
77    if stmts.is_empty() {
78        return Ok(None);
79    }
80    Ok(Some(Migration {
81        sql: stmts.join("\n\n") + "\n",
82        summary: summary.join("; "),
83        destructive,
84        needs_manual_edit,
85    }))
86}
87
88fn diff_table(
89    old: &TableDef,
90    new: &TableDef,
91    stmts: &mut Vec<String>,
92    summary: &mut Vec<String>,
93    destructive: &mut bool,
94    needs_manual_edit: &mut bool,
95) -> Result<(), Error> {
96    // Apply pending renames to a copy of the old table so the rest of the
97    // diff compares like-for-like. A rename marker whose source column no
98    // longer exists (attribute left in place after the migration ran) is
99    // ignored.
100    let mut eff = old.clone();
101    let mut renames: Vec<(String, String)> = Vec::new();
102    for c in &new.columns {
103        if let Some(from) = &c.renamed_from
104            && eff.column(&c.name).is_none()
105            && let Some(oc) = eff.columns.iter_mut().find(|oc| oc.name == *from)
106        {
107            oc.name = c.name.clone();
108            renames.push((from.clone(), c.name.clone()));
109        }
110    }
111
112    let added: Vec<&ColumnDef> = new
113        .columns
114        .iter()
115        .filter(|c| eff.column(&c.name).is_none())
116        .collect();
117    let dropped: Vec<ColumnDef> = eff
118        .columns
119        .iter()
120        .filter(|c| new.column(&c.name).is_none())
121        .cloned()
122        .collect();
123    let changed: Vec<String> = new
124        .columns
125        .iter()
126        .filter(|c| {
127            eff.column(&c.name)
128                .is_some_and(|oc| oc.signature() != c.signature())
129        })
130        .map(|c| c.name.clone())
131        .collect();
132    // Index toggles on otherwise-unchanged columns: plain CREATE/DROP INDEX.
133    let index_changes: Vec<(&ColumnDef, bool)> = new
134        .columns
135        .iter()
136        .filter_map(|c| {
137            let old_c = eff.column(&c.name)?;
138            (old_c.index != c.index && old_c.signature() == c.signature())
139                .then_some((c, c.index))
140        })
141        .collect();
142
143    // Composite unique/index changes: also plain CREATE/DROP INDEX, never a
144    // rebuild by themselves (same reasoning as single-column `index_changes`).
145    let unique_added: Vec<&Vec<String>> =
146        new.composite_uniques.iter().filter(|c| !old.composite_uniques.contains(c)).collect();
147    let unique_removed: Vec<&Vec<String>> =
148        old.composite_uniques.iter().filter(|c| !new.composite_uniques.contains(c)).collect();
149    let index_added: Vec<&Vec<String>> =
150        new.composite_indexes.iter().filter(|c| !old.composite_indexes.contains(c)).collect();
151    let index_removed: Vec<&Vec<String>> =
152        old.composite_indexes.iter().filter(|c| !new.composite_indexes.contains(c)).collect();
153
154    if renames.is_empty()
155        && added.is_empty()
156        && dropped.is_empty()
157        && changed.is_empty()
158        && index_changes.is_empty()
159        && unique_added.is_empty()
160        && unique_removed.is_empty()
161        && index_added.is_empty()
162        && index_removed.is_empty()
163    {
164        return Ok(());
165    }
166
167    let mut bits: Vec<String> = Vec::new();
168    bits.extend(renames.iter().map(|(f, t)| format!("rename {f} -> {t}")));
169    bits.extend(added.iter().map(|c| format!("add {}", c.name)));
170    bits.extend(dropped.iter().map(|c| format!("drop {}", c.name)));
171    bits.extend(changed.iter().map(|c| format!("change {c}")));
172    bits.extend(
173        index_changes
174            .iter()
175            .map(|(c, on)| format!("{} {}", if *on { "index" } else { "unindex" }, c.name)),
176    );
177    bits.extend(unique_added.iter().map(|c| format!("unique({})", c.join(","))));
178    bits.extend(unique_removed.iter().map(|c| format!("drop unique({})", c.join(","))));
179    bits.extend(index_added.iter().map(|c| format!("index({})", c.join(","))));
180    bits.extend(index_removed.iter().map(|c| format!("drop index({})", c.join(","))));
181
182    let rebuild = !changed.is_empty()
183        || added.iter().any(|c| !can_add_column(c))
184        || dropped.iter().any(|c| !can_drop_column(c));
185
186    if rebuild {
187        // The rebuild drops the old table (and its indexes) and recreates
188        // everything, so index changes need no separate statements.
189        stmts.push(rebuild_table_sql(old, new, needs_manual_edit));
190        stmts.extend(crate::sql::index_sqls(new));
191        stmts.extend(crate::sql::composite_index_sqls(new));
192        summary.push(format!("rebuild {} ({})", new.name, bits.join(", ")));
193    } else {
194        for (from, to) in &renames {
195            // SQLite keeps an index working across a column rename but keeps
196            // its old name; recreate it under the conventional name.
197            if let Some(c) = new.column(to)
198                && c.index
199            {
200                stmts.push(format!("DROP INDEX IF EXISTS {};", index_name(&new.name, from)));
201            }
202            stmts.push(format!("ALTER TABLE {} RENAME COLUMN {from} TO {to};", new.name));
203            if let Some(c) = new.column(to)
204                && c.index
205            {
206                stmts.push(create_index_sql(&new.name, &c.name));
207            }
208        }
209        for c in &added {
210            stmts.push(format!("ALTER TABLE {} ADD COLUMN {};", new.name, column_sql(c, false)));
211            if c.index {
212                stmts.push(create_index_sql(&new.name, &c.name));
213            }
214        }
215        for c in &dropped {
216            stmts.push(format!("ALTER TABLE {} DROP COLUMN {};", new.name, c.name));
217        }
218        for (c, on) in &index_changes {
219            stmts.push(if *on {
220                create_index_sql(&new.name, &c.name)
221            } else {
222                format!("DROP INDEX IF EXISTS {};", index_name(&new.name, &c.name))
223            });
224        }
225        for cols in &unique_removed {
226            stmts.push(format!("DROP INDEX IF EXISTS {};", composite_index_name(&new.name, cols)));
227        }
228        for cols in &unique_added {
229            stmts.push(format!(
230                "CREATE UNIQUE INDEX {} ON {} ({});",
231                composite_index_name(&new.name, cols),
232                new.name,
233                cols.join(", "),
234            ));
235        }
236        for cols in &index_removed {
237            stmts.push(format!("DROP INDEX IF EXISTS {};", composite_index_name(&new.name, cols)));
238        }
239        for cols in &index_added {
240            stmts.push(format!(
241                "CREATE INDEX {} ON {} ({});",
242                composite_index_name(&new.name, cols),
243                new.name,
244                cols.join(", "),
245            ));
246        }
247        summary.push(format!("{}: {}", new.name, bits.join(", ")));
248    }
249    if !dropped.is_empty() {
250        *destructive = true;
251    }
252    Ok(())
253}
254
255fn create_index_sql(table: &str, column: &str) -> String {
256    format!("CREATE INDEX {} ON {table} ({column});", index_name(table, column))
257}
258
259/// SQLite `ALTER TABLE ... ADD COLUMN` restrictions: no PRIMARY KEY or
260/// UNIQUE, NOT NULL needs a constant default, `CURRENT_TIMESTAMP` is not
261/// allowed as the default, and a REFERENCES clause needs a NULL default.
262fn can_add_column(c: &ColumnDef) -> bool {
263    let constant_default = matches!(&c.default, Some(d) if !matches!(d, DefaultValue::Now));
264    if c.primary_key || c.unique {
265        return false;
266    }
267    if c.references.is_some() {
268        return c.nullable && c.default.is_none();
269    }
270    c.nullable && !matches!(c.default, Some(DefaultValue::Now)) || constant_default
271}
272
273/// `DROP COLUMN` is refused by SQLite for pk/unique/indexed columns.
274fn can_drop_column(c: &ColumnDef) -> bool {
275    !c.primary_key && !c.unique && !c.index
276}
277
278fn rebuild_table_sql(old: &TableDef, new: &TableDef, needs_manual_edit: &mut bool) -> String {
279    let table = &new.name;
280    let tmp_name = format!("{table}_new");
281    let tmp = TableDef {
282        name: tmp_name.clone(),
283        ..new.clone()
284    };
285    let create = create_table_sql(&tmp);
286
287    let cols: Vec<String> = new.columns.iter().map(|c| c.name.clone()).collect();
288    let exprs: Vec<String> = new
289        .columns
290        .iter()
291        .map(|c| {
292            if let Some(from) = &c.renamed_from
293                && old.column(from).is_some()
294            {
295                return from.clone();
296            }
297            if old.column(&c.name).is_some() {
298                return c.name.clone();
299            }
300            if let Some(d) = &c.default {
301                return d.sql();
302            }
303            if c.nullable {
304                return "NULL".into();
305            }
306            *needs_manual_edit = true;
307            format!("NULL /* TODO: backfill NOT NULL column {} */", c.name)
308        })
309        .collect();
310
311    format!(
312        "-- {table}: SQLite cannot express this change with ALTER TABLE, so the\n\
313         -- table is rebuilt and its rows copied over.\n\
314         {create}\n\n\
315         INSERT INTO {tmp_name} ({})\nSELECT {}\nFROM {table};\n\n\
316         DROP TABLE {table};\n\n\
317         ALTER TABLE {tmp_name} RENAME TO {table};",
318        cols.join(", "),
319        exprs.join(", "),
320    )
321}