use std::collections::HashSet;
use std::path::Path;
use crate::core::{inventory, ModelEntry, ModelSchema};
use crate::sql::sqlx;
#[cfg(feature = "postgres")]
use crate::sql::sqlx::Row;
#[cfg(feature = "postgres")]
use crate::sql::sqlx::PgPool;
use super::file::{self, Migration, Operation};
use super::invert::invert;
use super::progress::{emit, MigrationEvent, MigrationObserver, Outcome};
use super::snapshot::SchemaSnapshot;
use super::{ddl, MigrateError};
pub const LEDGER_TABLE: &str = "__rustango_migrations__";
pub const SYSTEM_LEDGER_TABLE: &str = "__rustango_system_migrations__";
#[derive(Debug, Clone, Copy)]
pub struct Builder {
ledger: &'static str,
}
impl Default for Builder {
fn default() -> Self {
Self {
ledger: LEDGER_TABLE,
}
}
}
impl Builder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn ledger(mut self, name: &'static str) -> Self {
validate_ledger_name(name);
self.ledger = name;
self
}
#[must_use]
pub fn ledger_name(&self) -> &'static str {
self.ledger
}
#[cfg(feature = "postgres")]
pub async fn migrate(&self, pool: &PgPool, dir: &Path) -> Result<Vec<Migration>, MigrateError> {
migrate_with_ledger(pool, dir, self.ledger, None).await
}
#[cfg(feature = "postgres")]
pub async fn migrate_to(
&self,
pool: &PgPool,
dir: &Path,
target: &str,
) -> Result<Vec<Migration>, MigrateError> {
migrate_to_with_ledger(pool, dir, target, self.ledger).await
}
#[cfg(feature = "postgres")]
pub async fn migrate_embedded(
&self,
pool: &PgPool,
embedded: &[(&str, &str)],
) -> Result<Vec<Migration>, MigrateError> {
migrate_embedded_with_ledger(pool, embedded, self.ledger).await
}
#[cfg(feature = "postgres")]
pub async fn migrate_dry_run(
&self,
pool: &PgPool,
dir: &Path,
) -> Result<Vec<MigrationPreview>, MigrateError> {
migrate_dry_run_with_ledger(pool, dir, self.ledger).await
}
#[cfg(feature = "postgres")]
pub async fn downgrade(
&self,
pool: &PgPool,
dir: &Path,
steps: usize,
) -> Result<Vec<Migration>, MigrateError> {
downgrade_with_ledger(pool, dir, steps, self.ledger).await
}
#[cfg(feature = "postgres")]
pub async fn unapply(
&self,
pool: &PgPool,
dir: &Path,
name: &str,
) -> Result<Migration, MigrateError> {
unapply_with_ledger(pool, dir, name, self.ledger).await
}
#[cfg(feature = "postgres")]
pub async fn unapply_force(
&self,
pool: &PgPool,
dir: &Path,
name: &str,
) -> Result<Migration, MigrateError> {
unapply_force_with_ledger(pool, dir, name, self.ledger).await
}
#[cfg(feature = "postgres")]
pub async fn applied_set(&self, pool: &PgPool) -> Result<HashSet<String>, MigrateError> {
applied_set_for(pool, self.ledger).await
}
#[cfg(feature = "postgres")]
pub async fn ensure_ledger(&self, pool: &PgPool) -> Result<(), MigrateError> {
ensure_ledger_for(pool, self.ledger).await
}
}
fn validate_ledger_name(name: &str) {
let bytes = name.as_bytes();
let valid = !bytes.is_empty()
&& bytes.len() <= 63
&& (bytes[0].is_ascii_alphabetic() || bytes[0] == b'_')
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_');
assert!(
valid,
"Builder::ledger({name:?}) is not a valid SQL identifier — \
must match [A-Za-z_][A-Za-z0-9_]* and be ≤ 63 bytes"
);
}
#[cfg(feature = "postgres")]
const MIGRATE_LOCK_KEY: i64 = 0x5255_5354_4d49_4754;
#[must_use]
pub fn registered_models() -> Vec<&'static ModelSchema> {
inventory::iter::<ModelEntry>
.into_iter()
.map(|e| e.schema)
.collect()
}
fn bootstrap_models() -> Vec<&'static ModelSchema> {
registered_models()
.into_iter()
.filter(|m| m.managed)
.collect()
}
#[cfg(feature = "postgres")]
pub async fn apply_all(pool: &PgPool) -> Result<(), MigrateError> {
#[cfg(feature = "signals")]
use crate::signals::migrate::{
send_post_migrate, send_pre_migrate, PostMigrateContext, PreMigrateContext,
};
#[cfg(feature = "signals")]
send_pre_migrate(PreMigrateContext {
source: "apply_all",
})
.await;
let models = bootstrap_models();
for model in &models {
let sql = ddl::create_table_sql(model);
sqlx::query(&sql).execute(pool).await?;
}
for model in &models {
for sql in ddl::create_constraints_sql(model) {
sqlx::query(&sql).execute(pool).await?;
}
}
#[cfg(feature = "signals")]
send_post_migrate(PostMigrateContext {
source: "apply_all",
applied: Vec::new(),
})
.await;
Ok(())
}
#[cfg(feature = "postgres")]
pub async fn drop_all(pool: &PgPool) -> Result<(), MigrateError> {
for model in bootstrap_models() {
let sql = ddl::drop_table_sql(model, true, true);
sqlx::query(&sql).execute(pool).await?;
}
Ok(())
}
pub async fn apply_all_pool(pool: &crate::sql::Pool) -> Result<(), MigrateError> {
for model in bootstrap_models() {
ddl::check_on_delete(pool.dialect(), model).map_err(MigrateError::Validation)?;
}
#[cfg(feature = "signals")]
use crate::signals::migrate::{
send_post_migrate, send_pre_migrate, PostMigrateContext, PreMigrateContext,
};
#[cfg(feature = "signals")]
send_pre_migrate(PreMigrateContext {
source: "apply_all_pool",
})
.await;
let dialect = pool.dialect();
let models = bootstrap_models();
if let Some(sql) = ddl::ci_text_extension_sql(dialect, &models) {
crate::sql::raw_execute_pool(pool, sql, ::std::vec::Vec::new()).await?;
}
for model in &models {
let sql = ddl::create_table_sql_with_dialect(dialect, model);
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
for model in &models {
for sql in ddl::create_constraints_sql_with_dialect(dialect, model) {
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
}
for model in &models {
for sql in ddl::column_comment_statements_with_dialect(dialect, model) {
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
}
for model in &models {
for sql in ddl::table_comment_statements_with_dialect(dialect, model) {
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
}
#[cfg(feature = "signals")]
send_post_migrate(PostMigrateContext {
source: "apply_all_pool",
applied: Vec::new(),
})
.await;
Ok(())
}
pub async fn drop_all_pool(pool: &crate::sql::Pool) -> Result<(), MigrateError> {
let dialect = pool.dialect();
let cascade = dialect.name() == "postgres";
let models = bootstrap_models();
for model in &models {
for sql in ddl::drop_constraints_sql_with_dialect(dialect, model) {
let _ = crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await;
}
}
for model in &models {
let sql =
ddl::drop_table_sql_with_dialect(dialect, model, true, cascade);
crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
}
Ok(())
}
#[cfg(feature = "postgres")]
pub async fn migrate(pool: &PgPool, dir: &Path) -> Result<Vec<Migration>, MigrateError> {
Builder::default().migrate(pool, dir).await
}
#[cfg(feature = "postgres")]
pub async fn migrate_with_progress(
pool: &PgPool,
dir: &Path,
observer: &dyn MigrationObserver,
) -> Result<Vec<Migration>, MigrateError> {
migrate_with_ledger(pool, dir, LEDGER_TABLE, Some(observer)).await
}
#[cfg(feature = "postgres")]
async fn migrate_with_ledger(
pool: &PgPool,
dir: &Path,
ledger: &str,
observer: Option<&dyn MigrationObserver>,
) -> Result<Vec<Migration>, MigrateError> {
with_migrate_signals(
async {
ensure_ledger_for(pool, ledger).await?;
with_migrate_lock(pool, migrate_with_ledger_body(pool, dir, ledger, observer)).await
},
|newly| newly.iter().map(|m| m.name.clone()).collect(),
)
.await
}
#[cfg(feature = "postgres")]
#[cfg_attr(not(any(feature = "manage", feature = "tenancy")), allow(dead_code))]
pub(crate) async fn migrate_locked(
_: LockHeld,
pool: &PgPool,
dir: &Path,
observer: Option<&dyn MigrationObserver>,
) -> Result<Vec<Migration>, MigrateError> {
let enum_pool = crate::sql::Pool::Postgres(pool.clone());
create_ledger_locked(&enum_pool, LEDGER_TABLE).await?;
migrate_with_ledger_body(pool, dir, LEDGER_TABLE, observer).await
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Signals {
#[cfg_attr(
not(all(feature = "postgres", any(feature = "manage", feature = "tenancy"))),
allow(dead_code)
)]
Fire,
Skip,
}
pub(crate) async fn with_migrate_signals<T>(
run: impl std::future::Future<Output = Result<T, MigrateError>>,
applied: impl FnOnce(&T) -> Vec<String>,
) -> Result<T, MigrateError> {
#[cfg(feature = "signals")]
use crate::signals::migrate::{
send_post_migrate, send_pre_migrate, PostMigrateContext, PreMigrateContext,
};
#[cfg(feature = "signals")]
send_pre_migrate(PreMigrateContext { source: "migrate" }).await;
let out = run.await?;
#[cfg(feature = "signals")]
send_post_migrate(PostMigrateContext {
source: "migrate",
applied: applied(&out),
})
.await;
#[cfg(not(feature = "signals"))]
let _ = applied;
Ok(out)
}
#[cfg(feature = "postgres")]
async fn migrate_with_ledger_body(
pool: &PgPool,
dir: &Path,
ledger: &str,
observer: Option<&dyn MigrationObserver>,
) -> Result<Vec<Migration>, MigrateError> {
let all = file::list_dir(dir)?;
let applied = applied_set_for(pool, ledger).await?;
let pending = pending_migrations(&all, &applied);
let total = pending.len();
emit(observer, || MigrationEvent::Planned { total });
let mut newly = Vec::with_capacity(total);
for (i, mig) in pending.into_iter().enumerate() {
let index = i + 1;
emit(observer, || MigrationEvent::Started {
name: mig.name.clone(),
index,
total,
});
let began = std::time::Instant::now();
let step = reconcile_and_apply_pg(pool, &mig, &applied, ledger).await;
let outcome = match step {
Ok(outcome) => outcome,
Err(e) => {
emit(observer, || MigrationEvent::Failed {
name: mig.name.clone(),
index,
total,
error: e.to_string(),
});
return Err(e);
}
};
emit(observer, || MigrationEvent::Finished {
name: mig.name.clone(),
index,
total,
outcome,
elapsed: began.elapsed(),
});
newly.push(mig);
}
Ok(newly)
}
#[cfg(feature = "postgres")]
async fn reconcile_and_apply_pg(
pool: &PgPool,
mig: &Migration,
applied: &HashSet<String>,
ledger: &str,
) -> Result<Outcome, MigrateError> {
let enum_pool = crate::sql::Pool::Postgres(pool.clone());
Ok(match reconcile(&enum_pool, mig, applied, false).await? {
ReconcileAction::Fake => {
fake_apply_pool(&enum_pool, mig, ledger).await?;
Outcome::Faked
}
ReconcileAction::Run | ReconcileAction::RunPartial(_) => {
apply_one(pool, mig, ledger).await?;
Outcome::Ran
}
ReconcileAction::RunOutside(existing) => {
apply_one(pool, &outside_tables(mig, &existing), ledger).await?;
Outcome::RanPartial { skipped: existing }
}
})
}
fn backward_from(all: &[Migration], applied: &HashSet<String>, target: &str) -> Vec<Migration> {
all.iter()
.rev()
.filter(|m| m.name.as_str() > target && applied.contains(&m.name))
.cloned()
.collect()
}
fn forward_to(all: &[Migration], applied: &HashSet<String>, target: &str) -> Vec<Migration> {
pending_migrations(all, applied)
.into_iter()
.filter(|m| m.name.as_str() <= target)
.collect()
}
#[cfg(feature = "postgres")]
async fn with_migrate_lock<F, R>(pool: &PgPool, body: F) -> Result<R, MigrateError>
where
F: std::future::Future<Output = Result<R, MigrateError>>,
{
hold_migrate_lock(&crate::sql::Pool::Postgres(pool.clone()), Box::pin(body)).await
}
#[cfg(feature = "postgres")]
async fn pg_creation_schema(pool: &PgPool) -> Result<Option<String>, MigrateError> {
let pool = crate::sql::Pool::Postgres(pool.clone());
Ok(super::ensure::creation_schema(&pool).await?)
}
#[cfg(feature = "postgres")]
pub async fn applied_set(pool: &PgPool) -> Result<HashSet<String>, MigrateError> {
applied_set_for(pool, LEDGER_TABLE).await
}
#[cfg(feature = "postgres")]
async fn applied_set_for(pool: &PgPool, ledger: &str) -> Result<HashSet<String>, MigrateError> {
let rows = sqlx::query(&format!("SELECT name FROM {ledger}"))
.fetch_all(pool)
.await?;
let mut out = HashSet::with_capacity(rows.len());
for row in rows {
out.insert(row.try_get::<String, _>("name")?);
}
Ok(out)
}
#[cfg(feature = "postgres")]
pub async fn ensure_ledger(pool: &PgPool) -> Result<(), MigrateError> {
ensure_ledger_for(pool, LEDGER_TABLE).await
}
#[cfg(feature = "postgres")]
async fn ensure_ledger_for(pool: &PgPool, ledger: &str) -> Result<(), MigrateError> {
use crate::sql::{Dialect as _, Postgres};
let dialect = Postgres;
let mut tx = pool.begin().await?;
if let Some(xact_lock_sql) = dialect.acquire_xact_lock_sql() {
sqlx::query(&xact_lock_sql)
.bind(MIGRATE_LOCK_KEY)
.execute(&mut *tx)
.await?;
}
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS {ledger} (\
name TEXT PRIMARY KEY, \
applied_at {})",
dialect.timestamp_now_column()
);
sqlx::query(&create_sql).execute(&mut *tx).await?;
tx.commit().await?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationPreview {
pub name: String,
pub atomic: bool,
pub statements: Vec<String>,
}
#[cfg(feature = "postgres")]
pub async fn migrate_dry_run(
pool: &PgPool,
dir: &Path,
) -> Result<Vec<MigrationPreview>, MigrateError> {
Builder::default().migrate_dry_run(pool, dir).await
}
#[cfg(feature = "postgres")]
async fn migrate_dry_run_with_ledger(
pool: &PgPool,
dir: &Path,
ledger: &str,
) -> Result<Vec<MigrationPreview>, MigrateError> {
let pool = crate::sql::Pool::Postgres(pool.clone());
migrate_dry_run_pool_with_ledger(&pool, dir, ledger).await
}
pub fn sqlmigrate_one(
dir: &Path,
name: &str,
dialect: &dyn crate::sql::Dialect,
) -> Result<MigrationPreview, MigrateError> {
let all = file::list_dir(dir)?;
let mig = all.iter().find(|m| m.name == name).ok_or_else(|| {
MigrateError::Validation(format!("migration `{name}` not found in {}", dir.display()))
})?;
let before = prev_snapshot(&all, mig, dir)?;
preview_migration(mig, &before, dialect, LEDGER_TABLE)
}
struct Callbacks(Vec<&'static crate::migrate::callbacks::MigrationCallback>);
impl Callbacks {
fn resolve(ops: &[Operation]) -> Result<Self, MigrateError> {
ops.iter()
.filter_map(|op| match op {
Operation::Callback(c) => Some(c),
_ => None,
})
.map(|c| crate::migrate::callbacks::find(&c.name).ok_or_else(|| unregistered(&c.name)))
.collect::<Result<_, _>>()
.map(Self)
}
async fn run(
&self,
op: &crate::migrate::file::CallbackOp,
pool: crate::sql::Pool,
) -> Result<(), MigrateError> {
let cb = self
.0
.iter()
.find(|cb| cb.name == op.name)
.ok_or_else(|| unregistered(&op.name))?;
(cb.forward)(pool).await
}
}
fn unregistered(name: &str) -> MigrateError {
MigrateError::Validation(format!(
"migration callback `{name}` is not registered — \
call `rustango::register_migration_callback!(\"{name}\", …)` \
at startup",
))
}
fn callback_in_atomic(mig: &str, op: &crate::migrate::file::CallbackOp) -> MigrateError {
MigrateError::Validation(format!(
"{mig}: callback `{}` needs `\"atomic\": false` (#1626)",
op.name,
))
}
fn prev_snapshot(
all: &[Migration],
mig: &Migration,
dir: &Path,
) -> Result<SchemaSnapshot, MigrateError> {
let Some(prev_name) = &mig.prev else {
return Ok(SchemaSnapshot::default());
};
all.iter()
.find(|m| &m.name == prev_name)
.map(|m| m.snapshot.clone())
.ok_or_else(|| {
MigrateError::Validation(format!(
"migration `{}` declares prev=`{prev_name}` but that file is missing in {}",
mig.name,
dir.display()
))
})
}
fn preview_migration(
mig: &Migration,
before: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
ledger: &str,
) -> Result<MigrationPreview, MigrateError> {
let mut statements = Vec::new();
let mut deferred_fks: Vec<String> = Vec::new();
let mut live = LiveFks::new(before);
if mig.atomic {
statements.push("BEGIN".to_string());
}
for op in &mig.forward {
match op {
Operation::Schema(change) => {
let deferred = preview_schema_op(
change,
&mig.forward,
&mig.snapshot,
before,
dialect,
&mut live,
&mut statements,
)?;
deferred_fks.extend(deferred);
}
Operation::Data(d) => {
statements.push(d.sql.clone());
}
Operation::Callback(c) => {
statements.push(format!("-- RunPython: {}", c.name));
}
}
}
statements.extend(deferred_fks);
statements.push(format!(
"INSERT INTO {ledger} (name, applied_at) VALUES ('{}', <now>)",
mig.name.replace('\'', "''")
));
if mig.atomic {
statements.push("COMMIT".to_string());
}
Ok(MigrationPreview {
name: mig.name.clone(),
atomic: mig.atomic,
statements,
})
}
fn preview_schema_op(
change: &super::SchemaChange,
ops: &[Operation],
after: &SchemaSnapshot,
before: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
live: &mut LiveFks,
statements: &mut Vec<String>,
) -> Result<Vec<String>, MigrateError> {
let step = render_step(change, ops, after, dialect, None)?;
if let (Some(fk), Some(_)) = (&step.drop_fks, dialect.foreign_key_names_sql()) {
if let Some(name) = live.take(&fk.table, &fk.column) {
statements.extend(dialect.drop_foreign_key_sql(&fk.table, &name));
}
}
if let Some((table, column)) = &step.readded_now {
live.added(table, column);
}
if let (Some(u), Some(_)) = (&step.drop_unique, dialect.unique_index_names_sql()) {
let name = ddl::unique_constraint_name(&u.table, &u.column);
statements.extend(dialect.drop_unique_index_sql(&u.table, &name));
}
let earlier = ops
.iter()
.position(|op| matches!(op, Operation::Schema(c) if std::ptr::eq(c, change)))
.map_or(&[][..], |i| &ops[..i]);
let dropped = step
.index_fks
.as_ref()
.and_then(|ix| Some((ix, before.indexes.iter().find(|i| i.name == ix.index)?)));
let (mut drops, mut readd) = dropped
.and_then(|(ix, index)| {
let column = index.columns.first()?;
let field = before.table(&ix.table).and_then(|t| t.field(column));
let served = field.is_some_and(|f| f.unique || f.primary_key)
|| leading_indexes_at(before, earlier, &ix.table, column)
.iter()
.any(|name| *name != ix.index);
if served {
return None;
}
let names: Vec<String> = live
.get(&ix.table, column)
.filter(|_| !fk_deferred_earlier(&ix.table, column, earlier))
.into_iter()
.collect();
let (drops, readd) = ix.plan(column, &names, dialect);
if readd.is_some() {
live.added(&ix.table, column);
} else if !drops.is_empty() {
live.take(&ix.table, column);
}
Some((drops, readd.into_iter().collect::<Vec<_>>()))
})
.unwrap_or_default();
if let Some((ix, index)) = dropped {
let covers = |cols: &[String], fk: &[String]| cols.starts_with(fk);
let names: Vec<String> = before
.table(&ix.table)
.map_or(&[][..], |t| t.composite_fks.as_slice())
.iter()
.filter(|cf| covers(&index.columns, &cf.from))
.filter(|cf| {
!indexes_at(before, earlier, &ix.table)
.iter()
.any(|(n, cols)| *n != ix.index && covers(cols, &cf.from))
})
.map(|cf| cf.name.clone())
.collect();
let (d, r) = ix.plan_composites(&names, dialect);
drops.extend(d);
readd.extend(r);
}
statements.extend(drops);
statements.extend(step.batch.immediate);
if let Some(retry) = &step.retry {
statements.extend(retry.preview().map(|l| format!("-- {l}")));
}
statements.extend(readd);
if let Some(rebuild) = &step.batch.rebuild {
statements.extend(rebuild.statements(dialect));
statements.push(format!(
"-- re-create the indexes and triggers of {}",
rebuild.table()
));
}
live.follow(change);
Ok(step.batch.deferred_fks)
}
struct LiveFks(std::collections::HashMap<(String, String), String>);
impl LiveFks {
fn new(before: &SchemaSnapshot) -> Self {
let fields = before.tables.iter().flat_map(|t| {
t.fields
.iter()
.filter(|f| f.fk.is_some())
.map(|f| (t.name.clone(), f.column.clone()))
});
let junctions = before
.m2m_tables
.iter()
.flat_map(|m| [&m.src_col, &m.dst_col].map(|c| (m.through.clone(), c.clone())));
Self(
fields
.chain(junctions)
.map(|(t, c)| {
let name = ddl::fk_constraint_name(&t, &c);
((t, c), name)
})
.collect(),
)
}
fn get(&self, table: &str, column: &str) -> Option<String> {
self.0.get(&(table.to_owned(), column.to_owned())).cloned()
}
fn take(&mut self, table: &str, column: &str) -> Option<String> {
self.0.remove(&(table.to_owned(), column.to_owned()))
}
fn added(&mut self, table: &str, column: &str) {
let name = ddl::fk_constraint_name(table, column);
self.0.insert((table.to_owned(), column.to_owned()), name);
}
fn follow(&mut self, change: &super::SchemaChange) {
use super::SchemaChange as SC;
let rekey =
|map: &mut std::collections::HashMap<_, _>,
f: &dyn Fn(&(String, String)) -> Option<(String, String)>| {
let moved: Vec<_> = map
.keys()
.filter_map(|k| f(k).map(|n| (k.clone(), n)))
.collect();
for (old, new) in moved {
if let Some(name) = map.remove(&old) {
map.insert(new, name);
}
}
};
match change {
SC::RenameTable { old_name, new_name } => rekey(&mut self.0, &|(t, c)| {
(t == old_name).then(|| (new_name.clone(), c.clone()))
}),
SC::RenameColumn {
table,
old_column,
new_column,
} => rekey(&mut self.0, &|(t, c)| {
(t == table && c == old_column).then(|| (t.clone(), new_column.clone()))
}),
SC::DropColumn { table, column } => {
self.take(table, column);
}
SC::DropTable(t) | SC::DropM2MTable { through: t } => {
self.0.retain(|(table, _), _| table != t);
}
_ => {}
}
}
}
pub fn render_changes_between(
changes: &[super::SchemaChange],
before: &SchemaSnapshot,
after: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
) -> Result<Vec<String>, MigrateError> {
let ops: Vec<Operation> = changes.iter().cloned().map(Operation::Schema).collect();
let (mut statements, mut deferred) = (Vec::new(), Vec::new());
let mut live = LiveFks::new(before);
for op in &ops {
if let Operation::Schema(change) = op {
deferred.extend(preview_schema_op(
change,
&ops,
after,
before,
dialect,
&mut live,
&mut statements,
)?);
}
}
statements.extend(deferred);
Ok(statements)
}
#[cfg(feature = "postgres")]
async fn apply_atomic(pool: &PgPool, mig: &Migration, ledger: &str) -> Result<(), MigrateError> {
tracing::info!(migration = %mig.name, "applying (atomic)");
let schema = pg_creation_schema(pool).await?;
let mut tx = pool.begin().await?;
let mut deferred_fks: Vec<String> = Vec::new();
for op in &mig.forward {
match op {
Operation::Schema(change) => {
let step = render_step(
change,
&mig.forward,
&mig.snapshot,
&crate::sql::Postgres,
schema.as_deref(),
)?;
for stmt in pg_statements(&mut tx, &step).await? {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
deferred_fks.extend(step.batch.deferred_fks);
}
Operation::Data(d) => {
sqlx::query(&d.sql).execute(&mut *tx).await?;
}
Operation::Callback(c) => return Err(callback_in_atomic(&mig.name, c)),
}
}
for stmt in deferred_fks {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
sqlx::query(&ledger_insert_sql(&crate::sql::Postgres, ledger))
.bind(&mig.name)
.bind(chrono::Utc::now())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[cfg(feature = "postgres")]
pub async fn migrate_to(
pool: &PgPool,
dir: &Path,
target: &str,
) -> Result<Vec<Migration>, MigrateError> {
Builder::default().migrate_to(pool, dir, target).await
}
#[cfg(feature = "postgres")]
async fn migrate_to_with_ledger(
pool: &PgPool,
dir: &Path,
target: &str,
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
ensure_ledger_for(pool, ledger).await?;
with_migrate_lock(pool, async {
let all = file::list_dir(dir)?;
let applied = applied_set_for(pool, ledger).await?;
if target == "zero" {
return unapply_all_in_order(pool, dir, &all, &applied, ledger).await;
}
if !all.iter().any(|m| m.name == target) {
return Err(MigrateError::Validation(format!(
"target migration `{target}` not found in {}",
dir.display()
)));
}
let mut applied = applied;
let mut touched = Vec::new();
for mig in backward_from(&all, &applied, target) {
unapply_locked(pool, dir, &mig.name, ledger).await?;
applied.remove(&mig.name);
touched.push(mig);
}
for mig in forward_to(&all, &applied, target) {
reconcile_and_apply_pg(pool, &mig, &applied, ledger).await?;
touched.push(mig);
}
Ok(touched)
})
.await
}
#[cfg(feature = "postgres")]
pub async fn migrate_embedded(
pool: &PgPool,
embedded: &[(&str, &str)],
) -> Result<Vec<Migration>, MigrateError> {
Builder::default().migrate_embedded(pool, embedded).await
}
#[cfg(feature = "postgres")]
async fn migrate_embedded_with_ledger(
pool: &PgPool,
embedded: &[(&str, &str)],
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
ensure_ledger_for(pool, ledger).await?;
with_migrate_lock(pool, async {
let mut all: Vec<Migration> = Vec::with_capacity(embedded.len());
for (name, json) in embedded {
let mig = file::parse(json)?;
if mig.name != *name {
return Err(MigrateError::Validation(format!(
"embedded entry key `{name}` doesn't match migration `name` field `{}`",
mig.name,
)));
}
all.push(mig);
}
all.sort_by(|a, b| a.name.cmp(&b.name));
file::validate_chain(&all, "embedded slice")?;
let applied = applied_set_for(pool, ledger).await?;
let pending: Vec<Migration> = all
.into_iter()
.filter(|m| !applied.contains(&m.name))
.collect();
let mut newly = Vec::with_capacity(pending.len());
for mig in pending {
apply_one(pool, &mig, ledger).await?;
newly.push(mig);
}
Ok(newly)
})
.await
}
#[cfg(feature = "postgres")]
pub async fn downgrade(
pool: &PgPool,
dir: &Path,
steps: usize,
) -> Result<Vec<Migration>, MigrateError> {
Builder::default().downgrade(pool, dir, steps).await
}
#[cfg(feature = "postgres")]
async fn downgrade_with_ledger(
pool: &PgPool,
dir: &Path,
steps: usize,
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
if steps == 0 {
return Ok(Vec::new());
}
ensure_ledger_for(pool, ledger).await?;
with_migrate_lock(pool, async {
let all = file::list_dir(dir)?;
let applied = applied_set_for(pool, ledger).await?;
let applied_in_order: Vec<Migration> = all
.into_iter()
.filter(|m| applied.contains(&m.name))
.collect();
if applied_in_order.is_empty() {
return Ok(Vec::new());
}
let n = steps.min(applied_in_order.len());
let to_unapply: Vec<Migration> = applied_in_order.into_iter().rev().take(n).collect();
let mut touched = Vec::with_capacity(to_unapply.len());
for mig in to_unapply {
unapply_locked(pool, dir, &mig.name, ledger).await?;
touched.push(mig);
}
Ok(touched)
})
.await
}
#[cfg(feature = "postgres")]
async fn apply_one(pool: &PgPool, mig: &Migration, ledger: &str) -> Result<(), MigrateError> {
if mig.atomic {
apply_atomic(pool, mig, ledger).await
} else {
apply_loose(pool, mig, ledger).await
}
}
#[cfg(feature = "postgres")]
async fn unapply_all_in_order(
pool: &PgPool,
dir: &Path,
all: &[Migration],
applied: &HashSet<String>,
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
let mut to_unapply: Vec<Migration> = all
.iter()
.filter(|m| applied.contains(&m.name))
.cloned()
.collect();
to_unapply.reverse();
let mut touched = Vec::with_capacity(to_unapply.len());
for mig in to_unapply {
unapply_locked(pool, dir, &mig.name, ledger).await?;
touched.push(mig);
}
Ok(touched)
}
#[cfg(feature = "postgres")]
pub async fn unapply(pool: &PgPool, dir: &Path, name: &str) -> Result<Migration, MigrateError> {
Builder::default().unapply(pool, dir, name).await
}
#[cfg(feature = "postgres")]
async fn unapply_with_ledger(
pool: &PgPool,
dir: &Path,
name: &str,
ledger: &str,
) -> Result<Migration, MigrateError> {
ensure_ledger_for(pool, ledger).await?;
with_migrate_lock(pool, async {
check_is_head(pool, dir, name, ledger).await?;
unapply_locked(pool, dir, name, ledger).await
})
.await
}
#[cfg(feature = "postgres")]
pub async fn unapply_force(
pool: &PgPool,
dir: &Path,
name: &str,
) -> Result<Migration, MigrateError> {
Builder::default().unapply_force(pool, dir, name).await
}
#[cfg(feature = "postgres")]
async fn unapply_force_with_ledger(
pool: &PgPool,
dir: &Path,
name: &str,
ledger: &str,
) -> Result<Migration, MigrateError> {
ensure_ledger_for(pool, ledger).await?;
with_migrate_lock(pool, unapply_locked(pool, dir, name, ledger)).await
}
#[cfg(feature = "postgres")]
async fn check_is_head(
pool: &PgPool,
dir: &Path,
name: &str,
ledger: &str,
) -> Result<(), MigrateError> {
let applied = applied_set_for(pool, ledger).await?;
if !applied.contains(name) {
return Ok(());
}
let all = file::list_dir(dir)?;
let head = all
.iter()
.rev()
.find(|m| applied.contains(&m.name))
.map(|m| m.name.as_str());
match head {
Some(h) if h == name => Ok(()),
Some(h) => Err(MigrateError::Validation(format!(
"refusing to unapply `{name}` out of order: current head is `{h}`. \
Use `downgrade(pool, dir, n)` / `migrate_to(pool, dir, target)` for \
ordered rollback, or `unapply_force` to bypass.",
))),
None => Ok(()),
}
}
#[cfg(feature = "postgres")]
async fn unapply_locked(
pool: &PgPool,
dir: &Path,
name: &str,
ledger: &str,
) -> Result<Migration, MigrateError> {
let all = file::list_dir(dir)?;
let target = all
.iter()
.find(|m| m.name == name)
.cloned()
.ok_or_else(|| {
MigrateError::Validation(format!("migration `{name}` not found in {}", dir.display()))
})?;
let prev_snapshot = prev_snapshot(&all, &target, dir)?;
let inverted = invert(&target.forward, &prev_snapshot)?;
if target.atomic {
unapply_atomic(pool, &target, &inverted, &prev_snapshot, ledger).await?;
} else {
unapply_loose(pool, &target, &inverted, &prev_snapshot, ledger).await?;
}
Ok(target)
}
#[cfg(feature = "postgres")]
async fn unapply_atomic(
pool: &PgPool,
target: &Migration,
inverted: &[Operation],
snapshot: &SchemaSnapshot,
ledger: &str,
) -> Result<(), MigrateError> {
tracing::info!(migration = %target.name, "unapplying (atomic)");
let schema = pg_creation_schema(pool).await?;
let mut tx = pool.begin().await?;
let mut deferred_fks: Vec<String> = Vec::new();
for op in inverted {
match op {
Operation::Schema(change) => {
let step = render_step(
change,
inverted,
snapshot,
&crate::sql::Postgres,
schema.as_deref(),
)?;
for stmt in pg_statements(&mut tx, &step).await? {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
deferred_fks.extend(step.batch.deferred_fks);
}
Operation::Data(d) => {
sqlx::query(&d.sql).execute(&mut *tx).await?;
}
Operation::Callback(c) => return Err(callback_in_atomic(&target.name, c)),
}
}
for stmt in deferred_fks {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
sqlx::query(&format!("DELETE FROM {ledger} WHERE name = $1"))
.bind(&target.name)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[cfg(feature = "postgres")]
async fn unapply_loose(
pool: &PgPool,
target: &Migration,
inverted: &[Operation],
snapshot: &SchemaSnapshot,
ledger: &str,
) -> Result<(), MigrateError> {
let callbacks = Callbacks::resolve(inverted)?;
tracing::info!(migration = %target.name, "unapplying (non-atomic)");
let schema = pg_creation_schema(pool).await?;
let mut steps = render_steps(inverted, snapshot, &crate::sql::Postgres, schema.as_deref())?;
let mut deferred_fks: Vec<String> = Vec::new();
for op in inverted {
match op {
Operation::Schema(_) => {
let step = steps.next_step();
let mut conn = pool.acquire().await?;
for stmt in pg_statements(&mut conn, &step).await? {
sqlx::query(&stmt).execute(&mut *conn).await?;
}
deferred_fks.extend(step.batch.deferred_fks);
}
Operation::Data(d) => {
sqlx::query(&d.sql).execute(pool).await?;
}
Operation::Callback(c) => {
callbacks.run(c, pool.clone().into()).await?;
}
}
}
for stmt in deferred_fks {
sqlx::query(&stmt).execute(pool).await?;
}
sqlx::query(&format!("DELETE FROM {ledger} WHERE name = $1"))
.bind(&target.name)
.execute(pool)
.await?;
Ok(())
}
#[cfg(feature = "postgres")]
async fn apply_loose(pool: &PgPool, mig: &Migration, ledger: &str) -> Result<(), MigrateError> {
let callbacks = Callbacks::resolve(&mig.forward)?;
tracing::info!(migration = %mig.name, "applying (non-atomic)");
let schema = pg_creation_schema(pool).await?;
let mut steps = render_steps(
&mig.forward,
&mig.snapshot,
&crate::sql::Postgres,
schema.as_deref(),
)?;
let mut deferred_fks: Vec<String> = Vec::new();
for op in &mig.forward {
match op {
Operation::Schema(_) => {
let step = steps.next_step();
let mut conn = pool.acquire().await?;
for stmt in pg_statements(&mut conn, &step).await? {
sqlx::query(&stmt).execute(&mut *conn).await?;
}
deferred_fks.extend(step.batch.deferred_fks);
}
Operation::Data(d) => {
sqlx::query(&d.sql).execute(pool).await?;
}
Operation::Callback(c) => {
callbacks.run(c, pool.clone().into()).await?;
}
}
}
for stmt in deferred_fks {
sqlx::query(&stmt).execute(pool).await?;
}
sqlx::query(&ledger_insert_sql(&crate::sql::Postgres, ledger))
.bind(&mig.name)
.bind(chrono::Utc::now())
.execute(pool)
.await?;
Ok(())
}
pub async fn ensure_ledger_pool(pool: &crate::sql::Pool) -> Result<(), MigrateError> {
ensure_ledger_pool_with_ledger(pool, LEDGER_TABLE).await
}
pub async fn ensure_ledger_pool_with_ledger(
pool: &crate::sql::Pool,
ledger: &str,
) -> Result<(), MigrateError> {
with_migrate_lock_pool(pool, ledger, async { Ok(()) }).await
}
async fn create_ledger_locked(pool: &crate::sql::Pool, ledger: &str) -> Result<(), MigrateError> {
let timestamp_col = pool.dialect().timestamp_now_column();
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS {ledger} (\
name VARCHAR(255) PRIMARY KEY, \
applied_at {timestamp_col})"
);
crate::sql::raw_execute_pool(pool, &create_sql, ::std::vec::Vec::new()).await?;
Ok(())
}
pub(crate) async fn ledger_names(
_: LockHeld,
pool: &crate::sql::Pool,
ledger: &str,
) -> Result<HashSet<String>, MigrateError> {
create_ledger_locked(pool, ledger).await?;
applied_set_pool_with_ledger(pool, ledger).await
}
fn ledger_insert_sql(dialect: &dyn crate::sql::Dialect, ledger: &str) -> String {
let (p1, p2) = (dialect.placeholder(1), dialect.placeholder(2));
format!("INSERT INTO {ledger} (name, applied_at) VALUES ({p1}, {p2})")
}
pub async fn applied_set_pool(pool: &crate::sql::Pool) -> Result<HashSet<String>, MigrateError> {
applied_set_pool_with_ledger(pool, LEDGER_TABLE).await
}
pub async fn applied_set_pool_with_ledger(
pool: &crate::sql::Pool,
ledger: &str,
) -> Result<HashSet<String>, MigrateError> {
let sql = format!("SELECT name FROM {ledger}");
let rows: Vec<(String,)> = crate::sql::raw_query_pool(&sql, Vec::new(), pool).await?;
Ok(rows.into_iter().map(|(name,)| name).collect())
}
pub async fn migrate_pool(
pool: &crate::sql::Pool,
dir: &Path,
) -> Result<Vec<Migration>, MigrateError> {
migrate_pool_with_ledger(pool, dir, LEDGER_TABLE).await
}
pub async fn migrate_pool_with_progress(
pool: &crate::sql::Pool,
dir: &Path,
observer: &dyn MigrationObserver,
) -> Result<Vec<Migration>, MigrateError> {
migrate_pool_with_ledger_opts(
pool,
dir,
LEDGER_TABLE,
false,
ChainOrigin::OnDisk,
Some(observer),
)
.await
}
pub async fn migrate_pool_with_ledger_fake_initial_with_progress(
pool: &crate::sql::Pool,
dir: &Path,
ledger: &str,
observer: &dyn MigrationObserver,
) -> Result<Vec<Migration>, MigrateError> {
migrate_pool_with_ledger_opts(pool, dir, ledger, true, ChainOrigin::OnDisk, Some(observer))
.await
}
pub async fn migrate_pool_with_ledger(
pool: &crate::sql::Pool,
dir: &Path,
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
migrate_pool_with_ledger_opts(pool, dir, ledger, false, ChainOrigin::OnDisk, None).await
}
pub async fn migrate_pool_with_ledger_fake_initial(
pool: &crate::sql::Pool,
dir: &Path,
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
migrate_pool_with_ledger_opts(pool, dir, ledger, true, ChainOrigin::OnDisk, None).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ChainOrigin {
OnDisk,
Regenerated,
}
pub(crate) async fn migrate_system_chain(
_: LockHeld,
pool: &crate::sql::Pool,
chain: &super::make::SystemChain,
run_dir: &Path,
observer: Option<&dyn MigrationObserver>,
) -> Result<Vec<Migration>, MigrateError> {
let origin = chain.origin();
create_ledger_locked(pool, SYSTEM_LEDGER_TABLE).await?;
migrate_pool_body(pool, run_dir, SYSTEM_LEDGER_TABLE, true, origin, observer).await
}
pub(crate) async fn migrate_pool_locked(
_: LockHeld,
pool: &crate::sql::Pool,
dir: &Path,
observer: Option<&dyn MigrationObserver>,
) -> Result<Vec<Migration>, MigrateError> {
create_ledger_locked(pool, LEDGER_TABLE).await?;
migrate_pool_body(
pool,
dir,
LEDGER_TABLE,
false,
ChainOrigin::OnDisk,
observer,
)
.await
}
async fn migrate_pool_with_ledger_opts(
pool: &crate::sql::Pool,
dir: &Path,
ledger: &str,
fake_initial: bool,
origin: ChainOrigin,
observer: Option<&dyn MigrationObserver>,
) -> Result<Vec<Migration>, MigrateError> {
with_migrate_lock_pool(
pool,
ledger,
migrate_pool_body(pool, dir, ledger, fake_initial, origin, observer),
)
.await
}
async fn migrate_pool_body(
pool: &crate::sql::Pool,
dir: &Path,
ledger: &str,
fake_initial: bool,
origin: ChainOrigin,
observer: Option<&dyn MigrationObserver>,
) -> Result<Vec<Migration>, MigrateError> {
let all = file::list_dir(dir)?;
let mut applied = applied_set_pool_with_ledger(pool, ledger).await?;
if origin == ChainOrigin::Regenerated && !applied.is_empty() {
let mut unfixed = Vec::new();
for mig in &all {
let failed = converge_regenerated(pool, mig).await?;
if failed.is_empty() {
if applied.insert(mig.name.clone()) {
fake_apply_pool(pool, mig, ledger).await?;
}
} else {
unfixed.extend(failed);
}
}
if !unfixed.is_empty() {
return Err(MigrateError::Validation(format!(
"the framework schema is missing objects `migrate` cannot add; \
add them by hand, then rerun `migrate`:\n - {}",
unfixed.join("\n - ")
)));
}
}
let pending = pending_migrations(&all, &applied);
let total = pending.len();
emit(observer, || MigrationEvent::Planned { total });
let mut newly = Vec::with_capacity(total);
for (i, mig) in pending.into_iter().enumerate() {
let index = i + 1;
emit(observer, || MigrationEvent::Started {
name: mig.name.clone(),
index,
total,
});
let began = std::time::Instant::now();
let outcome = reconcile_and_apply(pool, &mig, &applied, ledger, fake_initial).await;
let outcome = match outcome {
Ok(outcome) => outcome,
Err(e) => {
emit(observer, || MigrationEvent::Failed {
name: mig.name.clone(),
index,
total,
error: e.to_string(),
});
return Err(e);
}
};
emit(observer, || MigrationEvent::Finished {
name: mig.name.clone(),
index,
total,
outcome,
elapsed: began.elapsed(),
});
newly.push(mig);
}
Ok(newly)
}
async fn reconcile_and_apply(
pool: &crate::sql::Pool,
mig: &Migration,
applied: &HashSet<String>,
ledger: &str,
fake_initial: bool,
) -> Result<Outcome, MigrateError> {
match reconcile(pool, mig, applied, fake_initial).await? {
ReconcileAction::Fake => {
fake_apply_pool(pool, mig, ledger).await?;
Ok(Outcome::Faked)
}
other => {
let (effective, outcome) = match other {
ReconcileAction::RunPartial(existing) => (
std::borrow::Cow::Owned(without_tables(mig, &existing)),
Outcome::RanPartial { skipped: existing },
),
ReconcileAction::RunOutside(existing) => (
std::borrow::Cow::Owned(outside_tables(mig, &existing)),
Outcome::RanPartial { skipped: existing },
),
_ => (std::borrow::Cow::Borrowed(mig), Outcome::Ran),
};
apply_one_pool(pool, &effective, ledger).await?;
Ok(outcome)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ReconcileAction {
Run,
Fake,
RunPartial(Vec<String>),
RunOutside(Vec<String>),
}
async fn reconcile(
pool: &crate::sql::Pool,
mig: &Migration,
applied: &HashSet<String>,
fake_initial: bool,
) -> Result<ReconcileAction, MigrateError> {
if !mig.replaces.is_empty() {
let present = mig.replaces.iter().filter(|n| applied.contains(*n)).count();
if present == mig.replaces.len() {
tracing::info!(
migration = %mig.name,
replaces = ?mig.replaces,
"reconciling squash — every replaced migration is already applied; \
recording it and tombstoning them without running DDL"
);
return Ok(ReconcileAction::Fake);
}
if present > 0 {
let missing: Vec<&str> = mig
.replaces
.iter()
.filter(|n| !applied.contains(*n))
.map(String::as_str)
.collect();
return Err(MigrateError::Validation(format!(
"cannot reconcile squash `{}`: it replaces {} migration(s) but only {} are \
recorded as applied (missing: {}). The database is in a partial state — \
resolve it by hand (see `migrate --fake <name>`) rather than guessing.",
mig.name,
mig.replaces.len(),
present,
missing.join(", "),
)));
}
let targets = create_table_targets(mig);
if targets.is_empty() {
return Ok(ReconcileAction::Run);
}
let existing = count_existing_tables(pool, &targets).await;
if existing == targets.len() {
let outside: Vec<&super::SchemaChange> = mig
.forward
.iter()
.filter_map(|op| match op {
Operation::Schema(c) if !targets.iter().any(|t| t == c.table()) => Some(c),
_ => None,
})
.collect();
let (present, absent) = applied_counts(pool, &outside).await?;
if present > 0 && absent > 0 {
return Err(MigrateError::Validation(format!(
"cannot reconcile squash `{}`: its tables exist and {present} of its other \
changes are applied, {absent} are not. The database is in a partial \
state — resolve it by hand (see `migrate --fake <name>`).",
mig.name
)));
}
if absent > 0 {
if mig
.forward
.iter()
.any(|op| !matches!(op, Operation::Schema(_)))
{
return Err(MigrateError::Validation(format!(
"cannot reconcile squash `{}`: its tables exist but its other changes \
do not, and whether its data operations ran is unknown. Resolve it \
by hand (see `migrate --fake <name>`).",
mig.name
)));
}
tracing::info!(
migration = %mig.name,
tables = %targets.join(", "),
"reconciling squash — its tables already exist (cross-ledger); \
running only its changes to other tables"
);
return Ok(ReconcileAction::RunOutside(targets));
}
tracing::info!(
migration = %mig.name,
tables = %targets.join(", "),
"reconciling squash — its tables already exist (cross-ledger \
fake-initial); recording it without running DDL"
);
return Ok(ReconcileAction::Fake);
}
if existing > 0 {
return Err(MigrateError::Validation(format!(
"cannot reconcile squash `{}`: {} of its {} tables already exist but the rest \
do not. The database is in a partial state — resolve it by hand (see \
`migrate --fake <name>`) rather than guessing.",
mig.name,
existing,
targets.len(),
)));
}
return Ok(ReconcileAction::Run);
}
if fake_initial {
if let Some(tables) = create_only_tables(mig) {
let existing: Vec<String> = {
let mut present = Vec::new();
for t in &tables {
if count_existing_tables(pool, std::slice::from_ref(t)).await == 1 {
present.push(t.clone());
}
}
present
};
if existing.len() == tables.len() {
tracing::warn!(
migration = %mig.name,
tables = %tables.join(", "),
"fake-initial: tables already exist (pre-migration \
`ensure_table` era) — recorded as applied without \
running its CREATE TABLE; existing data left intact"
);
return Ok(ReconcileAction::Fake);
}
if !existing.is_empty() {
tracing::warn!(
migration = %mig.name,
existing = %existing.join(", "),
"fake-initial: some framework tables already exist — creating \
only the missing ones and leaving the existing untouched"
);
return Ok(ReconcileAction::RunPartial(existing));
}
}
}
Ok(ReconcileAction::Run)
}
pub(crate) fn without_tables(mig: &Migration, existing: &[String]) -> Migration {
use super::diff::SchemaChange as SC;
let skip = |t: &String| existing.iter().any(|e| e == t);
let forward = mig
.forward
.iter()
.filter(|op| match op {
Operation::Schema(SC::CreateTable(t)) => !skip(t),
Operation::Schema(SC::CreateIndex { table, .. }) => !skip(table),
Operation::Schema(SC::CreateM2MTable { through, .. }) => !skip(through),
_ => true,
})
.cloned()
.collect();
Migration {
forward,
..mig.clone()
}
}
async fn applied_counts(
pool: &crate::sql::Pool,
changes: &[&super::SchemaChange],
) -> Result<(usize, usize), MigrateError> {
use super::SchemaChange as SC;
let schema = super::ensure::creation_schema(pool)
.await?
.unwrap_or_default();
let (mut present, mut absent) = (0, 0);
for change in changes {
let applied = match change {
SC::CreateTable(t) | SC::CreateM2MTable { through: t, .. } => {
Some(table_exists_here(pool, t).await)
}
SC::DropTable(t) | SC::DropM2MTable { through: t } => {
Some(!table_exists_here(pool, t).await)
}
SC::AddColumn { table, column } => Some(
super::ensure::live_columns(pool, table)
.await?
.contains(column),
),
SC::DropColumn { table, column } => Some(
!super::ensure::live_columns(pool, table)
.await?
.contains(column),
),
SC::CreateIndex { name, table, .. } => Some(
!super::inspectdb::index_columns(pool, &schema, table, name)
.await?
.is_empty(),
),
SC::DropIndex { name, table } => Some(
super::inspectdb::index_columns(pool, &schema, table, name)
.await?
.is_empty(),
),
_ => None,
};
match applied {
Some(true) => present += 1,
Some(false) => absent += 1,
None => {}
}
}
Ok((present, absent))
}
fn outside_tables(mig: &Migration, existing: &[String]) -> Migration {
let forward = mig
.forward
.iter()
.filter(|op| match op {
Operation::Schema(c) => !existing.iter().any(|e| e == c.table()),
_ => false,
})
.cloned()
.collect();
Migration {
forward,
..mig.clone()
}
}
fn pending_migrations(all: &[Migration], applied: &HashSet<String>) -> Vec<Migration> {
migration_states(all, applied)
.filter(|(_, s)| *s == MigrationState::Pending)
.map(|(m, _)| m.clone())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationState {
Applied,
Replaced,
Pending,
}
pub(crate) fn migration_states<'a>(
all: &'a [Migration],
applied: &'a HashSet<String>,
) -> impl Iterator<Item = (&'a Migration, MigrationState)> + 'a {
let superseded: HashSet<&str> = all
.iter()
.filter(|m| applied.contains(&m.name))
.flat_map(|m| m.replaces.iter().map(String::as_str))
.collect();
all.iter().map(move |m| {
let state = if applied.contains(&m.name) {
MigrationState::Applied
} else if superseded.contains(m.name.as_str()) {
MigrationState::Replaced
} else {
MigrationState::Pending
};
(m, state)
})
}
pub(crate) async fn applied_or_empty(
pool: &crate::sql::Pool,
ledger: &str,
) -> Result<HashSet<String>, MigrateError> {
if try_table_exists_here(pool, ledger).await? {
applied_set_pool_with_ledger(pool, ledger).await
} else {
Ok(HashSet::new())
}
}
pub(crate) async fn preview_pending(
pool: &crate::sql::Pool,
dir: &Path,
all: &[Migration],
scope: Option<super::MigrationScope>,
ledger: &str,
fake_initial: bool,
owned: &[String],
) -> Result<Vec<MigrationPreview>, MigrateError> {
let applied = applied_or_empty(pool, ledger).await?;
let mut out = Vec::new();
for mig in pending_migrations(all, &applied) {
if scope.is_some_and(|s| s != mig.scope) {
continue;
}
let before = prev_snapshot(all, &mig, dir)?;
let mig = without_tables(&mig, owned);
let effective = match reconcile(pool, &mig, &applied, fake_initial).await? {
ReconcileAction::Fake => {
out.push(faked_preview(&mig, ledger, pool.dialect()));
continue;
}
ReconcileAction::Run => mig,
ReconcileAction::RunPartial(existing) => without_tables(&mig, &existing),
ReconcileAction::RunOutside(existing) => outside_tables(&mig, &existing),
};
out.push(preview_migration(
&effective,
&before,
pool.dialect(),
ledger,
)?);
}
Ok(out)
}
fn faked_preview(
mig: &Migration,
ledger: &str,
dialect: &dyn crate::sql::Dialect,
) -> MigrationPreview {
let literal = |s: &str| format!("'{}'", s.replace('\'', "''"));
let (insert, _) = fake_statements(dialect, ledger, &literal(&mig.name));
let mut statements = vec![
"-- its end state is already present: recorded without running its SQL".to_owned(),
insert,
];
for r in &mig.replaces {
statements.push(fake_statements(dialect, ledger, &literal(r)).1);
}
MigrationPreview {
name: mig.name.clone(),
atomic: false,
statements,
}
}
fn create_table_targets(mig: &Migration) -> Vec<String> {
use super::diff::SchemaChange as SC;
mig.forward
.iter()
.filter_map(|op| match op {
Operation::Schema(SC::CreateTable(t) | SC::CreateM2MTable { through: t, .. }) => {
Some(t.clone())
}
_ => None,
})
.collect()
}
fn create_only_tables(mig: &Migration) -> Option<Vec<String>> {
use super::diff::SchemaChange as SC;
let mut tables: Vec<String> = Vec::new();
let mut targeted: Vec<&str> = Vec::new();
for op in &mig.forward {
match op {
Operation::Schema(SC::CreateTable(t)) => tables.push(t.clone()),
Operation::Schema(SC::CreateIndex { table, .. }) => targeted.push(table.as_str()),
Operation::Schema(SC::CreateM2MTable { through, .. }) => tables.push(through.clone()),
_ => return None,
}
}
if tables.is_empty() {
return None;
}
if targeted
.iter()
.any(|t| !tables.iter().any(|created| created == t))
{
return None;
}
Some(tables)
}
async fn converge_regenerated(
pool: &crate::sql::Pool,
mig: &Migration,
) -> Result<Vec<String>, MigrateError> {
use super::diff::SchemaChange as SC;
let mut groups: Vec<Vec<SC>> = Vec::new();
for op in &mig.forward {
let Operation::Schema(change) = op else {
continue;
};
let table = match change {
SC::CreateTable(t) => t,
SC::CreateM2MTable { through, .. } => through,
_ => continue,
};
if !table_exists_here(pool, table).await {
let indexes = mig.forward.iter().filter_map(|op| match op {
Operation::Schema(c @ SC::CreateIndex { table: t, .. }) if t == table => {
Some(c.clone())
}
_ => None,
});
groups.push(std::iter::once(change.clone()).chain(indexes).collect());
continue;
}
let (SC::CreateTable(_), Some(snap)) = (change, mig.snapshot.table(table)) else {
continue;
};
let live = super::ensure::live_columns(pool, table).await?;
groups.extend(
snap.fields
.iter()
.filter(|f| !live.contains(&f.column))
.map(|f| {
vec![SC::AddColumn {
table: table.clone(),
column: f.column.clone(),
}]
}),
);
groups.extend(
mig.snapshot
.indexes
.iter()
.filter(|i| &i.table == table)
.map(|i| vec![super::diff::create_index(i)]),
);
for column in live.iter().filter(|c| snap.field(c).is_none()) {
tracing::warn!(
target: "rustango::migrate",
"`{table}.{column}` is not in the framework schema; drop it by hand \
if it is NOT NULL with no default, or inserts fail"
);
}
}
let failed = super::ensure::converge_groups(pool, &mig.snapshot, &groups).await?;
warn_on_index_clash(pool, mig).await?;
Ok(failed)
}
async fn warn_on_index_clash(pool: &crate::sql::Pool, mig: &Migration) -> Result<(), MigrateError> {
let schema = super::ensure::creation_schema(pool)
.await?
.unwrap_or_default();
for idx in &mig.snapshot.indexes {
let live = super::inspectdb::index_columns(pool, &schema, &idx.table, &idx.name).await?;
let Some((table, _)) = live.first() else {
continue;
};
let columns: Vec<&str> = live.iter().map(|(_, c)| c.as_str()).collect();
if table != &idx.table || columns != idx.columns {
tracing::warn!(
target: "rustango::migrate",
"index `{}` is on `{table}` ({}), not `{}` ({}); drop or rename it by hand",
idx.name,
columns.join(", "),
idx.table,
idx.columns.join(", "),
);
}
}
Ok(())
}
async fn count_existing_tables(pool: &crate::sql::Pool, tables: &[String]) -> usize {
let mut n = 0;
for t in tables {
if table_exists_here(pool, t).await {
n += 1;
}
}
n
}
pub(crate) async fn table_exists_here(pool: &crate::sql::Pool, table: &str) -> bool {
try_table_exists_here(pool, table).await.unwrap_or(false)
}
pub(crate) async fn try_table_exists_here(
pool: &crate::sql::Pool,
table: &str,
) -> Result<bool, sqlx::Error> {
Ok(match pool {
#[cfg(feature = "postgres")]
crate::sql::Pool::Postgres(pg) => pg_table_exists(pg, None, table).await?,
#[cfg(feature = "mysql")]
crate::sql::Pool::Mysql(my) => {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM information_schema.tables \
WHERE table_schema = DATABASE() AND table_name = ?",
)
.bind(table)
.fetch_one(my)
.await?
> 0
}
#[cfg(feature = "sqlite")]
crate::sql::Pool::Sqlite(sq) => {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.bind(table)
.fetch_one(sq)
.await?
> 0
}
})
}
pub(crate) async fn try_table_resolves(
pool: &crate::sql::Pool,
table: &str,
) -> Result<bool, sqlx::Error> {
Ok(match pool {
#[cfg(feature = "postgres")]
crate::sql::Pool::Postgres(pg) => {
sqlx::query_scalar::<_, bool>("SELECT to_regclass(quote_ident($1)) IS NOT NULL")
.bind(table)
.fetch_one(pg)
.await?
}
#[cfg(feature = "mysql")]
crate::sql::Pool::Mysql(_) => try_table_exists_here(pool, table).await?,
#[cfg(feature = "sqlite")]
crate::sql::Pool::Sqlite(sq) => {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?",
)
.bind(table)
.fetch_one(sq)
.await?
> 0
}
})
}
#[cfg(all(feature = "tenancy", feature = "media", feature = "postgres"))]
pub(crate) async fn try_table_exists_in_schema(
pg: &sqlx::PgPool,
schema: &str,
table: &str,
) -> Result<bool, sqlx::Error> {
pg_table_exists(pg, Some(schema), table).await
}
#[cfg(feature = "postgres")]
async fn pg_table_exists(
pg: &sqlx::PgPool,
schema: Option<&str>,
table: &str,
) -> Result<bool, sqlx::Error> {
let n = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = COALESCE($1::name, current_schema()) AND c.relname = $2 \
AND c.relkind IN ('r', 'p', 'v', 'f')",
)
.bind(schema)
.bind(table)
.fetch_one(pg)
.await?;
Ok(n > 0)
}
fn fake_statements(
dialect: &dyn crate::sql::Dialect,
ledger: &str,
name: &str,
) -> (String, String) {
let table = dialect.quote_ident(ledger);
let name_col = dialect.quote_ident("name");
let conflict_tail = dialect.insert_on_conflict_skip(&[&name_col]);
(
format!("INSERT INTO {table} ({name_col}) VALUES ({name}) {conflict_tail}"),
format!("DELETE FROM {table} WHERE {name_col} = {name}"),
)
}
async fn fake_apply_pool(
pool: &crate::sql::Pool,
mig: &Migration,
ledger: &str,
) -> Result<(), MigrateError> {
let (insert, delete) = {
let dialect = pool.dialect();
fake_statements(dialect, ledger, &dialect.placeholder(1))
};
crate::sql::raw_execute_pool(
pool,
&insert,
vec![crate::core::SqlValue::String(mig.name.clone())],
)
.await
.map_err(|e| {
MigrateError::Validation(format!(
"fake-apply: ledger insert for `{}` failed: {e}",
mig.name
))
})?;
if mig.replaces.is_empty() {
return Ok(());
}
for replaced in &mig.replaces {
crate::sql::raw_execute_pool(
pool,
&delete,
vec![crate::core::SqlValue::String(replaced.clone())],
)
.await
.map_err(|e| {
MigrateError::Validation(format!(
"fake-apply: tombstoning `{replaced}` (replaced by `{}`) failed: {e}",
mig.name
))
})?;
}
Ok(())
}
async fn with_migrate_lock_pool<F, R>(
pool: &crate::sql::Pool,
ledger: &str,
body: F,
) -> Result<R, MigrateError>
where
F: std::future::Future<Output = Result<R, MigrateError>>,
{
hold_migrate_lock(
pool,
Box::pin(async {
create_ledger_locked(pool, ledger).await?;
body.await
}),
)
.await
}
#[derive(Clone, Copy)]
pub(crate) struct LockHeld(());
pub(crate) async fn with_migrate_lock_held<F, Fut, R>(
pool: &crate::sql::Pool,
body: F,
) -> Result<R, MigrateError>
where
F: FnOnce(LockHeld) -> Fut,
Fut: std::future::Future<Output = Result<R, MigrateError>>,
{
hold_migrate_lock(pool, Box::pin(body(LockHeld(())))).await
}
tokio::task_local! {
static LOCK_HELD: ();
static LOCK_TIMEOUT: std::time::Duration;
}
pub async fn with_lock_timeout<F: std::future::Future>(
timeout: std::time::Duration,
fut: F,
) -> F::Output {
LOCK_TIMEOUT.scope(timeout, fut).await
}
#[cfg_attr(not(feature = "mysql"), allow(dead_code))]
fn mysql_lock_taken(got: Option<i64>) -> Result<bool, sqlx::Error> {
got.map(|n| n == 1).ok_or_else(|| {
sqlx::Error::Protocol("GET_LOCK returned NULL: the server could not take the lock".into())
})
}
fn refuse_nested_lock() -> Result<(), MigrateError> {
if LOCK_HELD.try_with(|()| ()).is_ok() {
return Err(MigrateError::Validation(
"migrate called while this task holds the migrate lock; \
run it after the outer migrate returns"
.into(),
));
}
Ok(())
}
async fn hold_migrate_lock<F, R>(
pool: &crate::sql::Pool,
body: std::pin::Pin<Box<F>>,
) -> Result<R, MigrateError>
where
F: std::future::Future<Output = Result<R, MigrateError>>,
{
refuse_nested_lock()?;
let body = LOCK_HELD.scope((), body);
let dialect = pool.dialect();
let (Some(acquire), Some(release)) = (
dialect.acquire_session_lock_sql(),
dialect.release_session_lock_sql(),
) else {
return body.await;
};
match pool {
#[cfg(feature = "postgres")]
crate::sql::Pool::Postgres(pg) => {
let opts = pg.connect_options();
let connect_timeout = pg.options().get_acquire_timeout();
let mut held =
LockSession::<sqlx::PgConnection>::wait(&opts, connect_timeout, |conn| {
let sql = acquire.clone();
Box::pin(async move {
sqlx::query_scalar::<_, bool>(&sql)
.bind(MIGRATE_LOCK_KEY)
.fetch_one(&mut *conn)
.await
})
})
.await?;
let result = body.await;
let _ = sqlx::query(&release)
.bind(MIGRATE_LOCK_KEY)
.execute(held.conn())
.await;
held.close().await;
result
}
#[cfg(feature = "mysql")]
crate::sql::Pool::Mysql(my) => {
const NAME: &str = "rustango_migrate_";
let opts = my.connect_options();
let connect_timeout = my.options().get_acquire_timeout();
let mut held =
LockSession::<sqlx::MySqlConnection>::wait(&opts, connect_timeout, |conn| {
let sql = acquire.clone();
Box::pin(async move {
let got: Option<i64> = sqlx::query_scalar(&sql)
.bind(NAME)
.fetch_one(&mut *conn)
.await?;
mysql_lock_taken(got)
})
})
.await?;
let result = body.await;
let _ = sqlx::query(&release).bind(NAME).execute(held.conn()).await;
held.close().await;
result
}
#[cfg(feature = "sqlite")]
crate::sql::Pool::Sqlite(sq) => {
let Some(opts) = sqlite_lock_options(sq).await? else {
return body.await;
};
let connect_timeout = sq.options().get_acquire_timeout();
let held =
LockSession::<sqlx::SqliteConnection>::wait(&opts, connect_timeout, |conn| {
let sql = acquire.clone();
Box::pin(async move {
let busy = |e: &sqlx::Error| {
e.as_database_error()
.and_then(|d| d.code()?.parse::<i32>().ok())
.is_some_and(|c| c & 0xff == 5)
};
match sqlx::query(&sql).execute(&mut *conn).await {
Ok(_) => Ok(true),
Err(e) if busy(&e) => Ok(false),
Err(e) => Err(e),
}
})
})
.await;
let mut held = match held {
Ok(held) => held,
Err(MigrateError::Driver(e)) => {
tracing::warn!(
target: "rustango::migrate",
"migrating without the SQLite migrate lock: {e}"
);
return body.await;
}
Err(e) => return Err(e),
};
let result = body.await;
let _ = sqlx::query(&release).execute(held.conn()).await;
held.close().await;
result
}
}
}
#[cfg(feature = "sqlite")]
async fn sqlite_lock_options(
sq: &sqlx::SqlitePool,
) -> Result<Option<sqlx::sqlite::SqliteConnectOptions>, MigrateError> {
let file: String =
sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main'")
.fetch_one(sq)
.await?;
if file.is_empty() {
return Ok(None);
}
Ok(Some(
sqlx::sqlite::SqliteConnectOptions::new()
.filename(format!("{file}-migrate.lock"))
.create_if_missing(true)
.busy_timeout(std::time::Duration::ZERO),
))
}
struct LockSession<C: sqlx::Connection>(C);
type TryLock<'c> =
std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, sqlx::Error>> + Send + 'c>>;
impl<C: sqlx::Connection> LockSession<C> {
async fn wait<F>(
opts: &C::Options,
connect_timeout: std::time::Duration,
try_lock: F,
) -> Result<Self, MigrateError>
where
F: for<'c> Fn(&'c mut C) -> TryLock<'c>,
{
use sqlx::ConnectOptions as _;
use std::hash::{BuildHasher as _, Hasher as _};
use std::time::{Duration, Instant};
let limit = LOCK_TIMEOUT.try_with(|d| *d).ok();
let start = Instant::now();
let mut pause = Duration::from_millis(10);
let mut logged = false;
let mut conn = tokio::time::timeout(connect_timeout, opts.connect())
.await
.map_err(|_| sqlx::Error::PoolTimedOut)??;
loop {
if try_lock(&mut conn).await? {
return Ok(Self(conn));
}
if !std::mem::replace(&mut logged, true) {
tracing::info!(target: "rustango::migrate", "waiting for another run's migrate lock");
}
let rand = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
let sleep = pause / 2 + pause.mul_f64((rand % 1000) as f64 / 2000.0);
if let Some(limit) = limit.filter(|l| start.elapsed() + sleep > *l) {
return Err(MigrateError::LockTimeout(limit));
}
tokio::time::sleep(sleep).await;
pause = (pause * 2).min(Duration::from_millis(500));
}
}
fn conn(&mut self) -> &mut C {
&mut self.0
}
async fn close(self) {
let _ = self.0.close().await;
}
}
struct Step {
batch: super::diff::RenderedBatch,
retry: Option<super::ensure::FilledTableRetry>,
#[cfg_attr(not(any(feature = "mysql", feature = "postgres")), allow(dead_code))]
drop_fks: Option<ColumnRef>,
#[cfg_attr(not(any(feature = "mysql", feature = "postgres")), allow(dead_code))]
drop_referencing: Option<(String, String)>,
#[cfg_attr(not(feature = "mysql"), allow(dead_code))]
drop_unique: Option<ColumnRef>,
index_fks: Option<Box<IndexFks>>,
readded_now: Option<(String, String)>,
}
struct IndexFks {
table: String,
index: String,
columns: Option<Vec<(String, Option<String>)>>,
composites: Option<Vec<(String, String)>>,
}
fn leading_indexes_at<'a>(
before: &'a SchemaSnapshot,
earlier: &'a [Operation],
table: &str,
column: &str,
) -> Vec<&'a str> {
indexes_at(before, earlier, table)
.into_iter()
.filter(|(_, cs)| cs.first().is_some_and(|c| c == column))
.map(|(name, _)| name)
.collect()
}
fn indexes_at<'a>(
before: &'a SchemaSnapshot,
earlier: &'a [Operation],
table: &str,
) -> Vec<(&'a str, &'a [String])> {
use super::SchemaChange as SC;
let mut out: Vec<(&str, &[String])> = before
.indexes
.iter()
.filter(|i| i.table == table)
.map(|i| (i.name.as_str(), i.columns.as_slice()))
.collect();
for op in earlier {
match op {
Operation::Schema(SC::DropIndex { name, .. }) => out.retain(|(n, _)| n != name),
Operation::Schema(SC::CreateIndex {
name,
table: t,
columns,
..
}) if t == table => out.push((name, columns)),
_ => {}
}
}
out
}
fn fk_deferred_earlier(table: &str, column: &str, earlier: &[Operation]) -> bool {
use super::SchemaChange as SC;
earlier.iter().any(|op| match op {
Operation::Schema(SC::CreateTable(t)) => t == table,
Operation::Schema(
SC::AddColumn {
table: t,
column: c,
}
| SC::AlterColumnType {
table: t,
column: c,
..
}
| SC::AlterColumnMaxLength {
table: t,
column: c,
..
}
| SC::AlterFkOnDelete {
table: t,
column: c,
..
}
| SC::RenameColumn {
table: t,
new_column: c,
..
}
| SC::AlterColumnUnique {
table: t,
column: c,
unique: false,
},
) => t == table && c == column,
_ => false,
})
}
enum FkFate<'a> {
Keep,
Drop,
Readd(&'a str),
}
impl IndexFks {
fn at(
table: &str,
index: &str,
ops: &[Operation],
at_op: Option<usize>,
after: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<Self, MigrateError> {
let (earlier, later) = at_op.map_or((&[][..], &[][..]), |i| (&ops[..i], &ops[i + 1..]));
let (columns, composites) = if dropped_later(table, later) {
(None, None)
} else {
let (at, _) = super::rebuild::snapshot_at(table, later, after)
.map_err(MigrateError::Validation)?;
let at = at.table(table);
let mut columns = at
.map(|t| super::diff::column_fks(t, dialect, schema))
.transpose()
.map_err(MigrateError::Validation)?;
if let Some(cs) = &mut columns {
cs.retain(|(c, fk)| fk.is_none() || !fk_deferred_earlier(table, c, earlier));
}
let composites = at.map(|t| super::diff::composite_fks(t, dialect, schema));
(columns, composites)
};
Ok(Self {
table: table.to_owned(),
index: index.to_owned(),
columns,
composites,
})
}
fn plan_composites(
&self,
names: &[String],
dialect: &dyn crate::sql::Dialect,
) -> (Vec<String>, Vec<String>) {
let (mut drops, mut readds) = (Vec::new(), Vec::new());
for name in names {
let readd = match &self.composites {
None => None,
Some(cs) => match cs.iter().find(|(n, _)| n == name) {
Some((_, sql)) => Some(sql.clone()),
None => continue,
},
};
drops.extend(dialect.drop_foreign_key_sql(&self.table, name));
readds.extend(readd);
}
(drops, readds)
}
fn fate(&self, column: &str) -> FkFate<'_> {
let found = self
.columns
.as_ref()
.and_then(|cs| cs.iter().find(|(c, _)| c == column));
match found {
None => FkFate::Drop,
Some((_, None)) => FkFate::Keep,
Some((_, Some(sql))) => FkFate::Readd(sql),
}
}
fn plan(
&self,
column: &str,
names: &[String],
dialect: &dyn crate::sql::Dialect,
) -> (Vec<String>, Option<String>) {
let fate = self.fate(column);
if matches!(fate, FkFate::Keep) {
return (Vec::new(), None);
}
let drops = names
.iter()
.filter_map(|n| dialect.drop_foreign_key_sql(&self.table, n))
.collect();
match fate {
FkFate::Readd(sql) => (drops, Some(sql.to_owned())),
_ => (drops, None),
}
}
}
fn dropped_later(table: &str, later: &[Operation]) -> bool {
use super::SchemaChange as SC;
let mut name = table.to_owned();
for op in later {
match op {
Operation::Schema(SC::RenameTable { old_name, new_name }) if *old_name == name => {
name.clone_from(new_name);
}
Operation::Schema(SC::DropTable(t)) if *t == name => return true,
_ => {}
}
}
false
}
fn renamed_later(table: &str, later: &[Operation]) -> bool {
later.iter().any(|op| {
matches!(op, Operation::Schema(super::SchemaChange::RenameTable { old_name, .. }) if old_name == table)
})
}
#[cfg_attr(not(any(feature = "mysql", feature = "postgres")), allow(dead_code))]
struct ColumnRef {
table: String,
column: String,
keep: Vec<String>,
}
fn render_step(
change: &super::SchemaChange,
ops: &[Operation],
after: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<Step, MigrateError> {
use super::SchemaChange as SC;
let at_op = ops
.iter()
.position(|op| matches!(op, Operation::Schema(c) if std::ptr::eq(c, change)));
let later = at_op.map_or(&[][..], |i| &ops[i + 1..]);
let ended = match change {
SC::AddCompositeFk {
table,
name,
to,
from,
on,
} if !dialect.alters_by_rebuild() => Some(SC::AddCompositeFk {
table: super::rebuild::name_at_end(table, later),
name: name.clone(),
to: super::rebuild::name_at_end(to, later),
from: super::rebuild::columns_at_end(table, from, later),
on: super::rebuild::columns_at_end(to, on, later),
}),
_ => None,
};
let changes = std::slice::from_ref(ended.as_ref().unwrap_or(change));
let render = |snap: &SchemaSnapshot| {
super::diff::render_changes_split_in_schema(changes, snap, dialect, schema)
};
let retry = super::ensure::filled_table_retry(dialect, after, changes, render)
.transpose()
.map_err(MigrateError::Validation)?;
let rebuilt = match change {
SC::DropColumn { table, .. }
| SC::AlterFkOnDelete { table, .. }
| SC::AlterColumnType { table, .. }
| SC::AlterColumnNullable { table, .. }
| SC::AlterColumnDefault { table, .. }
| SC::AlterColumnMaxLength { table, .. }
| SC::AlterColumnUnique { table, .. }
| SC::AddCheckConstraint { table, .. }
| SC::DropCheckConstraint { table, .. }
| SC::AddCompositeFk { table, .. }
| SC::DropCompositeFk { table, .. } => Some(table.as_str()),
_ => None,
};
let modified = match change {
SC::AlterColumnType { table, .. }
| SC::AlterColumnNullable { table, .. }
| SC::AlterColumnDefault { table, .. }
| SC::AlterColumnMaxLength { table, .. }
| SC::AlterColumnUnique { table, .. } => Some(table.as_str()),
_ => None,
};
let reshaped = match change {
SC::AlterFkOnDelete { table, .. }
| SC::AlterColumnUnique {
table,
unique: false,
..
} => Some(table.as_str()),
SC::AddColumn { table, .. } | SC::CreateTable(table)
if after.table(table).is_none() && renamed_later(table, later) =>
{
Some(table.as_str())
}
_ if dialect.alters_by_rebuild() => rebuilt,
_ => modified.filter(|_| dialect.modifies_whole_column()),
};
let (at, renamed) = match reshaped {
Some(table) if later.iter().any(|op| touches_table(op, table)) => {
let (at, renamed) = super::rebuild::snapshot_at(table, later, after)
.map_err(MigrateError::Validation)?;
(Some(at), renamed)
}
_ => (None, false),
};
let snap = at.as_ref().unwrap_or(after);
let at_column = |table: &str, column: &str| ColumnRef {
table: table.to_owned(),
column: column.to_owned(),
keep: super::diff::declared_indexes(snap, table),
};
let has_fk = |table: &str, column: &str| {
snap.table(table)
.and_then(|t| t.field(column))
.is_some_and(|f| f.fk.is_some())
};
let renamed_fk = match change {
SC::RenameColumn {
table, new_column, ..
} if !dialect.inline_fks_in_create_table()
&& !fk_readded_later(table, new_column, later, dialect) =>
{
let end = super::rebuild::name_at_end(table, later);
let at_end =
super::rebuild::columns_at_end(table, std::slice::from_ref(new_column), later);
super::diff::column_fk_sql(after, &end, &at_end[0], dialect, schema)
.map_err(MigrateError::Validation)?
.map(|sql| (at_end[0] == *new_column, sql))
}
_ => None,
};
let drop_fks = match change {
SC::DropColumn { table, column } | SC::AlterFkOnDelete { table, column, .. } => {
Some(at_column(table, column))
}
SC::RenameColumn {
table, old_column, ..
} if renamed_fk.is_some() => Some(at_column(table, old_column)),
_ => readds_fk(change, dialect)
.filter(|(table, column)| has_fk(table, column))
.map(|(table, column)| at_column(table, column)),
};
let drop_referencing = match change {
SC::AlterColumnType { table, column, .. } if dialect.modifies_whole_column() => {
Some((table.clone(), column.clone()))
}
_ => None,
};
let drop_unique = match change {
SC::AlterColumnUnique {
table,
column,
unique: false,
} => Some(at_column(table, column)),
_ => None,
};
let index_fks = match change {
SC::DropIndex { name, table } if dialect.sole_leading_column_sql().is_some() => {
Some(Box::new(IndexFks::at(
table, name, ops, at_op, after, dialect, schema,
)?))
}
_ => None,
};
let mut batch = render(snap).map_err(MigrateError::Validation)?;
if let Some((true, sql)) = renamed_fk {
batch.deferred_fks.push(sql);
}
let column_renamed = !dialect.inline_fks_in_create_table()
&& readds_fk(change, dialect).is_some_and(|(table, column)| {
super::rebuild::columns_at_end(table, &[column.to_owned()], later)[0] != column
});
let readded_now = readds_fk(change, dialect)
.filter(|_| (renamed || column_renamed) && drop_fks.is_some())
.map(|(t, c)| (t.to_owned(), c.to_owned()));
if renamed || column_renamed {
let fks = std::mem::take(&mut batch.deferred_fks);
batch.immediate.extend(fks);
}
if let Some((table, column)) = &drop_referencing {
let end = super::rebuild::name_at_end(table, later);
let end_column = super::rebuild::columns_at_end(table, std::slice::from_ref(column), later);
for (t, c) in referencing_columns(after, &end, &end_column[0]) {
if !fk_comes_back_elsewhere(&t, &c, ops, at_op, dialect) {
batch.deferred_fks.extend(
super::diff::column_fk_sql(after, &t, &c, dialect, schema)
.map_err(MigrateError::Validation)?,
);
}
}
}
batch.rebuild = batch.rebuild.map(|r| {
let dropped: Vec<&str> = later
.iter()
.filter_map(|op| match op {
Operation::Schema(super::SchemaChange::DropColumn { table, column })
if table == r.table() =>
{
Some(column.as_str())
}
_ => None,
})
.collect();
dropped.into_iter().fold(r, |r, c| r.dropping(c))
});
Ok(Step {
batch,
retry,
drop_fks,
drop_referencing,
drop_unique,
index_fks,
readded_now,
})
}
struct Rendered(std::vec::IntoIter<Step>);
impl Rendered {
fn next_step(&mut self) -> Step {
self.0.next().expect("one step per schema op")
}
}
fn render_steps(
ops: &[Operation],
after: &SchemaSnapshot,
dialect: &dyn crate::sql::Dialect,
schema: Option<&str>,
) -> Result<Rendered, MigrateError> {
let steps: Result<Vec<Step>, MigrateError> = ops
.iter()
.filter_map(|op| match op {
Operation::Schema(change) => Some(render_step(change, ops, after, dialect, schema)),
_ => None,
})
.collect();
Ok(Rendered(steps?.into_iter()))
}
fn readds_fk<'a>(
change: &'a super::SchemaChange,
dialect: &dyn crate::sql::Dialect,
) -> Option<(&'a str, &'a str)> {
use super::SchemaChange as SC;
match change {
SC::AlterFkOnDelete { table, column, .. } => Some((table, column)),
SC::AlterColumnType { table, column, .. }
| SC::AlterColumnMaxLength { table, column, .. }
| SC::AlterColumnUnique {
table,
column,
unique: false,
} if dialect.modifies_whole_column() => Some((table, column)),
_ => None,
}
}
fn fk_comes_back_elsewhere(
t: &str,
c: &str,
ops: &[Operation],
at: Option<usize>,
dialect: &dyn crate::sql::Dialect,
) -> bool {
use super::SchemaChange as SC;
ops.iter().enumerate().any(|(i, op)| {
let Operation::Schema(change) = op else {
return false;
};
let rest = &ops[i + 1..];
let ends_as = |table: &str, column: &str| {
super::rebuild::name_at_end(table, rest) == t
&& super::rebuild::columns_at_end(table, &[column.to_owned()], rest)[0] == c
};
match change {
_ if Some(i) == at => false,
SC::CreateTable(table) => super::rebuild::name_at_end(table, rest) == t,
SC::AddColumn { table, column }
| SC::RenameColumn {
table,
new_column: column,
..
} => ends_as(table, column),
_ => readds_fk(change, dialect).is_some_and(|(table, column)| ends_as(table, column)),
}
})
}
fn referencing_columns(snap: &SchemaSnapshot, table: &str, column: &str) -> Vec<(String, String)> {
snap.tables
.iter()
.flat_map(|t| t.fields.iter().map(move |f| (t, f)))
.filter(|(_, f)| {
f.fk.as_ref()
.is_some_and(|r| r.to == table && r.on == column)
})
.map(|(t, f)| (t.name.clone(), f.column.clone()))
.collect()
}
fn fk_readded_later(
table: &str,
column: &str,
later: &[Operation],
dialect: &dyn crate::sql::Dialect,
) -> bool {
later.iter().enumerate().any(|(i, op)| {
let Operation::Schema(change) = op else {
return false;
};
readds_fk(change, dialect).is_some_and(|(t, c)| {
let before = &later[..i];
t == super::rebuild::name_at_end(table, before)
&& super::rebuild::columns_at_end(table, &[column.to_owned()], before)[0] == c
})
})
}
fn touches_table(op: &Operation, table: &str) -> bool {
matches!(op, Operation::Schema(c) if c.table() == table)
}
#[cfg(feature = "sqlite")]
fn rebuilds(
op: &Operation,
ops: &[Operation],
after: &SchemaSnapshot,
) -> Result<bool, MigrateError> {
let Operation::Schema(change) = op else {
return Ok(false);
};
Ok(render_step(change, ops, after, &crate::sql::Sqlite, None)?
.batch
.rebuild
.is_some())
}
#[cfg(feature = "sqlite")]
async fn run_step_sqlite(
tx: &mut super::rebuild::RebuildTx<'_>,
step: Step,
) -> Result<Vec<String>, MigrateError> {
let Step { batch, retry, .. } = step;
for (i, stmt) in batch.immediate.iter().enumerate() {
let Err(e) = sqlx::query(stmt).execute(&mut **tx).await else {
continue;
};
let Some(retry) = retry.filter(|_| i == 0 && super::ensure::is_non_constant_default(&e))
else {
return Err(e.into());
};
tracing::warn!(target: "rustango::migrate", "column {}", retry.warning);
for stmt in &retry.batch.immediate {
sqlx::query(stmt).execute(&mut **tx).await?;
}
return Ok(retry.batch.deferred_fks);
}
if let Some(rebuild) = &batch.rebuild {
rebuild.run(tx).await?;
}
Ok(batch.deferred_fks)
}
#[cfg(any(feature = "mysql", feature = "postgres"))]
macro_rules! step_statements {
($conn:expr, $step:expr, $dialect:expr) => {{
use crate::sql::Dialect as _;
let (step, dialect): (&Step, _) = ($step, $dialect);
let mut out = Vec::new();
if let (Some(fk), Some(sql)) = (&step.drop_fks, dialect.foreign_key_names_sql()) {
let names: Vec<String> = sqlx::query_scalar(sql)
.bind(&fk.table)
.bind(&fk.column)
.fetch_all(&mut *$conn)
.await?;
out.extend(
names
.iter()
.filter_map(|n| dialect.drop_foreign_key_sql(&fk.table, n)),
);
}
if let (Some((table, column)), Some(sql)) = (
&step.drop_referencing,
dialect.referencing_foreign_keys_sql(),
) {
let fks: Vec<(String, String)> = sqlx::query_as(sql)
.bind(table)
.bind(column)
.fetch_all(&mut *$conn)
.await?;
out.extend(
fks.iter()
.filter_map(|(t, n)| dialect.drop_foreign_key_sql(t, n)),
);
}
if let (Some(u), Some(sql)) = (&step.drop_unique, dialect.unique_index_names_sql()) {
let names: Vec<String> = sqlx::query_scalar(sql)
.bind(&u.table)
.bind(&u.column)
.fetch_all(&mut *$conn)
.await?;
out.extend(
names
.iter()
.filter(|n| !u.keep.contains(n))
.filter_map(|n| dialect.drop_unique_index_sql(&u.table, n)),
);
}
out.extend(step.batch.immediate.iter().cloned());
Ok(out)
}};
}
#[cfg(feature = "mysql")]
async fn mysql_statements(
conn: &mut sqlx::MySqlConnection,
step: &Step,
) -> Result<Vec<String>, sqlx::Error> {
let (mut out, readd) = match &step.index_fks {
Some(ix) => Box::pin(mysql_index_fks(conn, ix)).await?,
None => (Vec::new(), Vec::new()),
};
let rest: Result<Vec<String>, sqlx::Error> = step_statements!(conn, step, crate::sql::MySql);
out.extend(rest?);
out.extend(readd);
Ok(out)
}
#[cfg(feature = "mysql")]
async fn mysql_index_fks(
conn: &mut sqlx::MySqlConnection,
ix: &IndexFks,
) -> Result<(Vec<String>, Vec<String>), sqlx::Error> {
use crate::sql::Dialect as _;
let dialect = crate::sql::MySql;
let (Some(lead_sql), Some(names_sql), Some(composite_sql)) = (
dialect.sole_leading_column_sql(),
dialect.foreign_key_names_sql(),
dialect.composite_fks_needing_index_sql(),
) else {
return Ok((Vec::new(), Vec::new()));
};
let lead: Option<String> = sqlx::query_scalar(lead_sql)
.bind(&ix.table)
.bind(&ix.index)
.fetch_optional(&mut *conn)
.await?;
let (mut drops, mut readds) = (Vec::new(), Vec::new());
if let Some(column) = lead {
let names: Vec<String> = sqlx::query_scalar(names_sql)
.bind(&ix.table)
.bind(&column)
.fetch_all(&mut *conn)
.await?;
let (d, r) = ix.plan(&column, &names, &dialect);
drops.extend(d);
readds.extend(r);
}
let names: Vec<String> = sqlx::query_scalar(composite_sql)
.bind(&ix.table)
.bind(&ix.index)
.bind(&ix.index)
.fetch_all(&mut *conn)
.await?;
let (d, r) = ix.plan_composites(&names, &dialect);
drops.extend(d);
readds.extend(r);
Ok((drops, readds))
}
#[cfg(feature = "postgres")]
async fn pg_statements(
conn: &mut sqlx::PgConnection,
step: &Step,
) -> Result<Vec<String>, sqlx::Error> {
step_statements!(conn, step, crate::sql::Postgres)
}
#[cfg(any(feature = "sqlite", feature = "mysql"))]
#[derive(Clone, Copy)]
enum LedgerWrite<'a> {
Insert(&'a str),
Delete(&'a str),
}
#[cfg(feature = "mysql")]
async fn atomic_mysql(
my: &sqlx::MySqlPool,
name: &str,
ops: &[Operation],
after: &SchemaSnapshot,
schema: Option<&str>,
ledger: LedgerWrite<'_>,
) -> Result<(), MigrateError> {
if let Some(Operation::Callback(c)) = ops.iter().find(|op| matches!(op, Operation::Callback(_)))
{
return Err(callback_in_atomic(name, c));
}
let mut steps = render_steps(ops, after, &crate::sql::MySql, schema)?;
let mut tx = my.begin().await?;
let mut deferred_fks: Vec<String> = Vec::new();
let total = ops.len();
let mut applied = 0usize;
let mut ddl_applied = 0usize;
let mut ddl_sent = false;
macro_rules! stuck {
($e:expr) => {{
let e: sqlx::Error = $e;
if !ddl_sent || (applied == 0 && ddl_applied == 0) {
MigrateError::Driver(e)
} else {
MigrateError::PartiallyApplied {
migration: name.to_owned(),
applied,
total,
ddl_applied,
source: Box::new(e),
}
}
}};
}
for op in ops {
match op {
Operation::Schema(_) => {
let step = steps.next_step();
let stmts = mysql_statements(&mut tx, &step)
.await
.map_err(|e| stuck!(e))?;
for stmt in stmts {
if !ddl_sent {
tx.commit().await?;
ddl_sent = true;
tx = my.begin().await.map_err(|e| stuck!(e))?;
}
sqlx::query(&stmt)
.execute(&mut *tx)
.await
.map_err(|e| stuck!(e))?;
ddl_applied += 1;
}
deferred_fks.extend(step.batch.deferred_fks);
}
Operation::Data(d) => {
sqlx::query(&d.sql)
.execute(&mut *tx)
.await
.map_err(|e| stuck!(e))?;
}
Operation::Callback(c) => return Err(callback_in_atomic(name, c)),
}
applied += 1;
}
for stmt in deferred_fks {
sqlx::query(&stmt)
.execute(&mut *tx)
.await
.map_err(|e| stuck!(e))?;
}
match ledger {
LedgerWrite::Insert(ledger) => sqlx::query(&ledger_insert_sql(&crate::sql::MySql, ledger))
.bind(name)
.bind(chrono::Utc::now())
.execute(&mut *tx)
.await
.map_err(|e| stuck!(e))?,
LedgerWrite::Delete(ledger) => sqlx::query(&format!("DELETE FROM {ledger} WHERE name = ?"))
.bind(name)
.execute(&mut *tx)
.await
.map_err(|e| stuck!(e))?,
};
tx.commit().await.map_err(|e| stuck!(e))?;
Ok(())
}
#[cfg(feature = "sqlite")]
async fn atomic_sqlite(
sq: &sqlx::SqlitePool,
name: &str,
ops: &[Operation],
after: &SchemaSnapshot,
ledger: LedgerWrite<'_>,
) -> Result<(), MigrateError> {
if let Some(Operation::Callback(c)) = ops.iter().find(|op| matches!(op, Operation::Callback(_)))
{
return Err(callback_in_atomic(name, c));
}
let segments = sqlite_segments(ops, after)?;
let total = ops.len();
let mut deferred_fks: Vec<String> = Vec::new();
let mut applied = 0usize;
let mut ddl_applied = 0usize;
for (i, (range, rebuilds)) in segments.iter().enumerate() {
let last = i + 1 == segments.len();
let ledger = last.then_some(ledger);
let ran = atomic_sqlite_segment(
sq,
name,
ops,
range.clone(),
*rebuilds,
after,
&mut deferred_fks,
ledger,
)
.await;
let ran_ddl = ops[range.clone()]
.iter()
.filter(|op| matches!(op, Operation::Schema(_)))
.count();
match ran {
Ok(()) => {}
Err(SegmentFailed {
committed: true,
error,
}) if last => return Err(error),
Err(SegmentFailed { committed, error }) => {
if committed {
applied += range.len();
ddl_applied += ran_ddl;
}
if applied == 0 {
return Err(error);
}
let source = match error {
MigrateError::Driver(d) => d,
other => sqlx::Error::Protocol(other.to_string()),
};
return Err(MigrateError::PartiallyApplied {
migration: name.to_owned(),
applied,
total,
ddl_applied,
source: Box::new(source),
});
}
}
applied += range.len();
ddl_applied += ran_ddl;
}
Ok(())
}
#[cfg(feature = "sqlite")]
struct SegmentFailed {
committed: bool,
error: MigrateError,
}
#[cfg(feature = "sqlite")]
fn sqlite_segments(
ops: &[Operation],
after: &SchemaSnapshot,
) -> Result<Vec<(std::ops::Range<usize>, bool)>, MigrateError> {
let mut out: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
let (mut start, mut rebuilt, mut data) = (0, false, false);
for (i, op) in ops.iter().enumerate() {
let is_data = matches!(op, Operation::Data(_));
let is_rebuild = rebuilds(op, ops, after)?;
if (is_data && rebuilt) || (is_rebuild && data) {
out.push((start..i, rebuilt));
(start, rebuilt, data) = (i, false, false);
}
rebuilt |= is_rebuild;
data |= is_data;
}
out.push((start..ops.len(), rebuilt));
Ok(out)
}
#[cfg(feature = "sqlite")]
#[allow(clippy::too_many_arguments)]
async fn atomic_sqlite_segment(
sq: &sqlx::SqlitePool,
name: &str,
ops: &[Operation],
range: std::ops::Range<usize>,
rebuilds: bool,
after: &SchemaSnapshot,
deferred_fks: &mut Vec<String>,
ledger: Option<LedgerWrite<'_>>,
) -> Result<(), SegmentFailed> {
let fail = |committed, error| SegmentFailed { committed, error };
let mut conn = super::rebuild::RebuildConn::acquire(sq, rebuilds)
.await
.map_err(|e| fail(false, e.into()))?;
let result: Result<(), MigrateError> = async {
let mut tx = conn.begin().await?;
for op in &ops[range] {
match op {
Operation::Schema(change) => {
let step = render_step(change, ops, after, &crate::sql::Sqlite, None)?;
deferred_fks.extend(run_step_sqlite(&mut tx, step).await?);
}
Operation::Data(d) => {
sqlx::query(&d.sql).execute(&mut *tx).await?;
}
Operation::Callback(c) => return Err(callback_in_atomic(name, c)),
}
}
let Some(ledger) = ledger else {
return Ok(tx.commit().await?);
};
for stmt in std::mem::take(deferred_fks) {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
match ledger {
LedgerWrite::Insert(ledger) => {
sqlx::query(&ledger_insert_sql(&crate::sql::Sqlite, ledger))
.bind(name)
.bind(crate::sql::encode_datetime(chrono::Utc::now()))
.execute(&mut *tx)
.await?;
}
LedgerWrite::Delete(ledger) => {
sqlx::query(&format!("DELETE FROM {ledger} WHERE name = ?"))
.bind(name)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await
}
.await;
let finished = conn.finish().await;
result.map_err(|e| fail(false, e))?;
finished.map_err(|e| fail(true, e.into()))
}
async fn run_step_pool(pool: &crate::sql::Pool, step: Step) -> Result<Vec<String>, MigrateError> {
match pool {
#[cfg(feature = "sqlite")]
crate::sql::Pool::Sqlite(sq) => {
let mut conn =
super::rebuild::RebuildConn::acquire(sq, step.batch.rebuild.is_some()).await?;
let result: Result<Vec<String>, MigrateError> = async {
let mut tx = conn.begin().await?;
let deferred = run_step_sqlite(&mut tx, step).await?;
tx.commit().await?;
Ok(deferred)
}
.await;
let finished = conn.finish().await;
let deferred = result?;
finished?;
Ok(deferred)
}
#[cfg(feature = "mysql")]
crate::sql::Pool::Mysql(my) => {
let mut conn = my.acquire().await?;
for stmt in mysql_statements(&mut conn, &step).await? {
sqlx::query(&stmt).execute(&mut *conn).await?;
}
Ok(step.batch.deferred_fks)
}
#[cfg(feature = "postgres")]
crate::sql::Pool::Postgres(pg) => {
let mut conn = pg.acquire().await?;
for stmt in pg_statements(&mut conn, &step).await? {
sqlx::query(&stmt).execute(&mut *conn).await?;
}
Ok(step.batch.deferred_fks)
}
}
}
async fn apply_atomic_pool(
pool: &crate::sql::Pool,
mig: &Migration,
ledger: &str,
) -> Result<(), MigrateError> {
tracing::info!(migration = %mig.name, "applying (atomic, _pool)");
#[cfg_attr(
not(any(feature = "postgres", feature = "mysql")),
allow(unused_variables)
)]
let schema = super::ensure::creation_schema(pool).await?;
#[cfg_attr(not(feature = "postgres"), allow(unused_variables))]
let dialect = pool.dialect();
match pool {
#[cfg(feature = "postgres")]
crate::sql::Pool::Postgres(pg) => {
let mut tx = pg.begin().await?;
let mut deferred_fks: Vec<String> = Vec::new();
for op in &mig.forward {
match op {
Operation::Schema(change) => {
let step = render_step(
change,
&mig.forward,
&mig.snapshot,
dialect,
schema.as_deref(),
)?;
for stmt in pg_statements(&mut tx, &step).await? {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
deferred_fks.extend(step.batch.deferred_fks);
}
Operation::Data(d) => {
sqlx::query(&d.sql).execute(&mut *tx).await?;
}
Operation::Callback(c) => return Err(callback_in_atomic(&mig.name, c)),
}
}
for stmt in deferred_fks {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
sqlx::query(&ledger_insert_sql(&crate::sql::Postgres, ledger))
.bind(&mig.name)
.bind(chrono::Utc::now())
.execute(&mut *tx)
.await?;
tx.commit().await?;
}
#[cfg(feature = "mysql")]
crate::sql::Pool::Mysql(my) => {
tracing::warn!(
migration = %mig.name,
"MySQL commits on every DDL statement: the atomic wrapper only covers \
data operations before the first DDL. A failure after it leaves the \
migration partially applied and needs manual recovery (#559)."
);
atomic_mysql(
my,
&mig.name,
&mig.forward,
&mig.snapshot,
schema.as_deref(),
LedgerWrite::Insert(ledger),
)
.await?;
}
#[cfg(feature = "sqlite")]
crate::sql::Pool::Sqlite(sq) => {
atomic_sqlite(
sq,
&mig.name,
&mig.forward,
&mig.snapshot,
LedgerWrite::Insert(ledger),
)
.await?;
}
}
Ok(())
}
async fn apply_nonatomic_pool(
pool: &crate::sql::Pool,
mig: &Migration,
ledger: &str,
) -> Result<(), MigrateError> {
let callbacks = Callbacks::resolve(&mig.forward)?;
tracing::info!(migration = %mig.name, "applying (non-atomic, _pool)");
let schema = super::ensure::creation_schema(pool).await?;
let mut steps = render_steps(
&mig.forward,
&mig.snapshot,
pool.dialect(),
schema.as_deref(),
)?;
let mut deferred_fks: Vec<String> = Vec::new();
for op in &mig.forward {
match op {
Operation::Schema(_) => {
deferred_fks.extend(run_step_pool(pool, steps.next_step()).await?);
}
Operation::Data(d) => {
crate::sql::raw_execute_pool(pool, &d.sql, ::std::vec::Vec::new()).await?;
}
Operation::Callback(c) => {
callbacks.run(c, pool.clone()).await?;
}
}
}
for stmt in deferred_fks {
crate::sql::raw_execute_pool(pool, &stmt, ::std::vec::Vec::new()).await?;
}
let insert_sql = ledger_insert_sql(pool.dialect(), ledger);
crate::sql::raw_execute_pool(
pool,
&insert_sql,
::std::vec![
crate::core::SqlValue::String(mig.name.clone()),
crate::core::SqlValue::DateTime(chrono::Utc::now()),
],
)
.await?;
Ok(())
}
pub async fn migrate_to_pool(
pool: &crate::sql::Pool,
dir: &Path,
target: &str,
) -> Result<Vec<Migration>, MigrateError> {
migrate_to_pool_with_ledger(pool, dir, target, LEDGER_TABLE).await
}
pub async fn migrate_to_pool_with_ledger(
pool: &crate::sql::Pool,
dir: &Path,
target: &str,
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
with_migrate_lock_pool(pool, ledger, async {
let all = file::list_dir(dir)?;
let applied = applied_set_pool_with_ledger(pool, ledger).await?;
if target == "zero" {
return unapply_all_in_order_pool(pool, dir, &all, &applied, ledger).await;
}
if !all.iter().any(|m| m.name == target) {
return Err(MigrateError::Validation(format!(
"target migration `{target}` not found in {}",
dir.display()
)));
}
let mut applied = applied;
let mut touched = Vec::new();
for mig in backward_from(&all, &applied, target) {
unapply_locked_pool(pool, dir, &mig.name, ledger).await?;
applied.remove(&mig.name);
touched.push(mig);
}
for mig in forward_to(&all, &applied, target) {
reconcile_and_apply(pool, &mig, &applied, ledger, false).await?;
touched.push(mig);
}
Ok(touched)
})
.await
}
pub async fn downgrade_pool(
pool: &crate::sql::Pool,
dir: &Path,
steps: usize,
) -> Result<Vec<Migration>, MigrateError> {
downgrade_pool_with_ledger(pool, dir, steps, LEDGER_TABLE).await
}
pub async fn downgrade_pool_with_ledger(
pool: &crate::sql::Pool,
dir: &Path,
steps: usize,
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
if steps == 0 {
return Ok(Vec::new());
}
with_migrate_lock_pool(pool, ledger, async {
let all = file::list_dir(dir)?;
let applied = applied_set_pool_with_ledger(pool, ledger).await?;
let applied_in_order: Vec<Migration> = all
.into_iter()
.filter(|m| applied.contains(&m.name))
.collect();
if applied_in_order.is_empty() {
return Ok(Vec::new());
}
let n = steps.min(applied_in_order.len());
let to_unapply: Vec<Migration> = applied_in_order.into_iter().rev().take(n).collect();
let mut touched = Vec::with_capacity(to_unapply.len());
for mig in to_unapply {
unapply_locked_pool(pool, dir, &mig.name, ledger).await?;
touched.push(mig);
}
Ok(touched)
})
.await
}
pub async fn unapply_pool(
pool: &crate::sql::Pool,
dir: &Path,
name: &str,
) -> Result<Migration, MigrateError> {
unapply_pool_with_ledger(pool, dir, name, LEDGER_TABLE).await
}
pub async fn unapply_pool_with_ledger(
pool: &crate::sql::Pool,
dir: &Path,
name: &str,
ledger: &str,
) -> Result<Migration, MigrateError> {
with_migrate_lock_pool(pool, ledger, async {
check_is_head_pool(pool, dir, name, ledger).await?;
unapply_locked_pool(pool, dir, name, ledger).await
})
.await
}
pub async fn unapply_force_pool(
pool: &crate::sql::Pool,
dir: &Path,
name: &str,
) -> Result<Migration, MigrateError> {
unapply_force_pool_with_ledger(pool, dir, name, LEDGER_TABLE).await
}
async fn unapply_force_pool_with_ledger(
pool: &crate::sql::Pool,
dir: &Path,
name: &str,
ledger: &str,
) -> Result<Migration, MigrateError> {
with_migrate_lock_pool(pool, ledger, unapply_locked_pool(pool, dir, name, ledger)).await
}
pub async fn migrate_dry_run_pool(
pool: &crate::sql::Pool,
dir: &Path,
) -> Result<Vec<MigrationPreview>, MigrateError> {
migrate_dry_run_pool_with_ledger(pool, dir, LEDGER_TABLE).await
}
pub async fn migrate_dry_run_pool_with_ledger(
pool: &crate::sql::Pool,
dir: &Path,
ledger: &str,
) -> Result<Vec<MigrationPreview>, MigrateError> {
let all = file::list_dir(dir)?;
preview_pending(pool, dir, &all, None, ledger, false, &[]).await
}
async fn apply_one_pool(
pool: &crate::sql::Pool,
mig: &Migration,
ledger: &str,
) -> Result<(), MigrateError> {
if mig.atomic {
apply_atomic_pool(pool, mig, ledger).await
} else {
apply_nonatomic_pool(pool, mig, ledger).await
}
}
async fn unapply_all_in_order_pool(
pool: &crate::sql::Pool,
dir: &Path,
all: &[Migration],
applied: &HashSet<String>,
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
let mut to_unapply: Vec<Migration> = all
.iter()
.filter(|m| applied.contains(&m.name))
.cloned()
.collect();
to_unapply.reverse();
let mut touched = Vec::with_capacity(to_unapply.len());
for mig in to_unapply {
unapply_locked_pool(pool, dir, &mig.name, ledger).await?;
touched.push(mig);
}
Ok(touched)
}
async fn unapply_locked_pool(
pool: &crate::sql::Pool,
dir: &Path,
name: &str,
ledger: &str,
) -> Result<Migration, MigrateError> {
let all = file::list_dir(dir)?;
let target = all
.iter()
.find(|m| m.name == name)
.cloned()
.ok_or_else(|| {
MigrateError::Validation(format!("migration `{name}` not found in {}", dir.display()))
})?;
let prev_snapshot = prev_snapshot(&all, &target, dir)?;
let inverted = invert(&target.forward, &prev_snapshot)?;
if target.atomic {
unapply_atomic_pool(pool, &target, &inverted, &prev_snapshot, ledger).await?;
} else {
unapply_nonatomic_pool(pool, &target, &inverted, &prev_snapshot, ledger).await?;
}
Ok(target)
}
async fn check_is_head_pool(
pool: &crate::sql::Pool,
dir: &Path,
name: &str,
ledger: &str,
) -> Result<(), MigrateError> {
let applied = applied_set_pool_with_ledger(pool, ledger).await?;
if !applied.contains(name) {
return Ok(());
}
let all = file::list_dir(dir)?;
let head = all
.iter()
.rev()
.find(|m| applied.contains(&m.name))
.map(|m| m.name.as_str());
match head {
Some(h) if h == name => Ok(()),
Some(h) => Err(MigrateError::Validation(format!(
"refusing to unapply `{name}` out of order: current head is `{h}`. \
Use `downgrade_pool(pool, dir, n)` / `migrate_to_pool(pool, dir, target)` for \
ordered rollback, or `unapply_force_pool` to bypass.",
))),
None => Ok(()),
}
}
async fn unapply_atomic_pool(
pool: &crate::sql::Pool,
target: &Migration,
inverted: &[Operation],
snapshot: &SchemaSnapshot,
ledger: &str,
) -> Result<(), MigrateError> {
tracing::info!(migration = %target.name, "unapplying (atomic, _pool)");
#[cfg_attr(
not(any(feature = "postgres", feature = "mysql")),
allow(unused_variables)
)]
let schema = super::ensure::creation_schema(pool).await?;
match pool {
#[cfg(feature = "postgres")]
crate::sql::Pool::Postgres(pg) => {
let mut tx = pg.begin().await?;
let mut deferred_fks: Vec<String> = Vec::new();
for op in inverted {
match op {
Operation::Schema(change) => {
let step = render_step(
change,
inverted,
snapshot,
pool.dialect(),
schema.as_deref(),
)?;
for stmt in pg_statements(&mut tx, &step).await? {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
deferred_fks.extend(step.batch.deferred_fks);
}
Operation::Data(d) => {
sqlx::query(&d.sql).execute(&mut *tx).await?;
}
Operation::Callback(c) => return Err(callback_in_atomic(&target.name, c)),
}
}
for stmt in deferred_fks {
sqlx::query(&stmt).execute(&mut *tx).await?;
}
sqlx::query(&format!("DELETE FROM {ledger} WHERE name = $1"))
.bind(&target.name)
.execute(&mut *tx)
.await?;
tx.commit().await?;
}
#[cfg(feature = "mysql")]
crate::sql::Pool::Mysql(my) => {
tracing::warn!(
migration = %target.name,
"MySQL commits on every DDL statement: the atomic-unapply wrapper only \
covers data operations before the first DDL. A failure after it leaves \
the schema half-reverted and needs manual recovery (#559)."
);
atomic_mysql(
my,
&target.name,
inverted,
snapshot,
schema.as_deref(),
LedgerWrite::Delete(ledger),
)
.await?;
}
#[cfg(feature = "sqlite")]
crate::sql::Pool::Sqlite(sq) => {
atomic_sqlite(
sq,
&target.name,
inverted,
snapshot,
LedgerWrite::Delete(ledger),
)
.await?;
}
}
Ok(())
}
pub async fn migrate_embedded_pool(
pool: &crate::sql::Pool,
embedded: &[(&str, &str)],
) -> Result<Vec<Migration>, MigrateError> {
migrate_embedded_pool_with_ledger(pool, embedded, LEDGER_TABLE).await
}
async fn migrate_embedded_pool_with_ledger(
pool: &crate::sql::Pool,
embedded: &[(&str, &str)],
ledger: &str,
) -> Result<Vec<Migration>, MigrateError> {
with_migrate_lock_pool(pool, ledger, async {
let mut all: Vec<Migration> = Vec::with_capacity(embedded.len());
for (name, json) in embedded {
let mig = file::parse(json)?;
if mig.name != *name {
return Err(MigrateError::Validation(format!(
"embedded entry key `{name}` doesn't match migration `name` field `{}`",
mig.name,
)));
}
all.push(mig);
}
all.sort_by(|a, b| a.name.cmp(&b.name));
file::validate_chain(&all, "embedded slice")?;
let applied = applied_set_pool_with_ledger(pool, ledger).await?;
let pending: Vec<Migration> = all
.iter()
.filter(|m| !applied.contains(&m.name))
.cloned()
.collect();
let mut newly = Vec::with_capacity(pending.len());
for mig in pending {
apply_one_pool(pool, &mig, ledger).await?;
newly.push(mig);
}
Ok(newly)
})
.await
}
async fn unapply_nonatomic_pool(
pool: &crate::sql::Pool,
target: &Migration,
inverted: &[Operation],
snapshot: &SchemaSnapshot,
ledger: &str,
) -> Result<(), MigrateError> {
let callbacks = Callbacks::resolve(inverted)?;
tracing::info!(migration = %target.name, "unapplying (non-atomic, _pool)");
let schema = super::ensure::creation_schema(pool).await?;
let mut steps = render_steps(inverted, snapshot, pool.dialect(), schema.as_deref())?;
let mut deferred_fks: Vec<String> = Vec::new();
for op in inverted {
match op {
Operation::Schema(_) => {
deferred_fks.extend(run_step_pool(pool, steps.next_step()).await?);
}
Operation::Data(d) => {
crate::sql::raw_execute_pool(pool, &d.sql, ::std::vec::Vec::new()).await?;
}
Operation::Callback(c) => {
callbacks.run(c, pool.clone()).await?;
}
}
}
for stmt in deferred_fks {
crate::sql::raw_execute_pool(pool, &stmt, ::std::vec::Vec::new()).await?;
}
let placeholder = pool.dialect().placeholder(1);
let delete_sql = format!("DELETE FROM {ledger} WHERE name = {placeholder}");
crate::sql::raw_execute_pool(
pool,
&delete_sql,
::std::vec![crate::core::SqlValue::String(target.name.clone())],
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn mysql_get_lock_null_is_an_error() {
assert!(super::mysql_lock_taken(Some(1)).unwrap());
assert!(!super::mysql_lock_taken(Some(0)).unwrap());
assert!(super::mysql_lock_taken(None).is_err());
}
#[cfg(feature = "mysql")]
#[test]
fn render_between_drops_the_fk_before_its_column() {
use crate::migrate::{SchemaChange, SchemaSnapshot};
let table = |with_fk: bool| {
let mut fields = vec![
serde_json::json!({ "name": "id", "column": "id", "ty": "i64",
"nullable": false, "primary_key": true }),
];
if with_fk {
fields.push(
serde_json::json!({ "name": "p", "column": "p_id", "ty": "i64",
"nullable": true, "primary_key": false,
"fk": { "kind": "fk", "to": "parent", "on": "id" } }),
);
}
serde_json::json!({ "name": "child", "model": "Child", "fields": fields })
};
let snap = |with_fk: bool| -> SchemaSnapshot {
serde_json::from_value(serde_json::json!({ "tables": [table(with_fk)] })).unwrap()
};
let drop = [SchemaChange::DropColumn {
table: "child".into(),
column: "p_id".into(),
}];
let out =
super::render_changes_between(&drop, &snap(true), &snap(false), &crate::sql::MySql)
.unwrap();
assert!(out[0].contains("DROP FOREIGN KEY"), "{out:?}");
assert!(out[1].contains("DROP COLUMN `p_id`"), "{out:?}");
}
#[test]
fn render_between_drops_the_fk_around_its_index() {
use crate::migrate::{SchemaChange, SchemaSnapshot};
let snap = |indexed: bool| -> SchemaSnapshot {
let indexes = if indexed {
serde_json::json!([{ "name": "child_p_idx", "table": "child",
"columns": ["p_id"], "unique": false }])
} else {
serde_json::json!([])
};
serde_json::from_value(serde_json::json!({ "tables": [{
"name": "child", "model": "Child", "fields": [
{ "name": "id", "column": "id", "ty": "i64",
"nullable": false, "primary_key": true },
{ "name": "p", "column": "p_id", "ty": "i64",
"nullable": true, "primary_key": false,
"fk": { "kind": "fk", "to": "parent", "on": "id" } }] }],
"indexes": indexes }))
.unwrap()
};
let drop = [SchemaChange::DropIndex {
name: "child_p_idx".into(),
table: "child".into(),
}];
let out =
super::render_changes_between(&drop, &snap(true), &snap(false), &crate::sql::MySql)
.unwrap();
assert!(out[0].contains("DROP FOREIGN KEY"), "{out:?}");
assert!(out[1].starts_with("DROP INDEX"), "{out:?}");
assert!(out[2].contains("ADD CONSTRAINT"), "{out:?}");
let pg =
super::render_changes_between(&drop, &snap(true), &snap(false), &crate::sql::Postgres)
.unwrap();
assert_eq!(pg.len(), 1, "PG needs no index under an FK: {pg:?}");
}
#[test]
fn render_between_takes_the_fk_off_for_the_last_index() {
use crate::migrate::{SchemaChange, SchemaSnapshot};
let snap = |indexed: bool| -> SchemaSnapshot {
let indexes = if indexed {
serde_json::json!([
{ "name": "idx_a", "table": "child", "columns": ["p_id", "x"], "unique": false },
{ "name": "idx_b", "table": "child", "columns": ["p_id", "y"], "unique": false }])
} else {
serde_json::json!([])
};
serde_json::from_value(serde_json::json!({ "tables": [{
"name": "child", "model": "Child", "fields": [
{ "name": "id", "column": "id", "ty": "i64",
"nullable": false, "primary_key": true },
{ "name": "p", "column": "p_id", "ty": "i64",
"nullable": true, "primary_key": false,
"fk": { "kind": "fk", "to": "parent", "on": "id" } }] }],
"indexes": indexes }))
.unwrap()
};
let drop = |name: &str| SchemaChange::DropIndex {
name: name.into(),
table: "child".into(),
};
let out = super::render_changes_between(
&[drop("idx_a"), drop("idx_b")],
&snap(true),
&snap(false),
&crate::sql::MySql,
)
.unwrap();
assert!(out[0].starts_with("DROP INDEX `idx_a`"), "{out:?}");
assert!(out[1].contains("DROP FOREIGN KEY"), "{out:?}");
assert!(out[2].starts_with("DROP INDEX `idx_b`"), "{out:?}");
assert!(out[3].contains("ADD CONSTRAINT"), "{out:?}");
assert_eq!(out.len(), 4, "{out:?}");
}
#[test]
fn render_between_drops_a_composite_fk_around_its_index() {
use crate::migrate::{SchemaChange, SchemaSnapshot};
let snap = |indexed: bool, other: &[&str]| -> SchemaSnapshot {
let mut indexes = vec![serde_json::json!({ "name": "child_p_idx",
"table": "child", "columns": ["p_id"], "unique": false })];
if indexed {
indexes.push(
serde_json::json!({ "name": "child_pc_idx", "table": "child",
"columns": ["p_id", "code", "n"], "unique": false }),
);
}
if !other.is_empty() {
indexes.push(serde_json::json!({ "name": "child_other_idx",
"table": "child", "columns": other, "unique": false }));
}
let col = |c: &str| {
serde_json::json!({ "name": c, "column": c, "ty": "i64",
"nullable": true, "primary_key": false })
};
serde_json::from_value(serde_json::json!({ "tables": [{
"name": "child", "model": "Child", "fields": [
{ "name": "id", "column": "id", "ty": "i64",
"nullable": false, "primary_key": true },
col("p_id"), col("code"), col("n")],
"composite_fks": [{ "name": "child_pc_fk", "to": "parent",
"from": ["p_id", "code"], "on": ["id", "code"] }] }],
"indexes": indexes }))
.unwrap()
};
let drop = [SchemaChange::DropIndex {
name: "child_pc_idx".into(),
table: "child".into(),
}];
let render = |other: &[&str]| {
super::render_changes_between(
&drop,
&snap(true, other),
&snap(false, other),
&crate::sql::MySql,
)
.unwrap()
};
for other in [&[][..], &["code", "p_id"]] {
let out = render(other);
assert!(out[0].contains("DROP FOREIGN KEY `child_pc_fk`"), "{out:?}");
assert!(out[1].starts_with("DROP INDEX"), "{out:?}");
assert!(out[2].contains("ADD CONSTRAINT `child_pc_fk`"), "{out:?}");
assert_eq!(out.len(), 3, "{out:?}");
}
let out = render(&["p_id", "code"]);
assert_eq!(out.len(), 1, "another index serves it: {out:?}");
assert!(out[0].starts_with("DROP INDEX"), "{out:?}");
}
#[test]
fn render_between_renames_an_fk_once() {
use crate::migrate::{SchemaChange, SchemaSnapshot};
let snap = |column: &str, on_delete: &str| -> SchemaSnapshot {
serde_json::from_value(serde_json::json!({ "tables": [
{ "name": "author", "model": "Author", "fields": [
{ "name": "id", "column": "id", "ty": "i64",
"nullable": false, "primary_key": true }] },
{ "name": "book", "model": "Book", "fields": [
{ "name": "id", "column": "id", "ty": "i64",
"nullable": false, "primary_key": true },
{ "name": column, "column": column, "ty": "i64", "nullable": true,
"primary_key": false, "fk": { "kind": "fk", "to": "author",
"on": "id", "on_delete": on_delete } }] }] }))
.unwrap()
};
let rename = SchemaChange::RenameColumn {
table: "book".into(),
old_column: "author_id".into(),
new_column: "writer_id".into(),
};
let on_delete = SchemaChange::AlterFkOnDelete {
table: "book".into(),
column: "writer_id".into(),
from: Some("CASCADE".into()),
to: Some("SET NULL".into()),
};
let before = snap("author_id", "CASCADE");
for dialect in [
&crate::sql::Postgres as &dyn crate::sql::Dialect,
&crate::sql::MySql,
] {
let out = super::render_changes_between(
std::slice::from_ref(&rename),
&before,
&snap("writer_id", "CASCADE"),
dialect,
)
.unwrap();
assert!(out[0].contains("book_author_id_fkey"), "{out:?}");
assert!(out[2].contains("ADD CONSTRAINT") && out[2].contains("book_writer_id_fkey"));
assert_eq!(out.len(), 3, "{out:?}");
let both = [rename.clone(), on_delete.clone()];
let out = super::render_changes_between(
&both,
&before,
&snap("writer_id", "SET NULL"),
dialect,
)
.unwrap();
let adds = out.iter().filter(|s| s.contains("ADD CONSTRAINT")).count();
assert_eq!(adds, 1, "{out:?}");
assert!(out[1].contains("DROP") && out[1].contains("book_author_id_fkey"));
let chain = [
rename.clone(),
on_delete.clone(),
SchemaChange::RenameColumn {
table: "book".into(),
old_column: "writer_id".into(),
new_column: "owner_id".into(),
},
];
let out = super::render_changes_between(
&chain,
&before,
&snap("owner_id", "SET NULL"),
dialect,
)
.unwrap();
let fk_ops: Vec<&str> = out
.iter()
.filter_map(|s| {
let op = if s.contains("ADD CONSTRAINT") {
"add"
} else if s.contains("DROP CONSTRAINT") || s.contains("DROP FOREIGN KEY") {
"drop"
} else {
return None;
};
["author_id", "writer_id", "owner_id"]
.into_iter()
.find(|c| s.contains(&format!("book_{c}_fkey")))
.map(|_| op)
})
.collect();
assert_eq!(fk_ops, ["drop", "add", "drop", "add"], "{out:?}");
let named = |i: usize, c: &str| {
out.iter()
.filter(|s| s.contains("CONSTRAINT") || s.contains("FOREIGN KEY"))
.nth(i)
.is_some_and(|s| s.contains(&format!("book_{c}_fkey")))
};
assert!(named(0, "author_id") && named(1, "writer_id"), "{out:?}");
assert!(named(2, "writer_id") && named(3, "owner_id"), "{out:?}");
}
}
}