use narwhal_core::{Column, TableSchema};
use narwhal_schema_diff::{canonical_type, defaults_equal};
use narwhal_sql::Dialect;
use crate::ddl::quote_ident_public as quote_ident;
use crate::ddl::quote_qualified_public as quote_qualified;
#[derive(Debug, Clone)]
pub enum ColumnChange {
Added { column: Column },
Dropped { name: String },
Modified {
name: String,
from: Box<Column>,
to: Box<Column>,
},
}
#[must_use]
pub fn diff_columns(before: &TableSchema, after: &TableSchema) -> Vec<ColumnChange> {
let mut out = Vec::new();
for new in &after.columns {
match before.columns.iter().find(|c| c.name == new.name) {
None => out.push(ColumnChange::Added {
column: new.clone(),
}),
Some(old) if columns_equivalent(old, new) => {}
Some(old) => out.push(ColumnChange::Modified {
name: new.name.clone(),
from: Box::new(old.clone()),
to: Box::new(new.clone()),
}),
}
}
for old in &before.columns {
if !after.columns.iter().any(|c| c.name == old.name) {
out.push(ColumnChange::Dropped {
name: old.name.clone(),
});
}
}
out
}
#[must_use]
pub fn render_alter_statements(
before: &TableSchema,
after: &TableSchema,
dialect: Dialect,
) -> Vec<String> {
let table = quote_qualified(&after.table.schema, &after.table.name, dialect);
let mut out = Vec::new();
for change in diff_columns(before, after) {
match change {
ColumnChange::Added { column } => {
let mut line = format!(
"ALTER TABLE {table} ADD COLUMN {} {}",
quote_ident(&column.name, dialect),
column.data_type
);
if !column.nullable {
line.push_str(" NOT NULL");
}
if let Some(d) = &column.default {
line.push_str(" DEFAULT ");
line.push_str(d);
}
line.push(';');
out.push(line);
}
ColumnChange::Dropped { name } => {
out.push(format!(
"ALTER TABLE {table} DROP COLUMN {};",
quote_ident(&name, dialect)
));
}
ColumnChange::Modified { name, from, to } => {
let q = quote_ident(&name, dialect);
let type_changed = from.data_type != to.data_type;
let nullable_changed = from.nullable != to.nullable;
let default_changed = from.default != to.default;
if matches!(dialect, Dialect::MySql) {
let mut spec =
format!("ALTER TABLE {table} MODIFY COLUMN {q} {}", to.data_type);
if !to.nullable {
spec.push_str(" NOT NULL");
}
if let Some(d) = &to.default {
spec.push_str(" DEFAULT ");
spec.push_str(d);
}
spec.push(';');
out.push(spec);
} else {
if type_changed {
out.push(format!(
"ALTER TABLE {table} ALTER COLUMN {q} TYPE {};",
to.data_type
));
}
if nullable_changed {
let verb = if to.nullable {
"DROP NOT NULL"
} else {
"SET NOT NULL"
};
out.push(format!("ALTER TABLE {table} ALTER COLUMN {q} {verb};"));
}
if default_changed {
if let Some(d) = &to.default {
out.push(format!(
"ALTER TABLE {table} ALTER COLUMN {q} SET DEFAULT {d};"
));
} else {
out.push(format!(
"ALTER TABLE {table} ALTER COLUMN {q} DROP DEFAULT;"
));
}
}
}
}
}
}
out
}
fn columns_equivalent(a: &Column, b: &Column) -> bool {
canonical_type(&a.data_type) == canonical_type(&b.data_type)
&& a.nullable == b.nullable
&& defaults_equal(a.default.as_deref(), b.default.as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
use narwhal_core::{Column, Table, TableKind, TableSchema};
fn col(name: &str, ty: &str, nullable: bool) -> Column {
Column {
name: name.to_owned(),
data_type: ty.to_owned(),
nullable,
default: None,
primary_key: false,
}
}
fn schema(cols: Vec<Column>) -> TableSchema {
TableSchema {
table: Table {
schema: "public".into(),
name: "t".into(),
kind: TableKind::Table,
},
columns: cols,
indexes: Vec::new(),
foreign_keys: Vec::new(),
unique_constraints: Vec::new(),
}
}
#[test]
fn detects_added_dropped_modified() {
let before = schema(vec![col("id", "INT", false), col("name", "TEXT", true)]);
let after = schema(vec![
col("id", "BIGINT", false),
col("created_at", "TIMESTAMP", true),
]);
let changes = diff_columns(&before, &after);
assert_eq!(
changes.len(),
3,
"id modified, created_at added, name dropped"
);
assert!(
changes
.iter()
.any(|c| matches!(c, ColumnChange::Modified { name, .. } if name == "id"))
);
assert!(
changes.iter().any(
|c| matches!(c, ColumnChange::Added { column } if column.name == "created_at")
)
);
assert!(
changes
.iter()
.any(|c| matches!(c, ColumnChange::Dropped { name } if name == "name"))
);
}
#[test]
fn pg_alter_renders_type_change() {
let before = schema(vec![col("id", "INT", false)]);
let after = schema(vec![col("id", "BIGINT", false)]);
let stmts = render_alter_statements(&before, &after, Dialect::Postgres);
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("ALTER COLUMN"));
assert!(stmts[0].contains("TYPE BIGINT"));
}
#[test]
fn mysql_alter_uses_modify_column() {
let before = schema(vec![col("id", "INT", false)]);
let after = schema(vec![col("id", "BIGINT", false)]);
let stmts = render_alter_statements(&before, &after, Dialect::MySql);
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("MODIFY COLUMN"));
assert!(stmts[0].contains("BIGINT"));
}
#[test]
fn mysql_modify_column_collapses_type_and_nullable() {
let before = schema(vec![col("id", "INT", false)]);
let after = schema(vec![col("id", "BIGINT", true)]);
let stmts = render_alter_statements(&before, &after, Dialect::MySql);
assert_eq!(
stmts.len(),
1,
"expected one collapsed MODIFY COLUMN; got {stmts:?}"
);
assert!(stmts[0].contains("MODIFY COLUMN"));
assert!(stmts[0].contains("BIGINT"));
assert!(
!stmts[0].contains("NOT NULL"),
"unexpected NOT NULL: {}",
stmts[0]
);
}
#[test]
fn mysql_modify_column_collapses_type_nullable_and_default() {
let before = schema(vec![col("created_at", "DATETIME", true)]);
let mut after_col = col("created_at", "TIMESTAMP", false);
after_col.default = Some("CURRENT_TIMESTAMP".into());
let after = schema(vec![after_col]);
let stmts = render_alter_statements(&before, &after, Dialect::MySql);
assert_eq!(
stmts.len(),
1,
"expected one collapsed MODIFY COLUMN; got {stmts:?}"
);
let s = &stmts[0];
assert!(s.contains("MODIFY COLUMN"));
assert!(s.contains("TIMESTAMP"));
assert!(s.contains("NOT NULL"));
assert!(s.contains("DEFAULT CURRENT_TIMESTAMP"));
}
#[test]
fn postgres_modify_still_one_statement_per_sub_change() {
let before = schema(vec![col("id", "INT", true)]);
let after = schema(vec![col("id", "BIGINT", false)]);
let stmts = render_alter_statements(&before, &after, Dialect::Postgres);
assert_eq!(stmts.len(), 2);
assert!(stmts.iter().any(|s| s.contains("TYPE BIGINT")));
assert!(stmts.iter().any(|s| s.contains("SET NOT NULL")));
}
#[test]
fn add_column_includes_not_null_and_default() {
let before = schema(vec![]);
let mut new_col = col("created_at", "TIMESTAMP", false);
new_col.default = Some("now()".into());
let after = schema(vec![new_col]);
let stmts = render_alter_statements(&before, &after, Dialect::Postgres);
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("ADD COLUMN"));
assert!(stmts[0].contains("NOT NULL"));
assert!(stmts[0].contains("DEFAULT now()"));
}
#[test]
fn equivalent_types_collapse_to_same_canonical() {
let before = schema(vec![Column {
name: "name".into(),
data_type: "varchar(255)".into(),
nullable: true,
default: None,
primary_key: false,
}]);
let after = schema(vec![Column {
name: "name".into(),
data_type: "character varying(255)".into(),
nullable: true,
default: None,
primary_key: false,
}]);
let changes = diff_columns(&before, &after);
assert!(
changes.is_empty(),
"phantom diff should be suppressed: {changes:?}"
);
let before = schema(vec![col("id", "int", false)]);
let after = schema(vec![col("id", "int4", false)]);
let changes = diff_columns(&before, &after);
assert!(changes.is_empty(), "int vs int4 phantom diff: {changes:?}");
let before = schema(vec![Column {
name: "label".into(),
data_type: "text".into(),
nullable: true,
default: Some("'hello'".into()),
primary_key: false,
}]);
let after = schema(vec![Column {
name: "label".into(),
data_type: "text".into(),
nullable: true,
default: Some("'hello'::text".into()),
primary_key: false,
}]);
let changes = diff_columns(&before, &after);
assert!(changes.is_empty(), "default cast phantom diff: {changes:?}");
}
}