Skip to main content

cratefield_tables/
diff.rs

1//! What changed between two versions of a declaration, and what each
2//! change costs (issue #153).
3//!
4//! Everything this crate renders only creates. That is the right default
5//! — a generated `ALTER` is a generated guess about data nobody here can
6//! see — but it leaves the author of a `[tables]` section with no answer
7//! to the question they actually have, which is *"can I ship this?"*
8//!
9//! `docs/ROLLBACK.md` §5 already decides the vocabulary, so this speaks
10//! it rather than inventing one:
11//!
12//! - **Expand** — the change is additive. The old code keeps working
13//!   against the new schema, and a rollback stays free.
14//! - **Contract** — the change removes something. It is safe only once no
15//!   deployed build reads it, and after it a rollback past the switch is
16//!   not available. That is the trade, and it is the author's to accept.
17//! - **Rewrite** — the change cannot be expressed forward-only against
18//!   rows that already exist. Adding a `NOT NULL` column with no default
19//!   to a table with rows in it does not fail *later*, it fails; so does
20//!   narrowing a bound that existing rows already violate.
21//!
22//! **Nothing here refuses anything.** A diff is a report: the declaration
23//! rules in `Schema::validate` say what is legal, and this says what it
24//! costs. Refusing a contract would make the tool wrong for the case the
25//! discipline exists to serve — dropping a column on purpose, in its own
26//! migration, once nothing reads it.
27
28use std::collections::BTreeMap;
29
30use crate::schema::{FieldDef, FieldKind, ForeignKey, Schema, TableDef};
31
32/// What a change costs, in `docs/ROLLBACK.md` §5's vocabulary.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
34#[serde(rename_all = "lowercase")]
35// The set will grow — a move between tables is neither of the three — and
36// a new variant in an exhaustive public enum is a breaking change for
37// every downstream `match`.
38#[non_exhaustive]
39pub enum Step {
40    /// Additive. The old build keeps working; a rollback stays free.
41    Expand,
42    /// Removes something. Safe once nothing deployed reads it, and a
43    /// rollback past the switch is gone afterwards.
44    Contract,
45    /// Cannot be applied forward-only to rows that already exist.
46    Rewrite,
47}
48
49/// Declares [`Step::ALL`] and, from the same list, a match that has to be
50/// exhaustive.
51///
52/// `fz tables diff` and `fz tables drift` each summarise a report by
53/// counting the changes at every step, and each wrote the three out by
54/// hand. A fourth step would be counted by neither: the summary would
55/// stay plausible and under-report what a change costs, in the command
56/// whose whole job is saying what it costs.
57macro_rules! steps {
58    ($($variant:ident),+ $(,)?) => {
59        impl Step {
60            /// Every step, in the order a report summarises them.
61            pub const ALL: &'static [Step] = &[$(Step::$variant),+];
62        }
63
64        /// Never called. It exists so that a variant absent from the list
65        /// above is a compile error here.
66        #[expect(dead_code, reason = "its only job is to be exhaustive")]
67        fn every_step_is_in_all(step: Step) {
68            match step {
69                $(Step::$variant => {}),+
70            }
71        }
72    };
73}
74
75steps!(Expand, Contract, Rewrite);
76
77impl Step {
78    /// The wire form, lowercase and stable.
79    #[must_use]
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Self::Expand => "expand",
83            Self::Contract => "contract",
84            Self::Rewrite => "rewrite",
85        }
86    }
87}
88
89impl std::fmt::Display for Step {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str(self.as_str())
92    }
93}
94
95/// One difference between two declarations.
96#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
97pub struct Change {
98    /// The table it happened to.
99    pub table: String,
100    /// The field, when the change is a field's.
101    pub field: Option<String>,
102    pub step: Step,
103    /// One line for the person deciding whether to ship it. Written for
104    /// them, so it says what will happen rather than naming a rule.
105    pub detail: String,
106}
107
108impl Change {
109    fn table(table: &str, step: Step, detail: impl Into<String>) -> Self {
110        Self {
111            table: table.to_owned(),
112            field: None,
113            step,
114            detail: detail.into(),
115        }
116    }
117
118    fn field(table: &str, field: &str, step: Step, detail: impl Into<String>) -> Self {
119        Self {
120            table: table.to_owned(),
121            field: Some(field.to_owned()),
122            step,
123            detail: detail.into(),
124        }
125    }
126
127    /// `table.field: detail`, or `table: detail` for a whole table.
128    #[must_use]
129    pub fn line(&self) -> String {
130        match &self.field {
131            Some(field) => format!("{}.{field}: {}", self.table, self.detail),
132            None => format!("{}: {}", self.table, self.detail),
133        }
134    }
135}
136
137/// Every difference from `previous` to `current`, tables in name order and
138/// fields in the order the new declaration lists them.
139///
140/// Deterministic: the same pair always produces the same list, so a drift
141/// check can compare two runs.
142#[must_use]
143pub fn diff(previous: &Schema, current: &Schema) -> Vec<Change> {
144    let before: BTreeMap<&str, &TableDef> = previous
145        .tables
146        .iter()
147        .map(|table| (table.name.as_str(), table))
148        .collect();
149    let after: BTreeMap<&str, &TableDef> = current
150        .tables
151        .iter()
152        .map(|table| (table.name.as_str(), table))
153        .collect();
154
155    let mut changes = Vec::new();
156    for (name, table) in &after {
157        match before.get(name) {
158            None => changes.push(Change::table(
159                name,
160                Step::Expand,
161                format!("new table with {} fields", table.fields.len()),
162            )),
163            Some(old) => table_changes(old, table, &mut changes),
164        }
165    }
166    for name in before.keys() {
167        if !after.contains_key(name) {
168            changes.push(Change::table(
169                name,
170                Step::Contract,
171                "table is gone; every row in it goes with it",
172            ));
173        }
174    }
175    changes.sort_by(|a, b| (&a.table, &a.field).cmp(&(&b.table, &b.field)));
176    changes
177}
178
179fn table_changes(old: &TableDef, new: &TableDef, out: &mut Vec<Change>) {
180    if old.primary_key != new.primary_key {
181        out.push(Change::table(
182            &new.name,
183            Step::Rewrite,
184            format!(
185                "primary key changes from ({}) to ({}); no engine alters one in place",
186                old.primary_key.join(", "),
187                new.primary_key.join(", ")
188            ),
189        ));
190    }
191    foreign_key_changes(old, new, out);
192
193    let before: BTreeMap<&str, &FieldDef> = old
194        .fields
195        .iter()
196        .map(|field| (field.name.as_str(), field))
197        .collect();
198    for field in &new.fields {
199        match before.get(field.name.as_str()) {
200            None => out.push(added_field(&new.name, field)),
201            Some(old_field) => field_changes(&new.name, old_field, field, out),
202        }
203    }
204    for field in &old.fields {
205        if !new.fields.iter().any(|kept| kept.name == field.name) {
206            out.push(Change::field(
207                &new.name,
208                &field.name,
209                Step::Contract,
210                "column is gone; the values in it go with it",
211            ));
212        }
213    }
214}
215
216/// A new column is additive **unless the database would have to invent a
217/// value for the rows already there.** `NOT NULL` with no default is
218/// exactly that case, and it is the one an author hits first.
219fn added_field(table: &str, field: &FieldDef) -> Change {
220    if field.required && field.default.is_none() {
221        return Change::field(
222            table,
223            &field.name,
224            Step::Rewrite,
225            "new required column with no default; every existing row would need a value",
226        );
227    }
228    if field.unique {
229        return Change::field(
230            table,
231            &field.name,
232            Step::Rewrite,
233            "new unique column; existing rows would all take the same value and collide",
234        );
235    }
236    Change::field(table, &field.name, Step::Expand, "new optional column")
237}
238
239fn field_changes(table: &str, old: &FieldDef, new: &FieldDef, out: &mut Vec<Change>) {
240    if std::mem::discriminant(&old.kind) == std::mem::discriminant(&new.kind) {
241        kind_changes(table, &old.kind, &new.kind, &new.name, out);
242    } else {
243        out.push(Change::field(
244            table,
245            &new.name,
246            Step::Rewrite,
247            format!(
248                "type changes from {} to {}; the values already stored are the old type",
249                old.kind.as_str(),
250                new.kind.as_str()
251            ),
252        ));
253    }
254
255    match (old.required, new.required) {
256        (false, true) => out.push(Change::field(
257            table,
258            &new.name,
259            Step::Rewrite,
260            "column becomes required; rows that left it empty have nothing to put there",
261        )),
262        (true, false) => out.push(Change::field(
263            table,
264            &new.name,
265            Step::Expand,
266            "column becomes optional",
267        )),
268        _ => {}
269    }
270
271    match (old.unique, new.unique) {
272        (false, true) => out.push(Change::field(
273            table,
274            &new.name,
275            Step::Rewrite,
276            "column becomes unique; the rows already there may hold duplicates",
277        )),
278        (true, false) => out.push(Change::field(
279            table,
280            &new.name,
281            Step::Expand,
282            "column stops being unique",
283        )),
284        _ => {}
285    }
286
287    if old.indexed != new.indexed {
288        // An index is the one thing here that costs nothing either way:
289        // it holds no data and both engines build and drop them online.
290        let detail = if new.indexed {
291            "indexed"
292        } else {
293            "no longer indexed"
294        };
295        out.push(Change::field(table, &new.name, Step::Expand, detail));
296    }
297
298    if old.default != new.default {
299        // A default is read when a write omits the column, so it reaches
300        // new rows only. The rows already there keep what they have,
301        // which is why this is additive and why it is worth saying.
302        out.push(Change::field(
303            table,
304            &new.name,
305            Step::Expand,
306            "default changes; rows already written keep the value they have",
307        ));
308    }
309}
310
311/// Bounds and enum members: widening is additive, narrowing is not,
312/// because the rows already stored were written under the old rule.
313fn kind_changes(table: &str, old: &FieldKind, new: &FieldKind, field: &str, out: &mut Vec<Change>) {
314    match (old, new) {
315        (
316            FieldKind::Text {
317                min_len: old_min,
318                max_len: old_max,
319                format: old_format,
320            },
321            FieldKind::Text {
322                min_len: new_min,
323                max_len: new_max,
324                format: new_format,
325            },
326        ) => {
327            bounds(
328                table, field, *old_min, *new_min, *old_max, *new_max, "length ", out,
329            );
330            if old_format != new_format {
331                // A format is checked on write, so requiring one leaves
332                // stored values that were never checked against it.
333                let (step, detail) = if new_format.is_some() {
334                    (
335                        Step::Rewrite,
336                        "a format is required now; stored values were not checked against it",
337                    )
338                } else {
339                    (Step::Expand, "the format requirement is gone")
340                };
341                out.push(Change::field(table, field, step, detail));
342            }
343        }
344        (
345            FieldKind::Integer {
346                min: old_min,
347                max: old_max,
348            },
349            FieldKind::Integer {
350                min: new_min,
351                max: new_max,
352            },
353        ) => bounds(
354            table, field, *old_min, *new_min, *old_max, *new_max, "", out,
355        ),
356        (
357            FieldKind::Real {
358                min: old_min,
359                max: old_max,
360            },
361            FieldKind::Real {
362                min: new_min,
363                max: new_max,
364            },
365        ) => bounds(
366            table, field, *old_min, *new_min, *old_max, *new_max, "", out,
367        ),
368        (FieldKind::Enum { values: old_values }, FieldKind::Enum { values: new_values }) => {
369            enum_changes(table, field, old_values, new_values, out);
370        }
371        // Every other kind carries nothing to widen or narrow.
372        _ => {}
373    }
374}
375
376/// One report for a pair of bounds. Narrowing either end is a rewrite,
377/// because a row written under the old rule may already sit outside the
378/// new one; widening is additive and worth saying so.
379#[allow(clippy::too_many_arguments)]
380fn bounds<T: PartialOrd + Copy>(
381    table: &str,
382    field: &str,
383    old_min: Option<T>,
384    new_min: Option<T>,
385    old_max: Option<T>,
386    new_max: Option<T>,
387    what: &str,
388    out: &mut Vec<Change>,
389) {
390    if narrows(old_min, new_min, Bound::Lower) || narrows(old_max, new_max, Bound::Upper) {
391        out.push(Change::field(
392            table,
393            field,
394            Step::Rewrite,
395            format!("{what}bounds narrow; stored values may already be outside them"),
396        ));
397    } else if !same(old_min, new_min) || !same(old_max, new_max) {
398        out.push(Change::field(
399            table,
400            field,
401            Step::Expand,
402            format!("{what}bounds widen"),
403        ));
404    }
405}
406
407/// `Real`'s bounds are floats, so this compares with `PartialOrd` rather
408/// than `PartialEq` on `Option` — a `f64` is not `Eq`.
409fn same<T: PartialOrd>(a: Option<T>, b: Option<T>) -> bool {
410    match (a, b) {
411        (None, None) => true,
412        (Some(a), Some(b)) => a == b,
413        _ => false,
414    }
415}
416
417fn enum_changes(
418    table: &str,
419    field: &str,
420    old_values: &[String],
421    new_values: &[String],
422    out: &mut Vec<Change>,
423) {
424    let removed: Vec<&String> = old_values
425        .iter()
426        .filter(|value| !new_values.contains(value))
427        .collect();
428    if !removed.is_empty() {
429        out.push(Change::field(
430            table,
431            field,
432            Step::Rewrite,
433            format!(
434                "enum drops {}; rows already holding one would fail the new CHECK",
435                removed
436                    .iter()
437                    .map(|value| format!("`{value}`"))
438                    .collect::<Vec<_>>()
439                    .join(", ")
440            ),
441        ));
442    }
443    let added = new_values
444        .iter()
445        .filter(|value| !old_values.contains(value))
446        .count();
447    if added > 0 {
448        out.push(Change::field(
449            table,
450            field,
451            Step::Expand,
452            format!("enum gains {added} value(s)"),
453        ));
454    }
455}
456
457#[derive(Clone, Copy)]
458enum Bound {
459    Lower,
460    Upper,
461}
462
463/// Whether a bound got stricter. Adding a bound where there was none is
464/// always stricter: `None` admits everything.
465fn narrows<T: PartialOrd>(old: Option<T>, new: Option<T>, which: Bound) -> bool {
466    match (old, new) {
467        (_, None) => false,
468        (None, Some(_)) => true,
469        (Some(old), Some(new)) => match which {
470            Bound::Lower => new > old,
471            Bound::Upper => new < old,
472        },
473    }
474}
475
476fn foreign_key_changes(old: &TableDef, new: &TableDef, out: &mut Vec<Change>) {
477    let same = |a: &ForeignKey, b: &ForeignKey| a.field == b.field && a.references == b.references;
478    for key in &new.foreign_keys {
479        if !old.foreign_keys.iter().any(|had| same(had, key)) {
480            out.push(Change::field(
481                &new.name,
482                &key.field,
483                Step::Rewrite,
484                format!(
485                    "new reference to `{}`; rows already there may point at nothing",
486                    key.references
487                ),
488            ));
489        }
490    }
491    for key in &old.foreign_keys {
492        if !new.foreign_keys.iter().any(|kept| same(kept, key)) {
493            out.push(Change::field(
494                &new.name,
495                &key.field,
496                Step::Expand,
497                format!("reference to `{}` is gone", key.references),
498            ));
499        }
500    }
501}