use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use super::snapshot::{FieldSnapshot, RelationSnapshot, SchemaSnapshot, TableSnapshot};
fn default_index_method_diff() -> String {
"btree".to_owned()
}
fn default_exclusion_method() -> String {
"gist".to_owned()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SchemaChange {
CreateTable(String ),
DropTable(String ),
AddColumn {
table: String,
column: String,
},
DropColumn {
table: String,
column: String,
},
AlterColumnType {
table: String,
column: String,
from: String,
to: String,
},
AlterColumnNullable {
table: String,
column: String,
nullable: bool,
},
AlterColumnDefault {
table: String,
column: String,
from: Option<String>,
to: Option<String>,
},
AlterColumnMaxLength {
table: String,
column: String,
from: Option<u32>,
to: Option<u32>,
},
AlterColumnComment {
table: String,
column: String,
from: Option<String>,
to: Option<String>,
},
RenameTable {
old_name: String,
new_name: String,
},
RenameColumn {
table: String,
old_column: String,
new_column: String,
},
AlterColumnUnique {
table: String,
column: String,
unique: bool,
},
AlterFkOnDelete {
table: String,
column: String,
from: Option<String>,
to: Option<String>,
},
CreateIndex {
name: String,
table: String,
columns: Vec<String>,
unique: bool,
#[serde(default = "default_index_method_diff")]
method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
where_clause: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
include: Vec<String>,
},
DropIndex {
name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
table: String,
},
AddCheckConstraint {
name: String,
table: String,
expr: String,
},
DropCheckConstraint {
name: String,
table: String,
},
AddExclusionConstraint {
name: String,
table: String,
#[serde(default = "default_exclusion_method")]
using: String,
elements: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
where_clause: Option<String>,
},
DropExclusionConstraint {
name: String,
table: String,
},
CreateM2MTable {
through: String,
src_table: String,
src_col: String,
dst_table: String,
dst_col: String,
},
DropM2MTable {
through: String,
},
AddCompositeFk {
table: String,
name: String,
to: String,
from: Vec<String>,
on: Vec<String>,
},
DropCompositeFk {
table: String,
name: String,
},
}
impl SchemaChange {
pub(crate) fn table(&self) -> &str {
match self {
Self::CreateTable(t)
| Self::DropTable(t)
| Self::DropM2MTable { through: t }
| Self::CreateM2MTable { through: t, .. }
| Self::RenameTable { old_name: t, .. }
| Self::AddColumn { table: t, .. }
| Self::DropColumn { table: t, .. }
| Self::AlterColumnType { table: t, .. }
| Self::AlterColumnNullable { table: t, .. }
| Self::AlterColumnDefault { table: t, .. }
| Self::AlterColumnMaxLength { table: t, .. }
| Self::AlterColumnComment { table: t, .. }
| Self::RenameColumn { table: t, .. }
| Self::AlterColumnUnique { table: t, .. }
| Self::AlterFkOnDelete { table: t, .. }
| Self::CreateIndex { table: t, .. }
| Self::DropIndex { table: t, .. }
| Self::AddCheckConstraint { table: t, .. }
| Self::DropCheckConstraint { table: t, .. }
| Self::AddExclusionConstraint { table: t, .. }
| Self::DropExclusionConstraint { table: t, .. }
| Self::AddCompositeFk { table: t, .. }
| Self::DropCompositeFk { table: t, .. } => t,
}
}
pub(crate) fn touches(&self, table: &str, snapshot: &super::SchemaSnapshot) -> bool {
let fk_to = |f: &FieldSnapshot| f.fk.as_ref().is_some_and(|r| r.to == table);
self.table() == table
|| match self {
Self::RenameTable { new_name, .. } => new_name == table,
Self::CreateM2MTable {
src_table,
dst_table,
..
} => src_table == table || dst_table == table,
Self::AddCompositeFk { to, .. } => to == table,
Self::CreateTable(t) => snapshot.table(t).is_some_and(|s| {
s.fields.iter().any(fk_to) || s.composite_fks.iter().any(|c| c.to == table)
}),
Self::AddColumn { table: t, column }
| Self::AlterFkOnDelete {
table: t, column, ..
} => snapshot
.table(t)
.and_then(|s| s.field(column))
.is_some_and(fk_to),
_ => false,
}
}
}
#[must_use]
pub fn detect_changes(prev: &SchemaSnapshot, current: &SchemaSnapshot) -> Vec<SchemaChange> {
let mut changes = Vec::new();
let mut creates = Vec::new();
let mut fk_creates = Vec::new();
for pt in &prev.tables {
let Some(ct) = current.table(&pt.name) else {
continue;
};
for pf in &pt.composite_fks {
let now = ct.composite_fks.iter().find(|c| c.name == pf.name);
if now == Some(pf) {
continue;
}
changes.push(SchemaChange::DropCompositeFk {
table: pt.name.clone(),
name: pf.name.clone(),
});
fk_creates.extend(now.map(|c| add_composite_fk(&ct.name, c)));
}
}
for idx in &prev.indexes {
let changed = current.index(&idx.name).map(|c| {
(c.columns != idx.columns
|| c.unique != idx.unique
|| c.table != idx.table
|| c.method != idx.method
|| c.where_clause != idx.where_clause
|| c.include != idx.include)
.then_some(c)
});
if matches!(changed, None | Some(Some(_))) {
changes.push(SchemaChange::DropIndex {
name: idx.name.clone(),
table: idx.table.clone(),
});
}
if let Some(Some(c)) = changed {
creates.push(create_index(c));
}
}
for c in &prev.checks {
let now = current.check(&c.name);
if now != Some(c) {
changes.push(SchemaChange::DropCheckConstraint {
name: c.name.clone(),
table: c.table.clone(),
});
creates.extend(now.map(add_check));
}
}
for x in &prev.excludes {
let now = current.excludes.iter().find(|c| c.name == x.name);
if now != Some(x) {
changes.push(SchemaChange::DropExclusionConstraint {
name: x.name.clone(),
table: x.table.clone(),
});
creates.extend(now.map(add_exclude));
}
}
for mt in &prev.m2m_tables {
let now = current.m2m_table(&mt.through);
if let Some(renames) = now.filter(|c| *c != mt).and_then(|c| m2m_renames(mt, c)) {
changes.extend(renames);
} else if now != Some(mt) {
changes.push(SchemaChange::DropM2MTable {
through: mt.through.clone(),
});
creates.extend(now.map(create_m2m));
}
}
for t in ¤t.tables {
if prev.table(&t.name).is_none() {
changes.push(SchemaChange::CreateTable(t.name.clone()));
}
}
for t in ¤t.tables {
let Some(pt) = prev.table(&t.name) else {
continue;
};
for f in &t.fields {
if pt.field(&f.column).is_none() {
changes.push(SchemaChange::AddColumn {
table: t.name.clone(),
column: f.column.clone(),
});
}
}
}
for ct in ¤t.tables {
let Some(pt) = prev.table(&ct.name) else {
continue;
};
for cf in &ct.fields {
let Some(pf) = pt.field(&cf.column) else {
continue;
};
push_alter_changes(&ct.name, pf, cf, &mut changes);
}
}
for pt in &prev.tables {
let Some(t) = current.table(&pt.name) else {
continue;
};
for f in &pt.fields {
if t.field(&f.column).is_none() {
changes.push(SchemaChange::DropColumn {
table: pt.name.clone(),
column: f.column.clone(),
});
}
}
}
for name in dropped_tables_child_first(prev, current) {
changes.push(SchemaChange::DropTable(name.to_owned()));
}
for idx in ¤t.indexes {
if prev.index(&idx.name).is_none() {
changes.push(create_index(idx));
}
}
changes.append(&mut creates);
for c in ¤t.checks {
if prev.check(&c.name).is_none() {
changes.push(add_check(c));
}
}
for x in ¤t.excludes {
if !prev.excludes.iter().any(|p| p.name == x.name) {
changes.push(add_exclude(x));
}
}
for mt in ¤t.m2m_tables {
if prev.m2m_table(&mt.through).is_none() {
changes.push(create_m2m(mt));
}
}
for ct in ¤t.tables {
let Some(pt) = prev.table(&ct.name) else {
continue;
};
let prev_fks = pt.composite_fks.as_slice();
for cf in &ct.composite_fks {
if !prev_fks.iter().any(|p| p.name == cf.name) {
changes.push(add_composite_fk(&ct.name, cf));
}
}
}
changes.append(&mut fk_creates);
changes
}
pub(super) fn create_index(idx: &super::snapshot::IndexSnapshot) -> SchemaChange {
SchemaChange::CreateIndex {
name: idx.name.clone(),
table: idx.table.clone(),
columns: idx.columns.clone(),
unique: idx.unique,
method: idx.method.clone(),
where_clause: idx.where_clause.clone(),
include: idx.include.clone(),
}
}
fn add_check(c: &super::snapshot::CheckSnapshot) -> SchemaChange {
SchemaChange::AddCheckConstraint {
name: c.name.clone(),
table: c.table.clone(),
expr: c.expr.clone(),
}
}
pub(super) fn add_exclude(x: &super::snapshot::ExclusionSnapshot) -> SchemaChange {
SchemaChange::AddExclusionConstraint {
name: x.name.clone(),
table: x.table.clone(),
using: x.using.clone(),
elements: x.elements.clone(),
where_clause: x.where_clause.clone(),
}
}
fn m2m_renames(
old: &super::snapshot::M2MTableSnapshot,
new: &super::snapshot::M2MTableSnapshot,
) -> Option<Vec<SchemaChange>> {
let rename = |from: &str, to: &str| SchemaChange::RenameColumn {
table: old.through.clone(),
old_column: from.to_owned(),
new_column: to.to_owned(),
};
if is_self_ref(old) && is_self_ref(new) && old.src_table == new.src_table {
let (olds, news) = ([&old.src_col, &old.dst_col], [&new.src_col, &new.dst_col]);
let gone = olds.iter().find(|c| !news.contains(c))?;
let came = news.iter().find(|c| !olds.contains(c))?;
let kept = olds.iter().filter(|c| news.contains(c)).count();
return (kept == 1).then(|| vec![rename(gone, came)]);
}
let direct = (old.src_table == new.src_table && old.dst_table == new.dst_table)
.then_some([(&old.src_col, &new.src_col), (&old.dst_col, &new.dst_col)]);
let mirrored = (old.src_table == new.dst_table && old.dst_table == new.src_table)
.then_some([(&old.src_col, &new.dst_col), (&old.dst_col, &new.src_col)]);
let pairs = direct.or(mirrored)?;
let changed: Vec<_> = pairs.into_iter().filter(|(a, b)| a != b).collect();
Some(match changed[..] {
[(a, b)] => vec![rename(a, b)],
[(a, b), (c, d)] if b == c && d == a => {
let spare = swap_spare(a, b);
vec![rename(a, &spare), rename(c, d), rename(&spare, b)]
}
[(a, b), (c, d)] if b == c => vec![rename(c, d), rename(a, b)],
[(a, b), (c, d)] => vec![rename(a, b), rename(c, d)],
_ => Vec::new(),
})
}
fn is_self_ref(m: &super::snapshot::M2MTableSnapshot) -> bool {
m.src_table == m.dst_table
}
fn swap_spare(a: &str, b: &str) -> String {
let mut cut = a.len().min(56);
while !a.is_char_boundary(cut) {
cut -= 1;
}
let base = &a[..cut];
(0..)
.map(|i| format!("{base}_swp{i}"))
.find(|s| s != a && s != b)
.expect("an unbounded range finds a free name")
}
pub(super) fn create_m2m(mt: &super::snapshot::M2MTableSnapshot) -> SchemaChange {
SchemaChange::CreateM2MTable {
through: mt.through.clone(),
src_table: mt.src_table.clone(),
src_col: mt.src_col.clone(),
dst_table: mt.dst_table.clone(),
dst_col: mt.dst_col.clone(),
}
}
pub(super) fn add_composite_fk(
table: &str,
cf: &super::snapshot::CompositeFkSnapshot,
) -> SchemaChange {
SchemaChange::AddCompositeFk {
table: table.to_owned(),
name: cf.name.clone(),
to: cf.to.clone(),
from: cf.from.clone(),
on: cf.on.clone(),
}
}
fn dropped_tables_child_first<'a>(
prev: &'a SchemaSnapshot,
current: &SchemaSnapshot,
) -> Vec<&'a str> {
let references = |t: &TableSnapshot, target: &str| {
t.name != target
&& (t
.fields
.iter()
.any(|f| f.fk.as_ref().is_some_and(|r| r.to == target))
|| t.composite_fks.iter().any(|c| c.to == target))
};
let mut left: Vec<&TableSnapshot> = prev
.tables
.iter()
.filter(|t| current.table(&t.name).is_none())
.collect();
let mut out = Vec::with_capacity(left.len());
while !left.is_empty() {
let i = left
.iter()
.position(|t| !left.iter().any(|o| references(o, &t.name)))
.unwrap_or(0);
out.push(left.remove(i).name.as_str());
}
out
}
fn fk_identity(r: &RelationSnapshot) -> (&str, &str, &str) {
(&r.kind, &r.to, &r.on)
}
fn same_on_delete(p: &RelationSnapshot, c: &RelationSnapshot) -> bool {
fn effective(r: &RelationSnapshot) -> &str {
r.on_delete.as_deref().unwrap_or("NO ACTION")
}
effective(p).eq_ignore_ascii_case(effective(c))
}
fn push_alter_changes(
table: &str,
pf: &FieldSnapshot,
cf: &FieldSnapshot,
out: &mut Vec<SchemaChange>,
) {
let max_length = (pf.max_length != cf.max_length).then(|| SchemaChange::AlterColumnMaxLength {
table: table.to_owned(),
column: cf.column.clone(),
from: pf.max_length,
to: cf.max_length,
});
let leaving_string = pf.ty != cf.ty && cf.ty != "string";
if leaving_string {
out.extend(max_length.clone());
}
let ci_flip = cf.ty == "string" && pf.case_insensitive != cf.case_insensitive;
if pf.ty != cf.ty || ci_flip {
out.push(SchemaChange::AlterColumnType {
table: table.to_owned(),
column: cf.column.clone(),
from: pf.ty.clone(),
to: cf.ty.clone(),
});
}
if !leaving_string {
out.extend(max_length);
}
if pf.default != cf.default && pf.ty == cf.ty {
out.push(SchemaChange::AlterColumnDefault {
table: table.to_owned(),
column: cf.column.clone(),
from: pf.default.clone(),
to: cf.default.clone(),
});
}
if pf.nullable != cf.nullable {
out.push(SchemaChange::AlterColumnNullable {
table: table.to_owned(),
column: cf.column.clone(),
nullable: cf.nullable,
});
}
if pf.unique != cf.unique {
out.push(SchemaChange::AlterColumnUnique {
table: table.to_owned(),
column: cf.column.clone(),
unique: cf.unique,
});
}
if pf.db_comment != cf.db_comment {
out.push(SchemaChange::AlterColumnComment {
table: table.to_owned(),
column: cf.column.clone(),
from: pf.db_comment.clone(),
to: cf.db_comment.clone(),
});
}
if let (Some(p), Some(c)) = (&pf.fk, &cf.fk) {
if fk_identity(p) == fk_identity(c) && !same_on_delete(p, c) {
out.push(SchemaChange::AlterFkOnDelete {
table: table.to_owned(),
column: cf.column.clone(),
from: p.on_delete.clone(),
to: c.on_delete.clone(),
});
}
}
}
#[must_use]
pub fn detect_unsupported_field_changes(
prev: &SchemaSnapshot,
current: &SchemaSnapshot,
) -> Vec<String> {
let mut out = Vec::new();
for ct in ¤t.tables {
let Some(pt) = prev.table(&ct.name) else {
continue;
};
for cf in &ct.fields {
let Some(pf) = pt.field(&cf.column) else {
continue;
};
push_field_diffs(&ct.name, pf, cf, &mut out);
}
}
for pm in prev.m2m_tables.iter().filter(|m| is_self_ref(m)) {
let Some(cm) = current.m2m_table(&pm.through) else {
continue;
};
if cm != pm && is_self_ref(cm) && cm.src_table == pm.src_table {
if m2m_renames(pm, cm).is_none() {
out.push(format!(
"self-referencing M2M `{}` renamed both columns (`{}`, `{}` → `{}`, `{}`); \
rename one per migration, or write the RenameColumn ops by hand",
pm.through, pm.src_col, pm.dst_col, cm.src_col, cm.dst_col
));
}
}
}
out
}
fn push_field_diffs(table: &str, pf: &FieldSnapshot, cf: &FieldSnapshot, out: &mut Vec<String>) {
let col = &cf.column;
if pf.primary_key != cf.primary_key {
out.push(format!(
"`{table}.{col}` primary_key changed: {} → {}",
pf.primary_key, cf.primary_key
));
}
if pf.min != cf.min {
out.push(format!(
"`{table}.{col}` min changed: {:?} → {:?}",
pf.min, cf.min
));
}
if pf.max != cf.max {
out.push(format!(
"`{table}.{col}` max changed: {:?} → {:?}",
pf.max, cf.max
));
}
if pf.fk.as_ref().map(fk_identity) != cf.fk.as_ref().map(fk_identity) {
out.push(format!(
"`{table}.{col}` fk changed: {:?} → {:?}",
pf.fk, cf.fk
));
}
if pf.auto != cf.auto {
out.push(format!(
"`{table}.{col}` auto changed: {} → {}",
pf.auto, cf.auto
));
}
if pf.generated_as != cf.generated_as {
out.push(format!(
"`{table}.{col}` generated_as changed: {:?} → {:?}",
pf.generated_as, cf.generated_as
));
}
}
pub fn render_changes(
changes: &[SchemaChange],
current: &SchemaSnapshot,
) -> Result<Vec<String>, String> {
let RenderedBatch {
mut immediate,
deferred_fks,
warnings: _,
rebuild: _,
} = render_changes_split(changes, current)?;
immediate.extend(deferred_fks);
Ok(immediate)
}
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct RenderedBatch {
pub immediate: Vec<String>,
pub deferred_fks: Vec<String>,
pub warnings: Vec<String>,
pub rebuild: Option<super::rebuild::TableRebuild>,
}
impl RenderedBatch {
fn set_rebuild(&mut self, rebuild: super::rebuild::TableRebuild) -> Result<(), String> {
if self.rebuild.is_some() {
return Err(format!(
"rebuilding `{}` needs a batch of its own; render SQLite changes one at a time",
rebuild.table()
));
}
self.rebuild = Some(rebuild);
Ok(())
}
}
pub fn render_changes_split(
changes: &[SchemaChange],
current: &SchemaSnapshot,
) -> Result<RenderedBatch, String> {
render_changes_split_with_dialect(changes, current, &crate::sql::Postgres)
}
pub fn render_changes_split_with_dialect(
changes: &[SchemaChange],
current: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
) -> Result<RenderedBatch, String> {
render_changes_split_inner(changes, current, dialect, None, false, false)
}
pub(crate) fn render_changes_split_in_schema(
changes: &[SchemaChange],
current: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<RenderedBatch, String> {
render_changes_split_inner(changes, current, dialect, schema, false, true)
}
pub(crate) fn render_changes_split_for_empty(
changes: &[SchemaChange],
current: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<RenderedBatch, String> {
render_changes_split_inner(changes, current, dialect, schema, true, true)
}
fn fk_target(dialect: &dyn crate::sql::Dialect, schema: Option<&str>, table: &str) -> String {
let shared = || {
crate::core::ModelEntry::for_table(table)
.is_some_and(|e| e.schema.scope == crate::core::ModelScope::Registry)
};
match schema {
Some(s) if !shared() => {
format!("{}.{}", dialect.quote_ident(s), dialect.quote_ident(table))
}
_ => dialect.quote_ident(table),
}
}
fn alter_column_elsewhere(
change: &SchemaChange,
table: &str,
f: &FieldSnapshot,
current: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
unique_names: &UniqueNames,
out: &mut RenderedBatch,
) -> Result<(), String> {
let unique = match change {
SchemaChange::AlterColumnUnique { unique, .. } => Some(*unique),
_ => None,
};
if let Some(rebuild) = super::rebuild::TableRebuild::needed(dialect, current, table) {
let mut rebuild = rebuild?;
if unique == Some(false) {
rebuild = rebuild.dropping_unique(&f.column, declared_indexes(current, table));
}
if !f.nullable && f.generated_as.is_none() {
if let Some(expr) = &f.default {
let value = render_column_default(expr, &f.ty, f.max_length, dialect);
let col = dialect.quote_ident(&f.column);
rebuild = rebuild.copy_as(&f.column, format!("COALESCE({col}, {value})"));
}
}
return out.set_rebuild(rebuild);
}
let under_fk = matches!(
change,
SchemaChange::AlterColumnType { .. }
| SchemaChange::AlterColumnMaxLength { .. }
| SchemaChange::AlterColumnUnique { unique: false, .. }
);
if let Some(rel) = f.fk.as_ref().filter(|_| under_fk) {
out.deferred_fks
.push(field_fk_sql(table, &f.column, rel, dialect, schema)?);
}
match unique {
Some(true) => {
let name = unique_names.get(table, &f.column)?;
out.immediate
.push(dialect.add_unique_constraint_sql(table, &name, &f.column));
}
Some(false) => {}
None => {
if !f.nullable && f.default.is_some() {
out.immediate.push(fill_nulls_sql(table, f, dialect));
}
out.immediate.push(format!(
"ALTER TABLE {} MODIFY COLUMN {}",
dialect.quote_ident(table),
column_definition(f, dialect)
));
}
}
Ok(())
}
fn column_comment(
table: &str,
f: &FieldSnapshot,
dialect: &dyn crate::sql::Dialect,
) -> Option<String> {
let comment = f.db_comment.as_deref()?;
dialect.column_comment_statement(table, &f.column, comment)
}
pub(crate) fn declared_indexes(current: &SchemaSnapshot, table: &str) -> Vec<String> {
current
.indexes
.iter()
.filter(|i| i.table == table)
.map(|i| i.name.clone())
.collect()
}
fn column_definition(f: &FieldSnapshot, dialect: &dyn crate::sql::Dialect) -> String {
let mut sql = format!(
"{} {}",
dialect.quote_ident(&f.column),
sql_type_with_dialect(f, dialect)
);
if let Some(expr) = &f.generated_as {
let _ = write!(sql, " GENERATED ALWAYS AS ({expr}) STORED");
} else if let Some(expr) = &f.default {
let rendered = render_column_default(expr, &f.ty, f.max_length, dialect);
let _ = write!(sql, " DEFAULT {rendered}");
}
if !f.nullable {
sql.push_str(" NOT NULL");
}
if let Some(comment) = &f.db_comment {
if let Some(inline) = dialect.write_inline_column_comment(comment) {
sql.push_str(&inline);
}
}
sql
}
struct UniqueNames {
holders: std::collections::HashMap<String, Vec<String>>,
}
impl UniqueNames {
fn new(current: &SchemaSnapshot) -> Self {
let mut holders: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for t in ¤t.tables {
for f in t
.fields
.iter()
.filter(|f| f.unique && !f.primary_key && f.generated_as.is_none())
{
holders
.entry(super::ddl::unique_constraint_name(&t.name, &f.column))
.or_default()
.push(format!("`{}.{}`", t.name, f.column));
}
}
for idx in ¤t.indexes {
holders
.entry(idx.name.clone())
.or_default()
.push(format!("index `{}`", idx.name));
}
Self { holders }
}
fn get(&self, table: &str, column: &str) -> Result<String, String> {
let name = super::ddl::unique_constraint_name(table, column);
let me = format!("`{table}.{column}`");
let others: Vec<&str> = self
.holders
.get(&name)
.into_iter()
.flatten()
.map(String::as_str)
.filter(|h| *h != me)
.collect();
if others.is_empty() {
return Ok(name);
}
Err(format!(
"the UNIQUE on {me} is named `{name}`, which {} also uses; \
rename a table or column, or declare one as a named unique index",
others.join(", ")
))
}
}
struct FkNames {
holders: std::collections::HashMap<(String, String), Vec<String>>,
per_database: bool,
}
impl FkNames {
fn new(current: &SchemaSnapshot, dialect: &dyn crate::sql::Dialect) -> Self {
let mut names = Self {
holders: std::collections::HashMap::new(),
per_database: dialect.name() == "mysql",
};
if dialect.inline_fks_in_create_table() {
return names;
}
for t in ¤t.tables {
for f in t.fields.iter().filter(|f| f.fk.is_some()) {
let name = super::ddl::fk_constraint_name(&t.name, &f.column);
names.hold(&t.name, name, format!("`{}.{}`", t.name, f.column));
}
for c in &t.composite_fks {
names.hold(
&t.name,
c.name.clone(),
format!("`{}` FK `{}`", t.name, c.name),
);
}
}
for m in ¤t.m2m_tables {
for col in [&m.src_col, &m.dst_col] {
let name = super::ddl::fk_constraint_name(&m.through, col);
names.hold(&m.through, name, format!("`{}.{col}`", m.through));
}
}
names
}
fn key(&self, table: &str, name: String) -> (String, String) {
let scope = if self.per_database { "" } else { table };
(scope.to_owned(), name)
}
fn hold(&mut self, table: &str, name: String, holder: String) {
let key = self.key(table, name);
self.holders.entry(key).or_default().push(holder);
}
fn check(&self, table: &str, column: &str) -> Result<(), String> {
let name = super::ddl::fk_constraint_name(table, column);
let me = format!("`{table}.{column}`");
let others: Vec<&str> = self
.holders
.get(&self.key(table, name.clone()))
.into_iter()
.flatten()
.map(String::as_str)
.filter(|h| *h != me)
.collect();
if others.is_empty() {
return Ok(());
}
Err(format!(
"the FK on {me} is named `{name}`, which {} also uses; rename a table or column",
others.join(", ")
))
}
}
fn render_changes_split_inner(
changes: &[SchemaChange],
current: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
empty_tables: bool,
by_catalog: bool,
) -> Result<RenderedBatch, String> {
let mut out = RenderedBatch::default();
let unique_names = UniqueNames::new(current);
let fk_names = FkNames::new(current, dialect);
let mut ci_extension = dialect.ci_text_extension_sql();
for change in changes {
if writes_ci_text(change, current) {
out.immediate.extend(ci_extension.take().map(str::to_owned));
}
match change {
SchemaChange::CreateTable(name) => {
let table = current.table(name).ok_or_else(|| {
format!("CreateTable for `{name}` but no snapshot entry for it")
})?;
for f in table
.fields
.iter()
.filter(|f| f.unique && !f.primary_key && f.generated_as.is_none())
{
unique_names.get(name, &f.column)?;
}
for f in table.fields.iter().filter(|f| f.fk.is_some()) {
fk_names.check(name, &f.column)?;
}
out.immediate
.push(create_table_sql_from_snapshot_with_dialect(table, dialect));
for f in &table.fields {
out.immediate.extend(column_comment(name, f, dialect));
}
if !dialect.inline_fks_in_create_table() {
out.deferred_fks
.extend(constraints_sql_from_snapshot(table, dialect, schema)?);
}
}
SchemaChange::DropColumn { table, column } => {
if let Some(name) = unique_names
.get(table, column)
.ok()
.filter(|_| dialect.name() == "sqlite")
{
out.immediate.push(format!(
"DROP INDEX IF EXISTS {}",
dialect.quote_ident(&name)
));
}
if let Some(rebuild) = super::rebuild::TableRebuild::needed(dialect, current, table)
{
out.set_rebuild(rebuild?.dropping(column))?;
continue;
}
out.immediate.push(format!(
"ALTER TABLE {} DROP COLUMN {}",
dialect.quote_ident(table),
dialect.quote_ident(column),
));
}
SchemaChange::AlterFkOnDelete { table, column, .. } => {
if let Some(rebuild) = super::rebuild::TableRebuild::needed(dialect, current, table)
{
out.set_rebuild(rebuild?)?;
continue;
}
let rel = current
.table(table)
.and_then(|t| t.field(column))
.and_then(|f| f.fk.as_ref())
.ok_or_else(|| {
format!("AlterFkOnDelete for `{table}.{column}` but no FK in the snapshot")
})?;
out.deferred_fks
.push(field_fk_sql(table, column, rel, dialect, schema)?);
}
SchemaChange::AddColumn { table, column } => {
let t = current.table(table).ok_or_else(|| {
format!("AddColumn for `{table}.{column}` but table missing in snapshot")
})?;
let f = t.field(column).ok_or_else(|| {
format!("AddColumn for `{table}.{column}` but field missing in snapshot")
})?;
if !f.nullable && f.default.is_none() && !empty_tables {
return Err(format!(
"AddColumn `{table}.{column}` is NOT NULL with no `default` — \
Postgres can't backfill existing rows. Pick one:\n \
(1) Make the field `Option<…>` — column becomes nullable and existing \
rows get NULL.\n \
(2) Set `#[rustango(default = \"…\")]` so existing rows get the \
default backfill.\n \
(3) (dev iteration / fresh table only) Delete the pending migration \
JSON that emitted this `AddColumn`, then re-run `makemigrations` so \
`{column}` lands in the original `CreateTable` for `{table}` — \
see #84 in the backlog for the full `migrate --squash` proposal.\n \
Note: option (3) requires the column to NOT exist in the database \
yet (i.e. the `CreateTable` migration hasn't been applied, OR you're \
willing to drop and recreate the table). Option (1) or (2) is the \
right fix for any table that has production data.",
));
}
if dialect.name() == "mysql" && is_uuid_default(f) {
out.immediate
.extend(add_column_backfilled(table, f, dialect));
} else if dialect.name() == "mysql" && !f.nullable && f.default.is_none() {
out.immediate
.extend(add_column_then_not_null(table, f, dialect));
} else {
out.immediate.push(add_column_sql(table, f, dialect));
}
out.immediate.extend(column_comment(table, f, dialect));
if f.fk.is_some()
&& dialect.inline_fks_in_create_table()
&& inline_fk_on_add_column(f, dialect).is_none()
{
out.warnings.push(format!(
"`{table}.{column}` is added without its FOREIGN KEY: SQLite refuses \
REFERENCES with a non-NULL default on a table with rows. Rebuild \
the table by hand to add it (#559)."
));
}
if f.unique && !f.primary_key {
let name = unique_names.get(table, column)?;
out.immediate
.push(dialect.add_unique_constraint_sql(table, &name, column));
}
if let Some(rel) =
f.fk.as_ref()
.filter(|_| !dialect.inline_fks_in_create_table())
{
fk_names.check(table, column)?;
out.deferred_fks
.push(field_fk_sql(table, column, rel, dialect, schema)?);
}
}
SchemaChange::DropTable(name) => {
let cascade = if dialect.name() == "postgres" {
" CASCADE"
} else {
""
};
out.immediate
.push(format!("DROP TABLE {}{cascade}", dialect.quote_ident(name)));
}
SchemaChange::AlterColumnType { table, column, .. }
| SchemaChange::AlterColumnNullable { table, column, .. }
| SchemaChange::AlterColumnDefault { table, column, .. }
| SchemaChange::AlterColumnMaxLength { table, column, .. }
| SchemaChange::AlterColumnUnique { table, column, .. }
| SchemaChange::AlterColumnComment { table, column, .. }
if dialect.alters_by_rebuild() || dialect.modifies_whole_column() =>
{
if dialect.alters_by_rebuild()
&& matches!(
change,
SchemaChange::AlterColumnMaxLength { .. }
| SchemaChange::AlterColumnComment { .. }
)
{
continue;
}
let f = current
.table(table)
.and_then(|t| t.field(column))
.ok_or_else(|| {
format!("altering `{table}.{column}` but the snapshot has no such column")
})?;
alter_column_elsewhere(
change,
table,
f,
current,
dialect,
schema,
&unique_names,
&mut out,
)?;
}
SchemaChange::AlterColumnType {
table,
column,
from: _,
to,
} => {
let field = current
.table(table)
.and_then(|t| t.field(column))
.filter(|f| f.ty == *to);
let target = fk_target(dialect, schema, table);
let default = field.filter(|f| !f.auto && f.generated_as.is_none());
if default.is_some() {
out.immediate.push(format!(
r#"ALTER TABLE {target} ALTER COLUMN "{column}" DROP DEFAULT"#,
));
}
let string = field.filter(|_| to == "string");
out.immediate.push(match string {
Some(f) => format!(
r#"ALTER TABLE {target} ALTER COLUMN "{column}" TYPE {}"#,
sql_type_with_dialect(f, dialect)
),
None => {
let pg_to = pg_type_for_ty_name(to);
format!(
r#"ALTER TABLE {target} ALTER COLUMN "{column}" TYPE {pg_to} USING "{column}"::{pg_to}"#,
)
}
});
if let Some(f) = default {
if let Some(expr) = &f.default {
let value = render_column_default(expr, &f.ty, f.max_length, dialect);
out.immediate.push(format!(
r#"ALTER TABLE {target} ALTER COLUMN "{column}" SET DEFAULT {value}"#,
));
}
}
if field.is_some_and(|f| f.auto) && matches!(to.as_str(), "i16" | "i32" | "i64") {
const TAG: &str = "$rustango_seq$";
if target.contains(TAG) || column.contains(TAG) {
return Err(format!("`{table}.{column}`: a name cannot contain `{TAG}`"));
}
out.immediate.push(format!(
"DO {TAG} DECLARE s text := pg_get_serial_sequence({}, {}); BEGIN \
IF s IS NOT NULL THEN EXECUTE format('ALTER SEQUENCE %s AS {}', s); \
END IF; END {TAG}",
dialect.quote_literal(&target),
dialect.quote_literal(column),
pg_type_for_ty_name(to),
));
}
}
SchemaChange::AlterColumnNullable {
table,
column,
nullable,
} => {
let field = current.table(table).and_then(|t| t.field(column));
if let Some(f) = field.filter(|f| !*nullable && f.default.is_some()) {
out.immediate.push(fill_nulls_sql(table, f, dialect));
}
let action = if *nullable {
"DROP NOT NULL"
} else {
"SET NOT NULL"
};
out.immediate.push(format!(
r#"ALTER TABLE "{table}" ALTER COLUMN "{column}" {action}"#,
));
}
SchemaChange::AlterColumnDefault {
table,
column,
from: _,
to,
} => match to {
Some(expr) => {
let rendered: &str = if expr.is_empty() { "''" } else { expr };
out.immediate.push(format!(
r#"ALTER TABLE "{table}" ALTER COLUMN "{column}" SET DEFAULT {rendered}"#,
));
}
None => out.immediate.push(format!(
r#"ALTER TABLE "{table}" ALTER COLUMN "{column}" DROP DEFAULT"#,
)),
},
SchemaChange::AlterColumnMaxLength {
table,
column,
from: _,
to,
} => {
let ci = current
.table(table)
.and_then(|t| t.field(column))
.is_some_and(is_ci_text);
let pg_to = match to {
_ if ci => dialect.ci_text_type(*to),
Some(n) => format!("VARCHAR({n})"),
None => "TEXT".into(),
};
out.immediate.push(format!(
r#"ALTER TABLE "{table}" ALTER COLUMN "{column}" TYPE {pg_to}"#,
));
}
SchemaChange::AlterColumnComment {
table, column, to, ..
} => out.immediate.extend(dialect.column_comment_statement(
table,
column,
to.as_deref().unwrap_or(""),
)),
SchemaChange::AlterColumnUnique {
table,
column,
unique,
} => {
if *unique {
let name = unique_names.get(table, column)?;
out.immediate
.push(dialect.add_unique_constraint_sql(table, &name, column));
} else if !by_catalog {
out.immediate.push(format!(
"ALTER TABLE {} DROP CONSTRAINT {}",
dialect.quote_ident(table),
dialect.quote_ident(&unique_names.get(table, column)?),
));
}
}
SchemaChange::RenameTable { old_name, new_name } => {
out.immediate.push(format!(
"ALTER TABLE {} RENAME TO {}",
dialect.quote_ident(old_name),
dialect.quote_ident(new_name),
));
}
SchemaChange::RenameColumn {
table,
old_column,
new_column,
} => {
out.immediate.push(format!(
"ALTER TABLE {} RENAME COLUMN {} TO {}",
dialect.quote_ident(table),
dialect.quote_ident(old_column),
dialect.quote_ident(new_column),
));
}
SchemaChange::CreateIndex {
name,
table,
columns,
unique,
method,
where_clause,
include,
} => {
let unique_kw = if *unique { "UNIQUE " } else { "" };
let if_not_exists = if dialect.supports_create_index_if_not_exists() {
"IF NOT EXISTS "
} else {
""
};
let cols = columns
.iter()
.map(|c| dialect.quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let using = dialect.index_method_clause(method);
let where_suffix = if let Some(expr) = where_clause {
if dialect.supports_partial_index() {
format!(" WHERE {expr}")
} else {
out.warnings.push(format!(
"index {name:?} declares a partial WHERE \
clause ({expr:?}); {} has no partial-index \
syntax — emitting plain UNIQUE INDEX. Add \
an application-level uniqueness check to \
cover the partition.",
dialect.name()
));
String::new()
}
} else {
String::new()
};
let include_suffix = if include.is_empty() {
String::new()
} else if dialect.name() == "postgres" {
let cols = include
.iter()
.map(|c| dialect.quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
format!(" INCLUDE ({cols})")
} else {
out.warnings.push(format!(
"index {name:?} declares INCLUDE ({}); {} has \
no covering-index syntax — emitting plain \
index. Add a redundant non-key column to the \
key tuple if you need the cover.",
include.join(", "),
dialect.name()
));
String::new()
};
out.immediate.push(format!(
"CREATE {unique_kw}INDEX {if_not_exists}{} ON {}{} ({cols}){include_suffix}{where_suffix}",
dialect.quote_ident(name),
fk_target(dialect, schema, table),
using,
));
}
SchemaChange::DropIndex { name, table } => {
if dialect.name() == "mysql" {
if table.is_empty() {
return Err(format!(
"DropIndex for `{name}` carries no table, and MySQL needs one \
(`DROP INDEX <name> ON <table>`). This migration file was \
written before #1588 added the field. Add \
`\"table\": \"<owning table>\"` beside `\"name\"` in the \
DropIndex op, or regenerate the migration. PostgreSQL and \
SQLite apply the file unchanged."
));
}
out.immediate.push(format!(
"DROP INDEX {} ON {}",
dialect.quote_ident(name),
dialect.quote_ident(table),
));
continue;
}
out.immediate.push(format!(
"DROP INDEX IF EXISTS {}",
dialect.quote_ident(name)
));
}
SchemaChange::AddCheckConstraint { table, .. }
| SchemaChange::DropCheckConstraint { table, .. }
| SchemaChange::AddCompositeFk { table, .. }
| SchemaChange::DropCompositeFk { table, .. }
if dialect.alters_by_rebuild() =>
{
let dropped = matches!(
change,
SchemaChange::DropCheckConstraint { .. } | SchemaChange::DropCompositeFk { .. }
) && current.table(table).is_none();
if dropped {
continue;
}
if let Some(rebuild) = super::rebuild::TableRebuild::needed(dialect, current, table)
{
out.set_rebuild(rebuild?)?;
}
}
SchemaChange::AddCheckConstraint { name, table, expr } => {
out.immediate.push(format!(
"ALTER TABLE {} ADD CONSTRAINT {} CHECK ({expr})",
dialect.quote_ident(table),
dialect.quote_ident(name),
));
}
SchemaChange::DropCheckConstraint { name, table } => {
let Some(sql) = dialect.drop_check_constraint_sql(table, name) else {
return Err(format!(
"DropCheckConstraint for `{table}.{name}` is not yet supported on \
dialect `{}`. That dialect has no `ALTER TABLE DROP CONSTRAINT` \
syntax. Workaround: emit a hand-written `Operation::Data` (RunSQL) \
that rebuilds the table without the CHECK. Tracked in #559.",
dialect.name()
));
};
out.immediate.push(sql);
}
SchemaChange::AddExclusionConstraint {
name,
table,
using,
elements,
where_clause,
} => {
if dialect.name() != "postgres" {
tracing::warn!(
constraint = %name,
table = %table,
dialect = dialect.name(),
"skipping AddExclusionConstraint — PG-only, no equivalent on this backend",
);
continue;
}
let elem_sql: Vec<String> = elements
.iter()
.map(|(col, op)| format!(r#""{col}" WITH {op}"#))
.collect();
let mut stmt = format!(
r#"ALTER TABLE "{table}" ADD CONSTRAINT "{name}" EXCLUDE USING {using} ({})"#,
elem_sql.join(", "),
);
if let Some(pred) = where_clause {
stmt.push_str(&format!(" WHERE ({pred})"));
}
out.immediate.push(stmt);
}
SchemaChange::DropExclusionConstraint { name, table } => {
if dialect.name() != "postgres" {
tracing::warn!(
constraint = %name,
table = %table,
dialect = dialect.name(),
"skipping DropExclusionConstraint — PG-only",
);
continue;
}
out.immediate.push(format!(
r#"ALTER TABLE "{table}" DROP CONSTRAINT IF EXISTS "{name}""#,
));
}
SchemaChange::CreateM2MTable {
through,
src_table,
src_col,
dst_table,
dst_col,
} => {
fk_names.check(through, src_col)?;
fk_names.check(through, dst_col)?;
let q_through = dialect.quote_ident(through);
let q_src_col = dialect.quote_ident(src_col);
let q_dst_col = dialect.quote_ident(dst_col);
if dialect.inline_fks_in_create_table() {
let q_src_table = fk_target(dialect, schema, src_table);
let q_dst_table = fk_target(dialect, schema, dst_table);
let q_id = dialect.quote_ident("id");
let q_src_fk =
dialect.quote_ident(&super::ddl::fk_constraint_name(through, src_col));
let q_dst_fk =
dialect.quote_ident(&super::ddl::fk_constraint_name(through, dst_col));
out.immediate.push(format!(
"CREATE TABLE {q_through} ({q_src_col} BIGINT NOT NULL, {q_dst_col} BIGINT NOT NULL, \
PRIMARY KEY ({q_src_col}, {q_dst_col}), \
CONSTRAINT {q_src_fk} FOREIGN KEY ({q_src_col}) REFERENCES {q_src_table} ({q_id}) ON DELETE CASCADE, \
CONSTRAINT {q_dst_fk} FOREIGN KEY ({q_dst_col}) REFERENCES {q_dst_table} ({q_id}) ON DELETE CASCADE)",
));
} else {
out.immediate.push(format!(
"CREATE TABLE {q_through} ({q_src_col} BIGINT NOT NULL, {q_dst_col} BIGINT NOT NULL, \
PRIMARY KEY ({q_src_col}, {q_dst_col}))",
));
out.deferred_fks
.push(m2m_fk_sql(through, src_col, src_table, dialect, schema));
out.deferred_fks
.push(m2m_fk_sql(through, dst_col, dst_table, dialect, schema));
}
}
SchemaChange::DropM2MTable { through } => {
let cascade = if dialect.name() == "postgres" {
" CASCADE"
} else {
""
};
out.immediate.push(format!(
"DROP TABLE IF EXISTS {}{cascade}",
dialect.quote_ident(through)
));
}
SchemaChange::AddCompositeFk {
table,
name,
to,
from,
on,
} => {
out.deferred_fks
.push(composite_fk_sql(table, name, to, from, on, dialect, schema));
}
SchemaChange::DropCompositeFk { table, name } => {
let Some(sql) = dialect.drop_foreign_key_sql(table, name) else {
return Err(format!(
"DropCompositeFk for `{table}.{name}` is not yet supported on \
dialect `{}`. That dialect has no `ALTER TABLE DROP CONSTRAINT` \
syntax. Workaround: emit a hand-written `Operation::Data` (RunSQL) \
that rebuilds the table without the FK. Tracked in #559.",
dialect.name()
));
};
out.immediate.push(sql);
}
}
}
Ok(out)
}
fn is_ci_text(f: &FieldSnapshot) -> bool {
f.case_insensitive && f.ty == "string"
}
fn writes_ci_text(change: &SchemaChange, current: &SchemaSnapshot) -> bool {
let field = |t: &str, c: &str| current.table(t).and_then(|t| t.field(c));
match change {
SchemaChange::CreateTable(t) => current
.table(t)
.is_some_and(|t| t.fields.iter().any(is_ci_text)),
SchemaChange::AddColumn { table, column }
| SchemaChange::AlterColumnType { table, column, .. }
| SchemaChange::AlterColumnMaxLength { table, column, .. } => {
field(table, column).is_some_and(is_ci_text)
}
_ => false,
}
}
fn pg_type_for_ty_name(ty: &str) -> String {
match ty {
"i16" => "SMALLINT".into(),
"i32" => "INTEGER".into(),
"i64" => "BIGINT".into(),
"f32" => "REAL".into(),
"f64" => "DOUBLE PRECISION".into(),
"bool" => "BOOLEAN".into(),
"string" => "TEXT".into(),
"datetime" => "TIMESTAMPTZ".into(),
"date" => "DATE".into(),
"time" => "TIME".into(),
"uuid" => "UUID".into(),
"json" => "JSONB".into(),
"decimal" => "NUMERIC".into(),
"binary" => "BYTEA".into(),
"array_text" => "text[]".into(),
"array_int" => "integer[]".into(),
"array_bigint" => "bigint[]".into(),
"range_int" => "int4range".into(),
"range_bigint" => "int8range".into(),
"range_numeric" => "numrange".into(),
"range_date" => "daterange".into(),
"range_datetime" => "tstzrange".into(),
"hstore" => "hstore".into(),
other => other.to_uppercase(),
}
}
fn create_table_sql_from_snapshot_with_dialect(
t: &TableSnapshot,
dialect: &dyn crate::sql::Dialect,
) -> String {
create_table_sql_as(t, &t.name, dialect)
}
pub(super) fn create_table_sql_as(
t: &TableSnapshot,
name: &str,
dialect: &dyn crate::sql::Dialect,
) -> String {
let mut sql = format!("CREATE TABLE {} (", dialect.quote_ident(name));
let mut first = true;
for f in &t.fields {
if !first {
sql.push_str(", ");
}
first = false;
let _ = write!(
sql,
"{} {}",
dialect.quote_ident(&f.column),
sql_type_with_dialect(f, dialect)
);
if let Some(expr) = &f.generated_as {
let _ = write!(sql, " GENERATED ALWAYS AS ({expr}) STORED");
if !f.nullable {
sql.push_str(" NOT NULL");
}
if let Some(comment) = &f.db_comment {
if let Some(inline) = dialect.write_inline_column_comment(comment) {
sql.push_str(&inline);
}
}
continue;
}
if let Some(expr) = &f.default {
let rendered = render_column_default(expr, &f.ty, f.max_length, dialect);
let _ = write!(sql, " DEFAULT {rendered}");
}
if !f.nullable {
sql.push_str(" NOT NULL");
}
let serial_pk_inline = f.auto
&& matches!(f.ty.as_str(), "i16" | "i32" | "i64")
&& dialect.serial_type_includes_primary_key();
if f.primary_key && !serial_pk_inline {
sql.push_str(" PRIMARY KEY");
}
if f.min.is_some() || f.max.is_some() {
sql.push_str(" CHECK (");
let mut wrote = false;
if let Some(min) = f.min {
let _ = write!(sql, "{} >= {}", dialect.quote_ident(&f.column), min);
wrote = true;
}
if let Some(max) = f.max {
if wrote {
sql.push_str(" AND ");
}
let _ = write!(sql, "{} <= {}", dialect.quote_ident(&f.column), max);
}
sql.push(')');
}
if let Some(comment) = &f.db_comment {
if let Some(inline) = dialect.write_inline_column_comment(comment) {
sql.push_str(&inline);
}
}
if dialect.inline_fks_in_create_table() {
if let Some(rel) = &f.fk {
sql.push_str(&inline_references(rel, dialect));
}
}
}
for f in &t.fields {
if f.unique && !f.primary_key && f.generated_as.is_none() {
sql.push_str(", ");
sql.push_str(&super::ddl::unique_clause(dialect, &t.name, &f.column));
}
}
if dialect.inline_fks_in_create_table() {
for cf in &t.composite_fks {
sql.push_str(", FOREIGN KEY (");
let from_cols: Vec<String> = cf.from.iter().map(|c| dialect.quote_ident(c)).collect();
sql.push_str(&from_cols.join(", "));
sql.push_str(") REFERENCES ");
sql.push_str(&dialect.quote_ident(&cf.to));
sql.push_str(" (");
let on_cols: Vec<String> = cf.on.iter().map(|c| dialect.quote_ident(c)).collect();
sql.push_str(&on_cols.join(", "));
sql.push(')');
}
}
sql.push(')');
sql
}
fn constraints_sql_from_snapshot(
t: &TableSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<Vec<String>, String> {
let mut out: Vec<String> = t
.fields
.iter()
.filter_map(|f| {
f.fk.as_ref()
.map(|rel| field_fk_sql(&t.name, &f.column, rel, dialect, schema))
})
.collect::<Result<_, _>>()?;
out.extend(
composite_fks(t, dialect, schema)
.into_iter()
.map(|(_, sql)| sql),
);
Ok(out)
}
pub(crate) fn composite_fks(
t: &TableSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Vec<(String, String)> {
t.composite_fks
.iter()
.map(|cf| {
let sql =
composite_fk_sql(&t.name, &cf.name, &cf.to, &cf.from, &cf.on, dialect, schema);
(cf.name.clone(), sql)
})
.collect()
}
fn composite_fk_sql(
table: &str,
name: &str,
to: &str,
from: &[String],
on: &[String],
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> String {
let cols = |cs: &[String]| {
cs.iter()
.map(|c| dialect.quote_ident(c))
.collect::<Vec<_>>()
.join(", ")
};
format!(
"ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})",
dialect.quote_ident(table),
dialect.quote_ident(name),
cols(from),
fk_target(dialect, schema, to),
cols(on),
)
}
pub(crate) fn column_fks(
t: &TableSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<Vec<(String, Option<String>)>, String> {
t.fields
.iter()
.map(|f| {
let fk = f.fk.as_ref();
let sql = fk
.map(|rel| field_fk_sql(&t.name, &f.column, rel, dialect, schema))
.transpose()?;
Ok((f.column.clone(), sql))
})
.collect()
}
pub(crate) enum ColumnFk<'a> {
Field(&'a RelationSnapshot),
Junction(&'a str),
}
pub(crate) fn column_fk<'a>(
snap: &'a SchemaSnapshot,
table: &str,
column: &str,
) -> Option<ColumnFk<'a>> {
let field = snap.table(table).and_then(|t| t.field(column));
if let Some(rel) = field.and_then(|f| f.fk.as_ref()) {
return Some(ColumnFk::Field(rel));
}
snap.m2m_table(table).and_then(|m| {
[(&m.src_col, &m.src_table), (&m.dst_col, &m.dst_table)]
.into_iter()
.find_map(|(c, to)| (c == column).then_some(ColumnFk::Junction(to)))
})
}
pub(crate) fn column_fk_sql(
snap: &SchemaSnapshot,
table: &str,
column: &str,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<Option<String>, String> {
column_fk(snap, table, column)
.map(|fk| match fk {
ColumnFk::Field(rel) => field_fk_sql(table, column, rel, dialect, schema),
ColumnFk::Junction(to) => Ok(m2m_fk_sql(table, column, to, dialect, schema)),
})
.transpose()
}
fn m2m_fk_sql(
through: &str,
column: &str,
to: &str,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> String {
format!(
"ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON DELETE CASCADE",
dialect.quote_ident(through),
dialect.quote_ident(&super::ddl::fk_constraint_name(through, column)),
dialect.quote_ident(column),
fk_target(dialect, schema, to),
dialect.quote_ident("id"),
)
}
fn inline_references(rel: &RelationSnapshot, dialect: &dyn crate::sql::Dialect) -> String {
let mut s = format!(
" REFERENCES {} ({})",
dialect.quote_ident(&rel.to),
dialect.quote_ident(&rel.on),
);
if let Some(action) = &rel.on_delete {
let _ = write!(s, " ON DELETE {action}");
}
s
}
fn field_fk_sql(
table: &str,
column: &str,
rel: &RelationSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<String, String> {
let mut s = format!(
"ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})",
dialect.quote_ident(table),
dialect.quote_ident(&super::ddl::fk_constraint_name(table, column)),
dialect.quote_ident(column),
fk_target(dialect, schema, &rel.to),
dialect.quote_ident(&rel.on),
);
if let Some(action) = &rel.on_delete {
let set_default = crate::core::OnDeleteAction::SetDefault.as_sql();
if action.eq_ignore_ascii_case(set_default) && !dialect.supports_on_delete_set_default() {
return Err(format!(
"`{table}.{column}`: on_delete = \"set_default\" is not enforced on {} \
(the parent delete is refused); use another action (#1573)",
dialect.name()
));
}
let _ = write!(s, " ON DELETE {action}");
}
Ok(s)
}
fn render_column_default(
expr: &str,
ty: &str,
max_length: Option<u32>,
dialect: &dyn crate::sql::Dialect,
) -> String {
let expr_to_render = if expr.is_empty() { "''" } else { expr };
dialect.translate_default_expr(expr_to_render, ty, max_length)
}
fn add_column_sql(table: &str, f: &FieldSnapshot, dialect: &dyn crate::sql::Dialect) -> String {
let col_q = dialect.quote_ident(&f.column);
let mut sql = format!(
"ALTER TABLE {} ADD COLUMN {} {}",
dialect.quote_ident(table),
col_q,
sql_type_with_dialect(f, dialect)
);
if let Some(expr) = &f.default {
let rendered = render_column_default(expr, &f.ty, f.max_length, dialect);
let _ = write!(sql, " DEFAULT {rendered}");
}
if !f.nullable {
sql.push_str(" NOT NULL");
}
if f.min.is_some() || f.max.is_some() {
sql.push_str(" CHECK (");
let mut wrote = false;
if let Some(min) = f.min {
let _ = write!(sql, "{col_q} >= {min}");
wrote = true;
}
if let Some(max) = f.max {
if wrote {
sql.push_str(" AND ");
}
let _ = write!(sql, "{col_q} <= {max}");
}
sql.push(')');
}
sql.push_str(&inline_comment(f, dialect));
if let Some(rel) = inline_fk_on_add_column(f, dialect) {
sql.push_str(&inline_references(rel, dialect));
}
sql
}
pub(crate) fn is_uuid_default(f: &FieldSnapshot) -> bool {
f.default.as_deref().is_some_and(crate::sql::is_uuid_expr)
}
fn add_column_backfilled(
table: &str,
f: &FieldSnapshot,
dialect: &dyn crate::sql::Dialect,
) -> Vec<String> {
let bare = FieldSnapshot {
default: None,
nullable: true,
..f.clone()
};
let value = render_column_default(
f.default.as_deref().unwrap_or_default(),
&f.ty,
f.max_length,
dialect,
);
let null = if f.nullable { "" } else { " NOT NULL" };
vec![
add_column_sql(table, &bare, dialect),
fill_nulls_sql(table, f, dialect),
format!(
"ALTER TABLE {} MODIFY COLUMN {} {} DEFAULT {value}{null}{}",
dialect.quote_ident(table),
dialect.quote_ident(&f.column),
sql_type_with_dialect(f, dialect),
inline_comment(f, dialect),
),
]
}
fn inline_comment(f: &FieldSnapshot, dialect: &dyn crate::sql::Dialect) -> String {
f.db_comment
.as_deref()
.and_then(|c| dialect.write_inline_column_comment(c))
.unwrap_or_default()
}
fn add_column_then_not_null(
table: &str,
f: &FieldSnapshot,
dialect: &dyn crate::sql::Dialect,
) -> Vec<String> {
let bare = FieldSnapshot {
nullable: true,
..f.clone()
};
vec![
add_column_sql(table, &bare, dialect),
format!(
"ALTER TABLE {} MODIFY COLUMN {} {} NOT NULL{}",
dialect.quote_ident(table),
dialect.quote_ident(&f.column),
sql_type_with_dialect(f, dialect),
inline_comment(f, dialect),
),
]
}
pub(crate) fn fill_nulls_sql(
table: &str,
f: &FieldSnapshot,
dialect: &dyn crate::sql::Dialect,
) -> String {
let value = render_column_default(
f.default.as_deref().unwrap_or_default(),
&f.ty,
f.max_length,
dialect,
);
format!(
"UPDATE {t} SET {c} = {value} WHERE {c} IS NULL",
t = dialect.quote_ident(table),
c = dialect.quote_ident(&f.column),
)
}
fn inline_fk_on_add_column<'a>(
f: &'a FieldSnapshot,
dialect: &dyn crate::sql::Dialect,
) -> Option<&'a RelationSnapshot> {
f.fk.as_ref()
.filter(|_| dialect.inline_fks_in_create_table() && f.default.is_none())
}
#[cfg(test)]
fn sql_type(f: &FieldSnapshot) -> String {
sql_type_with_dialect(f, &crate::sql::Postgres)
}
fn sql_type_with_dialect(f: &FieldSnapshot, dialect: &dyn crate::sql::Dialect) -> String {
use crate::core::FieldType;
let ty = match f.ty.as_str() {
"i16" => Some(FieldType::I16),
"i32" => Some(FieldType::I32),
"i64" => Some(FieldType::I64),
"f32" => Some(FieldType::F32),
"f64" => Some(FieldType::F64),
"bool" => Some(FieldType::Bool),
"string" => Some(FieldType::String),
"datetime" => Some(FieldType::DateTime),
"date" => Some(FieldType::Date),
"time" => Some(FieldType::Time),
"uuid" => Some(FieldType::Uuid),
"json" => Some(FieldType::Json),
"decimal" => Some(FieldType::Decimal),
"binary" => Some(FieldType::Binary),
"array_text" => Some(FieldType::Array(crate::core::ArrayElem::Text)),
"array_int" => Some(FieldType::Array(crate::core::ArrayElem::Int)),
"array_bigint" => Some(FieldType::Array(crate::core::ArrayElem::BigInt)),
"range_int" => Some(FieldType::Range(crate::core::RangeElem::Int)),
"range_bigint" => Some(FieldType::Range(crate::core::RangeElem::BigInt)),
"range_numeric" => Some(FieldType::Range(crate::core::RangeElem::Numeric)),
"range_date" => Some(FieldType::Range(crate::core::RangeElem::Date)),
"range_datetime" => Some(FieldType::Range(crate::core::RangeElem::DateTime)),
"hstore" => Some(FieldType::HStore),
_ => None,
};
if let Some(t @ (FieldType::I16 | FieldType::I32 | FieldType::I64)) = ty {
if f.auto {
return dialect.serial_type(t).to_owned();
}
if f.primary_key && f.default.is_some() {
if let Some(pk_ty) = dialect.defaulted_integer_pk_type() {
return pk_ty.to_owned();
}
}
}
if is_ci_text(f) {
return dialect.ci_text_type(f.max_length);
}
if let Some(t) = ty {
return dialect.column_type(t, f.max_length);
}
f.ty.to_uppercase()
}
#[cfg(test)]
mod sql_type_tests {
use super::*;
use crate::migrate::snapshot::FieldSnapshot;
fn fs(ty: &str, auto: bool) -> FieldSnapshot {
FieldSnapshot {
name: "x".into(),
column: "x".into(),
ty: ty.into(),
nullable: false,
primary_key: false,
max_length: None,
min: None,
max: None,
default: None,
auto,
unique: false,
case_insensitive: false,
generated_as: None,
db_comment: None,
fk: None,
}
}
#[cfg(feature = "mysql")]
#[test]
fn mysql_not_null_add_on_empty_table_tightens_after() {
let snap = SchemaSnapshot {
tables: vec![TableSnapshot {
name: "t".into(),
model: "T".into(),
fields: vec![fs("i32", false)],
composite_fks: Vec::new(),
}],
..Default::default()
};
let add = [SchemaChange::AddColumn {
table: "t".into(),
column: "x".into(),
}];
let out = render_changes_split_for_empty(&add, &snap, &crate::sql::MySql, None).unwrap();
assert_eq!(out.immediate.len(), 2, "{:?}", out.immediate);
assert!(
!out.immediate[0].contains("NOT NULL"),
"{}",
out.immediate[0]
);
assert!(
out.immediate[1].starts_with("ALTER TABLE `t` MODIFY COLUMN `x` ")
&& out.immediate[1].ends_with(" NOT NULL"),
"{}",
out.immediate[1]
);
}
#[test]
fn auto_integer_emits_serial() {
assert_eq!(sql_type(&fs("i32", true)), "SERIAL");
assert_eq!(sql_type(&fs("i64", true)), "BIGSERIAL");
}
#[test]
fn auto_non_integer_falls_through_to_real_type() {
assert_eq!(sql_type(&fs("datetime", true)), "TIMESTAMPTZ");
assert_eq!(sql_type(&fs("date", true)), "DATE");
assert_eq!(sql_type(&fs("uuid", true)), "UUID");
assert_eq!(sql_type(&fs("bool", true)), "BOOLEAN");
assert_eq!(sql_type(&fs("string", true)), "TEXT");
}
#[test]
fn non_auto_passes_through_normally() {
assert_eq!(sql_type(&fs("i64", false)), "BIGINT");
assert_eq!(sql_type(&fs("datetime", false)), "TIMESTAMPTZ");
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_defaulted_integer_pk_is_not_the_rowid() {
let mut f = fs("i64", false);
f.primary_key = true;
f.default = Some("7".into());
assert_eq!(sql_type_with_dialect(&f, &crate::sql::Sqlite), "BIGINT");
f.default = None;
assert_eq!(sql_type_with_dialect(&f, &crate::sql::Sqlite), "INTEGER");
}
#[test]
fn drop_arms_are_dialect_aware() {
use crate::migrate::{SchemaChange, SchemaSnapshot};
let snap = SchemaSnapshot::from_models(&[]);
let drop_table = [SchemaChange::DropTable("foo".into())];
let pg =
render_changes_split_with_dialect(&drop_table, &snap, &crate::sql::Postgres).unwrap();
assert_eq!(
pg.immediate,
vec![r#"DROP TABLE "foo" CASCADE"#.to_string()]
);
#[cfg(feature = "mysql")]
{
let my =
render_changes_split_with_dialect(&drop_table, &snap, &crate::sql::MySql).unwrap();
assert_eq!(my.immediate, vec!["DROP TABLE `foo`".to_string()]);
}
#[cfg(feature = "sqlite")]
{
let sq =
render_changes_split_with_dialect(&drop_table, &snap, &crate::sql::Sqlite).unwrap();
assert_eq!(sq.immediate, vec![r#"DROP TABLE "foo""#.to_string()]);
}
#[cfg(feature = "mysql")]
{
let drop_col = [SchemaChange::DropColumn {
table: "t".into(),
column: "c".into(),
}];
let my =
render_changes_split_with_dialect(&drop_col, &snap, &crate::sql::MySql).unwrap();
assert_eq!(
my.immediate,
vec!["ALTER TABLE `t` DROP COLUMN `c`".to_string()]
);
}
}
fn clashing_uniques() -> SchemaSnapshot {
let uniq = |t: &str, c: &str| TableSnapshot {
name: t.into(),
model: t.into(),
fields: vec![FieldSnapshot {
name: c.into(),
column: c.into(),
unique: true,
nullable: true,
..fs("i64", false)
}],
composite_fks: vec![],
};
SchemaSnapshot {
tables: vec![uniq("a_b", "c"), uniq("a", "b_c")],
..SchemaSnapshot::default()
}
}
#[test]
fn clashing_unique_names_are_refused() {
use crate::migrate::SchemaChange;
let snap = clashing_uniques();
let err = render_changes_split_with_dialect(
&[SchemaChange::CreateTable("a_b".into())],
&snap,
&crate::sql::Postgres,
)
.expect_err("two UNIQUEs named a_b_c_key");
assert!(
err.contains("`a_b_c_key`") && err.contains("`a.b_c`"),
"{err}"
);
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_drop_column_keeps_another_tables_unique_index() {
use crate::migrate::SchemaChange;
let mut snap = clashing_uniques();
snap.tables[0].fields.clear();
let drop = [SchemaChange::DropColumn {
table: "a_b".into(),
column: "c".into(),
}];
let out = render_changes_split_with_dialect(&drop, &snap, &crate::sql::Sqlite).unwrap();
assert!(out.immediate.is_empty(), "{:?}", out.immediate);
assert_eq!(out.rebuild.as_ref().map(|r| r.table()), Some("a_b"));
snap.tables.remove(1);
let out = render_changes_split_with_dialect(&drop, &snap, &crate::sql::Sqlite).unwrap();
assert_eq!(out.immediate, [r#"DROP INDEX IF EXISTS "a_b_c_key""#]);
}
#[test]
fn alter_fk_on_delete_renders_per_dialect() {
use crate::migrate::{RelationSnapshot, SchemaChange};
let mut child = TableSnapshot {
name: "c".into(),
model: "c".into(),
fields: vec![FieldSnapshot {
name: "p_id".into(),
column: "p_id".into(),
fk: Some(RelationSnapshot {
kind: "fk".into(),
to: "p".into(),
on: "id".into(),
on_delete: Some("CASCADE".into()),
}),
..fs("i64", false)
}],
composite_fks: vec![],
};
child.fields.insert(0, fs("i64", true));
let snap = SchemaSnapshot {
tables: vec![child],
..SchemaSnapshot::default()
};
let alter = [SchemaChange::AlterFkOnDelete {
table: "c".into(),
column: "p_id".into(),
from: None,
to: Some("CASCADE".into()),
}];
let pg = render_changes_split_with_dialect(&alter, &snap, &crate::sql::Postgres).unwrap();
assert!(pg.immediate.is_empty() && pg.rebuild.is_none());
assert_eq!(
pg.deferred_fks,
[
r#"ALTER TABLE "c" ADD CONSTRAINT "c_p_id_fkey" FOREIGN KEY ("p_id") REFERENCES "p" ("id") ON DELETE CASCADE"#
]
);
#[cfg(feature = "sqlite")]
{
let sq = render_changes_split_with_dialect(&alter, &snap, &crate::sql::Sqlite).unwrap();
assert!(sq.immediate.is_empty() && sq.deferred_fks.is_empty());
let twice = [alter[0].clone(), alter[0].clone()];
assert!(render_changes_split_with_dialect(&twice, &snap, &crate::sql::Sqlite).is_err());
let stmts = sq
.rebuild
.expect("a rebuild")
.statements(&crate::sql::Sqlite);
assert!(stmts[0].starts_with(r#"CREATE TABLE "_rustango_rebuild_c""#));
assert!(stmts[0].contains("ON DELETE CASCADE"), "{}", stmts[0]);
assert_eq!(
stmts[stmts.len() - 2..],
[
r#"DROP TABLE "c""#,
r#"ALTER TABLE "_rustango_rebuild_c" RENAME TO "c""#
]
);
}
}
#[test]
fn set_default_is_refused_where_not_enforced() {
use crate::migrate::{RelationSnapshot, SchemaChange};
let child = TableSnapshot {
name: "c".into(),
model: "c".into(),
fields: vec![FieldSnapshot {
name: "p_id".into(),
column: "p_id".into(),
fk: Some(RelationSnapshot {
kind: "fk".into(),
to: "p".into(),
on: "id".into(),
on_delete: Some("SET DEFAULT".into()),
}),
..fs("i64", false)
}],
composite_fks: vec![],
};
let snap = SchemaSnapshot {
tables: vec![child],
..SchemaSnapshot::default()
};
let create = [SchemaChange::CreateTable("c".into())];
let pg = render_changes_split_with_dialect(&create, &snap, &crate::sql::Postgres).unwrap();
assert!(pg.deferred_fks[0].ends_with("ON DELETE SET DEFAULT"));
#[cfg(feature = "mysql")]
{
let err = render_changes_split_with_dialect(&create, &snap, &crate::sql::MySql)
.expect_err("MySQL accepts SET DEFAULT and then refuses the delete");
assert!(err.contains("set_default"), "{err}");
}
}
fn empty_snap() -> SchemaSnapshot {
SchemaSnapshot::default()
}
fn alter_snap() -> SchemaSnapshot {
SchemaSnapshot {
tables: vec![TableSnapshot {
name: "t".into(),
model: "t".into(),
fields: vec![
FieldSnapshot {
primary_key: true,
..fs("i64", true)
},
FieldSnapshot {
name: "c".into(),
column: "c".into(),
default: Some("7".into()),
..fs("i64", false)
},
],
composite_fks: vec![],
}],
..SchemaSnapshot::default()
}
}
#[cfg(any(feature = "mysql", feature = "sqlite"))]
fn not_null() -> Vec<SchemaChange> {
vec![SchemaChange::AlterColumnNullable {
table: "t".into(),
column: "c".into(),
nullable: false,
}]
}
#[test]
fn alter_column_type_emits_pg_sql_on_postgres() {
let snap = empty_snap();
let changes = vec![SchemaChange::AlterColumnType {
table: "t".into(),
column: "c".into(),
from: "i32".into(),
to: "i64".into(),
}];
let out =
render_changes_split_with_dialect(&changes, &snap, &crate::sql::Postgres).unwrap();
assert_eq!(out.immediate.len(), 1);
assert!(out.immediate[0].contains("ALTER COLUMN \"c\" TYPE"));
}
fn ci_snap() -> SchemaSnapshot {
let string = |name: &str, ci: bool| FieldSnapshot {
name: name.into(),
column: name.into(),
max_length: Some(100),
case_insensitive: ci,
nullable: true,
..fs("string", false)
};
SchemaSnapshot {
tables: vec![TableSnapshot {
name: "t".into(),
model: "t".into(),
fields: vec![
FieldSnapshot {
primary_key: true,
..fs("i64", true)
},
string("email", true),
string("name", false),
],
composite_fks: vec![],
}],
..SchemaSnapshot::default()
}
}
fn add(column: &str) -> SchemaChange {
SchemaChange::AddColumn {
table: "t".into(),
column: column.into(),
}
}
#[test]
fn citext_extension_precedes_the_first_citext_column() {
let render = |changes: &[SchemaChange], dialect: &dyn crate::sql::Dialect| {
render_changes_split_with_dialect(changes, &ci_snap(), dialect)
.unwrap()
.immediate
};
let prelude = "CREATE EXTENSION IF NOT EXISTS citext SCHEMA public;";
let pg = render(
&[
add("name"),
SchemaChange::CreateTable("t".into()),
add("email"),
],
&crate::sql::Postgres,
);
assert_eq!(pg.iter().filter(|s| *s == prelude).count(), 1, "{pg:?}");
assert_eq!(pg[1], prelude, "{pg:?}");
assert!(!render(&[add("name")], &crate::sql::Postgres).contains(&prelude.to_owned()));
#[cfg(feature = "sqlite")]
assert!(!render(&[add("email")], &crate::sql::Sqlite).contains(&prelude.to_owned()));
}
#[cfg(feature = "mysql")]
#[test]
fn alter_column_is_a_modify_on_mysql() {
let out = render_changes_split_with_dialect(¬_null(), &alter_snap(), &crate::sql::MySql)
.unwrap();
assert_eq!(
out.immediate,
[
"UPDATE `t` SET `c` = 7 WHERE `c` IS NULL",
"ALTER TABLE `t` MODIFY COLUMN `c` BIGINT DEFAULT 7 NOT NULL"
]
);
}
#[cfg(feature = "sqlite")]
#[test]
fn alter_column_is_a_rebuild_on_sqlite() {
let out =
render_changes_split_with_dialect(¬_null(), &alter_snap(), &crate::sql::Sqlite)
.unwrap();
assert!(out.immediate.is_empty());
let stmts = out
.rebuild
.expect("a rebuild")
.statements(&crate::sql::Sqlite);
assert!(
stmts[1].contains(r#"SELECT "x", COALESCE("c", 7) FROM "t""#),
"{}",
stmts[1]
);
}
#[cfg(feature = "sqlite")]
#[test]
fn alter_column_max_length_is_a_no_op_on_sqlite() {
let snap = empty_snap();
let changes = vec![SchemaChange::AlterColumnMaxLength {
table: "t".into(),
column: "c".into(),
from: Some(50),
to: Some(100),
}];
let batch = render_changes_split_with_dialect(&changes, &snap, &crate::sql::Sqlite)
.expect("AlterColumnMaxLength must be accepted on SQLite");
assert!(
batch.immediate.is_empty() && batch.deferred_fks.is_empty(),
"no statement should be emitted, got {:?} / {:?}",
batch.immediate,
batch.deferred_fks,
);
}
fn make_create_m2m() -> Vec<SchemaChange> {
vec![SchemaChange::CreateM2MTable {
through: "post_tags".into(),
src_table: "posts".into(),
src_col: "post_id".into(),
dst_table: "tags".into(),
dst_col: "tag_id".into(),
}]
}
#[test]
fn create_m2m_table_postgres_defers_fk_constraints() {
let snap = empty_snap();
let out =
render_changes_split_with_dialect(&make_create_m2m(), &snap, &crate::sql::Postgres)
.unwrap();
assert_eq!(out.immediate.len(), 1);
assert!(out.immediate[0].contains(r#"CREATE TABLE "post_tags""#));
assert!(out.immediate[0].contains(r#"PRIMARY KEY ("post_id", "tag_id")"#));
assert_eq!(out.deferred_fks.len(), 2);
assert!(out.deferred_fks[0].contains(r#"ADD CONSTRAINT "post_tags_post_id_fkey""#));
assert!(out.deferred_fks[0].contains(r#"REFERENCES "posts" ("id")"#));
assert!(out.deferred_fks[1].contains(r#"ADD CONSTRAINT "post_tags_tag_id_fkey""#));
assert!(out.deferred_fks[1].contains(r#"REFERENCES "tags" ("id")"#));
}
#[test]
fn mysql_add_column_modify_keeps_the_comment() {
let snap: SchemaSnapshot = serde_json::from_value(serde_json::json!({ "tables": [{
"name": "t", "model": "T", "fields": [
{ "name": "c", "column": "c", "ty": "i64", "nullable": false,
"primary_key": false, "db_comment": "kept" },
{ "name": "u", "column": "u", "ty": "uuid", "nullable": false,
"primary_key": false, "default": "gen_random_uuid()",
"db_comment": "kept" }] }] }))
.unwrap();
for column in ["c", "u"] {
let add = [SchemaChange::AddColumn {
table: "t".into(),
column: column.into(),
}];
let out =
render_changes_split_for_empty(&add, &snap, &crate::sql::MySql, None).unwrap();
let modify = out.immediate.iter().find(|s| s.contains("MODIFY COLUMN"));
assert!(
modify.is_some_and(|s| s.ends_with("COMMENT 'kept'")),
"{:?}",
out.immediate
);
}
}
#[cfg(feature = "mysql")]
#[test]
fn create_m2m_table_mysql_uses_backtick_quoting() {
let snap = empty_snap();
let out = render_changes_split_with_dialect(&make_create_m2m(), &snap, &crate::sql::MySql)
.unwrap();
assert_eq!(out.immediate.len(), 1);
assert!(out.immediate[0].contains("CREATE TABLE `post_tags`"));
assert!(out.immediate[0].contains("PRIMARY KEY (`post_id`, `tag_id`)"));
assert!(
!out.immediate[0].contains('"'),
"MySQL must not contain ANSI double-quotes: {}",
out.immediate[0]
);
assert_eq!(out.deferred_fks.len(), 2);
assert!(out.deferred_fks[0].contains("ADD CONSTRAINT `post_tags_post_id_fkey`"));
assert!(out.deferred_fks[0].contains("REFERENCES `posts` (`id`)"));
for stmt in &out.deferred_fks {
assert!(!stmt.contains('"'), "MySQL FK must use backticks: {stmt}");
}
}
#[test]
fn drop_index_postgres_uses_ansi_quoting_with_if_exists() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropIndex {
name: "idx_post_slug".into(),
table: "post".into(),
}];
let out =
render_changes_split_with_dialect(&changes, &snap, &crate::sql::Postgres).unwrap();
assert_eq!(
out.immediate,
vec![r#"DROP INDEX IF EXISTS "idx_post_slug""#.to_string()]
);
}
#[cfg(feature = "sqlite")]
#[test]
fn drop_index_sqlite_uses_ansi_quoting_with_if_exists() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropIndex {
name: "idx_post_slug".into(),
table: "post".into(),
}];
let out = render_changes_split_with_dialect(&changes, &snap, &crate::sql::Sqlite).unwrap();
assert_eq!(
out.immediate,
vec![r#"DROP INDEX IF EXISTS "idx_post_slug""#.to_string()]
);
}
#[cfg(feature = "mysql")]
#[test]
fn drop_index_mysql_names_the_table() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropIndex {
name: "idx_post_slug".into(),
table: "post".into(),
}];
let out = render_changes_split_with_dialect(&changes, &snap, &crate::sql::MySql)
.expect("DropIndex renders on MySQL now that the variant carries the table");
assert_eq!(
out.immediate,
vec!["DROP INDEX `idx_post_slug` ON `post`".to_string()]
);
}
#[cfg(feature = "mysql")]
#[test]
fn drop_index_mysql_without_a_table_says_which_file_to_fix() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropIndex {
name: "idx_post_slug".into(),
table: String::new(),
}];
let err = render_changes_split_with_dialect(&changes, &snap, &crate::sql::MySql)
.expect_err("an empty table cannot render on MySQL");
assert!(err.contains("idx_post_slug"), "{err}");
assert!(err.contains("carries no table"), "{err}");
assert!(err.contains("\"table\""), "names the field to add: {err}");
}
#[test]
fn drop_index_without_a_table_still_renders_on_postgres() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropIndex {
name: "idx_post_slug".into(),
table: String::new(),
}];
let out =
render_changes_split_with_dialect(&changes, &snap, &crate::sql::Postgres).unwrap();
assert_eq!(
out.immediate,
vec![r#"DROP INDEX IF EXISTS "idx_post_slug""#.to_string()]
);
}
#[test]
fn add_check_constraint_postgres_works() {
let snap = empty_snap();
let changes = vec![SchemaChange::AddCheckConstraint {
name: "ck_post_views_nonneg".into(),
table: "posts".into(),
expr: "views >= 0".into(),
}];
let out =
render_changes_split_with_dialect(&changes, &snap, &crate::sql::Postgres).unwrap();
assert_eq!(
out.immediate,
vec![
r#"ALTER TABLE "posts" ADD CONSTRAINT "ck_post_views_nonneg" CHECK (views >= 0)"#
.to_string()
]
);
}
#[cfg(feature = "mysql")]
#[test]
fn add_check_constraint_mysql_uses_backticks() {
let snap = empty_snap();
let changes = vec![SchemaChange::AddCheckConstraint {
name: "ck_post_views_nonneg".into(),
table: "posts".into(),
expr: "views >= 0".into(),
}];
let out = render_changes_split_with_dialect(&changes, &snap, &crate::sql::MySql).unwrap();
assert_eq!(
out.immediate,
vec![
"ALTER TABLE `posts` ADD CONSTRAINT `ck_post_views_nonneg` CHECK (views >= 0)"
.to_string()
]
);
}
#[test]
fn unique_drop_is_rendered_offline_only() {
let drop = vec![SchemaChange::AlterColumnUnique {
table: "t".into(),
column: "c".into(),
unique: false,
}];
let snap = alter_snap();
let offline = render_changes(&drop, &snap).unwrap();
assert_eq!(
offline,
vec![r#"ALTER TABLE "t" DROP CONSTRAINT "t_c_key""#.to_string()]
);
let runner =
render_changes_split_in_schema(&drop, &snap, &crate::sql::Postgres, None).unwrap();
assert!(runner.immediate.is_empty(), "{:?}", runner.immediate);
}
#[cfg(feature = "sqlite")]
#[test]
fn check_constraints_rebuild_on_sqlite() {
let mut snap = alter_snap();
snap.checks.push(super::super::snapshot::CheckSnapshot {
name: "ck_t_c".into(),
table: "t".into(),
expr: "c >= 0".into(),
});
let add = vec![SchemaChange::AddCheckConstraint {
name: "ck_t_c".into(),
table: "t".into(),
expr: "c >= 0".into(),
}];
let batch = render_changes_split_with_dialect(&add, &snap, &crate::sql::Sqlite).unwrap();
let sql = batch
.rebuild
.expect("a rebuild")
.statements(&crate::sql::Sqlite);
assert!(
sql[0].contains(r#"CONSTRAINT "ck_t_c" CHECK (c >= 0)"#),
"{sql:?}"
);
snap.checks.clear();
let drop = vec![SchemaChange::DropCheckConstraint {
name: "ck_t_c".into(),
table: "t".into(),
}];
let batch = render_changes_split_with_dialect(&drop, &snap, &crate::sql::Sqlite).unwrap();
let sql = batch
.rebuild
.expect("a rebuild")
.statements(&crate::sql::Sqlite);
assert!(!sql[0].contains("CHECK"), "{sql:?}");
let gone =
render_changes_split_with_dialect(&drop, &empty_snap(), &crate::sql::Sqlite).unwrap();
assert!(gone.rebuild.is_none() && gone.immediate.is_empty());
}
#[cfg(feature = "mysql")]
#[test]
fn drop_check_constraint_mysql_uses_drop_check() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropCheckConstraint {
name: "ck_x".into(),
table: "t".into(),
}];
let out = render_changes_split_with_dialect(&changes, &snap, &crate::sql::MySql).unwrap();
assert_eq!(
out.immediate,
vec!["ALTER TABLE `t` DROP CHECK `ck_x`".to_string()]
);
assert!(
!out.immediate[0].contains("IF EXISTS"),
"MySQL parses no `IF EXISTS` on a constraint drop"
);
}
#[test]
fn drop_check_constraint_postgres_keeps_if_exists() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropCheckConstraint {
name: "ck_x".into(),
table: "t".into(),
}];
let out =
render_changes_split_with_dialect(&changes, &snap, &crate::sql::Postgres).unwrap();
assert_eq!(
out.immediate,
vec![r#"ALTER TABLE "t" DROP CONSTRAINT IF EXISTS "ck_x""#.to_string()]
);
}
fn make_add_composite_fk() -> Vec<SchemaChange> {
vec![SchemaChange::AddCompositeFk {
table: "child".into(),
name: "fk_child_parent_composite".into(),
to: "parent".into(),
from: vec!["pa_id".into(), "pb_id".into()],
on: vec!["a_id".into(), "b_id".into()],
}]
}
#[test]
fn add_composite_fk_postgres_defers_with_ansi_quoting() {
let snap = empty_snap();
let out = render_changes_split_with_dialect(
&make_add_composite_fk(),
&snap,
&crate::sql::Postgres,
)
.unwrap();
assert_eq!(out.immediate.len(), 0);
assert_eq!(out.deferred_fks.len(), 1);
let stmt = &out.deferred_fks[0];
assert!(stmt.contains(r#"ALTER TABLE "child""#));
assert!(stmt.contains(r#"ADD CONSTRAINT "fk_child_parent_composite""#));
assert!(stmt.contains(r#"FOREIGN KEY ("pa_id", "pb_id")"#));
assert!(stmt.contains(r#"REFERENCES "parent" ("a_id", "b_id")"#));
}
#[test]
fn in_schema_render_qualifies_every_fk_target() {
let snap = empty_snap();
let mut changes = make_create_m2m();
changes.extend(make_add_composite_fk());
let out =
render_changes_split_in_schema(&changes, &snap, &crate::sql::Postgres, Some("t1"))
.unwrap();
assert!(out.deferred_fks[0].contains(r#"REFERENCES "t1"."posts" ("id")"#));
assert!(out.deferred_fks[1].contains(r#"REFERENCES "t1"."tags" ("id")"#));
assert!(out.deferred_fks[2].contains(r#"REFERENCES "t1"."parent" ("a_id", "b_id")"#));
}
#[test]
fn in_schema_render_qualifies_the_sequence_widen() {
let snap: SchemaSnapshot = serde_json::from_value(serde_json::json!({ "tables": [{
"name": "item", "model": "Item", "fields": [{
"name": "id", "column": "id", "ty": "i64", "nullable": false,
"primary_key": true, "auto": true }] }] }))
.unwrap();
let widen = [SchemaChange::AlterColumnType {
table: "item".into(),
column: "id".into(),
from: "i32".into(),
to: "i64".into(),
}];
let out = render_changes_split_in_schema(&widen, &snap, &crate::sql::Postgres, Some("t1"))
.unwrap();
let seq = out
.immediate
.iter()
.find(|s| s.contains("pg_get_serial_sequence"));
assert!(
seq.is_some_and(|s| s.contains(r#"pg_get_serial_sequence('"t1"."item"', 'id')"#)),
"{:?}",
out.immediate
);
assert!(
out.immediate[0].starts_with(r#"ALTER TABLE "t1"."item" ALTER COLUMN"#),
"and the ALTER beside it: {:?}",
out.immediate
);
}
#[test]
fn in_schema_render_qualifies_a_field_fk() {
let table: TableSnapshot = serde_json::from_value(serde_json::json!({
"name": "child", "model": "Child", "fields": [{
"name": "user_id", "column": "user_id", "ty": "i64",
"nullable": false, "primary_key": false,
"fk": { "kind": "fk", "to": "rustango_users", "on": "id" },
}],
}))
.unwrap();
let pg = &crate::sql::Postgres;
let fk = constraints_sql_from_snapshot(&table, pg, Some("t1")).unwrap();
assert!(
fk[0].contains(r#"REFERENCES "t1"."rustango_users" ("id")"#),
"{fk:?}"
);
let fk = constraints_sql_from_snapshot(&table, pg, None).unwrap();
assert!(
fk[0].contains(r#"REFERENCES "rustango_users" ("id")"#),
"{fk:?}"
);
}
#[cfg(feature = "mysql")]
#[test]
fn add_composite_fk_mysql_uses_backticks() {
let snap = empty_snap();
let out =
render_changes_split_with_dialect(&make_add_composite_fk(), &snap, &crate::sql::MySql)
.unwrap();
assert_eq!(out.deferred_fks.len(), 1);
let stmt = &out.deferred_fks[0];
assert!(stmt.contains("ALTER TABLE `child`"));
assert!(stmt.contains("ADD CONSTRAINT `fk_child_parent_composite`"));
assert!(stmt.contains("FOREIGN KEY (`pa_id`, `pb_id`)"));
assert!(stmt.contains("REFERENCES `parent` (`a_id`, `b_id`)"));
assert!(
!stmt.contains('"'),
"MySQL output must not contain ANSI quotes: {stmt}"
);
}
#[test]
fn drop_composite_fk_postgres_uses_ansi_quoting() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropCompositeFk {
table: "child".into(),
name: "fk_x".into(),
}];
let out =
render_changes_split_with_dialect(&changes, &snap, &crate::sql::Postgres).unwrap();
assert_eq!(
out.immediate,
vec![r#"ALTER TABLE "child" DROP CONSTRAINT IF EXISTS "fk_x""#.to_string()]
);
}
#[cfg(feature = "mysql")]
#[test]
fn drop_composite_fk_mysql_uses_drop_foreign_key() {
let snap = empty_snap();
let changes = vec![SchemaChange::DropCompositeFk {
table: "child".into(),
name: "fk_x".into(),
}];
let out = render_changes_split_with_dialect(&changes, &snap, &crate::sql::MySql).unwrap();
assert_eq!(
out.immediate,
vec!["ALTER TABLE `child` DROP FOREIGN KEY `fk_x`".to_string()]
);
assert!(
!out.immediate[0].contains("IF EXISTS"),
"MySQL parses no `IF EXISTS` on a constraint drop"
);
}
#[cfg(feature = "sqlite")]
#[test]
fn create_m2m_table_sqlite_inlines_fks_in_create() {
let snap = empty_snap();
let out = render_changes_split_with_dialect(&make_create_m2m(), &snap, &crate::sql::Sqlite)
.unwrap();
assert_eq!(out.immediate.len(), 1);
assert_eq!(
out.deferred_fks.len(),
0,
"SQLite must NOT defer FK constraints: {:?}",
out.deferred_fks
);
let stmt = &out.immediate[0];
assert!(stmt.contains(r#"CREATE TABLE "post_tags""#));
assert!(stmt.contains(r#"PRIMARY KEY ("post_id", "tag_id")"#));
assert!(stmt.contains(r#"CONSTRAINT "post_tags_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts" ("id") ON DELETE CASCADE"#));
assert!(stmt.contains(r#"CONSTRAINT "post_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags" ("id") ON DELETE CASCADE"#));
}
}