use super::compare::Change;
pub fn emit(changes: &[Change]) -> (String, usize) {
let mut sql = String::new();
let mut todos = 0usize;
sql.push_str("-- Migration scaffold generated by `sntl migrate diff`\n");
sql.push_str("-- Review TODO comments and remove the leading `-- ` to apply.\n\n");
for c in changes {
match c {
Change::AddTable(t) => {
sql.push_str(&format!("CREATE TABLE {} (\n", t.name));
for (i, col) in t.columns.iter().enumerate() {
sql.push_str(" ");
sql.push_str(&col.name);
sql.push(' ');
sql.push_str(&col.pg_type.pg_type);
if !col.nullable {
sql.push_str(" NOT NULL");
}
if let Some(d) = &col.default {
sql.push_str(&format!(" DEFAULT {d}"));
}
if i + 1 != t.columns.len() {
sql.push(',');
}
sql.push('\n');
}
let pk: Vec<&str> = t
.columns
.iter()
.filter(|c| c.primary_key)
.map(|c| c.name.as_str())
.collect();
if !pk.is_empty() {
sql.push_str(&format!(" , PRIMARY KEY ({})\n", pk.join(", ")));
}
sql.push_str(");\n\n");
}
Change::DropTable { name } => {
todos += 1;
sql.push_str("-- TODO: confirm DROP, destructive\n");
sql.push_str(&format!("-- DROP TABLE {name} CASCADE;\n\n"));
}
Change::AddColumn { table, column } => {
let has_default = column.default.is_some();
let nullable = column.nullable;
if has_default || nullable {
sql.push_str(&format!(
"ALTER TABLE {table} ADD COLUMN {} {}",
column.name, column.pg_type.pg_type
));
if !nullable {
sql.push_str(" NOT NULL");
}
if let Some(d) = &column.default {
sql.push_str(&format!(" DEFAULT {d}"));
}
sql.push_str(";\n\n");
} else {
todos += 1;
sql.push_str("-- TODO: NOT NULL without default — backfill required\n");
sql.push_str(&format!(
"-- ALTER TABLE {table} ADD COLUMN {} {} NOT NULL;\n\n",
column.name, column.pg_type.pg_type
));
}
}
Change::DropColumn { table, column } => {
todos += 1;
sql.push_str("-- TODO: confirm DROP, destructive\n");
sql.push_str(&format!("-- ALTER TABLE {table} DROP COLUMN {column};\n\n"));
}
Change::AlterColumnType {
table,
column,
from,
to,
} => {
if is_widening(from, to) {
sql.push_str(&format!(
"ALTER TABLE {table} ALTER COLUMN {column} TYPE {to};\n\n"
));
} else {
todos += 1;
sql.push_str(&format!("-- TODO: cast may lose data ({from} → {to})\n"));
sql.push_str(&format!(
"-- ALTER TABLE {table} ALTER COLUMN {column} TYPE {to} USING {column}::{to};\n\n"
));
}
}
Change::AlterColumnNullable {
table,
column,
to: true,
} => {
sql.push_str(&format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL;\n\n"
));
}
Change::AlterColumnNullable {
table,
column,
to: false,
} => {
todos += 1;
sql.push_str("-- TODO: backfill NULLs first\n");
sql.push_str(&format!(
"-- ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL;\n\n"
));
}
Change::AlterColumnDefault {
table,
column,
to: Some(d),
} => {
sql.push_str(&format!(
"ALTER TABLE {table} ALTER COLUMN {column} SET DEFAULT {d};\n\n"
));
}
Change::AlterColumnDefault {
table,
column,
to: None,
} => {
sql.push_str(&format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT;\n\n"
));
}
Change::AddPrimaryKey { table, columns } => {
sql.push_str(&format!(
"ALTER TABLE {table} ADD PRIMARY KEY ({});\n\n",
columns.join(", ")
));
}
Change::DropPrimaryKey { table } => {
todos += 1;
sql.push_str("-- TODO: usually a structural change, review\n");
sql.push_str(&format!(
"-- ALTER TABLE {table} DROP CONSTRAINT {table}_pkey;\n\n"
));
}
Change::AddUnique { table, columns } => {
let cs = columns.join("_");
let cols = columns.join(", ");
sql.push_str(&format!(
"CREATE UNIQUE INDEX {table}_{cs}_key ON {table} ({cols});\n\n"
));
}
Change::DropUnique { table, columns } => {
let cs = columns.join("_");
todos += 1;
sql.push_str("-- TODO: confirm drop of UNIQUE\n");
sql.push_str(&format!("-- DROP INDEX {table}_{cs}_key;\n\n"));
}
}
}
(sql, todos)
}
fn is_widening(from: &str, to: &str) -> bool {
matches!(
(from, to),
("int2", "int4")
| ("int2", "int8")
| ("int4", "int8")
| ("float4", "float8")
| ("time", "timestamp")
| ("time", "timetz")
| ("timestamp", "timestamptz")
| ("date", "timestamp")
| ("date", "timestamptz")
)
}