#![cfg(feature = "c-sqlite-tests")]
use asupersync::runtime::RuntimeBuilder;
use asupersync::{Cx, Outcome};
use sqlmodel::prelude::*;
use sqlmodel_schema::MigrationStatus;
use sqlmodel_sqlite::SqliteConnection;
fn unwrap_outcome<T>(outcome: Outcome<T, Error>) -> T {
match outcome {
Outcome::Ok(v) => v,
Outcome::Err(e) => panic!("unexpected error: {e}"),
Outcome::Cancelled(r) => panic!("cancelled: {r:?}"),
Outcome::Panicked(p) => panic!("panicked: {p:?}"),
}
}
fn migrations() -> Vec<Migration> {
vec![
Migration::new(
"0001_create_widgets",
"create widgets",
"CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
"DROP TABLE widgets",
),
Migration::new(
"0002_seed_widgets",
"seed one widget",
"INSERT INTO widgets (id, name) VALUES (1, 'gear')",
"DELETE FROM widgets WHERE id = 1",
),
Migration::new(
"0003_create_gadgets",
"create gadgets",
"CREATE TABLE gadgets (id INTEGER PRIMARY KEY, widget_id INTEGER NOT NULL REFERENCES widgets(id))",
"DROP TABLE gadgets",
),
]
}
async fn table_exists(cx: &Cx, conn: &SqliteConnection, table: &str) -> bool {
let rows = unwrap_outcome(
conn.query(
cx,
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
&[Value::Text(table.to_string())],
)
.await,
);
rows[0].get_as::<i64>(0).unwrap() == 1
}
async fn recorded_ids(cx: &Cx, conn: &SqliteConnection, table: &str) -> Vec<String> {
let rows = unwrap_outcome(
conn.query(cx, &format!("SELECT id FROM {table} ORDER BY id"), &[])
.await,
);
rows.iter()
.map(|r| r.get_as::<String>(0).unwrap())
.collect()
}
#[test]
fn migration_runner_applies_records_is_idempotent_and_rolls_back_on_sqlite() {
let rt = RuntimeBuilder::current_thread()
.build()
.expect("create asupersync runtime");
let cx = Cx::for_testing();
rt.block_on(async {
let conn = SqliteConnection::open_memory().expect("open sqlite memory db");
let runner = MigrationRunner::new(migrations()).table_name("schema-history");
let tracking = "schemahistory";
unwrap_outcome(runner.init(&cx, &conn).await);
assert!(
table_exists(&cx, &conn, tracking).await,
"tracking table created"
);
let status = unwrap_outcome(runner.status(&cx, &conn).await);
assert_eq!(status.len(), 3);
assert!(status.iter().all(|(_, s)| *s == MigrationStatus::Pending));
let applied = unwrap_outcome(runner.migrate(&cx, &conn).await);
assert_eq!(
applied,
vec![
"0001_create_widgets",
"0002_seed_widgets",
"0003_create_gadgets"
]
);
assert!(table_exists(&cx, &conn, "widgets").await);
assert!(table_exists(&cx, &conn, "gadgets").await);
assert_eq!(
recorded_ids(&cx, &conn, tracking).await,
vec![
"0001_create_widgets",
"0002_seed_widgets",
"0003_create_gadgets"
],
"every applied migration has a tracking row (raw SQL check)"
);
let seeded = unwrap_outcome(
conn.query(&cx, "SELECT name FROM widgets WHERE id = 1", &[])
.await,
);
assert_eq!(seeded[0].get_as::<String>(0).unwrap(), "gear");
let status = unwrap_outcome(runner.status(&cx, &conn).await);
for (_, s) in &status {
match s {
MigrationStatus::Applied { at } => {
assert!(*at > 1_600_000_000, "unix seconds: {at}")
}
other => panic!("expected Applied, got {other:?}"),
}
}
let applied_again = unwrap_outcome(runner.migrate(&cx, &conn).await);
assert!(
applied_again.is_empty(),
"nothing pending on the second run"
);
assert_eq!(recorded_ids(&cx, &conn, tracking).await.len(), 3);
let rolled = unwrap_outcome(runner.rollback(&cx, &conn).await);
assert_eq!(rolled.as_deref(), Some("0003_create_gadgets"));
assert!(
!table_exists(&cx, &conn, "gadgets").await,
"down SQL executed"
);
assert!(
table_exists(&cx, &conn, "widgets").await,
"earlier migrations untouched"
);
assert_eq!(
recorded_ids(&cx, &conn, tracking).await,
vec!["0001_create_widgets", "0002_seed_widgets"]
);
let reapplied = unwrap_outcome(runner.migrate(&cx, &conn).await);
assert_eq!(reapplied, vec!["0003_create_gadgets"]);
assert!(table_exists(&cx, &conn, "gadgets").await);
});
}
#[test]
fn failing_migration_is_not_recorded_and_earlier_ones_stay_applied() {
let rt = RuntimeBuilder::current_thread()
.build()
.expect("create asupersync runtime");
let cx = Cx::for_testing();
rt.block_on(async {
let conn = SqliteConnection::open_memory().expect("open sqlite memory db");
let mut list = migrations();
list.push(Migration::new(
"0004_broken",
"references a missing table",
"INSERT INTO does_not_exist (id) VALUES (1)",
"DELETE FROM does_not_exist WHERE id = 1",
));
let runner = MigrationRunner::new(list);
let result = runner.migrate(&cx, &conn).await;
assert!(
matches!(result, Outcome::Err(_)),
"a failing up-migration must surface as an error, got {result:?}"
);
assert_eq!(
recorded_ids(&cx, &conn, "_sqlmodel_migrations").await,
vec![
"0001_create_widgets",
"0002_seed_widgets",
"0003_create_gadgets"
]
);
let status = unwrap_outcome(runner.status(&cx, &conn).await);
let broken = status
.iter()
.find(|(id, _)| id == "0004_broken")
.expect("broken migration listed");
assert_eq!(broken.1, MigrationStatus::Pending);
let rolled = unwrap_outcome(runner.rollback(&cx, &conn).await);
assert_eq!(rolled.as_deref(), Some("0003_create_gadgets"));
});
}
#[test]
fn rollback_on_a_fresh_database_is_a_no_op() {
let rt = RuntimeBuilder::current_thread()
.build()
.expect("create asupersync runtime");
let cx = Cx::for_testing();
rt.block_on(async {
let conn = SqliteConnection::open_memory().expect("open sqlite memory db");
let runner = MigrationRunner::new(migrations());
let rolled = unwrap_outcome(runner.rollback(&cx, &conn).await);
assert_eq!(rolled, None);
});
}