use persistant_relational::{RelError, RelationalBackend, Value};
use persistant_sqlx::{FromSqlxError, MigrateError, Migration, PgBackend, migrations_from_sqlx};
use sha2::{Digest, Sha384};
use sqlx::SqlSafeStr;
use sqlx::migrate::{Migration as SqlxMigration, MigrationType, Migrator};
async fn isolated(name: &str) -> Option<PgBackend> {
let Ok(url) = std::env::var("PERSISTANT_TEST_DATABASE_URL") else {
assert!(
std::env::var("PERSISTANT_REQUIRE_DATABASE").as_deref() != Ok("1"),
"PERSISTANT_REQUIRE_DATABASE=1 but PERSISTANT_TEST_DATABASE_URL is not set"
);
eprintln!("skipped: PERSISTANT_TEST_DATABASE_URL is not set");
return None;
};
let schema = format!("persistant_mig_{name}");
let admin = PgBackend::connect(&url).await.expect("connect");
admin
.execute(&format!("DROP SCHEMA IF EXISTS {schema} CASCADE"), &[])
.await
.unwrap();
admin
.execute(&format!("CREATE SCHEMA {schema}"), &[])
.await
.unwrap();
let sep = if url.contains('?') { '&' } else { '?' };
let scoped = format!("{url}{sep}options=-c%20search_path%3D{schema}");
Some(PgBackend::connect(&scoped).await.expect("connect scoped"))
}
fn two() -> Vec<Migration> {
vec![
Migration::new(
1,
"create things",
"CREATE TABLE things (id INT PRIMARY KEY, n TEXT);",
),
Migration::new(2, "add index", "CREATE INDEX things_n ON things (n);"),
]
}
#[tokio::test]
async fn applies_in_order_then_a_second_run_applies_nothing() {
let Some(db) = isolated("applies").await else {
return;
};
let r = db.migrate(&two()).await.unwrap();
assert_eq!(r.applied, vec![1, 2]);
assert!(r.already_applied.is_empty());
db.execute("INSERT INTO things VALUES (1, 'x')", &[])
.await
.unwrap();
let again = db.migrate(&two()).await.unwrap();
assert!(again.applied.is_empty());
assert_eq!(again.already_applied, vec![1, 2]);
assert_eq!(db.applied_versions().await.unwrap(), vec![1, 2]);
}
#[tokio::test]
async fn a_new_migration_is_added_to_an_existing_ledger() {
let Some(db) = isolated("adds").await else {
return;
};
db.migrate(&two()).await.unwrap();
let mut three = two();
three.push(Migration::new(
3,
"add column",
"ALTER TABLE things ADD COLUMN extra INT;",
));
let r = db.migrate(&three).await.unwrap();
assert_eq!(r.applied, vec![3]);
assert_eq!(r.already_applied, vec![1, 2]);
}
#[tokio::test]
async fn an_edited_applied_migration_is_refused_by_checksum() {
let Some(db) = isolated("edited").await else {
return;
};
db.migrate(&two()).await.unwrap();
let mut edited = two();
edited[0].sql = "CREATE TABLE things (id INT PRIMARY KEY, n TEXT, sneaky INT);".to_string();
let e = db.migrate(&edited).await.unwrap_err();
assert_eq!(e, MigrateError::ChecksumMismatch { version: 1 });
}
#[tokio::test]
async fn a_database_newer_than_the_program_is_refused() {
let Some(db) = isolated("newer").await else {
return;
};
db.migrate(&two()).await.unwrap();
let e = db.migrate(&two()[..1]).await.unwrap_err();
assert_eq!(e, MigrateError::NewerThanProgram { version: 2 });
}
#[tokio::test]
async fn a_failing_migration_rolls_back_and_names_its_version_and_class() {
let Some(db) = isolated("fails").await else {
return;
};
let migs = vec![
Migration::new(1, "ok", "CREATE TABLE ok_table (id INT PRIMARY KEY);"),
Migration::new(
2,
"half then break",
"CREATE TABLE half (id INT); INSERT INTO ok_table VALUES (1); INSERT INTO ok_table VALUES (1);",
),
];
let e = db.migrate(&migs).await.unwrap_err();
assert!(
matches!(
&e,
MigrateError::Failed {
version: 2,
error: RelError::UniqueViolation { .. }
}
),
"{e:?}"
);
assert_eq!(db.applied_versions().await.unwrap(), vec![1]);
let half = db
.query("SELECT to_regclass('half')::text", &[])
.await
.unwrap();
assert_eq!(
half[0].values[0],
Value::Null,
"the failed migration's DDL was rolled back"
);
}
#[tokio::test]
async fn the_ledger_is_sqlxs_own_shape_with_sha384_checksums() {
let Some(db) = isolated("shape").await else {
return;
};
let migs = two();
db.migrate(&migs).await.unwrap();
let cols = db
.query(
"SELECT column_name FROM information_schema.columns \
WHERE table_schema = current_schema() AND table_name = '_sqlx_migrations' \
ORDER BY column_name",
&[],
)
.await
.unwrap();
let names: Vec<&str> = cols.iter().filter_map(|r| r.text(0)).collect();
assert_eq!(
names,
vec![
"checksum",
"description",
"execution_time",
"installed_on",
"success",
"version"
]
);
let rows = db
.query(
"SELECT version, checksum FROM _sqlx_migrations ORDER BY version",
&[],
)
.await
.unwrap();
for (row, m) in rows.iter().zip(&migs) {
let want = Sha384::digest(m.sql.as_bytes());
assert_eq!(row.int(0), Some(m.version));
assert_eq!(
row.bytes(1),
Some(&want[..]),
"checksum is SHA-384 of the migration text, as sqlx writes it"
);
}
}
fn sqlx_mig(v: i64, kind: MigrationType, sql: &'static str, no_tx: bool) -> SqlxMigration {
SqlxMigration::new(
v,
format!("m{v}").into(),
kind,
sqlx::AssertSqlSafe(sql).into_sql_str(),
no_tx,
)
}
#[test]
fn a_migrator_maps_to_migrations_and_down_halves_are_dropped() {
let m = Migrator::with_migrations(vec![
sqlx_mig(1, MigrationType::Simple, "CREATE TABLE a (id INT);", false),
sqlx_mig(
2,
MigrationType::ReversibleUp,
"CREATE TABLE b (id INT);",
false,
),
sqlx_mig(2, MigrationType::ReversibleDown, "DROP TABLE b;", false),
]);
let got = migrations_from_sqlx(&m).unwrap();
assert_eq!(
got,
vec![
Migration::new(1, "m1", "CREATE TABLE a (id INT);"),
Migration::new(2, "m2", "CREATE TABLE b (id INT);"),
]
);
}
#[test]
fn what_sqlx_would_do_differently_is_refused() {
let mut m =
Migrator::with_migrations(vec![sqlx_mig(1, MigrationType::Simple, "SELECT 1;", true)]);
assert_eq!(
migrations_from_sqlx(&m),
Err(FromSqlxError::NoTransaction { version: 1 })
);
m = Migrator::with_migrations(vec![sqlx_mig(1, MigrationType::Simple, "SELECT 1;", false)]);
m.dangerous_set_table_name("other_ledger");
assert!(matches!(
migrations_from_sqlx(&m),
Err(FromSqlxError::LedgerTable { .. })
));
let mut m =
Migrator::with_migrations(vec![sqlx_mig(1, MigrationType::Simple, "SELECT 1;", false)]);
m.set_locking(false);
assert_eq!(
migrations_from_sqlx(&m),
Err(FromSqlxError::Setting("locking = false"))
);
let ok =
Migrator::with_migrations(vec![sqlx_mig(1, MigrationType::Simple, "SELECT 1;", false)]);
assert!(migrations_from_sqlx(&ok).is_ok());
}
#[tokio::test]
async fn a_database_migrated_by_sqlx_is_accepted_and_the_other_way_round() {
let Some(db) = isolated("from_sqlx").await else {
return;
};
let sqlx_side = Migrator::with_migrations(vec![
sqlx_mig(
1,
MigrationType::Simple,
"CREATE TABLE sx (id INT PRIMARY KEY);",
false,
),
sqlx_mig(
2,
MigrationType::Simple,
"CREATE INDEX sx_id ON sx (id);",
false,
),
]);
sqlx_side.run(db.pool()).await.unwrap();
let ours = migrations_from_sqlx(&sqlx_side).unwrap();
let r = db.migrate(&ours).await.unwrap();
assert!(r.applied.is_empty());
assert_eq!(r.already_applied, vec![1, 2]);
let stored: Vec<Vec<u8>> =
sqlx::query_scalar("SELECT checksum FROM _sqlx_migrations ORDER BY version")
.fetch_all(db.pool())
.await
.unwrap();
let want: Vec<Vec<u8>> = sqlx_side.iter().map(|m| m.checksum.to_vec()).collect();
assert_eq!(stored, want);
}
static FRESH: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn schemas_named_fresh(db: &PgBackend) -> i64 {
sqlx::query_scalar("SELECT count(*) FROM pg_namespace WHERE nspname LIKE 'persistant_fresh_%'")
.fetch_one(db.pool())
.await
.unwrap()
}
#[tokio::test]
async fn verify_fresh_installs_on_an_empty_schema_and_leaves_nothing() {
let _one = FRESH.lock().await;
let Some(db) = isolated("fresh_ok").await else {
return;
};
let before = schemas_named_fresh(&db).await;
let r = db.verify_fresh(&two()).await.unwrap();
assert_eq!(r.applied, vec![1, 2]);
assert!(db.applied_versions().await.unwrap().is_empty());
assert!(db.execute("SELECT * FROM things", &[]).await.is_err());
assert_eq!(schemas_named_fresh(&db).await, before);
assert_eq!(db.migrate(&two()).await.unwrap().applied, vec![1, 2]);
}
#[tokio::test]
async fn verify_fresh_fails_for_a_migration_that_only_works_on_an_existing_database() {
let _one = FRESH.lock().await;
let Some(db) = isolated("fresh_bad").await else {
return;
};
db.execute("CREATE TABLE legacy (id INT)", &[])
.await
.unwrap();
let ms = vec![Migration::new(
1,
"uses legacy",
"ALTER TABLE legacy ADD COLUMN n INT;",
)];
let before = schemas_named_fresh(&db).await;
assert!(matches!(
db.verify_fresh(&ms).await,
Err(MigrateError::Failed { version: 1, .. })
));
assert_eq!(schemas_named_fresh(&db).await, before);
assert_eq!(db.migrate(&ms).await.unwrap().applied, vec![1]);
}
#[tokio::test]
async fn the_report_carries_each_migrations_checksum() {
let Some(db) = isolated("report_checksums").await else {
return;
};
let want: Vec<(i64, Vec<u8>)> = two()
.iter()
.map(|m| (m.version, Sha384::digest(m.sql.as_bytes()).to_vec()))
.collect();
let first = db.migrate(&two()).await.unwrap();
assert_eq!(first.checksums, want);
let again = db.migrate(&two()).await.unwrap();
assert!(again.applied.is_empty());
assert_eq!(again.checksums, want);
}
#[tokio::test]
async fn applied_checksums_reads_the_ledger_without_applying_anything() {
let Some(db) = isolated("applied_checksums").await else {
return;
};
assert!(db.applied_checksums().await.unwrap().is_empty());
let report = db.migrate(&two()).await.unwrap();
assert_eq!(db.applied_checksums().await.unwrap(), report.checksums);
}