use crate::backends::DatabaseConnection;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationRecord {
pub app: String,
pub name: String,
pub applied: DateTime<Utc>,
}
pub struct MigrationRecorder {
records: Vec<MigrationRecord>,
}
pub struct DatabaseMigrationRecorder {
connection: DatabaseConnection,
}
#[cfg(feature = "postgres")]
pub(crate) struct CockroachdbSchemaLock {
_tx: sqlx::Transaction<'static, sqlx::Postgres>,
}
impl MigrationRecorder {
pub fn new() -> Self {
Self {
records: Vec::new(),
}
}
pub fn record_applied(&mut self, app: &str, name: &str) {
self.records.push(MigrationRecord {
app: app.to_string(),
name: name.to_string(),
applied: Utc::now(),
});
}
pub fn get_applied_migrations(&self) -> &[MigrationRecord] {
&self.records
}
pub fn is_applied(&self, app: &str, name: &str) -> bool {
self.records.iter().any(|r| r.app == app && r.name == name)
}
pub fn ensure_schema_table(&self) {
}
pub async fn ensure_schema_table_async<T>(&self, _pool: &T) -> super::Result<()> {
Ok(())
}
pub async fn is_applied_async<T>(
&self,
_pool: &T,
app: &str,
name: &str,
) -> super::Result<bool> {
Ok(self.is_applied(app, name))
}
pub async fn record_applied_async<T>(
&mut self,
_pool: &T,
app: &str,
name: &str,
) -> super::Result<()> {
self.record_applied(app, name);
Ok(())
}
pub fn unapply(&mut self, app: &str, name: &str) {
self.records.retain(|r| !(r.app == app && r.name == name));
}
pub fn get_applied_for_app(&self, app: &str) -> Vec<MigrationRecord> {
self.records
.iter()
.filter(|r| r.app == app)
.cloned()
.collect()
}
pub async fn unapply_async<T>(
&mut self,
_pool: &T,
app: &str,
name: &str,
) -> super::Result<()> {
self.unapply(app, name);
Ok(())
}
pub async fn get_applied_for_app_async<T>(
&self,
_pool: &T,
app: &str,
) -> super::Result<Vec<MigrationRecord>> {
Ok(self.get_applied_for_app(app))
}
}
impl Default for MigrationRecorder {
fn default() -> Self {
Self::new()
}
}
impl DatabaseMigrationRecorder {
pub fn new(connection: DatabaseConnection) -> Self {
Self { connection }
}
pub async fn ensure_schema_table(&self) -> super::Result<()> {
#[cfg(feature = "mysql")]
use crate::backends::types::DatabaseType;
#[cfg(feature = "postgres")]
if self.connection.is_cockroachdb() {
return self.ensure_schema_table_cockroachdb().await;
}
match self.connection.database_type() {
#[cfg(feature = "mysql")]
DatabaseType::Mysql => self.ensure_schema_table_mysql().await,
_ => {
self.acquire_schema_lock().await?;
let result = self.ensure_schema_table_internal().await;
let _ = self.release_schema_lock().await;
result
}
}
}
#[cfg(feature = "postgres")]
async fn ensure_schema_table_cockroachdb(&self) -> super::Result<()> {
let _lock = self.acquire_cockroachdb_schema_lock().await?;
self.ensure_schema_table_internal().await
}
#[cfg(feature = "postgres")]
pub(crate) async fn acquire_cockroachdb_schema_lock(
&self,
) -> super::Result<CockroachdbSchemaLock> {
let pool = self.connection.into_postgres().ok_or_else(|| {
super::MigrationError::DatabaseError(crate::backends::DatabaseError::ConnectionError(
"PostgreSQL backend unavailable when acquiring CockroachDB schema lock".to_string(),
))
})?;
self.bootstrap_cockroachdb_schema_lock(&pool).await?;
let mut tx = pool.begin().await.map_err(|e| {
super::MigrationError::DatabaseError(crate::backends::DatabaseError::QueryError(
format!("Failed to begin CockroachDB migration lock transaction: {e}"),
))
})?;
sqlx::query("SELECT 1 FROM _reinhardt_migration_lock WHERE id = 1 FOR UPDATE")
.execute(&mut *tx)
.await
.map_err(|e| {
super::MigrationError::DatabaseError(crate::backends::DatabaseError::QueryError(
format!("Failed to acquire CockroachDB migration lock row: {e}"),
))
})?;
Ok(CockroachdbSchemaLock { _tx: tx })
}
#[cfg(feature = "postgres")]
async fn bootstrap_cockroachdb_schema_lock(&self, pool: &sqlx::PgPool) -> super::Result<()> {
const MAX_ATTEMPTS: usize = 5;
for attempt in 1..=MAX_ATTEMPTS {
sqlx::query(
"CREATE TABLE IF NOT EXISTS _reinhardt_migration_lock (\
id INT PRIMARY KEY, locked_at TIMESTAMPTZ DEFAULT now())",
)
.execute(pool)
.await
.map_err(|e| {
super::MigrationError::DatabaseError(crate::backends::DatabaseError::QueryError(
format!("Failed to create CockroachDB migration lock table: {e}"),
))
})?;
let insert_result = sqlx::query(
"INSERT INTO _reinhardt_migration_lock (id) VALUES (1) \
ON CONFLICT (id) DO NOTHING",
)
.execute(pool)
.await;
match insert_result {
Ok(_) => return Ok(()),
Err(e)
if attempt < MAX_ATTEMPTS
&& is_retryable_cockroachdb_lock_bootstrap_error(&e) =>
{
tokio::time::sleep(std::time::Duration::from_millis(50 * attempt as u64)).await;
}
Err(e) => {
return Err(super::MigrationError::DatabaseError(
crate::backends::DatabaseError::QueryError(format!(
"Failed to seed CockroachDB migration lock row: {e}"
)),
));
}
}
}
Ok(())
}
}
#[cfg(feature = "postgres")]
fn is_retryable_cockroachdb_lock_bootstrap_error(error: &sqlx::Error) -> bool {
is_cockroachdb_constraint_visibility_error(&error.to_string())
}
fn is_retryable_cockroachdb_record_applied_error(error: &crate::backends::DatabaseError) -> bool {
match error {
crate::backends::DatabaseError::QueryError(message) => {
is_cockroachdb_constraint_visibility_error(message)
}
_ => false,
}
}
fn is_cockroachdb_constraint_visibility_error(message: &str) -> bool {
message.contains(
"there is no unique or exclusion constraint matching the ON CONFLICT specification",
)
}
impl DatabaseMigrationRecorder {
#[cfg(feature = "mysql")]
async fn ensure_schema_table_mysql(&self) -> super::Result<()> {
let pool = self.connection.into_mysql().ok_or_else(|| {
super::MigrationError::DatabaseError(crate::backends::DatabaseError::ConnectionError(
"MySQL backend unavailable when acquiring schema lock".to_string(),
))
})?;
let mut conn = pool.acquire().await.map_err(|e| {
super::MigrationError::DatabaseError(crate::backends::DatabaseError::ConnectionError(
format!("Failed to acquire MySQL connection for schema lock: {e}"),
))
})?;
let locked: Option<i64> = sqlx::query_scalar("SELECT GET_LOCK('reinhardt_migrations', 10)")
.fetch_one(&mut *conn)
.await
.map_err(|e| {
super::MigrationError::DatabaseError(crate::backends::DatabaseError::QueryError(
format!("Failed to call GET_LOCK on MySQL: {e}"),
))
})?;
if locked != Some(1) {
return Err(super::MigrationError::DatabaseError(
crate::backends::DatabaseError::QueryError(
"Failed to acquire migration lock (timeout)".to_string(),
),
));
}
let result = self.ensure_schema_table_internal().await;
let release_result: Result<Option<i64>, _> =
sqlx::query_scalar("SELECT RELEASE_LOCK('reinhardt_migrations')")
.fetch_one(&mut *conn)
.await;
match release_result {
Ok(Some(1)) => {}
Ok(other) => {
tracing::warn!(
result = ?other,
"RELEASE_LOCK did not release the MySQL migration advisory lock; \
the session will release it on connection close"
);
}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to call RELEASE_LOCK for the MySQL migration advisory lock; \
the session will release it on connection close"
);
}
}
result
}
async fn check_index_exists(&self, table: &str, index: &str) -> super::Result<bool> {
let query = "SELECT EXISTS(
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND index_name = ?
) as exists_flag";
let result = self
.connection
.fetch_one(query, vec![table.into(), index.into()])
.await
.map_err(super::MigrationError::DatabaseError)?;
if let Ok(exists) = result.get::<bool>("exists_flag") {
Ok(exists)
} else if let Ok(exists_int) = result.get::<i64>("exists_flag") {
Ok(exists_int > 0)
} else {
Ok(false)
}
}
async fn acquire_schema_lock(&self) -> super::Result<()> {
use crate::backends::types::DatabaseType;
match self.connection.database_type() {
DatabaseType::Postgres => {
self.connection
.execute(
"SELECT pg_advisory_lock(hashtext('reinhardt_migrations'))",
vec![],
)
.await
.map_err(super::MigrationError::DatabaseError)?;
}
DatabaseType::Mysql => {
let result = self
.connection
.fetch_one(
"SELECT GET_LOCK('reinhardt_migrations', 10) as locked",
vec![],
)
.await
.map_err(super::MigrationError::DatabaseError)?;
let locked = if let Ok(val) = result.get::<i64>("locked") {
val == 1
} else {
result.get::<bool>("locked").unwrap_or_default()
};
if !locked {
return Err(super::MigrationError::DatabaseError(
crate::backends::DatabaseError::QueryError(
"Failed to acquire migration lock (timeout)".to_string(),
),
));
}
}
DatabaseType::Sqlite => {
}
}
Ok(())
}
async fn release_schema_lock(&self) -> super::Result<()> {
use crate::backends::types::DatabaseType;
match self.connection.database_type() {
DatabaseType::Postgres => {
self.connection
.execute(
"SELECT pg_advisory_unlock(hashtext('reinhardt_migrations'))",
vec![],
)
.await
.map_err(super::MigrationError::DatabaseError)?;
}
DatabaseType::Mysql => {
self.connection
.execute("SELECT RELEASE_LOCK('reinhardt_migrations')", vec![])
.await
.map_err(super::MigrationError::DatabaseError)?;
}
DatabaseType::Sqlite => {
}
}
Ok(())
}
pub(crate) async fn ensure_schema_table_internal(&self) -> super::Result<()> {
use crate::backends::types::DatabaseType;
use reinhardt_query::prelude::{
Alias, ColumnDef, Expr, MySqlQueryBuilder, PostgresQueryBuilder, Query,
QueryStatementBuilder, SqliteQueryBuilder,
};
let (create_table_sql, create_index_sql) = {
let create_table_stmt = Query::create_table()
.table(Alias::new("reinhardt_migrations"))
.if_not_exists()
.col(
ColumnDef::new("id")
.integer()
.not_null(true)
.auto_increment(true)
.primary_key(true),
)
.col(ColumnDef::new("app").string_len(255).not_null(true))
.col(ColumnDef::new("name").string_len(255).not_null(true))
.col(
ColumnDef::new("applied")
.timestamp()
.not_null(true)
.default(Expr::current_timestamp().into_simple_expr()),
)
.to_owned();
let create_index_stmt = Query::create_index()
.if_not_exists()
.name("reinhardt_migrations_app_name_unique")
.table(Alias::new("reinhardt_migrations"))
.col(Alias::new("app"))
.col(Alias::new("name"))
.unique()
.to_owned();
match self.connection.database_type() {
DatabaseType::Postgres => (
create_table_stmt.to_string(PostgresQueryBuilder),
create_index_stmt.to_string(PostgresQueryBuilder),
),
DatabaseType::Mysql => (
create_table_stmt.to_string(MySqlQueryBuilder),
create_index_stmt.to_string(MySqlQueryBuilder),
),
DatabaseType::Sqlite => (
create_table_stmt.to_string(SqliteQueryBuilder),
create_index_stmt.to_string(SqliteQueryBuilder),
),
}
};
self.connection
.execute(&create_table_sql, vec![])
.await
.map_err(super::MigrationError::DatabaseError)?;
if self.connection.database_type() == DatabaseType::Mysql {
let index_exists = self
.check_index_exists(
"reinhardt_migrations",
"reinhardt_migrations_app_name_unique",
)
.await?;
if !index_exists {
self.connection
.execute(&create_index_sql, vec![])
.await
.map_err(super::MigrationError::DatabaseError)?;
}
} else {
self.connection
.execute(&create_index_sql, vec![])
.await
.map_err(super::MigrationError::DatabaseError)?;
}
Ok(())
}
pub async fn is_applied(&self, app: &str, name: &str) -> super::Result<bool> {
use crate::backends::types::DatabaseType;
use reinhardt_query::prelude::{
Alias, Expr, ExprTrait, MySqlQueryBuilder, PostgresQueryBuilder, Query,
QueryStatementBuilder, SqliteQueryBuilder,
};
let subquery = Query::select()
.expr(Expr::value(1))
.from(Alias::new("reinhardt_migrations"))
.and_where(Expr::col(Alias::new("app")).eq(app))
.and_where(Expr::col(Alias::new("name")).eq(name))
.to_owned();
let stmt = Query::select()
.expr_as(Expr::exists(subquery), Alias::new("exists_flag"))
.to_owned();
let sql = match self.connection.database_type() {
DatabaseType::Postgres => stmt.to_string(PostgresQueryBuilder),
DatabaseType::Mysql => stmt.to_string(MySqlQueryBuilder),
DatabaseType::Sqlite => stmt.to_string(SqliteQueryBuilder),
};
let rows = self
.connection
.fetch_all(&sql, vec![])
.await
.map_err(super::MigrationError::DatabaseError)?;
if rows.is_empty() {
return Ok(false);
}
let row = &rows[0];
if let Ok(exists) = row.get::<bool>("exists_flag") {
Ok(exists)
} else if let Ok(exists_int) = row.get::<i64>("exists_flag") {
Ok(exists_int > 0)
} else {
Ok(false)
}
}
pub async fn record_applied(&self, app: &str, name: &str) -> super::Result<()> {
use crate::backends::types::DatabaseType;
use reinhardt_query::prelude::{
Alias, MySqlQueryBuilder, PostgresQueryBuilder, Query, QueryStatementBuilder,
SqliteQueryBuilder,
};
let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let stmt = Query::insert()
.into_table(Alias::new("reinhardt_migrations"))
.columns([Alias::new("app"), Alias::new("name"), Alias::new("applied")])
.values_panic([app.to_string(), name.to_string(), now])
.to_owned();
let sql = match self.connection.database_type() {
DatabaseType::Postgres => {
let base_sql = stmt.to_string(PostgresQueryBuilder::new());
format!("{} ON CONFLICT (app, name) DO NOTHING", base_sql)
}
DatabaseType::Mysql => {
let base_sql = stmt.to_string(MySqlQueryBuilder::new());
base_sql.replacen("INSERT", "INSERT IGNORE", 1)
}
DatabaseType::Sqlite => {
let base_sql = stmt.to_string(SqliteQueryBuilder::new());
base_sql.replacen("INSERT", "INSERT OR IGNORE", 1)
}
};
let max_attempts = if self.connection.is_cockroachdb() {
5
} else {
1
};
for attempt in 1..=max_attempts {
match self.connection.execute(&sql, vec![]).await {
Ok(_) => return Ok(()),
Err(e)
if self.connection.is_cockroachdb()
&& attempt < max_attempts
&& is_retryable_cockroachdb_record_applied_error(&e) =>
{
tokio::time::sleep(std::time::Duration::from_millis(50 * attempt as u64)).await;
}
Err(e) => return Err(super::MigrationError::DatabaseError(e)),
}
}
Ok(())
}
pub async fn get_applied_migrations(&self) -> super::Result<Vec<MigrationRecord>> {
use crate::backends::types::DatabaseType;
use reinhardt_query::prelude::{
Alias, MySqlQueryBuilder, Order, PostgresQueryBuilder, Query, QueryStatementBuilder,
SqliteQueryBuilder,
};
let stmt = Query::select()
.columns([Alias::new("app"), Alias::new("name"), Alias::new("applied")])
.from(Alias::new("reinhardt_migrations"))
.order_by(Alias::new("applied"), Order::Asc)
.to_owned();
let sql = match self.connection.database_type() {
DatabaseType::Postgres => stmt.to_string(PostgresQueryBuilder),
DatabaseType::Mysql => stmt.to_string(MySqlQueryBuilder),
DatabaseType::Sqlite => stmt.to_string(SqliteQueryBuilder),
};
let rows = self
.connection
.fetch_all(&sql, vec![])
.await
.map_err(super::MigrationError::DatabaseError)?;
let db_type = self.connection.database_type();
let mut records = Vec::new();
for row in rows {
let app: String = row
.get("app")
.map_err(super::MigrationError::DatabaseError)?;
let name: String = row
.get("name")
.map_err(super::MigrationError::DatabaseError)?;
let applied: DateTime<Utc> = match db_type {
DatabaseType::Sqlite => {
let applied_str: String = row
.get("applied")
.map_err(super::MigrationError::DatabaseError)?;
chrono::NaiveDateTime::parse_from_str(&applied_str, "%Y-%m-%d %H:%M:%S")
.map(|naive| naive.and_utc())
.map_err(|e| {
super::MigrationError::DatabaseError(
crate::backends::DatabaseError::TypeError(format!(
"Failed to parse SQLite timestamp '{}': {}",
applied_str, e
)),
)
})?
}
_ => row
.get("applied")
.map_err(super::MigrationError::DatabaseError)?,
};
records.push(MigrationRecord { app, name, applied });
}
Ok(records)
}
pub async fn unapply(&self, app: &str, name: &str) -> super::Result<()> {
use crate::backends::types::DatabaseType;
use reinhardt_query::prelude::{
Alias, Expr, ExprTrait, MySqlQueryBuilder, PostgresQueryBuilder, Query,
QueryStatementBuilder, SqliteQueryBuilder,
};
let stmt = Query::delete()
.from_table(Alias::new("reinhardt_migrations"))
.and_where(Expr::col(Alias::new("app")).eq(app))
.and_where(Expr::col(Alias::new("name")).eq(name))
.to_owned();
let sql = match self.connection.database_type() {
DatabaseType::Postgres => stmt.to_string(PostgresQueryBuilder),
DatabaseType::Mysql => stmt.to_string(MySqlQueryBuilder),
DatabaseType::Sqlite => stmt.to_string(SqliteQueryBuilder),
};
self.connection
.execute(&sql, vec![])
.await
.map_err(super::MigrationError::DatabaseError)?;
Ok(())
}
pub async fn get_applied_for_app(&self, app: &str) -> super::Result<Vec<MigrationRecord>> {
use crate::backends::types::DatabaseType;
use reinhardt_query::prelude::{
Alias, Expr, ExprTrait, MySqlQueryBuilder, Order, PostgresQueryBuilder, Query,
QueryStatementBuilder, SqliteQueryBuilder,
};
let stmt = Query::select()
.columns([Alias::new("app"), Alias::new("name"), Alias::new("applied")])
.from(Alias::new("reinhardt_migrations"))
.and_where(Expr::col(Alias::new("app")).eq(app))
.order_by(Alias::new("applied"), Order::Asc)
.to_owned();
let sql = match self.connection.database_type() {
DatabaseType::Postgres => stmt.to_string(PostgresQueryBuilder),
DatabaseType::Mysql => stmt.to_string(MySqlQueryBuilder),
DatabaseType::Sqlite => stmt.to_string(SqliteQueryBuilder),
};
let rows = self
.connection
.fetch_all(&sql, vec![])
.await
.map_err(super::MigrationError::DatabaseError)?;
let db_type = self.connection.database_type();
let mut records = Vec::new();
for row in rows {
let app_val: String = row
.get("app")
.map_err(super::MigrationError::DatabaseError)?;
let name: String = row
.get("name")
.map_err(super::MigrationError::DatabaseError)?;
let applied: DateTime<Utc> = match db_type {
DatabaseType::Sqlite => {
let applied_str: String = row
.get("applied")
.map_err(super::MigrationError::DatabaseError)?;
chrono::NaiveDateTime::parse_from_str(&applied_str, "%Y-%m-%d %H:%M:%S")
.map(|naive| naive.and_utc())
.map_err(|e| {
super::MigrationError::DatabaseError(
crate::backends::DatabaseError::TypeError(format!(
"Failed to parse SQLite timestamp '{}': {}",
applied_str, e
)),
)
})?
}
_ => row
.get("applied")
.map_err(super::MigrationError::DatabaseError)?,
};
records.push(MigrationRecord {
app: app_val,
name,
applied,
});
}
Ok(records)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_migration_recorder_creation() {
let recorder = MigrationRecorder::new();
assert_eq!(recorder.get_applied_migrations().len(), 0);
}
#[test]
fn test_record_applied() {
let mut recorder = MigrationRecorder::new();
recorder.record_applied("auth", "0001_initial");
assert_eq!(recorder.get_applied_migrations().len(), 1);
assert!(recorder.is_applied("auth", "0001_initial"));
}
#[test]
fn test_is_applied() {
let mut recorder = MigrationRecorder::new();
assert!(!recorder.is_applied("auth", "0001_initial"));
recorder.record_applied("auth", "0001_initial");
assert!(recorder.is_applied("auth", "0001_initial"));
assert!(!recorder.is_applied("auth", "0002_add_field"));
}
#[test]
fn test_get_applied_migrations() {
let mut recorder = MigrationRecorder::new();
recorder.record_applied("auth", "0001_initial");
recorder.record_applied("users", "0001_initial");
recorder.record_applied("auth", "0002_add_field");
let migrations = recorder.get_applied_migrations();
assert_eq!(migrations.len(), 3);
assert!(
migrations
.iter()
.any(|m| m.app == "auth" && m.name == "0001_initial")
);
assert!(
migrations
.iter()
.any(|m| m.app == "users" && m.name == "0001_initial")
);
assert!(
migrations
.iter()
.any(|m| m.app == "auth" && m.name == "0002_add_field")
);
}
#[test]
fn test_migration_record_contains_timestamp() {
let mut recorder = MigrationRecorder::new();
let before = Utc::now();
recorder.record_applied("auth", "0001_initial");
let after = Utc::now();
let migrations = recorder.get_applied_migrations();
assert_eq!(migrations.len(), 1);
let record = &migrations[0];
assert!(record.applied >= before);
assert!(record.applied <= after);
}
#[test]
fn test_multiple_apps_migrations() {
let mut recorder = MigrationRecorder::new();
recorder.record_applied("auth", "0001_initial");
recorder.record_applied("auth", "0002_add_field");
recorder.record_applied("users", "0001_initial");
recorder.record_applied("posts", "0001_initial");
assert!(recorder.is_applied("auth", "0001_initial"));
assert!(recorder.is_applied("auth", "0002_add_field"));
assert!(recorder.is_applied("users", "0001_initial"));
assert!(recorder.is_applied("posts", "0001_initial"));
assert!(!recorder.is_applied("comments", "0001_initial"));
}
#[tokio::test]
async fn test_async_record_applied() {
let mut recorder = MigrationRecorder::new();
recorder
.record_applied_async(&(), "auth", "0001_initial")
.await
.unwrap();
assert!(recorder.is_applied("auth", "0001_initial"));
}
#[tokio::test]
async fn test_async_is_applied() {
let mut recorder = MigrationRecorder::new();
recorder.record_applied("auth", "0001_initial");
let result = recorder
.is_applied_async(&(), "auth", "0001_initial")
.await
.unwrap();
assert!(result);
let result_not_applied = recorder
.is_applied_async(&(), "auth", "0002_add_field")
.await
.unwrap();
assert!(!result_not_applied);
}
#[tokio::test]
async fn test_ensure_schema_table_async() {
let recorder = MigrationRecorder::new();
let result = recorder.ensure_schema_table_async(&()).await;
assert!(result.is_ok());
}
}