use sea_orm::{
ConnectionTrait, DatabaseBackend, DatabaseConnection, QueryResult, Statement, TransactionTrait,
};
use crate::MigrationError;
fn database_error(message: String, source: sea_orm::DbErr) -> MigrationError {
MigrationError::Database {
message,
source: Some(Box::new(source)),
}
}
#[derive(Debug, Clone, Copy)]
pub struct EmbeddedMigration {
pub version: u32,
pub migration_id: &'static str,
pub comment: &'static str,
pub postgres_sql_blob: &'static str,
pub mysql_sql_blob: &'static str,
pub sqlite_sql_blob: &'static str,
}
impl EmbeddedMigration {
pub const fn new(
version: u32,
migration_id: &'static str,
comment: &'static str,
postgres_sql_blob: &'static str,
mysql_sql_blob: &'static str,
sqlite_sql_blob: &'static str,
) -> Self {
Self {
version,
migration_id,
comment,
postgres_sql_blob,
mysql_sql_blob,
sqlite_sql_blob,
}
}
pub const fn sql_blob(self, backend: DatabaseBackend) -> &'static str {
if matches!(backend, DatabaseBackend::MySql) {
self.mysql_sql_blob
} else if matches!(backend, DatabaseBackend::Sqlite) {
self.sqlite_sql_blob
} else {
self.postgres_sql_blob
}
}
}
pub fn split_sql_blob(blob: &str) -> impl Iterator<Item = &str> {
blob.split_terminator('\0').filter(|sql| !sql.is_empty())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct MigrationRuntimeOptions {
pub lock_timeout_ms: Option<u64>,
pub statement_timeout_ms: Option<u64>,
}
impl MigrationRuntimeOptions {
#[must_use]
pub const fn from_millis(
lock_timeout_ms: Option<u64>,
statement_timeout_ms: Option<u64>,
) -> Self {
Self {
lock_timeout_ms,
statement_timeout_ms,
}
}
fn is_noop(self) -> bool {
self.lock_timeout_ms.is_none() && self.statement_timeout_ms.is_none()
}
}
fn pre_txn_timeout_sql(backend: DatabaseBackend, options: MigrationRuntimeOptions) -> Vec<String> {
let mut out = Vec::new();
if matches!(backend, DatabaseBackend::Sqlite)
&& let Some(ms) = options.lock_timeout_ms
{
out.push(format!("PRAGMA busy_timeout = {ms}"));
}
out
}
fn in_txn_timeout_sql(backend: DatabaseBackend, options: MigrationRuntimeOptions) -> Vec<String> {
let mut out = Vec::new();
match backend {
DatabaseBackend::Postgres => {
if let Some(ms) = options.lock_timeout_ms {
out.push(format!("SET LOCAL lock_timeout = {ms}"));
}
if let Some(ms) = options.statement_timeout_ms {
out.push(format!("SET LOCAL statement_timeout = {ms}"));
}
}
DatabaseBackend::MySql => {
if let Some(ms) = options.lock_timeout_ms {
let secs = ms.div_ceil(1000).max(1);
out.push(format!("SET SESSION innodb_lock_wait_timeout = {secs}"));
}
if let Some(ms) = options.statement_timeout_ms {
out.push(format!("SET SESSION max_execution_time = {ms}"));
}
}
_ => {}
}
out
}
#[cfg(not(tarpaulin_include))]
pub async fn run_embedded_migrations(
pool: &DatabaseConnection,
version_table: &str,
verbose: bool,
migrations: &[EmbeddedMigration],
) -> Result<(), MigrationError> {
run_embedded_migrations_with_options(
pool,
version_table,
verbose,
migrations,
MigrationRuntimeOptions::default(),
)
.await
}
#[cfg(not(tarpaulin_include))]
#[expect(
clippy::print_stderr,
reason = "verbose runtime migrations stream progress diagnostics to stderr while leaving host stdout application-owned"
)]
pub async fn run_embedded_migrations_with_options(
pool: &DatabaseConnection,
version_table: &str,
verbose: bool,
migrations: &[EmbeddedMigration],
options: MigrationRuntimeOptions,
) -> Result<(), MigrationError> {
let backend = pool.get_database_backend();
let q = if matches!(backend, DatabaseBackend::MySql) {
'`'
} else {
'"'
};
let create_table_sql = format!(
"CREATE TABLE IF NOT EXISTS {q}{version_table}{q} (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
);
let stmt = Statement::from_string(backend, create_table_sql);
pool.execute_raw(stmt)
.await
.map_err(|e| database_error(format!("Failed to create version table: {e}"), e))?;
let alter_sql = format!("ALTER TABLE {q}{version_table}{q} ADD COLUMN id TEXT DEFAULT ''");
let stmt = Statement::from_string(backend, alter_sql);
let _ = pool.execute_raw(stmt).await;
if !options.is_noop() {
for sql in pre_txn_timeout_sql(backend, options) {
if verbose {
eprintln!("[vespertide] {sql}");
}
let stmt = Statement::from_string(backend, sql.clone());
pool.execute_raw(stmt)
.await
.map_err(|e| database_error(format!("Failed to apply timeout '{sql}': {e}"), e))?;
}
}
let txn = pool
.begin()
.await
.map_err(|e| database_error(format!("Failed to begin transaction: {e}"), e))?;
if !options.is_noop() {
for sql in in_txn_timeout_sql(backend, options) {
if verbose {
eprintln!("[vespertide] {sql}");
}
let stmt = Statement::from_string(backend, sql.clone());
txn.execute_raw(stmt)
.await
.map_err(|e| database_error(format!("Failed to apply timeout '{sql}': {e}"), e))?;
}
}
let select_sql = format!("SELECT MAX(version) as version FROM {q}{version_table}{q}");
let stmt = Statement::from_string(backend, select_sql);
let version_result = txn
.query_one_raw(stmt)
.await
.map_err(|e| database_error(format!("Failed to read version: {e}"), e))?;
let version_i32 = version_result
.and_then(|row| row.try_get::<i32>("", "version").ok())
.unwrap_or(0);
let version = u32::try_from(version_i32).unwrap_or(0);
let select_ids_sql = format!("SELECT version, id FROM {q}{version_table}{q}");
let stmt = Statement::from_string(backend, select_ids_sql);
let id_rows = txn
.query_all_raw(stmt)
.await
.map_err(|e| database_error(format!("Failed to read version ids: {e}"), e))?;
let version_ids = collect_version_ids(&id_rows);
if verbose {
eprintln!("[vespertide] Current database version: {version}");
}
for migration in migrations {
if version >= migration.version {
continue;
}
if let Some(db_id) = version_ids.get(&migration.version)
&& !migration.migration_id.is_empty()
&& !db_id.is_empty()
&& db_id != migration.migration_id
{
return Err(MigrationError::IdMismatch {
version: migration.version,
expected: migration.migration_id.to_string(),
found: db_id.clone(),
});
}
if verbose {
eprintln!(
"[vespertide] Applying migration v{} ({})",
migration.version, migration.comment
);
}
let sql_blob = migration.sql_blob(backend);
let sqls: Vec<_> = split_sql_blob(sql_blob).collect();
for (sql_idx, sql) in sqls.iter().enumerate() {
if verbose {
eprintln!("[vespertide] [{}/{}] {}", sql_idx + 1, sqls.len(), sql);
}
let stmt = Statement::from_string(backend, (*sql).to_owned());
txn.execute_raw(stmt)
.await
.map_err(|e| database_error(format!("Failed to execute SQL '{sql}': {e}"), e))?;
}
let insert_sql = format!(
"INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')",
version_table, migration.version, migration.migration_id
);
let stmt = Statement::from_string(backend, insert_sql);
txn.execute_raw(stmt)
.await
.map_err(|e| database_error(format!("Failed to insert version: {e}"), e))?;
if verbose {
eprintln!(
"[vespertide] Migration v{} applied successfully",
migration.version
);
}
}
txn.commit()
.await
.map_err(|e| database_error(format!("Failed to commit transaction: {e}"), e))?;
Ok(())
}
fn collect_version_ids(rows: &[QueryResult]) -> std::collections::HashMap<u32, String> {
let mut version_ids = std::collections::HashMap::new();
for row in rows {
if let Ok(found_version) = row.try_get::<i32>("", "version") {
let id = row.try_get::<String>("", "id").unwrap_or_default();
if let Ok(found_version) = u32::try_from(found_version) {
version_ids.insert(found_version, id);
}
}
}
version_ids
}
#[cfg(test)]
mod tests {
use std::error::Error;
use sea_orm::DatabaseBackend;
use crate::MigrationError;
use super::{
EmbeddedMigration, MigrationRuntimeOptions, collect_version_ids, database_error,
in_txn_timeout_sql, pre_txn_timeout_sql, split_sql_blob,
};
#[test]
fn split_sql_blob_ignores_empty_segments() {
let sqls: Vec<_> =
split_sql_blob("CREATE TABLE users ();\0\0ALTER TABLE users;\0").collect();
assert_eq!(sqls, vec!["CREATE TABLE users ();", "ALTER TABLE users;"]);
}
#[test]
fn split_sql_blob_empty_input_yields_no_segments() {
let sqls: Vec<_> = split_sql_blob("").collect();
assert!(sqls.is_empty());
}
#[test]
fn split_sql_blob_single_segment_no_terminator() {
let sqls: Vec<_> = split_sql_blob("SELECT 1").collect();
assert_eq!(sqls, vec!["SELECT 1"]);
}
#[test]
fn embedded_migration_selects_backend_blob() {
let migration = EmbeddedMigration::new(1, "id", "comment", "pg\0", "mysql\0", "sqlite\0");
assert_eq!(migration.sql_blob(DatabaseBackend::Postgres), "pg\0");
assert_eq!(migration.sql_blob(DatabaseBackend::MySql), "mysql\0");
assert_eq!(migration.sql_blob(DatabaseBackend::Sqlite), "sqlite\0");
}
#[test]
fn embedded_migration_new_records_all_fields() {
let migration =
EmbeddedMigration::new(42, "uuid-7", "create users", "pg-sql", "my-sql", "lite-sql");
assert_eq!(migration.version, 42);
assert_eq!(migration.migration_id, "uuid-7");
assert_eq!(migration.comment, "create users");
assert_eq!(migration.postgres_sql_blob, "pg-sql");
assert_eq!(migration.mysql_sql_blob, "my-sql");
assert_eq!(migration.sqlite_sql_blob, "lite-sql");
let migration_copy = migration;
assert_eq!(migration_copy.version, migration.version);
}
#[test]
fn db_err_conversion_preserves_source_chain() {
let db_err = sea_orm::DbErr::Custom("connection refused".to_owned());
let err = MigrationError::from(db_err);
assert_eq!(
err.to_string(),
"database error: Custom Error: connection refused"
);
assert!(err.source().is_some());
assert!(matches!(
&err,
MigrationError::Database {
source: Some(_),
..
}
));
assert!(
err.source()
.is_some_and(|source| source.to_string() == "Custom Error: connection refused")
);
}
#[test]
fn database_error_wraps_message_and_chains_source() {
let db_err = sea_orm::DbErr::Custom("nope".to_owned());
let err = database_error("apply failed".to_owned(), db_err);
match &err {
MigrationError::Database { message, source } => {
assert_eq!(message, "apply failed");
let chained = source.as_ref().expect("source set by database_error");
assert_eq!(chained.to_string(), "Custom Error: nope");
}
other => panic!("expected Database variant, got {other:?}"),
}
assert!(err.source().is_some());
}
#[test]
fn migration_runtime_options_default_is_noop() {
let opts = MigrationRuntimeOptions::default();
assert_eq!(opts.lock_timeout_ms, None);
assert_eq!(opts.statement_timeout_ms, None);
assert!(opts.is_noop());
}
#[test]
fn migration_runtime_options_from_millis_records_both_timeouts() {
let opts = MigrationRuntimeOptions::from_millis(Some(1000), Some(2000));
assert_eq!(opts.lock_timeout_ms, Some(1000));
assert_eq!(opts.statement_timeout_ms, Some(2000));
assert!(!opts.is_noop());
}
#[test]
fn migration_runtime_options_from_millis_lock_only_is_not_noop() {
let opts = MigrationRuntimeOptions::from_millis(Some(500), None);
assert_eq!(opts.lock_timeout_ms, Some(500));
assert_eq!(opts.statement_timeout_ms, None);
assert!(!opts.is_noop());
}
#[test]
fn migration_runtime_options_from_millis_statement_only_is_not_noop() {
let opts = MigrationRuntimeOptions::from_millis(None, Some(3000));
assert_eq!(opts.lock_timeout_ms, None);
assert_eq!(opts.statement_timeout_ms, Some(3000));
assert!(!opts.is_noop());
}
#[test]
fn migration_runtime_options_from_millis_no_timeouts_is_noop() {
let opts = MigrationRuntimeOptions::from_millis(None, None);
assert!(opts.is_noop());
assert_eq!(opts, MigrationRuntimeOptions::default());
}
#[test]
fn migration_runtime_options_supports_copy_clone_eq() {
let a = MigrationRuntimeOptions::from_millis(Some(10), Some(20));
let b = a;
let c = a;
assert_eq!(a, b);
assert_eq!(a, c);
assert_ne!(a, MigrationRuntimeOptions::default());
}
#[test]
fn pre_txn_timeout_sql_postgres_emits_nothing() {
let opts = MigrationRuntimeOptions::from_millis(Some(500), Some(1000));
let sqls = pre_txn_timeout_sql(DatabaseBackend::Postgres, opts);
assert!(sqls.is_empty());
}
#[test]
fn pre_txn_timeout_sql_mysql_emits_nothing() {
let opts = MigrationRuntimeOptions::from_millis(Some(500), Some(1000));
let sqls = pre_txn_timeout_sql(DatabaseBackend::MySql, opts);
assert!(sqls.is_empty());
}
#[test]
fn pre_txn_timeout_sql_sqlite_with_lock_emits_busy_timeout() {
let opts = MigrationRuntimeOptions::from_millis(Some(750), None);
let sqls = pre_txn_timeout_sql(DatabaseBackend::Sqlite, opts);
assert_eq!(sqls, vec!["PRAGMA busy_timeout = 750".to_owned()]);
}
#[test]
fn pre_txn_timeout_sql_sqlite_without_lock_emits_nothing() {
let opts = MigrationRuntimeOptions::from_millis(None, Some(9999));
let sqls = pre_txn_timeout_sql(DatabaseBackend::Sqlite, opts);
assert!(sqls.is_empty());
}
#[test]
fn pre_txn_timeout_sql_sqlite_default_emits_nothing() {
let sqls = pre_txn_timeout_sql(DatabaseBackend::Sqlite, MigrationRuntimeOptions::default());
assert!(sqls.is_empty());
}
#[test]
fn in_txn_timeout_sql_postgres_both_timeouts() {
let opts = MigrationRuntimeOptions::from_millis(Some(500), Some(2000));
let sqls = in_txn_timeout_sql(DatabaseBackend::Postgres, opts);
assert_eq!(
sqls,
vec![
"SET LOCAL lock_timeout = 500".to_owned(),
"SET LOCAL statement_timeout = 2000".to_owned(),
]
);
}
#[test]
fn in_txn_timeout_sql_postgres_lock_only() {
let opts = MigrationRuntimeOptions::from_millis(Some(500), None);
let sqls = in_txn_timeout_sql(DatabaseBackend::Postgres, opts);
assert_eq!(sqls, vec!["SET LOCAL lock_timeout = 500".to_owned()]);
}
#[test]
fn in_txn_timeout_sql_postgres_statement_only() {
let opts = MigrationRuntimeOptions::from_millis(None, Some(2000));
let sqls = in_txn_timeout_sql(DatabaseBackend::Postgres, opts);
assert_eq!(sqls, vec!["SET LOCAL statement_timeout = 2000".to_owned()]);
}
#[test]
fn in_txn_timeout_sql_postgres_default_emits_nothing() {
let sqls = in_txn_timeout_sql(
DatabaseBackend::Postgres,
MigrationRuntimeOptions::default(),
);
assert!(sqls.is_empty());
}
#[test]
fn in_txn_timeout_sql_mysql_rounds_lock_up_to_seconds() {
let opts = MigrationRuntimeOptions::from_millis(Some(1500), Some(7000));
let sqls = in_txn_timeout_sql(DatabaseBackend::MySql, opts);
assert_eq!(
sqls,
vec![
"SET SESSION innodb_lock_wait_timeout = 2".to_owned(),
"SET SESSION max_execution_time = 7000".to_owned(),
]
);
}
#[test]
fn in_txn_timeout_sql_mysql_sub_second_lock_rounds_up_to_one() {
let opts = MigrationRuntimeOptions::from_millis(Some(250), None);
let sqls = in_txn_timeout_sql(DatabaseBackend::MySql, opts);
assert_eq!(
sqls,
vec!["SET SESSION innodb_lock_wait_timeout = 1".to_owned()]
);
}
#[test]
fn in_txn_timeout_sql_mysql_exact_second_lock() {
let opts = MigrationRuntimeOptions::from_millis(Some(1000), None);
let sqls = in_txn_timeout_sql(DatabaseBackend::MySql, opts);
assert_eq!(
sqls,
vec!["SET SESSION innodb_lock_wait_timeout = 1".to_owned()]
);
}
#[test]
fn in_txn_timeout_sql_mysql_statement_only() {
let opts = MigrationRuntimeOptions::from_millis(None, Some(3000));
let sqls = in_txn_timeout_sql(DatabaseBackend::MySql, opts);
assert_eq!(
sqls,
vec!["SET SESSION max_execution_time = 3000".to_owned()]
);
}
#[test]
fn in_txn_timeout_sql_mysql_default_emits_nothing() {
let sqls = in_txn_timeout_sql(DatabaseBackend::MySql, MigrationRuntimeOptions::default());
assert!(sqls.is_empty());
}
#[test]
fn in_txn_timeout_sql_sqlite_emits_nothing_even_with_both_set() {
let opts = MigrationRuntimeOptions::from_millis(Some(500), Some(2000));
let sqls = in_txn_timeout_sql(DatabaseBackend::Sqlite, opts);
assert!(sqls.is_empty());
}
#[test]
fn collect_version_ids_empty_rows_yields_empty_map() {
let rows: Vec<sea_orm::QueryResult> = Vec::new();
let ids = collect_version_ids(&rows);
assert!(ids.is_empty());
}
#[test]
fn collect_version_ids_reads_positive_versions_and_skips_invalid_rows() {
fn row(version: Option<i32>, id: Option<&str>) -> sea_orm::QueryResult {
let mut values = std::collections::BTreeMap::new();
if let Some(version) = version {
values.insert("version".to_owned(), version.into());
}
if let Some(id) = id {
values.insert("id".to_owned(), id.into());
}
sea_orm::ProxyRow::new(values).into()
}
let rows = vec![
row(Some(7), Some("create-users")),
row(Some(-1), Some("negative")),
row(None, Some("missing-version")),
row(Some(8), None),
];
let ids = collect_version_ids(&rows);
assert_eq!(ids.len(), 2);
assert_eq!(ids.get(&7).map(String::as_str), Some("create-users"));
assert_eq!(ids.get(&8).map(String::as_str), Some(""));
assert!(!ids.contains_key(&u32::MAX));
}
}