//! SeaORM migration Rust code generation — `up`/`down` files, CREATE TABLE, FK, indexes, triggers.
use crate::migration::utils::{
helpers::col_type_to_method,
types::{Changes, DbKind, ParsedColumn, ParsedSchema},
};
/// Generates the migration file for a CREATE TABLE.
pub fn generate_create_file(schema: &ParsedSchema, db_kind: &DbKind) -> String {
// FKs always inline in CREATE TABLE: SQLite cannot ALTER-ADD a foreign key
// constraint to an existing table, so a separately-created FK breaks it —
// inline is valid on every engine, not just required on SQLite (confirmed
// via the contributions-table fix earlier this session).
let cols = build_create_table_cols(schema, db_kind, true);
let idx_stmts = build_index_create_stmts(schema);
let trigger_stmts = build_updated_at_trigger_stmts(schema);
let enum_stmts = build_enum_type_stmts(schema);
let enum_drops = build_enum_type_drops(schema);
let idx_drops = build_index_drop_stmts(schema);
let trigger_drops = build_updated_at_trigger_drops(schema);
let mut up = String::new();
up.push_str(&enum_stmts);
up.push_str(" manager\n");
up.push_str(" .create_table(\n");
up.push_str(" Table::create()\n");
up.push_str(&format!(
" .table(Alias::new(\"{}\"))\n",
schema.table_name
));
up.push_str(" .if_not_exists()\n");
up.push_str(&cols);
up.push_str(" .to_owned()\n");
up.push_str(" )\n");
up.push_str(" .await?;\n\n");
up.push_str(&idx_stmts);
up.push_str(&trigger_stmts);
up.push_str(" Ok(())\n");
let mut down = String::new();
down.push_str(&trigger_drops);
down.push_str(&idx_drops);
down.push_str(" manager\n");
down.push_str(" .drop_table(Table::drop()\n");
down.push_str(&format!(
" .table(Alias::new(\"{}\"))\n",
schema.table_name
));
down.push_str(" .to_owned())\n");
down.push_str(" .await?;\n");
down.push_str(&enum_drops);
down.push_str(" Ok(())\n");
format!(
"use sea_orm_migration::prelude::*;\n\n\
#[derive(DeriveMigrationName)]\n\
pub struct Migration;\n\n\
#[async_trait::async_trait]\n\
impl MigrationTrait for Migration {{\n\
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{\n\
{up}\
}}\n\n\
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{\n\
{down}\
}}\n\
}}\n",
up = up,
down = down,
)
}
/// Generates the snapshot file (includes FK stmts so diffs detect FK additions/removals).
pub fn generate_snapshot_file(schema: &ParsedSchema) -> String {
// Snapshot keeps FKs as separate stmts (never inline) so parser_seaorm round-trip is stable.
let cols = build_create_table_cols(schema, &DbKind::Other, false);
let fk_stmts = build_fk_create_stmts(schema);
let idx_stmts = build_index_create_stmts(schema);
let fk_drops = build_fk_drop_stmts(schema);
let idx_drops = build_index_drop_stmts(schema);
let mut up = String::new();
up.push_str(" manager\n");
up.push_str(" .create_table(\n");
up.push_str(" Table::create()\n");
up.push_str(&format!(
" .table(Alias::new(\"{}\"))\n",
schema.table_name
));
up.push_str(" .if_not_exists()\n");
up.push_str(&cols);
up.push_str(" .to_owned()\n");
up.push_str(" )\n");
up.push_str(" .await?;\n\n");
up.push_str(&fk_stmts);
up.push_str(&idx_stmts);
up.push_str(" Ok(())\n");
let mut down = String::new();
down.push_str(&fk_drops);
down.push_str(&idx_drops);
down.push_str(" manager\n");
down.push_str(" .drop_table(Table::drop()\n");
down.push_str(&format!(
" .table(Alias::new(\"{}\"))\n",
schema.table_name
));
down.push_str(" .to_owned())\n");
down.push_str(" .await?;\n");
down.push_str(" Ok(())\n");
format!(
"use sea_orm_migration::prelude::*;\n\n\
#[derive(DeriveMigrationName)]\n\
pub struct Migration;\n\n\
#[async_trait::async_trait]\n\
impl MigrationTrait for Migration {{\n\
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{\n\
{up}\
}}\n\n\
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{\n\
{down}\
}}\n\
}}\n",
up = up,
down = down,
)
}
/// Generates a single migration that adds all FK constraints for a batch of new tables.
/// Placed last in lib.rs so all tables exist before FK constraints are applied.
pub fn generate_relations_file(schemas: &[&ParsedSchema]) -> String {
let mut up = String::new();
let mut down = String::new();
for schema in schemas {
up.push_str(&build_fk_create_stmts(schema));
}
for schema in schemas.iter().rev() {
down.push_str(&build_fk_drop_stmts(schema));
}
let up_param = if !up.trim().is_empty() {
"manager"
} else {
"_manager"
};
let down_param = if !down.trim().is_empty() {
"manager"
} else {
"_manager"
};
format!(
"use sea_orm_migration::prelude::*;\n\n\
#[derive(DeriveMigrationName)]\n\
pub struct Migration;\n\n\
#[async_trait::async_trait]\n\
impl MigrationTrait for Migration {{\n\
async fn up(&self, {up_param}: &SchemaManager) -> Result<(), DbErr> {{\n\
{up} Ok(())\n\
}}\n\n\
async fn down(&self, {down_param}: &SchemaManager) -> Result<(), DbErr> {{\n\
{down} Ok(())\n\
}}\n\
}}\n",
up = up,
down = down,
)
}
// Every function below writes a `CREATE TYPE`/`DROP TYPE` — Postgres-only syntax —
// unconditionally into the generated migration, wrapped in a runtime
// `manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres`
// check rather than gated by `db_kind` at generation time. A migration file is
// fixed forever once written; deciding "include this or not" from whatever engine
// `makemigrations` happened to target freezes the file to that ONE engine
// (confirmed in practice: a Postgres-generated `CREATE TYPE ... AS ENUM` run against
// MariaDB is a straight syntax error). Checking the real backend at migration-RUN
// time instead makes the same generated file portable across all three.
fn build_enum_type_stmts(schema: &ParsedSchema) -> String {
build_enum_create_stmts_for_cols(&schema.columns)
}
fn build_enum_type_drops(schema: &ParsedSchema) -> String {
build_enum_drop_stmts_for_cols(&schema.columns)
}
fn build_enum_create_stmts_for_cols(cols: &[ParsedColumn]) -> String {
let mut out = String::new();
for col in cols {
if col.enum_string_values.is_empty() {
continue;
}
let name = col.enum_name.as_deref().unwrap_or(&col.name);
// No builder alternative exists for this idempotent form (Postgres itself has
// no `CREATE TYPE IF NOT EXISTS`, and sea-query's `TypeCreateStatement` has no
// `if_not_exists()` either — confirmed against its full method list) — raw SQL
// stays, but the values it interpolates are escaped like any other SQL string
// literal (`'` doubled), which the builder does automatically and this doesn't.
let variants: Vec<String> = col
.enum_string_values
.iter()
.map(|v| format!("'{}'", v.replace('\'', "''")))
.collect();
out.push_str(&format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager.get_connection().execute_unprepared(\n \"DO $$ BEGIN CREATE TYPE {name} AS ENUM ({variants}); EXCEPTION WHEN duplicate_object THEN NULL; END $$\"\n ).await?;\n }}\n\n",
name = name,
variants = variants.join(", "),
));
}
out
}
fn build_enum_drop_stmts_for_cols(cols: &[ParsedColumn]) -> String {
let mut out = String::new();
for col in cols {
if col.enum_string_values.is_empty() {
continue;
}
let name = col.enum_name.as_deref().unwrap_or(&col.name);
out.push_str(&format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager.get_connection().execute_unprepared(\"DROP TYPE IF EXISTS {name}\").await?;\n }}\n\n",
name = name,
));
}
out
}
/// True if `body` has a line that isn't blank or a `//` comment — i.e. it actually calls `manager`.
/// A type-change diff only emits a `// Manual migration required.` comment, which is non-empty
/// text but no real statement, so a plain `is_empty()` check picks `manager` and leaves it unused.
fn body_has_code(body: &str) -> bool {
body.lines()
.any(|line| !line.trim().is_empty() && !line.trim_start().starts_with("//"))
}
pub fn generate_alter_file(change: &Changes) -> String {
let (up_body, down_body) = build_alter_bodies(change);
let enum_creates = build_enum_create_stmts_for_cols(&change.added_columns);
let enum_drops = build_enum_drop_stmts_for_cols(&change.added_columns);
let full_up = format!("{}{}", enum_creates, up_body);
let full_down = format!("{}{}", down_body, enum_drops);
let up_param = if body_has_code(&full_up) {
"manager"
} else {
"_manager"
};
let down_param = if body_has_code(&full_down) {
"manager"
} else {
"_manager"
};
format!(
"use sea_orm_migration::prelude::*;\n\n#[derive(DeriveMigrationName)]\npub struct Migration;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {{\n async fn up(&self, {up_param}: &SchemaManager) -> Result<(), DbErr> {{\n{up}\n Ok(())\n }}\n\n async fn down(&self, {down_param}: &SchemaManager) -> Result<(), DbErr> {{\n{down}\n Ok(())\n }}\n}}\n",
up_param = up_param,
down_param = down_param,
up = full_up.trim_end(),
down = full_down.trim_end()
)
}
pub fn generate_batch_up_file(changes: &[&Changes], timestamp: &str) -> String {
let mut body = String::new();
for change in changes {
append_up_ops(change, &mut body);
}
format!(
"// Batch up - auto-generated by runique\n// Timestamp: {0}\n// Tables: {1}\nuse sea_orm_migration::prelude::*;\n\n#[derive(DeriveMigrationName)]\npub struct Migration;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {{\n async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{\n{2}\n Ok(())\n }}\n\n async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {{\n Ok(())\n }}\n}}\n",
timestamp,
changes
.iter()
.map(|c| c.table_name.as_str())
.collect::<Vec<_>>()
.join(", "),
body.trim_end()
)
}
pub fn generate_batch_down_file(changes: &[&Changes], timestamp: &str) -> String {
let mut body = String::new();
for change in changes {
append_down_ops(change, &mut body);
}
format!(
"// Batch down - auto-generated by runique\n// Timestamp: {0}\n// Tables: {1}\nuse sea_orm_migration::prelude::*;\n\n#[derive(DeriveMigrationName)]\npub struct Migration;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {{\n async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> {{\n Ok(())\n }}\n\n async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{\n{2}\n Ok(())\n }}\n}}\n",
timestamp,
changes
.iter()
.map(|c| c.table_name.as_str())
.collect::<Vec<_>>()
.join(", "),
body.trim_end()
)
}
fn build_create_table_cols(schema: &ParsedSchema, db_kind: &DbKind, inline_fks: bool) -> String {
let mut cols = String::new();
if let Some(ref pk) = schema.primary_key {
cols.push_str(&format!("{}\n", render_pk_col(pk)));
}
for col in schema.columns.iter().filter(|c| !c.ignored) {
cols.push_str(&format!(
" .col({})\n",
render_column_def(col, db_kind)
));
}
// SQLite cannot add FK constraints to an existing table (no ALTER TABLE ADD CONSTRAINT),
// so for SQLite the FKs are declared INLINE here instead of in the relations migration.
if inline_fks {
for fk in &schema.foreign_keys {
cols.push_str(&format!(
" .foreign_key(\n ForeignKey::create()\n .name(\"{table}_{from}_{to_table}_fkey\")\n .from(Alias::new(\"{table}\"), Alias::new(\"{from}\"))\n .to(Alias::new(\"{to_table}\"), Alias::new(\"{to_col}\"))\n .on_delete(ForeignKeyAction::{on_delete})\n .on_update(ForeignKeyAction::{on_update})\n )\n",
table = schema.table_name,
from = fk.from_column,
to_table = fk.to_table,
to_col = fk.to_column,
on_delete = fk.on_delete,
on_update = fk.on_update,
));
}
}
cols
}
fn build_fk_create_stmts(schema: &ParsedSchema) -> String {
let mut out = String::new();
for fk in &schema.foreign_keys {
out.push_str(&format!(
" manager\n .create_foreign_key(\n ForeignKey::create()\n .name(\"{table}_{from}_{to_table}_fkey\")\n .from(Alias::new(\"{table}\"), Alias::new(\"{from}\"))\n .to(Alias::new(\"{to_table}\"), Alias::new(\"{to_col}\"))\n .on_delete(ForeignKeyAction::{on_delete})\n .on_update(ForeignKeyAction::{on_update})\n .to_owned(),\n )\n .await?;\n\n",
table = schema.table_name,
from = fk.from_column,
to_table = fk.to_table,
to_col = fk.to_column,
on_delete = fk.on_delete,
on_update = fk.on_update
));
}
out
}
fn build_index_create_stmts(schema: &ParsedSchema) -> String {
let mut out = String::new();
for idx in &schema.indexes {
out.push_str(&render_create_index_stmt(
&schema.table_name,
&idx.name,
&idx.columns,
idx.unique,
));
out.push('\n');
}
out
}
fn build_fk_drop_stmts(schema: &ParsedSchema) -> String {
let mut out = String::new();
for fk in &schema.foreign_keys {
out.push_str(&format!(
" manager\n .drop_foreign_key(\n ForeignKey::drop()\n .table(Alias::new(\"{table}\"))\n .name(\"{table}_{from}_{to}_fkey\")\n .to_owned(),\n )\n .await?;\n\n",
table = schema.table_name,
from = fk.from_column,
to = fk.to_table
));
}
out
}
fn build_index_drop_stmts(schema: &ParsedSchema) -> String {
let mut out = String::new();
for idx in &schema.indexes {
out.push_str(&format!(
" manager\n .drop_index(Index::drop().name(\"{idx}\").table(Alias::new(\"{table}\")).to_owned())\n .await?;\n\n",
idx = idx.name,
table = schema.table_name
));
}
out
}
fn build_alter_bodies(change: &Changes) -> (String, String) {
let mut up = String::new();
let mut down = String::new();
// 0) RENAME columns first — later ops (modify/add) may target the new name.
// `ALTER TABLE … RENAME COLUMN` is portable (PG / MySQL 8+ / MariaDB 10.5+ / SQLite 3.25+).
for (old, new) in &change.renamed_columns {
push_rename_column(&mut up, &change.table_name, old, new);
}
// 1) DROP indexes (up) / DROP added indexes (down)
for idx in &change.dropped_indexes {
push_drop_index(&mut up, &change.table_name, &idx.name);
}
for idx in &change.added_indexes {
push_drop_index(&mut down, &change.table_name, &idx.name);
}
// 2) DROP FK
for fk in &change.dropped_fks {
push_drop_fk(&mut up, &change.table_name, &fk.from_column, &fk.to_table);
}
for fk in &change.added_fks {
push_drop_fk(&mut down, &change.table_name, &fk.from_column, &fk.to_table);
}
// 3) DROP columns
for col in &change.dropped_columns {
push_drop_column(&mut up, &change.table_name, &col.name);
}
for col in &change.added_columns {
push_drop_column(&mut down, &change.table_name, &col.name);
}
// 4) MODIFY columns
for (old, new) in &change.modified_columns {
// Becoming (or stopping being) an enum: `col_type` alone doesn't capture this
// (an enum column keeps col_type "String"), so this must be checked before the
// generic "type change => manual" fallback below, which would otherwise catch
// ordinary type changes but silently miss this one entirely.
if old.enum_string_values.is_empty() != new.enum_string_values.is_empty() {
let (up_op, down_op) = build_enum_transition(&change.table_name, old, new);
up.push_str(&up_op);
down.push_str(&down_op);
continue;
}
// type change => manual
if old.col_type != new.col_type {
up.push_str(&format!(
" // WARNING: type change on column '{col}': {old} -> {new}\n // Manual migration required.\n\n",
col = new.name,
old = old.col_type,
new = new.col_type
));
continue;
}
// nullable -> not_null => destructive unless you backfill
if old.nullable && !new.nullable {
// Generates modify_column anyway (risky if NULLs exist)
push_modify_column(
&mut up,
&change.table_name,
&new.name,
&new.col_type,
new.nullable,
new.unique,
);
push_modify_column(
&mut down,
&change.table_name,
&old.name,
&old.col_type,
old.nullable,
old.unique,
);
continue;
}
// safe modify
push_modify_column(
&mut up,
&change.table_name,
&new.name,
&new.col_type,
new.nullable,
new.unique,
);
push_modify_column(
&mut down,
&change.table_name,
&old.name,
&old.col_type,
old.nullable,
old.unique,
);
}
// 5) ADD columns
for col in &change.added_columns {
push_add_column(&mut up, &change.table_name, col);
}
// 6) Recreate dropped columns in DOWN (now correct because we store ParsedColumn)
for col in &change.dropped_columns {
push_add_column(&mut down, &change.table_name, col);
}
// 7) ADD FK
for fk in &change.added_fks {
push_create_fk(
&mut up,
&change.table_name,
&fk.from_column,
&fk.to_table,
&fk.to_column,
&fk.on_delete,
&fk.on_update,
);
}
for fk in &change.dropped_fks {
push_create_fk(
&mut down,
&change.table_name,
&fk.from_column,
&fk.to_table,
&fk.to_column,
&fk.on_delete,
&fk.on_update,
);
}
// 8) ADD indexes
for idx in &change.added_indexes {
push_create_index(
&mut up,
&change.table_name,
&idx.name,
&idx.columns,
idx.unique,
);
}
for idx in &change.dropped_indexes {
push_create_index(
&mut down,
&change.table_name,
&idx.name,
&idx.columns,
idx.unique,
);
}
// 9) Enum value renames — both branches (Postgres native `RENAME VALUE` vs.
// VARCHAR-backed `UPDATE`) are always emitted; which one runs is decided at
// migration-run time via the real backend, not baked in at generation time
// (a migration file is fixed forever once written — see the enum CREATE/DROP
// helpers above for the same reasoning).
// Both `ALTER TYPE ... RENAME VALUE`/`ADD VALUE` (Postgres) have a builder —
// `sea_query::extension::postgres::Type` — that escapes values the same way
// every other sea-query statement does (`prepare_value`, doubled single quotes),
// unlike hand-written SQL strings. Used here instead of raw `execute_unprepared`
// for exactly that reason; only the idempotent `CREATE TYPE` DO-block above has
// no builder equivalent at all (Postgres itself has no `CREATE TYPE IF NOT EXISTS`).
//
// The type NAME passed to `.name()` is lowercased here (only here): the raw
// `CREATE TYPE` DO-block above is never quoted, so Postgres folds it to lowercase
// at creation time — but `Type::alter()`'s own renderer always quotes its type
// name, preserving whatever case it's given. Passing the original mixed case
// (e.g. "ChangelogCategory") makes it look for `"ChangelogCategory"` — a
// different, non-existent identifier from the lowercase one actually stored.
// `CREATE`/`DROP TYPE` and `ColumnType::Enum` stay as-is: neither is ever quoted
// (confirmed in sea-query's Postgres backend), so both already resolve to the
// same lowercase-folded object regardless of the case written in the Rust source.
for (col, enum_name, old_val, new_val) in &change.enum_renames {
let enum_name_lc = enum_name.to_lowercase();
up.push_str(&format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager\n .alter_type(\n sea_query::extension::postgres::Type::alter()\n .name(Alias::new(\"{enum_name_lc}\"))\n .rename_value(Alias::new(\"{old}\"), Alias::new(\"{new}\"))\n .to_owned(),\n )\n .await?;\n }} else {{\n manager\n .get_connection()\n .execute(\n Query::update()\n .table(Alias::new(\"{table}\"))\n .value(Alias::new(\"{col}\"), \"{new}\")\n .and_where(Expr::col(Alias::new(\"{col}\")).eq(\"{old}\"))\n .to_owned(),\n )\n .await?;\n }}\n\n",
enum_name_lc = enum_name_lc, old = old_val, new = new_val, table = change.table_name, col = col,
));
down.push_str(&format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager\n .alter_type(\n sea_query::extension::postgres::Type::alter()\n .name(Alias::new(\"{enum_name_lc}\"))\n .rename_value(Alias::new(\"{new}\"), Alias::new(\"{old}\"))\n .to_owned(),\n )\n .await?;\n }} else {{\n manager\n .get_connection()\n .execute(\n Query::update()\n .table(Alias::new(\"{table}\"))\n .value(Alias::new(\"{col}\"), \"{old}\")\n .and_where(Expr::col(Alias::new(\"{col}\")).eq(\"{new}\"))\n .to_owned(),\n )\n .await?;\n }}\n\n",
enum_name_lc = enum_name_lc, old = old_val, new = new_val, table = change.table_name, col = col,
));
}
// 10) Enum value additions/removals — Postgres native enum only; VARCHAR-backed
// enums (non-PG) accept any string, so no DDL is needed there. Guarded at
// runtime rather than skipped at generation time, same reasoning as above.
for (_col, enum_name, val) in &change.enum_value_adds {
let enum_name_lc = enum_name.to_lowercase();
up.push_str(&format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager\n .alter_type(\n sea_query::extension::postgres::Type::alter()\n .name(Alias::new(\"{enum_name_lc}\"))\n .add_value(Alias::new(\"{val}\"))\n .if_not_exists()\n .to_owned(),\n )\n .await?;\n }}\n\n",
enum_name_lc = enum_name_lc, val = val,
));
}
for (_col, enum_name, val) in &change.enum_value_drops {
let enum_name_lc = enum_name.to_lowercase();
up.push_str(&format!(
" // WARNING: value '{val}' removed from {enum_name} — manual migration required (Postgres cannot drop an enum value).\n\n",
val = val, enum_name = enum_name,
));
down.push_str(&format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager\n .alter_type(\n sea_query::extension::postgres::Type::alter()\n .name(Alias::new(\"{enum_name_lc}\"))\n .add_value(Alias::new(\"{val}\"))\n .if_not_exists()\n .to_owned(),\n )\n .await?;\n }}\n\n",
enum_name_lc = enum_name_lc, val = val,
));
}
// 11) Reverse the column renames last in DOWN (undo of section 0).
for (old, new) in &change.renamed_columns {
push_rename_column(&mut down, &change.table_name, new, old);
}
(up, down)
}
/// A column entering or leaving enum-ness, both directions (`up`: old -> new,
/// `down`: new -> old) — reusing sea-query's builder (`ColumnDef::using`) rather
/// than hand-written SQL for the `ALTER COLUMN ... TYPE ... USING` cast: bare
/// `modify_column` renders `ALTER COLUMN x TYPE enum_type` with no `USING`
/// clause at all, which Postgres rejects on an existing non-enum column (it
/// requires an explicit cast to know how to reinterpret each row's data).
/// `.using()` is Postgres-specific — the other backends' `TableAlterOption`
/// renderers never read `column_def.spec.using`, so setting it unconditionally
/// is harmless there; MySQL's `ENUM(...)` is inline (no separate type to create
/// first) and SQLite has no native enum (plain column either way).
fn build_enum_transition(table: &str, old: &ParsedColumn, new: &ParsedColumn) -> (String, String) {
let up = render_enum_column_change(table, old, new);
let down = render_enum_column_change(table, new, old);
(up, down)
}
/// Renders one direction of an enum-transition ALTER: `from` is the column's
/// current shape, `to` is what it becomes after this statement runs.
fn render_enum_column_change(table: &str, from: &ParsedColumn, to: &ParsedColumn) -> String {
let mut out = String::new();
let from_is_enum = !from.enum_string_values.is_empty();
let to_is_enum = !to.enum_string_values.is_empty();
// The enum TYPE must exist before any column can be cast into it — Postgres only
// (MySQL's enum is inline on the column, SQLite has no native enum type at all),
// checked at migration-run time rather than baked in at generation time (same
// reasoning as the enum CREATE/DROP helpers above).
if to_is_enum && !from_is_enum {
let enum_name = to.enum_name.as_deref().unwrap_or(&to.name);
// See `build_enum_create_stmts_for_cols` above: no builder alternative for this
// idempotent form exists, so values are escaped like any other SQL string literal.
let variants = to
.enum_string_values
.iter()
.map(|v| format!("'{}'", v.replace('\'', "''")))
.collect::<Vec<_>>()
.join(", ");
out.push_str(&format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager.get_connection().execute_unprepared(\n \"DO $$ BEGIN CREATE TYPE {enum_name} AS ENUM ({variants}); EXCEPTION WHEN duplicate_object THEN NULL; END $$\"\n ).await?;\n }}\n\n",
));
}
let col_def = if to_is_enum {
let enum_name = to.enum_name.as_deref().unwrap_or(&to.name);
let variants: Vec<String> = to
.enum_string_values
.iter()
.map(|v| format!("Alias::new(\"{}\").into_iden()", v))
.collect();
format!(
"ColumnDef::new_with_type(Alias::new(\"{col}\"), ColumnType::Enum {{ name: Alias::new(\"{enum_name}\").into_iden(), variants: vec![{variants}] }})",
col = to.name,
enum_name = enum_name,
variants = variants.join(", "),
)
} else {
format!(
"ColumnDef::new(Alias::new(\"{col}\")).{ty}",
col = to.name,
ty = col_type_to_method(&to.col_type),
)
};
// Postgres reinterprets each existing row's value through this cast target —
// the enum name when entering it, `text` when leaving back to a plain column.
let cast_target = if to_is_enum {
to.enum_name.as_deref().unwrap_or(&to.name).to_string()
} else {
"text".to_string()
};
// No `.null()`/`.not_null()` on this modify_column: sea-query's Postgres backend
// renders `spec.nullable` as a SEPARATE "ALTER COLUMN ... SET/DROP NOT NULL" clause,
// and `.using()` is appended at the very end of the whole statement regardless of
// which clause it "belongs" to — combining both produces the invalid
// "TYPE t, ALTER COLUMN x SET NOT NULL USING expr" (USING stranded after NOT NULL
// instead of right after TYPE). An enum transition never changes nullability
// anyway, so the column's existing NOT NULL/NULL constraint is simply left alone.
// sea-query's SQLite backend `panic!`s unconditionally on ANY `modify_column`
// (not a SQL-syntax rejection at runtime — the statement builder itself refuses
// to render one), so this whole ALTER must be skipped there rather than merely
// guarded like the CREATE/DROP TYPE calls. Harmless to skip: SQLite has no native
// enum (`ColumnType::Enum` renders as plain `enum_text`), so the column already
// behaves as free-form text before and after this migration on that engine.
out.push_str(&format!(
" if manager.get_connection().get_database_backend() != sea_orm::DbBackend::Sqlite {{\n manager\n .alter_table(\n Table::alter()\n .table(Alias::new(\"{table}\"))\n .modify_column({col_def}.using(Expr::col(Alias::new(\"{col}\")).cast_as(Alias::new(\"{cast_target}\"))))\n .to_owned(),\n )\n .await?;\n }}\n\n",
table = table,
col_def = col_def,
col = to.name,
cast_target = cast_target,
));
// The old type is only droppable once nothing references it any more — after
// the ALTER above has moved the column off it.
if from_is_enum && !to_is_enum {
let enum_name = from.enum_name.as_deref().unwrap_or(&from.name);
out.push_str(&format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager.get_connection().execute_unprepared(\"DROP TYPE IF EXISTS {enum_name}\").await?;\n }}\n\n",
));
}
out
}
fn append_up_ops(change: &Changes, buf: &mut String) {
for (old, new) in &change.renamed_columns {
push_rename_column(buf, &change.table_name, old, new);
}
for idx in &change.dropped_indexes {
push_drop_index(buf, &change.table_name, &idx.name);
}
for fk in &change.dropped_fks {
push_drop_fk(buf, &change.table_name, &fk.from_column, &fk.to_table);
}
for col in &change.dropped_columns {
push_drop_column(buf, &change.table_name, &col.name);
}
for col in &change.added_columns {
push_add_column(buf, &change.table_name, col);
}
for fk in &change.added_fks {
push_create_fk(
buf,
&change.table_name,
&fk.from_column,
&fk.to_table,
&fk.to_column,
&fk.on_delete,
&fk.on_update,
);
}
for idx in &change.added_indexes {
push_create_index(buf, &change.table_name, &idx.name, &idx.columns, idx.unique);
}
}
fn append_down_ops(change: &Changes, buf: &mut String) {
for idx in &change.added_indexes {
push_drop_index(buf, &change.table_name, &idx.name);
}
for fk in &change.added_fks {
push_drop_fk(buf, &change.table_name, &fk.from_column, &fk.to_table);
}
for col in &change.added_columns {
push_drop_column(buf, &change.table_name, &col.name);
}
// recreate dropped columns
for col in &change.dropped_columns {
push_add_column(buf, &change.table_name, col);
}
for fk in &change.dropped_fks {
push_create_fk(
buf,
&change.table_name,
&fk.from_column,
&fk.to_table,
&fk.to_column,
&fk.on_delete,
&fk.on_update,
);
}
for idx in &change.dropped_indexes {
push_create_index(buf, &change.table_name, &idx.name, &idx.columns, idx.unique);
}
for (old, new) in &change.renamed_columns {
push_rename_column(buf, &change.table_name, new, old);
}
}
fn render_pk_col(pk: &ParsedColumn) -> String {
let ty = col_type_to_method(&pk.col_type);
// auto_increment only makes sense for integer-ish PK.
let autoinc_ok = matches!(
pk.col_type.as_str(),
"Integer" | "BigInteger" | "SmallInteger" | "TinyInteger" | "Unsigned" | "BigUnsigned"
);
let mut s = format!(
" .col(ColumnDef::new(Alias::new(\"{name}\")).{ty}.not_null()",
name = pk.name,
ty = ty
);
if autoinc_ok {
s.push_str(".auto_increment()");
}
s.push_str(".primary_key())");
s
}
fn render_column_def(col: &ParsedColumn, db_kind: &DbKind) -> String {
let null = if col.nullable {
".null()"
} else {
".not_null()"
};
let uniq = if col.unique { ".unique_key()" } else { "" };
let default = if col.has_default_now {
".default(Expr::current_timestamp())".to_string()
} else if let Some(v) = &col.default_value {
format!(".default({})", v)
} else {
String::new()
};
let on_update = if col.updated_at && *db_kind == DbKind::Mysql {
".extra(\"ON UPDATE CURRENT_TIMESTAMP\")"
} else {
""
};
if !col.enum_string_values.is_empty() {
let name = col.enum_name.as_deref().unwrap_or(&col.name);
let variants: Vec<String> = col
.enum_string_values
.iter()
.map(|v| format!("Alias::new(\"{}\").into_iden()", v))
.collect();
format!(
"ColumnDef::new_with_type(Alias::new(\"{name}\"), ColumnType::Enum {{ name: Alias::new(\"{enum_name}\").into_iden(), variants: vec![{variants}] }}){null}{uniq}{default}",
name = col.name,
enum_name = name,
variants = variants.join(", "),
null = null,
uniq = uniq,
default = default,
)
} else {
let ty = col_type_to_method(&col.col_type);
format!(
"ColumnDef::new(Alias::new(\"{name}\")).{ty}{null}{uniq}{default}{on_update}",
name = col.name,
ty = ty,
null = null,
uniq = uniq,
default = default,
on_update = on_update,
)
}
}
/// Generates PostgreSQL triggers for `updated_at` columns.
/// For MySQL, handling is inline via `.extra("ON UPDATE CURRENT_TIMESTAMP")`.
/// Always emitted into the generated file, wrapped in a runtime backend check —
/// same reasoning as the enum CREATE/DROP helpers: a migration file is fixed
/// forever once written, so deciding "include this or not" from whatever engine
/// `makemigrations` happened to target freezes the file to that one engine.
fn build_updated_at_trigger_stmts(schema: &ParsedSchema) -> String {
let updated_at_cols: Vec<_> = schema.columns.iter().filter(|c| c.updated_at).collect();
if updated_at_cols.is_empty() {
return String::new();
}
let table = &schema.table_name;
let fn_name = format!("set_updated_at_{}", table);
let trigger_name = format!("trg_{}_updated_at", table);
format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager.get_connection().execute_unprepared(\n \"CREATE OR REPLACE FUNCTION {fn_name}() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql;\"\n ).await?;\n manager.get_connection().execute_unprepared(\n \"CREATE TRIGGER {trigger_name} BEFORE UPDATE ON {table} FOR EACH ROW EXECUTE PROCEDURE {fn_name}();\"\n ).await?;\n }}\n\n",
fn_name = fn_name,
trigger_name = trigger_name,
table = table,
)
}
/// Drops PostgreSQL `updated_at` triggers in the `down` block. Same runtime-guard
/// reasoning as `build_updated_at_trigger_stmts` above.
fn build_updated_at_trigger_drops(schema: &ParsedSchema) -> String {
let has_updated_at = schema.columns.iter().any(|c| c.updated_at);
if !has_updated_at {
return String::new();
}
let table = &schema.table_name;
let fn_name = format!("set_updated_at_{}", table);
let trigger_name = format!("trg_{}_updated_at", table);
format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager.get_connection().execute_unprepared(\n \"DROP TRIGGER IF EXISTS {trigger_name} ON {table};\"\n ).await?;\n manager.get_connection().execute_unprepared(\n \"DROP FUNCTION IF EXISTS {fn_name}();\"\n ).await?;\n }}\n\n",
trigger_name = trigger_name,
table = table,
fn_name = fn_name,
)
}
fn push_drop_index(buf: &mut String, table: &str, idx_name: &str) {
buf.push_str(&format!(
" manager\n .drop_index(Index::drop().name(\"{idx}\").table(Alias::new(\"{table}\")).to_owned())\n .await?;\n\n",
idx = idx_name,
table = table
));
}
fn push_drop_fk(buf: &mut String, table: &str, from_col: &str, to_table: &str) {
buf.push_str(&format!(
" manager\n .drop_foreign_key(\n ForeignKey::drop()\n .table(Alias::new(\"{table}\"))\n .name(\"{table}_{from}_{to}_fkey\")\n .to_owned(),\n )\n .await?;\n\n",
table = table,
from = from_col,
to = to_table
));
}
fn push_rename_column(buf: &mut String, table: &str, from: &str, to: &str) {
buf.push_str(&format!(
" manager\n .alter_table(\n Table::alter()\n .table(Alias::new(\"{table}\"))\n .rename_column(Alias::new(\"{from}\"), Alias::new(\"{to}\"))\n .to_owned(),\n )\n .await?;\n\n",
table = table,
from = from,
to = to
));
}
fn push_drop_column(buf: &mut String, table: &str, col: &str) {
buf.push_str(&format!(
" manager\n .alter_table(\n Table::alter()\n .table(Alias::new(\"{table}\"))\n .drop_column(Alias::new(\"{col}\"))\n .to_owned(),\n )\n .await?;\n\n",
table = table,
col = col
));
}
fn push_modify_column(
buf: &mut String,
table: &str,
col: &str,
col_type: &str,
nullable: bool,
unique: bool,
) {
let null = if nullable { ".null()" } else { ".not_null()" };
let uniq = if unique { ".unique_key()" } else { "" };
// sea-query's SQLite backend `panic!`s unconditionally on ANY `modify_column`
// (its ALTER TABLE only ever supports ADD/RENAME/DROP COLUMN — a real SQLite
// limitation, not a sea-query gap), so this ALTER must be skipped there rather
// than merely guarded. Unlike the enum-transition case, skipping here genuinely
// loses the constraint change on SQLite (nullable/unique/type stays whatever it
// was) — flagged with a warning comment in the generated file rather than
// silently dropped, since there is no equivalent SQLite statement to fall back to.
buf.push_str(&format!(
" // WARNING: SQLite cannot ALTER a column's type/nullable/unique constraint\n // (sea-query panics on any `modify_column` there) — this change only applies on\n // Postgres/MySQL; on SQLite the column keeps its current definition unchanged.\n if manager.get_connection().get_database_backend() != sea_orm::DbBackend::Sqlite {{\n manager\n .alter_table(\n Table::alter()\n .table(Alias::new(\"{table}\"))\n .modify_column(ColumnDef::new(Alias::new(\"{col}\")).{ty}{null}{uniq})\n .to_owned(),\n )\n .await?;\n }}\n\n",
table = table,
col = col,
ty = col_type_to_method(col_type),
null = null,
uniq = uniq
));
}
fn push_add_column(buf: &mut String, table: &str, col: &ParsedColumn) {
buf.push_str(&format!(
" manager\n .alter_table(\n Table::alter()\n .table(Alias::new(\"{table}\"))\n .add_column({coldef})\n .to_owned(),\n )\n .await?;\n\n",
table = table,
coldef = render_column_def(col, &DbKind::Other),
));
}
fn push_create_fk(
buf: &mut String,
table: &str,
from_col: &str,
to_table: &str,
to_col: &str,
on_delete: &str,
on_update: &str,
) {
buf.push_str(&format!(
" manager\n .create_foreign_key(\n ForeignKey::create()\n .name(\"{table}_{from}_{to_table}_fkey\")\n .from(Alias::new(\"{table}\"), Alias::new(\"{from}\"))\n .to(Alias::new(\"{to_table}\"), Alias::new(\"{to_col}\"))\n .on_delete(ForeignKeyAction::{on_delete})\n .on_update(ForeignKeyAction::{on_update})\n .to_owned(),\n )\n .await?;\n\n",
table = table,
from = from_col,
to_table = to_table,
to_col = to_col,
on_delete = on_delete,
on_update = on_update
));
}
fn push_create_index(
buf: &mut String,
table: &str,
idx_name: &str,
columns: &[String],
unique: bool,
) {
buf.push_str(&render_create_index_stmt(table, idx_name, columns, unique));
buf.push('\n');
}
fn render_create_index_stmt(
table: &str,
idx_name: &str,
columns: &[String],
unique: bool,
) -> String {
let mut cols_chain = String::new();
for c in columns {
cols_chain.push_str(&format!(
" .col(Alias::new(\"{c}\"))\n",
c = c
));
}
let uniq_line = if unique {
" .unique()\n"
} else {
""
};
format!(
" manager\n .create_index(\n Index::create()\n .name(\"{idx}\")\n .table(Alias::new(\"{table}\"))\n{cols}{uniq} .to_owned(),\n )\n .await?;\n",
idx = idx_name,
table = table,
cols = cols_chain,
uniq = uniq_line
)
}