sntl-migrate 0.1.2

Migrations library + CLI helpers for Sentinel ORM
Documentation
use super::compare::Change;

/// Generate a SQL skeleton from a list of changes plus a count of how many
/// emitted blocks need human review (TODO markers).
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")
    )
}