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, quote_ident};
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 {};", quote_ident(&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()).then_some((c, c.index))
139        })
140        .collect();
141
142    // Composite unique/index changes: also plain CREATE/DROP INDEX, never a
143    // rebuild by themselves (same reasoning as single-column `index_changes`).
144    let unique_added: Vec<&Vec<String>> = new
145        .composite_uniques
146        .iter()
147        .filter(|c| !old.composite_uniques.contains(c))
148        .collect();
149    let unique_removed: Vec<&Vec<String>> = old
150        .composite_uniques
151        .iter()
152        .filter(|c| !new.composite_uniques.contains(c))
153        .collect();
154    let index_added: Vec<&Vec<String>> = new
155        .composite_indexes
156        .iter()
157        .filter(|c| !old.composite_indexes.contains(c))
158        .collect();
159    let index_removed: Vec<&Vec<String>> = old
160        .composite_indexes
161        .iter()
162        .filter(|c| !new.composite_indexes.contains(c))
163        .collect();
164
165    if renames.is_empty()
166        && added.is_empty()
167        && dropped.is_empty()
168        && changed.is_empty()
169        && index_changes.is_empty()
170        && unique_added.is_empty()
171        && unique_removed.is_empty()
172        && index_added.is_empty()
173        && index_removed.is_empty()
174    {
175        return Ok(());
176    }
177
178    let mut bits: Vec<String> = Vec::new();
179    bits.extend(renames.iter().map(|(f, t)| format!("rename {f} -> {t}")));
180    bits.extend(added.iter().map(|c| format!("add {}", c.name)));
181    bits.extend(dropped.iter().map(|c| format!("drop {}", c.name)));
182    bits.extend(changed.iter().map(|c| format!("change {c}")));
183    bits.extend(
184        index_changes
185            .iter()
186            .map(|(c, on)| format!("{} {}", if *on { "index" } else { "unindex" }, c.name)),
187    );
188    bits.extend(
189        unique_added
190            .iter()
191            .map(|c| format!("unique({})", c.join(","))),
192    );
193    bits.extend(
194        unique_removed
195            .iter()
196            .map(|c| format!("drop unique({})", c.join(","))),
197    );
198    bits.extend(
199        index_added
200            .iter()
201            .map(|c| format!("index({})", c.join(","))),
202    );
203    bits.extend(
204        index_removed
205            .iter()
206            .map(|c| format!("drop index({})", c.join(","))),
207    );
208
209    let rebuild = !changed.is_empty()
210        || added.iter().any(|c| !can_add_column(c))
211        || dropped.iter().any(|c| !can_drop_column(c));
212
213    if rebuild {
214        // The rebuild drops the old table (and its indexes) and recreates
215        // everything, so index changes need no separate statements.
216        stmts.push(rebuild_table_sql(old, new, needs_manual_edit));
217        stmts.extend(crate::sql::index_sqls(new));
218        stmts.extend(crate::sql::composite_index_sqls(new));
219        summary.push(format!("rebuild {} ({})", new.name, bits.join(", ")));
220    } else {
221        for (from, to) in &renames {
222            // SQLite keeps an index working across a column rename but keeps
223            // its old name; recreate it under the conventional name.
224            if let Some(c) = new.column(to)
225                && c.index
226            {
227                stmts.push(format!(
228                    "DROP INDEX IF EXISTS {};",
229                    quote_ident(&index_name(&new.name, from))
230                ));
231            }
232            stmts.push(format!(
233                "ALTER TABLE {} RENAME COLUMN {} TO {};",
234                quote_ident(&new.name),
235                quote_ident(from),
236                quote_ident(to)
237            ));
238            if let Some(c) = new.column(to)
239                && c.index
240            {
241                stmts.push(create_index_sql(&new.name, &c.name));
242            }
243        }
244        for c in &added {
245            stmts.push(format!(
246                "ALTER TABLE {} ADD COLUMN {};",
247                quote_ident(&new.name),
248                column_sql(c, false)
249            ));
250            if c.index {
251                stmts.push(create_index_sql(&new.name, &c.name));
252            }
253        }
254        for c in &dropped {
255            stmts.push(format!(
256                "ALTER TABLE {} DROP COLUMN {};",
257                quote_ident(&new.name),
258                quote_ident(&c.name)
259            ));
260        }
261        for (c, on) in &index_changes {
262            stmts.push(if *on {
263                create_index_sql(&new.name, &c.name)
264            } else {
265                format!(
266                    "DROP INDEX IF EXISTS {};",
267                    quote_ident(&index_name(&new.name, &c.name))
268                )
269            });
270        }
271        for cols in &unique_removed {
272            stmts.push(format!(
273                "DROP INDEX IF EXISTS {};",
274                quote_ident(&composite_index_name(&new.name, cols))
275            ));
276        }
277        for cols in &unique_added {
278            stmts.push(format!(
279                "CREATE UNIQUE INDEX {} ON {} ({});",
280                quote_ident(&composite_index_name(&new.name, cols)),
281                quote_ident(&new.name),
282                cols.iter()
283                    .map(|c| quote_ident(c))
284                    .collect::<Vec<_>>()
285                    .join(", "),
286            ));
287        }
288        for cols in &index_removed {
289            stmts.push(format!(
290                "DROP INDEX IF EXISTS {};",
291                quote_ident(&composite_index_name(&new.name, cols))
292            ));
293        }
294        for cols in &index_added {
295            stmts.push(format!(
296                "CREATE INDEX {} ON {} ({});",
297                quote_ident(&composite_index_name(&new.name, cols)),
298                quote_ident(&new.name),
299                cols.iter()
300                    .map(|c| quote_ident(c))
301                    .collect::<Vec<_>>()
302                    .join(", "),
303            ));
304        }
305        summary.push(format!("{}: {}", new.name, bits.join(", ")));
306    }
307    if !dropped.is_empty() {
308        *destructive = true;
309    }
310    Ok(())
311}
312
313fn create_index_sql(table: &str, column: &str) -> String {
314    format!(
315        "CREATE INDEX {} ON {} ({});",
316        quote_ident(&index_name(table, column)),
317        quote_ident(table),
318        quote_ident(column)
319    )
320}
321
322/// SQLite `ALTER TABLE ... ADD COLUMN` restrictions: no PRIMARY KEY or
323/// UNIQUE, NOT NULL needs a constant default, `CURRENT_TIMESTAMP` is not
324/// allowed as the default, and a REFERENCES clause needs a NULL default.
325fn can_add_column(c: &ColumnDef) -> bool {
326    let constant_default = matches!(&c.default, Some(d) if !matches!(d, DefaultValue::Now));
327    if c.primary_key || c.unique {
328        return false;
329    }
330    if c.references.is_some() {
331        return c.nullable && c.default.is_none();
332    }
333    c.nullable && !matches!(c.default, Some(DefaultValue::Now)) || constant_default
334}
335
336/// `DROP COLUMN` is refused by SQLite for pk/unique/indexed columns.
337fn can_drop_column(c: &ColumnDef) -> bool {
338    !c.primary_key && !c.unique && !c.index
339}
340
341fn rebuild_table_sql(old: &TableDef, new: &TableDef, needs_manual_edit: &mut bool) -> String {
342    let table = &new.name;
343    let tmp_name = format!("{table}_new");
344    let tmp = TableDef {
345        name: tmp_name.clone(),
346        ..new.clone()
347    };
348    let create = create_table_sql(&tmp);
349
350    let cols: Vec<String> = new.columns.iter().map(|c| quote_ident(&c.name)).collect();
351    let exprs: Vec<String> = new
352        .columns
353        .iter()
354        .map(|c| {
355            if let Some(from) = &c.renamed_from
356                && old.column(from).is_some()
357            {
358                return quote_ident(from);
359            }
360            if old.column(&c.name).is_some() {
361                return quote_ident(&c.name);
362            }
363            if let Some(d) = &c.default {
364                return d.sql();
365            }
366            if c.nullable {
367                return "NULL".into();
368            }
369            *needs_manual_edit = true;
370            format!("NULL /* TODO: backfill NOT NULL column {} */", c.name)
371        })
372        .collect();
373
374    format!(
375        "-- {table}: SQLite cannot express this change with ALTER TABLE, so the\n\
376         -- table is rebuilt and its rows copied over.\n\
377         {create}\n\n\
378         INSERT INTO {qtmp} ({})\nSELECT {}\nFROM {qtable};\n\n\
379         DROP TABLE {qtable};\n\n\
380         ALTER TABLE {qtmp} RENAME TO {qtable};",
381        cols.join(", "),
382        exprs.join(", "),
383        qtmp = quote_ident(&tmp_name),
384        qtable = quote_ident(table),
385    )
386}