//! SeaORM migration Rust code generation — `up`/`down` files, CREATE TABLE, FK, indexes, triggers.
use crate::migration::utils::{
helpers::{col_type_method, col_type_to_method},
types::{Changes, ParsedColumn, ParsedFk, ParsedIndex, ParsedSchema},
};
/// Builds the FK constraint name. Length (MariaDB/MySQL hard-reject over 64 characters) is
/// validated upfront, over the whole change set, before any file generation runs — see
/// `check_identifier_lengths` in `cli/makemigration.rs` — so this stays a pure formatter.
/// The create and drop statements for the same FK must always agree on the exact name, so
/// this is the single place that builds it.
fn fk_constraint_name(table: &str, from: &str, to_table: &str) -> String {
format!("{table}_{from}_{to_table}_fkey")
}
/// Generates the migration file for a CREATE TABLE.
pub fn generate_create_file(schema: &ParsedSchema) -> String {
generate_create_file_in_cycle(schema, &CycleKeys::default())
}
/// Foreign keys of an FK cycle between new tables: Postgres and MySQL refuse
/// a key to a table that doesn't exist yet, so these are left out of their
/// table's CREATE and added once the target exists. SQLite only checks keys
/// when rows are written, so there they stay inline — it can't add one later.
#[derive(Debug, Default)]
pub struct CycleKeys<'a> {
/// This table's keys to tables created after it.
pub forward: Vec<&'a ParsedFk>,
/// Keys of tables created before this one that point to it: (their table, key).
pub closing: Vec<(&'a str, &'a ParsedFk)>,
}
const NOT_SQLITE: &str =
"manager.get_connection().get_database_backend() != sea_orm::DbBackend::Sqlite";
/// The CREATE file of a table, with the keys of an FK cycle placed per engine.
pub fn generate_create_file_in_cycle(schema: &ParsedSchema, keys: &CycleKeys<'_>) -> String {
let (forward, closing) = (&keys.forward, &keys.closing);
// FKs inline in CREATE TABLE: SQLite cannot ALTER-ADD a foreign key to an
// existing table. Only the forward keys of a cycle wait on other engines.
let inline = ParsedSchema {
foreign_keys: schema
.foreign_keys
.iter()
.filter(|fk| !forward.contains(fk))
.cloned()
.collect(),
..schema.clone()
};
let cols = build_create_table_cols(&inline, true);
let idx_stmts = build_index_create_stmts(schema, true);
let enum_stmts = build_enum_type_stmts(schema);
let enum_drops = build_enum_type_drops(schema);
let mut up = String::new();
up.push_str(&enum_stmts);
if forward.is_empty() {
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");
} else {
up.push_str(" let mut table = Table::create();\n");
up.push_str(" table\n");
up.push_str(&format!(
" .table(Alias::new(\"{}\"))\n",
schema.table_name
));
up.push_str(" .if_not_exists()\n");
up.push_str(cols.trim_end());
up.push_str(";\n");
up.push_str(
" // FK cycle: the target table doesn't exist yet. SQLite takes the key\n",
);
up.push_str(" // now; Postgres/MySQL get it once the target is created.\n");
up.push_str(" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Sqlite {\n");
for fk in forward {
up.push_str(&format!(
" table.foreign_key(\n{});\n",
render_fk_create(&schema.table_name, fk, " ")
));
}
up.push_str(" }\n");
up.push_str(" manager.create_table(table.to_owned()).await?;\n\n");
}
up.push_str(&idx_stmts);
if !closing.is_empty() {
up.push_str(&format!(" if {NOT_SQLITE} {{\n"));
for (from_table, fk) in closing {
up.push_str(&format!(
" manager\n .create_foreign_key(\n{}\n .to_owned(),\n )\n .await?;\n",
render_fk_create(from_table, fk, " ")
));
}
up.push_str(" }\n");
}
up.push_str(" Ok(())\n");
let mut down = String::new();
if !closing.is_empty() {
down.push_str(&format!(" if {NOT_SQLITE} {{\n"));
for (from_table, fk) in closing {
down.push_str(&format!(
" manager\n .drop_foreign_key(\n ForeignKey::drop()\n .table(Alias::new(\"{from_table}\"))\n .name(\"{name}\")\n .to_owned(),\n )\n .await?;\n",
name = fk_constraint_name(from_table, &fk.from_column, &fk.to_table),
));
}
down.push_str(" }\n");
}
// No explicit drop_index here: DROP TABLE already removes every index defined on it,
// and dropping one by name first fails on MariaDB/MySQL when it's still backing an
// active FK constraint (error 1553) — the FK's own drop only happens implicitly with
// the table, never explicitly before this point in a CREATE-table migration's down().
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).
/// First line of every snapshot that records column lengths. A snapshot
/// without it predates them: its `None` lengths mean "unknown", not "none".
pub const SNAPSHOT_LENGTHS_MARKER: &str = "// runique: column lengths recorded";
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, false);
let fk_stmts = build_fk_create_stmts(schema);
let idx_stmts = build_index_create_stmts(schema, false);
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!(
"{SNAPSHOT_LENGTHS_MARKER}\n\
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,
)
}
// 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 {
cols.iter()
.filter(|col| !col.enum_string_values.is_empty())
.map(|col| {
enum_create_stmt(
col.enum_name.as_deref().unwrap_or(&col.name),
&col.enum_string_values,
)
})
.collect()
}
fn build_enum_drop_stmts_for_cols(cols: &[ParsedColumn]) -> String {
cols.iter()
.filter(|col| !col.enum_string_values.is_empty())
.map(|col| enum_drop_stmt(col.enum_name.as_deref().unwrap_or(&col.name)))
.collect()
}
/// Creates the Postgres enum type `name`, unless it exists already — Postgres
/// has no `CREATE TYPE IF NOT EXISTS`, so the catalogue is looked up first (an
/// enum shared by several tables is created by the first migration only).
///
/// The name is lowercased: the builder quotes type names, and every other
/// reference to the type (`ColumnType::Enum`, `Type::alter()` below) uses the
/// lowercase form.
fn enum_create_stmt(name: &str, values: &[String]) -> String {
let name = name.to_lowercase();
let values = values
.iter()
.map(|v| format!("Alias::new({v:?})"))
.collect::<Vec<_>>()
.join(", ");
format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n let exists = manager\n .get_connection()\n .query_one(\n &Query::select()\n .expr(Expr::val(1))\n .from((Alias::new(\"pg_catalog\"), Alias::new(\"pg_type\")))\n .and_where(Expr::col(Alias::new(\"typname\")).eq({name:?}))\n .to_owned(),\n )\n .await?\n .is_some();\n if !exists {{\n manager\n .create_type(\n sea_query::extension::postgres::Type::create()\n .as_enum(Alias::new({name:?}))\n .values([{values}])\n .to_owned(),\n )\n .await?;\n }}\n }}\n\n"
)
}
/// Drops the Postgres enum type `name` if it exists.
fn enum_drop_stmt(name: &str) -> String {
let name = name.to_lowercase();
format!(
" if manager.get_connection().get_database_backend() == sea_orm::DbBackend::Postgres {{\n manager\n .drop_type(\n sea_query::extension::postgres::Type::drop()\n .if_exists()\n .name(Alias::new({name:?}))\n .to_owned(),\n )\n .await?;\n }}\n\n"
)
}
/// 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("//"))
}
/// Generates a standalone `ALTER TABLE` migration file from a single [`Changes`] set
/// (renames, add/drop columns, FKs, indexes, enum transitions, etc.), each op emitted
/// in dependency-safe order in both `up` and `down`.
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()
)
}
fn build_create_table_cols(schema: &ParsedSchema, 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)
));
}
// 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(\"{fk_name}\")\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",
fk_name = fk_constraint_name(&schema.table_name, &fk.from_column, &fk.to_table),
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
}
/// `ForeignKey::create()…` for the key `fk` of `table`, each line indented by `indent`.
fn render_fk_create(table: &str, fk: &ParsedFk, indent: &str) -> String {
format!(
"{indent}ForeignKey::create()\n{indent} .name(\"{name}\")\n{indent} .from(Alias::new(\"{table}\"), Alias::new(\"{from}\"))\n{indent} .to(Alias::new(\"{to_table}\"), Alias::new(\"{to_col}\"))\n{indent} .on_delete(ForeignKeyAction::{on_delete})\n{indent} .on_update(ForeignKeyAction::{on_update})",
name = fk_constraint_name(table, &fk.from_column, &fk.to_table),
from = fk.from_column,
to_table = fk.to_table,
to_col = fk.to_column,
on_delete = fk.on_delete,
on_update = fk.on_update,
)
}
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(\"{fk_name}\")\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",
fk_name = fk_constraint_name(&schema.table_name, &fk.from_column, &fk.to_table),
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
}
/// `guard_fk_indexes`: wrap a foreign key's own index in the MySQL check of
/// [`unless_mysql_fk`] — for the files that run, never for snapshots, which
/// `parser_seaorm` reads back as plain statements.
fn build_index_create_stmts(schema: &ParsedSchema, guard_fk_indexes: bool) -> String {
let mut out = String::new();
for idx in &schema.indexes {
let stmt =
render_create_index_stmt(&schema.table_name, &idx.name, &idx.columns, idx.unique);
match fk_index_column(&schema.table_name, idx).filter(|_| guard_fk_indexes) {
Some(col) => out.push_str(&unless_mysql_fk(&schema.table_name, col, &stmt)),
None => out.push_str(&stmt),
}
out.push('\n');
}
out
}
/// The column of a foreign key's own index (`idx_{table}_{column}`, one column,
/// not unique) — what `to_schema` adds for every `belongs_to`.
fn fk_index_column<'a>(table: &str, idx: &'a ParsedIndex) -> Option<&'a str> {
match idx.columns.as_slice() {
[col] if !idx.unique && idx.name == format!("idx_{table}_{col}") => Some(col),
_ => None,
}
}
/// Runs `stmt` unless the database is MySQL/MariaDB and `column` holds a
/// foreign key there. InnoDB already indexes every foreign key column: an
/// explicit index would replace that one and then couldn't be dropped while
/// the key exists (error 1553, which broke `migrate down`). Checked at run time
/// so one file works on every engine.
fn unless_mysql_fk(table: &str, column: &str, stmt: &str) -> String {
format!(
r#" {{
let backs_fk = manager.get_connection().get_database_backend() == sea_orm::DbBackend::MySql
&& manager
.get_connection()
.query_one(
&Query::select()
.expr(Expr::val(1))
.from((Alias::new("information_schema"), Alias::new("KEY_COLUMN_USAGE")))
.and_where(Expr::col(Alias::new("TABLE_SCHEMA")).eq(Expr::cust("DATABASE()")))
.and_where(Expr::col(Alias::new("TABLE_NAME")).eq({table:?}))
.and_where(Expr::col(Alias::new("COLUMN_NAME")).eq({column:?}))
.and_where(Expr::col(Alias::new("REFERENCED_TABLE_NAME")).is_not_null())
.to_owned(),
)
.await?
.is_some();
if !backs_fk {{
{body} }}
}}
"#,
body = indent(stmt, " "),
)
}
fn indent(code: &str, prefix: &str) -> String {
code.lines()
.map(|l| {
if l.is_empty() {
"\n".to_string()
} else {
format!("{prefix}{l}\n")
}
})
.collect()
}
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(\"{fk_name}\")\n .to_owned(),\n )\n .await?;\n\n",
fk_name = fk_constraint_name(&schema.table_name, &fk.from_column, &fk.to_table),
table = schema.table_name,
));
}
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);
}
for idx in &change.added_indexes {
push_drop_index(&mut down, &change.table_name, idx);
}
// 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);
push_modify_column(&mut down, &change.table_name, old);
continue;
}
// safe modify
push_modify_column(&mut up, &change.table_name, new);
push_modify_column(&mut down, &change.table_name, old);
}
// 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);
}
for idx in &change.dropped_indexes {
push_create_index(&mut down, &change.table_name, idx);
}
// 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).
// `ALTER TYPE ... RENAME VALUE`/`ADD VALUE` go through
// `sea_query::extension::postgres::Type`, like the CREATE/DROP TYPE helpers
// above; the type name is lowercased the same way (the builder quotes it).
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);
out.push_str(&enum_create_stmt(enum_name, &to.enum_string_values));
}
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_method(&to.col_type, to.max_length),
)
};
// 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(&enum_drop_stmt(enum_name));
}
out
}
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) -> 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()
};
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_method(&col.col_type, col.max_length);
format!(
"ColumnDef::new(Alias::new(\"{name}\")).{ty}{null}{uniq}{default}",
name = col.name,
ty = ty,
null = null,
uniq = uniq,
default = default,
)
}
}
fn push_drop_index(buf: &mut String, table: &str, idx: &ParsedIndex) {
let stmt = format!(
" manager\n .drop_index(Index::drop().name(\"{name}\").table(Alias::new(\"{table}\")).to_owned())\n .await?;\n",
name = idx.name,
table = table
);
match fk_index_column(table, idx) {
Some(col) => buf.push_str(&unless_mysql_fk(table, col, &stmt)),
None => buf.push_str(&stmt),
}
buf.push('\n');
}
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(\"{fk_name}\")\n .to_owned(),\n )\n .await?;\n\n",
fk_name = fk_constraint_name(table, from_col, to_table),
table = 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, column: &ParsedColumn) {
let col = &column.name;
let nullable = column.nullable;
let unique = column.unique;
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_method(&column.col_type, column.max_length),
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),
));
}
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(\"{fk_name}\")\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",
fk_name = fk_constraint_name(table, from_col, to_table),
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: &ParsedIndex) {
let stmt = render_create_index_stmt(table, &idx.name, &idx.columns, idx.unique);
match fk_index_column(table, idx) {
Some(col) => buf.push_str(&unless_mysql_fk(table, col, &stmt)),
None => buf.push_str(&stmt),
}
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
)
}