use sqlx::{Executor, Row};
use super::dialect::{JobDb, JobPool, sql};
use super::error::MigrateError;
type Conn = <JobDb as sqlx::Database>::Connection;
const STATEMENT_SEPARATOR: &str = "--;;";
const MIGRATIONS: &[(&str, &str)] = &[("0001_jobs", sql::SCHEMA)];
pub async fn apply(pool: &JobPool) -> Result<(), MigrateError> {
let mut conn = pool.acquire().await?;
apply_on(&mut conn).await
}
pub async fn apply_tx(tx: &mut sqlx::Transaction<'_, JobDb>) -> Result<(), MigrateError> {
apply_on(&mut *tx).await
}
async fn apply_on(conn: &mut Conn) -> Result<(), MigrateError> {
conn.execute(sql::CREATE_HISTORY).await?;
let Some(lock) = sql::LOCK else {
return apply_pending(conn).await;
};
sqlx::query(lock).execute(&mut *conn).await?;
let result = apply_pending(&mut *conn).await;
if let Some(unlock) = sql::UNLOCK {
let _ = sqlx::query(unlock).execute(&mut *conn).await;
}
result
}
async fn apply_pending(conn: &mut Conn) -> Result<(), MigrateError> {
for &(version, migration) in MIGRATIONS {
let applied: i64 = sqlx::query(sql::COUNT_APPLIED)
.bind(version)
.fetch_one(&mut *conn)
.await?
.try_get::<i64, _>(0)?;
if applied > 0 {
continue;
}
for statement in statements(migration) {
conn.execute(statement).await?;
}
sqlx::query(sql::RECORD_APPLIED)
.bind(version)
.execute(&mut *conn)
.await?;
}
Ok(())
}
fn statements(migration: &str) -> impl Iterator<Item = &str> {
let mut pieces = Vec::new();
let mut start = 0;
let mut offset = 0;
for line in migration.split_inclusive('\n') {
if line.trim() == STATEMENT_SEPARATOR {
pieces.push(&migration[start..offset]);
start = offset + line.len();
}
offset += line.len();
}
pieces.push(&migration[start..]);
pieces
.into_iter()
.map(str::trim)
.filter(|statement| !statement.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bundled_migration_splits_into_at_least_one_statement() {
for &(version, migration) in MIGRATIONS {
let count = statements(migration).count();
assert!(count > 0, "{version} produced no statements");
}
}
#[test]
fn a_comment_that_mentions_the_separator_does_not_split_the_file() {
let sql = "-- Statements are separated by a line reading `--;;`.\n\
CREATE TABLE a (i INT)";
let split: Vec<_> = statements(sql).collect();
assert_eq!(split.len(), 1, "split into {split:?}");
assert!(split[0].starts_with("-- Statements"));
}
#[test]
fn no_bundled_statement_begins_mid_comment() {
for &(version, migration) in MIGRATIONS {
for statement in statements(migration) {
assert!(
!statement.starts_with('`'),
"{version} produced a fragment, not a statement: {statement:.40}"
);
}
}
}
#[test]
fn splitting_drops_blank_fragments_and_trims_each_statement() {
let split: Vec<_> =
statements(" CREATE TABLE a (i INT) \n--;;\n\n--;;\nSELECT 1\n--;;\n").collect();
assert_eq!(split, vec!["CREATE TABLE a (i INT)", "SELECT 1"]);
}
}
#[cfg(all(test, feature = "test-kit"))]
mod live_tests {
use super::*;
use crate::jobs::test_support::{enqueue, queue, rows};
async fn applied(pool: &JobPool) -> Vec<String> {
sqlx::query("SELECT version FROM arcature_jobs_schema_migrations")
.fetch_all(pool)
.await
.expect("read the migration history")
.iter()
.map(|row| row.try_get::<String, _>("version").expect("version"))
.collect()
}
#[tokio::test]
async fn the_bundled_migration_runs_and_leaves_a_usable_table() {
let Some(fixture) = queue().await else {
return;
};
let pool = fixture.pool();
let versions = applied(pool).await;
for &(version, _) in MIGRATIONS {
assert!(
versions.iter().any(|applied| applied == version),
"{version} is not recorded in the history table: {versions:?}"
);
}
let enqueued = enqueue(pool, 1).await;
assert_eq!(
rows(pool).await,
vec![(enqueued[0], "pending".to_owned(), 0)],
"the migrated table did not accept an ordinary enqueue"
);
}
#[tokio::test]
async fn applying_again_changes_nothing() {
let Some(fixture) = queue().await else {
return;
};
let pool = fixture.pool();
let before = applied(pool).await;
apply(pool).await.expect("apply a second time");
apply(pool).await.expect("apply a third time");
let after = applied(pool).await;
assert_eq!(
after.len(),
before.len(),
"re-applying added history rows: {before:?} then {after:?}"
);
}
#[tokio::test]
async fn applying_inside_a_rolled_back_transaction_leaves_the_history_consistent() {
let Some(fixture) = queue().await else {
return;
};
let pool = fixture.pool();
let mut tx = pool.begin().await.expect("begin");
apply_tx(&mut tx).await.expect("apply inside a transaction");
tx.rollback().await.expect("roll back");
apply(pool).await.expect("apply after the rollback");
let versions = applied(pool).await;
assert_eq!(
versions.len(),
MIGRATIONS.len(),
"the history holds {versions:?} for {} migration(s)",
MIGRATIONS.len()
);
}
}