#[path = "fixtures/baseline_dir.rs"]
pub mod baseline_dir;
#[path = "fixtures/embedded_list.rs"]
pub mod embedded_list;
use toolu_orm_cli::migrate::{run_migrate, run_migrate_embedded, EmbeddedMigration, MigrateError};
use toolu_orm_connection::{Database, LibsqlConnection};
use toolu_orm_core::dialect::Dialect;
use toolu_orm_core::journal::compute_hash;
use baseline_dir::{migrations_dir, write_migrations};
use embedded_list::{
as_files, honest, list, tampered, OwnedMigration, CREATE_SQL, FAILING_SQL, MAKE_T_SQL, SEED_T_SQL,
};
type TestResult = Result<(), Box<dyn std::error::Error>>;
const POSTS_SQL: &str = "CREATE TABLE posts (id TEXT PRIMARY KEY);";
const THIRD_SQL: &str = "CREATE TABLE c (id TEXT);";
async fn connect() -> Result<LibsqlConnection, Box<dyn std::error::Error>> {
Ok(Database::init_local(":memory:").await?.connect()?)
}
async fn migrate(conn: &LibsqlConnection, owned: &[OwnedMigration]) -> Result<u32, MigrateError> {
run_migrate_embedded(conn, &list(owned), Dialect::Sqlite).await
}
async fn scalar(conn: &LibsqlConnection, sql: &str) -> Result<i64, Box<dyn std::error::Error>> {
let mut rows = conn.inner_conn().query(sql, ()).await?;
let row = rows.next().await?.ok_or("scalar query returned no row")?;
Ok(row.get::<i64>(0)?)
}
async fn has_table(conn: &LibsqlConnection, name: &str) -> Result<i64, Box<dyn std::error::Error>> {
scalar(
conn,
&format!("SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = '{name}'"),
)
.await
}
async fn recorded(conn: &LibsqlConnection) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut rows = conn
.inner_conn()
.query("SELECT name FROM _migrations ORDER BY id", ())
.await?;
let mut names = Vec::new();
while let Some(row) = rows.next().await? {
names.push(row.get::<String>(0)?);
}
Ok(names)
}
fn two_migrations() -> Vec<OwnedMigration> {
vec![
honest("0001_init.sql", CREATE_SQL),
honest("0002_posts.sql", POSTS_SQL),
]
}
#[tokio::test]
async fn an_embedded_list_applies_every_statement_of_every_migration() -> TestResult {
let conn = connect().await?;
assert_eq!(migrate(&conn, &two_migrations()).await?, 2);
assert_eq!(has_table(&conn, "users").await?, 1);
assert_eq!(
has_table(&conn, "audit").await?,
1,
"the statement after the breakpoint must run too"
);
assert_eq!(has_table(&conn, "posts").await?, 1);
assert_eq!(recorded(&conn).await?, ["0001_init.sql", "0002_posts.sql"]);
assert_eq!(
scalar(
&conn,
&format!(
"SELECT count(*) FROM _migrations WHERE name = '0001_init.sql' AND hash = '{}'",
compute_hash(CREATE_SQL)
)
)
.await?,
1,
"the declared hash must be what is recorded"
);
Ok(())
}
#[tokio::test]
async fn re_running_applies_nothing_and_a_new_entry_applies_alone() -> TestResult {
let conn = connect().await?;
let mut migrations = two_migrations();
assert_eq!(migrate(&conn, &migrations).await?, 2);
assert_eq!(migrate(&conn, &migrations).await?, 0);
migrations.push(honest("0003_c.sql", THIRD_SQL));
assert_eq!(migrate(&conn, &migrations).await?, 1);
assert_eq!(has_table(&conn, "c").await?, 1);
assert_eq!(
recorded(&conn).await?,
["0001_init.sql", "0002_posts.sql", "0003_c.sql"]
);
Ok(())
}
#[tokio::test]
async fn an_edited_migration_fails_the_hash_check_after_the_earlier_one_applied() -> TestResult {
let conn = connect().await?;
let migrations = vec![
honest("0001_init.sql", CREATE_SQL),
tampered(
"0002_posts.sql",
POSTS_SQL,
"CREATE TABLE posts (id TEXT PRIMARY KEY, tampered TEXT);",
),
];
let Err(err) = migrate(&conn, &migrations).await else {
return Err("a tampered embedded migration was applied".into());
};
assert!(
matches!(err, MigrateError::HashMismatch { ref file, ref expected, .. }
if file == "0002_posts.sql" && expected == &compute_hash(POSTS_SQL)),
"got {err:?}"
);
assert_eq!(has_table(&conn, "posts").await?, 0);
assert_eq!(
recorded(&conn).await?,
["0001_init.sql"],
"the valid migration before it stays applied"
);
Ok(())
}
#[tokio::test]
async fn a_repeated_name_is_rejected_before_anything_is_written() -> TestResult {
let conn = connect().await?;
let migrations = vec![
honest("0001_init.sql", CREATE_SQL),
honest("0001_init.sql", POSTS_SQL),
];
let Err(err) = migrate(&conn, &migrations).await else {
return Err("a list repeating a name was accepted".into());
};
assert!(
matches!(err, MigrateError::DuplicateMigration(_)),
"got {err:?}"
);
assert!(err.to_string().contains("0001_init.sql"), "{err}");
assert_eq!(
has_table(&conn, "_migrations").await?,
0,
"a rejected list must not even create the bookkeeping table"
);
assert_eq!(has_table(&conn, "users").await?, 0);
Ok(())
}
#[tokio::test]
async fn an_empty_list_still_creates_the_migrations_table() -> TestResult {
let conn = connect().await?;
assert_eq!(run_migrate_embedded(&conn, &[], Dialect::Sqlite).await?, 0);
assert_eq!(scalar(&conn, "SELECT count(*) FROM _migrations").await?, 0);
Ok(())
}
#[tokio::test]
async fn a_failing_statement_rolls_back_only_its_own_migration() -> TestResult {
let conn = connect().await?;
let migrations = vec![
honest("0001_init.sql", CREATE_SQL),
honest("0002_bad.sql", FAILING_SQL),
];
let Err(err) = migrate(&conn, &migrations).await else {
return Err("a failing statement did not stop the run".into());
};
assert!(matches!(err, MigrateError::Database(_)), "got {err:?}");
assert!(err.to_string().contains("0002_bad.sql"), "{err}");
assert_eq!(
has_table(&conn, "half").await?,
0,
"the first statement's table survived the rollback"
);
assert_eq!(recorded(&conn).await?, ["0001_init.sql"]);
Ok(())
}
#[tokio::test]
async fn the_list_order_wins_over_the_name_order() -> TestResult {
let conn = connect().await?;
let migrations = vec![
honest("0009_make_t.sql", MAKE_T_SQL),
honest("0001_seed_t.sql", SEED_T_SQL),
];
assert_eq!(migrate(&conn, &migrations).await?, 2);
assert_eq!(scalar(&conn, "SELECT count(*) FROM t").await?, 1);
assert_eq!(
recorded(&conn).await?,
["0009_make_t.sql", "0001_seed_t.sql"]
);
Ok(())
}
#[test]
fn verify_hash_checks_a_migration_without_a_database() {
let hash = compute_hash(CREATE_SQL);
let good = EmbeddedMigration {
name: "0001_init.sql",
sql: CREATE_SQL,
hash: &hash,
};
assert!(good.verify_hash().is_ok());
let bad = EmbeddedMigration {
name: "0001_init.sql",
sql: POSTS_SQL,
hash: &hash,
};
assert!(matches!(
bad.verify_hash(),
Err(MigrateError::HashMismatch { .. })
));
}
#[tokio::test]
async fn a_directory_migrated_database_accepts_the_equivalent_embedded_list() -> TestResult {
let (_tmp, dir) = migrations_dir()?;
let migrations = two_migrations();
write_migrations(&dir, &as_files(&migrations))?;
let conn = connect().await?;
assert_eq!(run_migrate(&conn, &dir, Dialect::Sqlite).await?, 2);
let from_dir = recorded(&conn).await?;
assert_eq!(
migrate(&conn, &migrations).await?,
0,
"the same migrations from memory must look already applied"
);
assert_eq!(recorded(&conn).await?, from_dir);
Ok(())
}
#[tokio::test]
async fn an_embedded_migrated_database_accepts_the_equivalent_directory() -> TestResult {
let (_tmp, dir) = migrations_dir()?;
let migrations = two_migrations();
write_migrations(&dir, &as_files(&migrations))?;
let conn = connect().await?;
assert_eq!(migrate(&conn, &migrations).await?, 2);
assert_eq!(
run_migrate(&conn, &dir, Dialect::Sqlite).await?,
0,
"the same migrations from disk must look already applied"
);
assert_eq!(recorded(&conn).await?, ["0001_init.sql", "0002_posts.sql"]);
Ok(())
}