use std::ops::Deref;
use sqlx::migrate::{Migrate, Migrator};
use sqlx::Acquire;
use super::error::{StorageError, StorageResult};
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
pub(crate) async fn apply<'a, A>(migrator: &Migrator, conn: A) -> StorageResult<()>
where
A: Acquire<'a> + Send,
<A::Connection as Deref>::Target: Migrate,
{
migrator
.run(conn)
.await
.map_err(|e| StorageError::MigrationFailed(e.to_string()))
}
pub async fn run_migrations<'a, A>(conn: A) -> StorageResult<()>
where
A: Acquire<'a> + Send,
<A::Connection as Deref>::Target: Migrate,
{
apply(&MIGRATOR, conn).await
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::SqlitePool;
static GOOD_MIGRATOR: Migrator = sqlx::migrate!("./src/storage/test_fixtures/migrations/good");
static BAD_MIGRATOR: Migrator = sqlx::migrate!("./src/storage/test_fixtures/migrations/bad");
async fn fresh_sqlite_pool() -> SqlitePool {
SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite in-memory pool")
}
#[tokio::test]
async fn apply_good_succeeds_on_fresh_sqlite() {
let pool = fresh_sqlite_pool().await;
apply(&GOOD_MIGRATOR, &pool)
.await
.expect("apply must succeed on a fresh SQLite database");
}
#[tokio::test]
async fn apply_is_idempotent_on_sqlite() {
let pool = fresh_sqlite_pool().await;
apply(&GOOD_MIGRATOR, &pool).await.expect("first apply ok");
apply(&GOOD_MIGRATOR, &pool)
.await
.expect("re-applying the same migrator must be a no-op");
}
#[tokio::test]
async fn apply_creates_sqlx_migrations_tracking_table_on_sqlite() {
let pool = fresh_sqlite_pool().await;
apply(&GOOD_MIGRATOR, &pool).await.expect("apply ok");
let applied: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations")
.fetch_one(&pool)
.await
.expect("_sqlx_migrations table must exist and be queryable");
assert!(applied >= 1, "expected at least one tracked migration, got {applied}");
}
#[tokio::test]
async fn apply_bad_returns_migration_failed_on_sqlite() {
let pool = fresh_sqlite_pool().await;
let err = apply(&BAD_MIGRATOR, &pool)
.await
.expect_err("apply must fail when a migration file contains invalid SQL");
assert!(
matches!(err, StorageError::MigrationFailed(_)),
"expected StorageError::MigrationFailed, got: {err:?}"
);
}
#[tokio::test]
async fn run_migrations_against_production_dir_succeeds_on_sqlite() {
let pool = fresh_sqlite_pool().await;
run_migrations(&pool)
.await
.expect("production migrator must apply cleanly on a fresh SQLite DB");
}
#[tokio::test]
async fn apply_good_succeeds_and_is_idempotent_on_postgres() {
use sqlx::postgres::PgPoolOptions;
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::runners::AsyncRunner;
let container = Postgres::default()
.start()
.await
.expect("failed to start postgres testcontainer (is Docker running?)");
let host = container.get_host().await.expect("container host");
let port = container.get_host_port_ipv4(5432).await.expect("container port");
let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to postgres container");
apply(&GOOD_MIGRATOR, &pool)
.await
.expect("apply must succeed on a fresh PostgreSQL database");
apply(&GOOD_MIGRATOR, &pool)
.await
.expect("re-applying the same migrator must be a no-op on PostgreSQL");
let applied: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations")
.fetch_one(&pool)
.await
.expect("_sqlx_migrations table must exist on PostgreSQL");
assert!(
applied >= 1,
"expected at least one tracked migration on PostgreSQL, got {applied}"
);
}
}