Skip to main content

siderite_migrations/
autodetector.rs

1//! Deterministic schema diff: [`diff`] compares two [`ProjectState`]s.
2//!
3//! Renames are emitted only when listed in [`RenameHints`]. Without a hint a
4//! renamed field is `RemoveField` + `AddField` (the column is dropped and
5//! recreated; data is not preserved) and a renamed model is
6//! `DeleteModel` + `CreateModel`. Those unhinted pairs are **refused** when
7//! the dropped and added table or column have the same shape.
8
9use crate::error::MigrationError;
10use crate::operation::Operation;
11use crate::state::{FieldState, ModelState, ProjectState, auto_index_name};
12use std::collections::{BTreeMap, BTreeSet, HashMap};
13
14/// Explicit rename pairs, plus explicit drop approvals.
15///
16/// The autodetector never infers renames from similarity; a missing hint
17/// becomes remove+add (and is refused when the two sides have the same
18/// shape). When the drop is intentional, approve it with
19/// [`allow_drop_model`](Self::allow_drop_model) /
20/// [`allow_drop_field`](Self::allow_drop_field) instead of splitting the
21/// change across two migrations.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct RenameHints {
24    /// `(model_name, old_field_name) → new_field_name`.
25    pub fields: BTreeMap<(String, String), String>,
26    /// `old_model_name → new_model_name`.
27    pub models: BTreeMap<String, String>,
28    /// `from`-side model names that may be deleted even when a same-shaped
29    /// model is created.
30    pub allow_model_drops: BTreeSet<String>,
31    /// `(model_name, field_name)` pairs on the `from` side that may be
32    /// removed even when a same-shaped field is added.
33    pub allow_field_drops: BTreeSet<(String, String)>,
34}
35
36impl RenameHints {
37    /// No hints.
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Record that `model.old_name` became `new_name`.
43    #[must_use]
44    pub fn rename_field(
45        mut self,
46        model: impl Into<String>,
47        old_name: impl Into<String>,
48        new_name: impl Into<String>,
49    ) -> Self {
50        self.fields
51            .insert((model.into(), old_name.into()), new_name.into());
52        self
53    }
54
55    /// Record that model `old_name` became `new_name`.
56    #[must_use]
57    pub fn rename_model(
58        mut self,
59        old_name: impl Into<String>,
60        new_name: impl Into<String>,
61    ) -> Self {
62        self.models.insert(old_name.into(), new_name.into());
63        self
64    }
65
66    /// Approve deleting model `name` even when a same-shaped model is
67    /// created in the same diff. Use this when the drop is intentional and
68    /// the new model is genuinely unrelated (the autodetector cannot tell
69    /// that apart from a rename, so it refuses without an explicit answer).
70    #[must_use]
71    pub fn allow_drop_model(mut self, name: impl Into<String>) -> Self {
72        self.allow_model_drops.insert(name.into());
73        self
74    }
75
76    /// Approve removing field `field` of `model` even when a same-shaped
77    /// field is added in the same diff.
78    #[must_use]
79    pub fn allow_drop_field(mut self, model: impl Into<String>, field: impl Into<String>) -> Self {
80        self.allow_field_drops.insert((model.into(), field.into()));
81        self
82    }
83}
84
85/// Operations that turn `from` into `to`, in a deterministic order:
86///
87/// 1. Delete constraints, indexes, fields
88/// 2. Delete models (reverse FK dependency order)
89/// 3. Rename fields
90/// 4. Create models (topological by FK dependencies; cyclic FKs are
91///    deferred as [`Operation::AddField`])
92/// 5. Add / alter fields
93/// 6. Create indexes and constraints
94pub fn diff(from: &ProjectState, to: &ProjectState) -> Result<Vec<Operation>, MigrationError> {
95    diff_with(from, to, &RenameHints::default())
96}
97
98/// [`diff`] honouring explicit field- and model-rename hints.
99///
100/// # Errors
101/// [`MigrationError::State`] when a model or field is deleted and another of
102/// the same shape is created without a rename hint (that would drop data).
103pub fn diff_with(
104    from: &ProjectState,
105    to: &ProjectState,
106    hints: &RenameHints,
107) -> Result<Vec<Operation>, MigrationError> {
108    let mut ops = Vec::new();
109
110    let from_names: BTreeSet<&str> = from.models.keys().map(String::as_str).collect();
111    let to_names: BTreeSet<&str> = to.models.keys().map(String::as_str).collect();
112
113    let renamed_models: BTreeMap<&str, &str> = hints
114        .models
115        .iter()
116        .filter(|(old, new)| from.models.contains_key(*old) && to.models.contains_key(*new))
117        .map(|(old, new)| (old.as_str(), new.as_str()))
118        .collect();
119    let renamed_from: BTreeSet<&str> = renamed_models.keys().copied().collect();
120    let renamed_to: BTreeSet<&str> = renamed_models.values().copied().collect();
121
122    let deleted: Vec<&str> = from_names
123        .difference(&to_names)
124        .copied()
125        .filter(|n| !renamed_from.contains(n))
126        .collect();
127    let created: Vec<&str> = to_names
128        .difference(&from_names)
129        .copied()
130        .filter(|n| !renamed_to.contains(n))
131        .collect();
132    let common: Vec<&str> = from_names.intersection(&to_names).copied().collect();
133
134    refuse_unhinted_model_renames(from, to, &deleted, &created, hints)?;
135
136    let pairs: Vec<(&str, &str)> = common
137        .iter()
138        .map(|n| (*n, *n))
139        .chain(renamed_models.iter().map(|(o, n)| (*o, *n)))
140        .collect();
141
142    // --- deletes on models that survive (including renamed), then deleted models ---
143    for (old_name, new_name) in &pairs {
144        let old = &from.models[*old_name];
145        let new = &to.models[*new_name];
146        let renamed = hints_for(hints, old_name);
147        refuse_unhinted_field_renames(old, new, &renamed, hints)?;
148        push_deletes(&mut ops, old, new, &renamed);
149    }
150    for name in reverse_topo(&deleted, from) {
151        ops.push(Operation::DeleteModel {
152            name: name.to_owned(),
153        });
154    }
155
156    for (old_name, new_name) in &renamed_models {
157        ops.push(Operation::RenameModel {
158            old_name: (*old_name).to_owned(),
159            new_name: (*new_name).to_owned(),
160            table: to.models[*new_name].table.clone(),
161        });
162    }
163
164    // --- field renames ---
165    for (old_name, new_name) in &pairs {
166        let renamed = hints_for(hints, old_name);
167        for (from_field, to_field) in &renamed {
168            if from.models[*old_name].field(from_field).is_some()
169                && to.models[*new_name].field(to_field).is_some()
170            {
171                ops.push(Operation::RenameField {
172                    model: (*new_name).to_owned(),
173                    old_name: from_field.clone(),
174                    new_name: to_field.clone(),
175                });
176            }
177        }
178    }
179
180    // --- creates ---
181    let created_states: Vec<&ModelState> = created.iter().map(|n| &to.models[*n]).collect();
182    let (ordered, deferred) = order_creates(&created_states, to);
183    for model in ordered {
184        let mut create = model.clone();
185        let extra = deferred
186            .get(model.name.as_str())
187            .cloned()
188            .unwrap_or_default();
189        if !extra.is_empty() {
190            let extra_names: BTreeSet<&str> = extra.iter().map(|f| f.name.as_str()).collect();
191            create
192                .fields
193                .retain(|f| !extra_names.contains(f.name.as_str()));
194            create.indexes.retain(|i| {
195                i.columns
196                    .iter()
197                    .all(|c| create.fields.iter().any(|f| f.column == *c))
198            });
199            create.constraints.retain(|c| match c {
200                crate::state::ConstraintState::Unique { columns, .. } => columns
201                    .iter()
202                    .all(|col| create.fields.iter().any(|f| f.column == *col)),
203                crate::state::ConstraintState::Check { .. } => extra.is_empty(),
204            });
205        }
206        ops.push(Operation::CreateModel { model: create });
207        if let Some(fields) = deferred.get(model.name.as_str()) {
208            for field in fields {
209                ops.push(Operation::AddField {
210                    model: model.name.clone(),
211                    field: field.clone(),
212                });
213            }
214        }
215    }
216
217    // --- adds / alters on surviving models ---
218    for (old_name, new_name) in &pairs {
219        let old = &from.models[*old_name];
220        let new = &to.models[*new_name];
221        let renamed = hints_for(hints, old_name);
222        push_adds_and_alters(&mut ops, old, new, &renamed);
223    }
224
225    Ok(ops)
226}
227
228fn refuse_unhinted_model_renames(
229    from: &ProjectState,
230    to: &ProjectState,
231    deleted: &[&str],
232    created: &[&str],
233    hints: &RenameHints,
234) -> Result<(), MigrationError> {
235    for old_name in deleted {
236        if hints.allow_model_drops.contains(*old_name) {
237            continue;
238        }
239        let old = &from.models[*old_name];
240        for new_name in created {
241            let new = &to.models[*new_name];
242            if same_model_shape(old, new) {
243                return Err(MigrationError::state(format!(
244                    "model `{old_name}` was deleted and `{new_name}` was created with the same columns; \
245                     pass RenameHints::rename_model(\"{old_name}\", \"{new_name}\") to preserve data, \
246                     RenameHints::allow_drop_model(\"{old_name}\") when the drop is intentional, \
247                     or split the drop and the create into two migrations"
248                )));
249            }
250        }
251    }
252    Ok(())
253}
254
255fn refuse_unhinted_field_renames(
256    old: &ModelState,
257    new: &ModelState,
258    renamed: &BTreeMap<String, String>,
259    hints: &RenameHints,
260) -> Result<(), MigrationError> {
261    let renamed_from: BTreeSet<&str> = renamed.keys().map(String::as_str).collect();
262    let renamed_to: BTreeSet<&str> = renamed.values().map(String::as_str).collect();
263    let removed: Vec<&FieldState> = old
264        .fields
265        .iter()
266        .filter(|f| !renamed_from.contains(f.name.as_str()) && new.field(&f.name).is_none())
267        .collect();
268    let added: Vec<&FieldState> = new
269        .fields
270        .iter()
271        .filter(|f| !renamed_to.contains(f.name.as_str()) && old.field(&f.name).is_none())
272        .collect();
273    for lost in &removed {
274        if hints
275            .allow_field_drops
276            .contains(&(old.name.clone(), lost.name.clone()))
277        {
278            continue;
279        }
280        for gained in &added {
281            if same_column_shape(lost, gained) {
282                return Err(MigrationError::state(format!(
283                    "field `{}.{}` was removed and `{}.{}` was added with the same type; \
284                     pass RenameHints::rename_field(\"{}\", \"{}\", \"{}\") to preserve data, \
285                     RenameHints::allow_drop_field(\"{}\", \"{}\") when the drop is intentional, \
286                     or split the drop and the add into two migrations",
287                    old.name,
288                    lost.name,
289                    new.name,
290                    gained.name,
291                    old.name,
292                    lost.name,
293                    gained.name,
294                    old.name,
295                    lost.name
296                )));
297            }
298        }
299    }
300    Ok(())
301}
302
303/// Conservative on purpose: an id-only model matches every other id-only
304/// model, but weakening the predicate would silently drop data on a genuine
305/// rename. Intentional drops go through
306/// [`RenameHints::allow_drop_model`].
307fn same_model_shape(a: &ModelState, b: &ModelState) -> bool {
308    if a.fields.is_empty() {
309        return false;
310    }
311    let cols = |m: &ModelState| {
312        let mut v: Vec<_> = m
313            .fields
314            .iter()
315            .map(|f| (f.column.clone(), f.sql_type, f.nullable, f.max_length))
316            .collect();
317        v.sort_by(|x, y| x.0.cmp(&y.0));
318        v
319    };
320    cols(a) == cols(b)
321}
322
323fn same_column_shape(a: &FieldState, b: &FieldState) -> bool {
324    a.sql_type == b.sql_type
325        && a.nullable == b.nullable
326        && a.max_length == b.max_length
327        && a.primary_key == b.primary_key
328        && a.unique == b.unique
329}
330
331fn hints_for(hints: &RenameHints, model: &str) -> BTreeMap<String, String> {
332    hints
333        .fields
334        .iter()
335        .filter_map(|((m, old), new)| {
336            if m == model {
337                Some((old.clone(), new.clone()))
338            } else {
339                None
340            }
341        })
342        .collect()
343}
344
345fn push_deletes(
346    ops: &mut Vec<Operation>,
347    old: &ModelState,
348    new: &ModelState,
349    renamed: &BTreeMap<String, String>,
350) {
351    let renamed_from: BTreeSet<&str> = renamed.keys().map(String::as_str).collect();
352
353    for constraint in &old.constraints {
354        if new.constraint(constraint.name()).is_none() {
355            ops.push(Operation::DeleteConstraint {
356                model: old.name.clone(),
357                name: constraint.name().to_owned(),
358            });
359        }
360    }
361
362    let old_auto_indexes = auto_index_names(old);
363    let new_auto_indexes = auto_index_names(new);
364    for index in &old.indexes {
365        if new.index(&index.name).is_none() {
366            ops.push(Operation::DeleteIndex {
367                model: old.name.clone(),
368                name: index.name.clone(),
369            });
370        }
371    }
372    for name in &old_auto_indexes {
373        if !new_auto_indexes.contains(name) && old.index(name).is_none() {
374            ops.push(Operation::DeleteIndex {
375                model: old.name.clone(),
376                name: name.clone(),
377            });
378        }
379    }
380
381    for field in &old.fields {
382        if renamed_from.contains(field.name.as_str()) {
383            continue;
384        }
385        if new.field(&field.name).is_none() {
386            ops.push(Operation::RemoveField {
387                model: old.name.clone(),
388                name: field.name.clone(),
389            });
390        }
391    }
392}
393
394fn push_adds_and_alters(
395    ops: &mut Vec<Operation>,
396    old: &ModelState,
397    new: &ModelState,
398    renamed: &BTreeMap<String, String>,
399) {
400    let old_by_new_name: HashMap<&str, &FieldState> = {
401        let mut m = HashMap::new();
402        for field in &old.fields {
403            let key = renamed
404                .get(&field.name)
405                .map(String::as_str)
406                .unwrap_or(field.name.as_str());
407            m.insert(key, field);
408        }
409        m
410    };
411    let renamed_from: BTreeSet<&str> = renamed.keys().map(String::as_str).collect();
412
413    for field in &new.fields {
414        if renamed.values().any(|n| n == &field.name) {
415            if let Some(old_field) = old_by_new_name.get(field.name.as_str()) {
416                let mut comparable = (*old_field).clone();
417                comparable.name = field.name.clone();
418                if comparable.column
419                    == renamed
420                        .iter()
421                        .find_map(|(o, n)| (n == &field.name).then_some(o.as_str()))
422                        .unwrap_or("")
423                {
424                    comparable.column = field.column.clone();
425                }
426                if &comparable != field {
427                    ops.push(Operation::AlterField {
428                        model: new.name.clone(),
429                        name: field.name.clone(),
430                        field: field.clone(),
431                    });
432                }
433            }
434            continue;
435        }
436        if old.field(&field.name).is_none() && !renamed_from.contains(field.name.as_str()) {
437            ops.push(Operation::AddField {
438                model: new.name.clone(),
439                field: field.clone(),
440            });
441        } else if let Some(old_field) = old.field(&field.name)
442            && old_field != field
443        {
444            ops.push(Operation::AlterField {
445                model: new.name.clone(),
446                name: field.name.clone(),
447                field: field.clone(),
448            });
449        }
450    }
451
452    let old_auto = auto_index_names(old);
453    let new_auto = auto_index_names(new);
454    for name in &new_auto {
455        if !old_auto.contains(name)
456            && new.index(name).is_none()
457            && let Some(field) = new
458                .fields
459                .iter()
460                .find(|f| &auto_index_name(&new.table, &f.column) == name)
461        {
462            ops.push(Operation::CreateIndex {
463                model: new.name.clone(),
464                index: crate::state::IndexState {
465                    name: name.clone(),
466                    columns: vec![field.column.clone()],
467                    unique: false,
468                },
469            });
470        }
471    }
472    for index in &new.indexes {
473        if old.index(&index.name).is_none() {
474            ops.push(Operation::CreateIndex {
475                model: new.name.clone(),
476                index: index.clone(),
477            });
478        }
479    }
480    for constraint in &new.constraints {
481        if old.constraint(constraint.name()).is_none() {
482            ops.push(Operation::AddConstraint {
483                model: new.name.clone(),
484                constraint: constraint.clone(),
485            });
486        }
487    }
488}
489
490fn auto_index_names(model: &ModelState) -> BTreeSet<String> {
491    model
492        .fields
493        .iter()
494        .filter(|f| f.index && !f.unique && !f.primary_key)
495        .map(|f| auto_index_name(&model.table, &f.column))
496        .collect()
497}
498
499/// Topological create order and FK fields that had to be deferred to break cycles.
500fn order_creates<'a>(
501    created: &[&'a ModelState],
502    project: &ProjectState,
503) -> (Vec<&'a ModelState>, BTreeMap<String, Vec<FieldState>>) {
504    let names: BTreeSet<&str> = created.iter().map(|m| m.name.as_str()).collect();
505    let by_name: BTreeMap<&str, &ModelState> =
506        created.iter().map(|m| (m.name.as_str(), *m)).collect();
507
508    let mut deps: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
509    for model in created {
510        let mut d = BTreeSet::new();
511        for field in &model.fields {
512            if let Some(fk) = &field.fk
513                && let Some(target) = project.model_for_table(&fk.target_table)
514                && names.contains(target.name.as_str())
515                && target.name != model.name
516            {
517                d.insert(target.name.as_str());
518            }
519        }
520        deps.insert(model.name.as_str(), d);
521    }
522
523    let mut deferred: BTreeMap<String, Vec<FieldState>> = BTreeMap::new();
524    let mut ordered_names = match kahn(&names, &deps) {
525        Ok(order) => order,
526        Err(rest) => {
527            // Strip FKs that point at other uncreated models in the leftover set.
528            let leftover: BTreeSet<&str> = rest.iter().copied().collect();
529            for name in &leftover {
530                let model = by_name[name];
531                let extra: Vec<FieldState> = model
532                    .fields
533                    .iter()
534                    .filter(|f| {
535                        f.fk.as_ref().is_some_and(|fk| {
536                            project.model_for_table(&fk.target_table).is_some_and(|t| {
537                                leftover.contains(t.name.as_str()) && t.name != model.name
538                            })
539                        })
540                    })
541                    .cloned()
542                    .collect();
543                if !extra.is_empty() {
544                    deferred.insert((*name).to_owned(), extra);
545                    if let Some(d) = deps.get_mut(name) {
546                        d.retain(|dep| !leftover.contains(dep));
547                    }
548                }
549            }
550            match kahn(&names, &deps) {
551                Ok(order) => order,
552                Err(rest) => {
553                    let mut all: Vec<&str> = names.iter().copied().collect();
554                    all.sort_unstable();
555                    let mut rest_sorted = rest;
556                    rest_sorted.sort_unstable();
557                    let mut order: Vec<&str> = all
558                        .iter()
559                        .copied()
560                        .filter(|n| !rest_sorted.contains(n))
561                        .collect();
562                    order.extend(rest_sorted);
563                    order
564                }
565            }
566        }
567    };
568
569    let ordered: Vec<&ModelState> = ordered_names.drain(..).map(|n| by_name[n]).collect();
570    (ordered, deferred)
571}
572
573fn kahn<'a>(
574    names: &BTreeSet<&'a str>,
575    deps: &BTreeMap<&'a str, BTreeSet<&'a str>>,
576) -> Result<Vec<&'a str>, Vec<&'a str>> {
577    let mut indeg: BTreeMap<&str, usize> = BTreeMap::new();
578    for n in names {
579        indeg.insert(*n, deps.get(n).map(BTreeSet::len).unwrap_or(0));
580    }
581    let mut ready_set: BTreeSet<&str> = indeg
582        .iter()
583        .filter(|(_, d)| **d == 0)
584        .map(|(n, _)| *n)
585        .collect();
586    let mut order = Vec::new();
587    let mut remaining = indeg;
588    while let Some(n) = ready_set.iter().next().copied() {
589        ready_set.remove(n);
590        order.push(n);
591        remaining.remove(n);
592        for (node, ds) in deps {
593            if ds.contains(n)
594                && let Some(deg) = remaining.get_mut(node)
595            {
596                *deg = deg.saturating_sub(1);
597                if *deg == 0 {
598                    ready_set.insert(node);
599                }
600            }
601        }
602    }
603    if remaining.is_empty() {
604        Ok(order)
605    } else {
606        Err(remaining.keys().copied().collect())
607    }
608}
609
610fn reverse_topo<'a>(deleted: &[&'a str], from: &'a ProjectState) -> Vec<&'a str> {
611    let names: BTreeSet<&str> = deleted.iter().copied().collect();
612    let mut deps: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
613    for name in &names {
614        let mut d = BTreeSet::new();
615        if let Some(model) = from.model(name) {
616            for field in &model.fields {
617                if let Some(fk) = &field.fk
618                    && let Some(target) = from.model_for_table(&fk.target_table)
619                    && names.contains(target.name.as_str())
620                    && target.name != model.name
621                {
622                    d.insert(target.name.as_str());
623                }
624            }
625        }
626        deps.insert(*name, d);
627    }
628    match kahn(&names, &deps) {
629        Ok(mut order) => {
630            order.reverse();
631            order
632        }
633        Err(_) => {
634            let mut all: Vec<&str> = names.iter().copied().collect();
635            all.sort_unstable();
636            all.reverse();
637            all
638        }
639    }
640}