use std::collections::BTreeMap;
use crate::schema::{FieldDef, FieldKind, ForeignKey, Schema, TableDef};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Step {
Expand,
Contract,
Rewrite,
}
macro_rules! steps {
($($variant:ident),+ $(,)?) => {
impl Step {
pub const ALL: &'static [Step] = &[$(Step::$variant),+];
}
#[expect(dead_code, reason = "its only job is to be exhaustive")]
fn every_step_is_in_all(step: Step) {
match step {
$(Step::$variant => {}),+
}
}
};
}
steps!(Expand, Contract, Rewrite);
impl Step {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Expand => "expand",
Self::Contract => "contract",
Self::Rewrite => "rewrite",
}
}
}
impl std::fmt::Display for Step {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Change {
pub table: String,
pub field: Option<String>,
pub step: Step,
pub detail: String,
}
impl Change {
fn table(table: &str, step: Step, detail: impl Into<String>) -> Self {
Self {
table: table.to_owned(),
field: None,
step,
detail: detail.into(),
}
}
fn field(table: &str, field: &str, step: Step, detail: impl Into<String>) -> Self {
Self {
table: table.to_owned(),
field: Some(field.to_owned()),
step,
detail: detail.into(),
}
}
#[must_use]
pub fn line(&self) -> String {
match &self.field {
Some(field) => format!("{}.{field}: {}", self.table, self.detail),
None => format!("{}: {}", self.table, self.detail),
}
}
}
#[must_use]
pub fn diff(previous: &Schema, current: &Schema) -> Vec<Change> {
let before: BTreeMap<&str, &TableDef> = previous
.tables
.iter()
.map(|table| (table.name.as_str(), table))
.collect();
let after: BTreeMap<&str, &TableDef> = current
.tables
.iter()
.map(|table| (table.name.as_str(), table))
.collect();
let mut changes = Vec::new();
for (name, table) in &after {
match before.get(name) {
None => changes.push(Change::table(
name,
Step::Expand,
format!("new table with {} fields", table.fields.len()),
)),
Some(old) => table_changes(old, table, &mut changes),
}
}
for name in before.keys() {
if !after.contains_key(name) {
changes.push(Change::table(
name,
Step::Contract,
"table is gone; every row in it goes with it",
));
}
}
changes.sort_by(|a, b| (&a.table, &a.field).cmp(&(&b.table, &b.field)));
changes
}
fn table_changes(old: &TableDef, new: &TableDef, out: &mut Vec<Change>) {
if old.primary_key != new.primary_key {
out.push(Change::table(
&new.name,
Step::Rewrite,
format!(
"primary key changes from ({}) to ({}); no engine alters one in place",
old.primary_key.join(", "),
new.primary_key.join(", ")
),
));
}
foreign_key_changes(old, new, out);
let before: BTreeMap<&str, &FieldDef> = old
.fields
.iter()
.map(|field| (field.name.as_str(), field))
.collect();
for field in &new.fields {
match before.get(field.name.as_str()) {
None => out.push(added_field(&new.name, field)),
Some(old_field) => field_changes(&new.name, old_field, field, out),
}
}
for field in &old.fields {
if !new.fields.iter().any(|kept| kept.name == field.name) {
out.push(Change::field(
&new.name,
&field.name,
Step::Contract,
"column is gone; the values in it go with it",
));
}
}
}
fn added_field(table: &str, field: &FieldDef) -> Change {
if field.required && field.default.is_none() {
return Change::field(
table,
&field.name,
Step::Rewrite,
"new required column with no default; every existing row would need a value",
);
}
if field.unique {
return Change::field(
table,
&field.name,
Step::Rewrite,
"new unique column; existing rows would all take the same value and collide",
);
}
Change::field(table, &field.name, Step::Expand, "new optional column")
}
fn field_changes(table: &str, old: &FieldDef, new: &FieldDef, out: &mut Vec<Change>) {
if std::mem::discriminant(&old.kind) == std::mem::discriminant(&new.kind) {
kind_changes(table, &old.kind, &new.kind, &new.name, out);
} else {
out.push(Change::field(
table,
&new.name,
Step::Rewrite,
format!(
"type changes from {} to {}; the values already stored are the old type",
old.kind.as_str(),
new.kind.as_str()
),
));
}
match (old.required, new.required) {
(false, true) => out.push(Change::field(
table,
&new.name,
Step::Rewrite,
"column becomes required; rows that left it empty have nothing to put there",
)),
(true, false) => out.push(Change::field(
table,
&new.name,
Step::Expand,
"column becomes optional",
)),
_ => {}
}
match (old.unique, new.unique) {
(false, true) => out.push(Change::field(
table,
&new.name,
Step::Rewrite,
"column becomes unique; the rows already there may hold duplicates",
)),
(true, false) => out.push(Change::field(
table,
&new.name,
Step::Expand,
"column stops being unique",
)),
_ => {}
}
if old.indexed != new.indexed {
let detail = if new.indexed {
"indexed"
} else {
"no longer indexed"
};
out.push(Change::field(table, &new.name, Step::Expand, detail));
}
if old.default != new.default {
out.push(Change::field(
table,
&new.name,
Step::Expand,
"default changes; rows already written keep the value they have",
));
}
}
fn kind_changes(table: &str, old: &FieldKind, new: &FieldKind, field: &str, out: &mut Vec<Change>) {
match (old, new) {
(
FieldKind::Text {
min_len: old_min,
max_len: old_max,
format: old_format,
},
FieldKind::Text {
min_len: new_min,
max_len: new_max,
format: new_format,
},
) => {
bounds(
table, field, *old_min, *new_min, *old_max, *new_max, "length ", out,
);
if old_format != new_format {
let (step, detail) = if new_format.is_some() {
(
Step::Rewrite,
"a format is required now; stored values were not checked against it",
)
} else {
(Step::Expand, "the format requirement is gone")
};
out.push(Change::field(table, field, step, detail));
}
}
(
FieldKind::Integer {
min: old_min,
max: old_max,
},
FieldKind::Integer {
min: new_min,
max: new_max,
},
) => bounds(
table, field, *old_min, *new_min, *old_max, *new_max, "", out,
),
(
FieldKind::Real {
min: old_min,
max: old_max,
},
FieldKind::Real {
min: new_min,
max: new_max,
},
) => bounds(
table, field, *old_min, *new_min, *old_max, *new_max, "", out,
),
(FieldKind::Enum { values: old_values }, FieldKind::Enum { values: new_values }) => {
enum_changes(table, field, old_values, new_values, out);
}
_ => {}
}
}
#[allow(clippy::too_many_arguments)]
fn bounds<T: PartialOrd + Copy>(
table: &str,
field: &str,
old_min: Option<T>,
new_min: Option<T>,
old_max: Option<T>,
new_max: Option<T>,
what: &str,
out: &mut Vec<Change>,
) {
if narrows(old_min, new_min, Bound::Lower) || narrows(old_max, new_max, Bound::Upper) {
out.push(Change::field(
table,
field,
Step::Rewrite,
format!("{what}bounds narrow; stored values may already be outside them"),
));
} else if !same(old_min, new_min) || !same(old_max, new_max) {
out.push(Change::field(
table,
field,
Step::Expand,
format!("{what}bounds widen"),
));
}
}
fn same<T: PartialOrd>(a: Option<T>, b: Option<T>) -> bool {
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => a == b,
_ => false,
}
}
fn enum_changes(
table: &str,
field: &str,
old_values: &[String],
new_values: &[String],
out: &mut Vec<Change>,
) {
let removed: Vec<&String> = old_values
.iter()
.filter(|value| !new_values.contains(value))
.collect();
if !removed.is_empty() {
out.push(Change::field(
table,
field,
Step::Rewrite,
format!(
"enum drops {}; rows already holding one would fail the new CHECK",
removed
.iter()
.map(|value| format!("`{value}`"))
.collect::<Vec<_>>()
.join(", ")
),
));
}
let added = new_values
.iter()
.filter(|value| !old_values.contains(value))
.count();
if added > 0 {
out.push(Change::field(
table,
field,
Step::Expand,
format!("enum gains {added} value(s)"),
));
}
}
#[derive(Clone, Copy)]
enum Bound {
Lower,
Upper,
}
fn narrows<T: PartialOrd>(old: Option<T>, new: Option<T>, which: Bound) -> bool {
match (old, new) {
(_, None) => false,
(None, Some(_)) => true,
(Some(old), Some(new)) => match which {
Bound::Lower => new > old,
Bound::Upper => new < old,
},
}
}
fn foreign_key_changes(old: &TableDef, new: &TableDef, out: &mut Vec<Change>) {
let same = |a: &ForeignKey, b: &ForeignKey| a.field == b.field && a.references == b.references;
for key in &new.foreign_keys {
if !old.foreign_keys.iter().any(|had| same(had, key)) {
out.push(Change::field(
&new.name,
&key.field,
Step::Rewrite,
format!(
"new reference to `{}`; rows already there may point at nothing",
key.references
),
));
}
}
for key in &old.foreign_keys {
if !new.foreign_keys.iter().any(|kept| same(kept, key)) {
out.push(Change::field(
&new.name,
&key.field,
Step::Expand,
format!("reference to `{}` is gone", key.references),
));
}
}
}