use std::string::{String, ToString};
use std::sync::{Arc, LazyLock, Mutex};
use std::vec::Vec;
use rusqlite::{Connection, Transaction};
use rusqlite_migration::{HookError, HookResult, M, Migrations, SchemaVersion};
use super::errors::SqliteStoreError;
use super::schema::SchemaHash;
pub(crate) const CLIENT_MIGRATIONS: [SqliteMigration; 2] = [
SqliteMigration::new(
include_str!("../migrations/0001_init.sql"),
"0x06fd2450cfc7d5f06dc28f10b04f4bedadd712a4a8b78482688733402626ba42",
),
SqliteMigration::new(
include_str!("../migrations/0002_index_tuning.sql"),
"0x8ca8394a0c5e58642bc66f381cf7aa680eb66171702b6a73cf17704ad60be493",
),
];
static CLIENT_MIGRATOR: LazyLock<SqliteMigrator> =
LazyLock::new(|| SqliteMigrator::new(&CLIENT_MIGRATIONS));
pub(crate) type MigrationHook = fn(&Transaction<'_>) -> HookResult;
type RejectionReport = Arc<Mutex<Option<SqliteStoreError>>>;
#[derive(Debug, Clone, Copy)]
pub(crate) struct SqliteMigration {
sql: &'static str,
hook: Option<MigrationHook>,
expected_hash: &'static str,
}
impl SqliteMigration {
pub(crate) const fn new(sql: &'static str, expected_hash: &'static str) -> Self {
Self { sql, hook: None, expected_hash }
}
#[cfg_attr(not(test), expect(dead_code, reason = "no shipped migration needs a hook yet"))]
pub(crate) const fn with_hook(
sql: &'static str,
hook: MigrationHook,
expected_hash: &'static str,
) -> Self {
Self { sql, hook: Some(hook), expected_hash }
}
#[cfg(test)]
fn to_library_migration(self) -> M<'static> {
match self.hook {
Some(hook) => M::up_with_hook(self.sql, hook),
None => M::up(self.sql),
}
.foreign_key_check()
}
}
#[derive(Debug)]
pub(crate) struct SqliteMigrator {
migrations: Vec<SqliteMigration>,
}
impl SqliteMigrator {
pub(crate) fn client() -> &'static Self {
&CLIENT_MIGRATOR
}
pub(crate) fn new(migrations: &[SqliteMigration]) -> Self {
Self { migrations: migrations.to_vec() }
}
pub(crate) fn latest_version(&self) -> usize {
self.migrations.len()
}
pub(crate) fn expected_hash(&self, version: usize) -> &'static str {
self.migrations[version - 1].expected_hash
}
#[cfg(test)]
pub(crate) fn has_pending(&self, conn: &Connection) -> Result<bool, SqliteStoreError> {
match Self::library_migrations(&self.migrations).current_version(conn)? {
SchemaVersion::Inside(ver) => Ok(ver.get() < self.latest_version()),
SchemaVersion::NoneSet | SchemaVersion::Outside(_) => Ok(false),
}
}
pub(crate) fn apply(&self, conn: &mut Connection) -> Result<(), SqliteStoreError> {
let rejection = RejectionReport::default();
let migrations = self.verified_library_migrations(&rejection);
match migrations.current_version(conn)? {
SchemaVersion::NoneSet => {
if !Self::is_empty_database(conn)? {
return Err(SqliteStoreError::NotAClientStore);
}
},
SchemaVersion::Inside(ver) => {
if let Some((expected, actual)) = self.schema_mismatch_at(conn, ver.get())? {
return Err(SqliteStoreError::SchemaDrift {
version: ver.get(),
expected,
actual,
});
}
},
SchemaVersion::Outside(ver) => {
return Err(SqliteStoreError::SchemaTooNew {
found: ver.get(),
supported: self.latest_version(),
});
},
}
migrations.to_latest(conn).map_err(|err| {
take_rejection(&rejection).unwrap_or_else(|| SqliteStoreError::from(err))
})
}
#[cfg(test)]
pub(crate) fn migrate_to_version(
&self,
conn: &mut Connection,
version: usize,
) -> Result<(), SqliteStoreError> {
Self::library_migrations(&self.migrations)
.to_version(conn, version)
.map_err(Into::into)
}
#[cfg(test)]
fn library_migrations(migrations: &[SqliteMigration]) -> Migrations<'static> {
Migrations::new(
migrations.iter().copied().map(SqliteMigration::to_library_migration).collect(),
)
}
fn verified_library_migrations(&self, rejection: &RejectionReport) -> Migrations<'static> {
let migrations = self
.migrations
.iter()
.enumerate()
.map(|(index, migration)| {
let version = index + 1;
let hook = migration.hook;
let expected = migration.expected_hash;
let rejection = Arc::clone(rejection);
M::up_with_hook(migration.sql, move |tx: &Transaction<'_>| {
if let Some(hook) = hook {
hook(tx)?;
}
let actual = SchemaHash::of(tx).map_err(|err| hook_error(&err))?.to_string();
if actual == expected {
return Ok(());
}
let mismatch = SqliteStoreError::MigratedSchemaMismatch {
version,
expected: expected.to_owned(),
actual,
};
let message = mismatch.to_string();
*rejection.lock().expect("rejection lock not poisoned") = Some(mismatch);
Err(HookError::Hook(message))
})
.foreign_key_check()
})
.collect();
Migrations::new(migrations)
}
fn schema_mismatch_at(
&self,
conn: &Connection,
version: usize,
) -> Result<Option<(String, String)>, SqliteStoreError> {
let expected = self.expected_hash(version);
let actual = SchemaHash::of(conn)?.to_string();
Ok((actual != expected).then(|| (expected.to_owned(), actual)))
}
fn is_empty_database(conn: &Connection) -> Result<bool, SqliteStoreError> {
let objects: u32 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'",
[],
|row| row.get(0),
)?;
Ok(objects == 0)
}
}
fn hook_error(err: &SqliteStoreError) -> HookError {
HookError::Hook(err.to_string())
}
fn take_rejection(rejection: &RejectionReport) -> Option<SqliteStoreError> {
rejection.lock().expect("rejection lock not poisoned").take()
}
#[cfg(test)]
pub(crate) mod tests {
use rusqlite::Connection;
use super::{CLIENT_MIGRATIONS, SqliteMigration, SqliteMigrator};
use crate::db_management::errors::SqliteStoreError;
pub(crate) fn damaging_migration() -> SqliteMigrator {
let last = *CLIENT_MIGRATIONS.last().expect("the client ships at least one migration");
let mut migrations = CLIENT_MIGRATIONS.to_vec();
migrations.push(SqliteMigration::new("DROP TABLE input_notes;", last.expected_hash));
SqliteMigrator::new(&migrations)
}
#[test]
fn a_rejected_migration_is_rolled_back() {
let mut conn = Connection::open_in_memory().unwrap();
SqliteMigrator::client().apply(&mut conn).unwrap();
let damaging = damaging_migration();
let err = damaging.apply(&mut conn).unwrap_err();
let SqliteStoreError::MigratedSchemaMismatch { version, expected, actual } = err else {
panic!(
"a migration that builds the wrong schema should be reported as a mismatch, got {err:?}"
);
};
assert_eq!(version, damaging.latest_version());
assert_ne!(expected, actual);
let tables: u32 = conn
.query_row("SELECT COUNT(*) FROM sqlite_schema WHERE name = 'input_notes'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(tables, 1, "the dropped table should have come back");
let version: usize = conn.query_row("PRAGMA user_version", [], |row| row.get(0)).unwrap();
assert_eq!(
version,
SqliteMigrator::client().latest_version(),
"the version should not have advanced"
);
}
#[test]
fn migration_schema_hashes_are_stable() {
let mut conn = Connection::open_in_memory().unwrap();
if let Err(err) = SqliteMigrator::client().apply(&mut conn) {
panic!(
"a migration builds a different schema than the one it is pinned to. Append a new \
migration instead of editing an existing one. If this is a new migration, pin the \
hash reported below as `found` and leave the ones before it alone. {err}"
);
}
}
}