use async_trait::async_trait;
use sqlx::{postgres::PgConnection, Connection, Executor, PgPool};
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::driver::{AppliedMigration, LockGuard, MigrationDriver};
use crate::error::{Error, Result};
use crate::migration::Migration;
fn validate_ident(s: &str) -> Result<()> {
if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(Error::InvalidIdentifier(s.to_owned()));
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct PostgresConfig {
pub schema: Option<String>,
pub table: String,
pub advisory_lock_key: i64,
}
impl Default for PostgresConfig {
fn default() -> Self {
Self {
schema: None,
table: "00_schema_migrations".to_owned(),
advisory_lock_key: 918273645,
}
}
}
pub struct PgLockGuard {
conn: Arc<Mutex<Option<PgConnection>>>,
key: i64,
}
impl LockGuard for PgLockGuard {}
impl Drop for PgLockGuard {
fn drop(&mut self) {
let conn_arc = Arc::clone(&self.conn);
let key = self.key;
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let mut guard = conn_arc.lock().await;
if let Some(mut conn) = guard.take() {
if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(key)
.execute(&mut conn)
.await
{
eprintln!("WARN soma-schema: pg_advisory_unlock(key={key}) failed: {e}; lock will be released when the connection closes");
}
let _ = conn.close().await;
}
});
}
}
}
#[derive(Debug)]
pub struct PostgresDriver {
pool: PgPool,
config: PostgresConfig,
}
impl PostgresDriver {
pub fn new(pool: PgPool, config: PostgresConfig) -> Result<Self> {
validate_ident(&config.table)?;
if let Some(ref s) = config.schema {
validate_ident(s)?;
}
if pool.options().get_max_connections() < 2 {
return Err(Error::PoolTooSmall);
}
Ok(Self { pool, config })
}
fn qualified_table(&self) -> String {
match self.config.schema.as_deref() {
Some(s) => format!("\"{s}\".\"{}\"", &self.config.table),
None => format!("\"{}\"", &self.config.table),
}
}
fn set_search_path_sql(&self) -> Option<String> {
self.config
.schema
.as_deref()
.map(|s| format!("SET LOCAL search_path TO \"{s}\""))
}
}
#[async_trait]
impl MigrationDriver for PostgresDriver {
async fn acquire_lock(&self) -> Result<Box<dyn LockGuard>> {
let mut conn = self.pool.acquire().await?.detach();
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(self.config.advisory_lock_key)
.execute(&mut conn)
.await?;
let guard = PgLockGuard {
conn: Arc::new(Mutex::new(Some(conn))),
key: self.config.advisory_lock_key,
};
Ok(Box::new(guard))
}
async fn run_setup_sql(&self, name: &str, sql: &str) -> Result<()> {
let name = name.to_owned();
let sql = sql.to_owned();
let mut tx = self.pool.begin().await?;
if let Some(sp) = self.set_search_path_sql() {
sqlx::query(&sp)
.execute(&mut *tx)
.await
.map_err(|e| Error::SetupFailed {
file: name.clone(),
source: e,
})?;
}
{
let conn: &mut PgConnection = &mut tx;
conn.execute(sqlx::raw_sql(&sql))
.await
.map_err(|e| Error::SetupFailed {
file: name.clone(),
source: e,
})?;
}
tx.commit().await.map_err(|e| Error::SetupFailed {
file: name,
source: e,
})?;
Ok(())
}
async fn ensure_tracking_table(&self) -> Result<()> {
let mut tx = self.pool.begin().await?;
if let Some(schema) = &self.config.schema {
let create_schema = format!("CREATE SCHEMA IF NOT EXISTS \"{schema}\"");
sqlx::query(&create_schema).execute(&mut *tx).await?;
}
if let Some(sp) = self.set_search_path_sql() {
sqlx::query(&sp).execute(&mut *tx).await?;
}
let qt = self.qualified_table();
let create_table = format!(
r#"CREATE TABLE IF NOT EXISTS {qt} (
version INTEGER NOT NULL,
file VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
checksum TEXT NOT NULL,
description TEXT,
batch INTEGER NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
applied_by TEXT NOT NULL DEFAULT current_user,
execution_ms INTEGER,
PRIMARY KEY (version, file)
)"#
);
sqlx::query(&create_table).execute(&mut *tx).await?;
tx.commit().await?;
Ok(())
}
async fn applied(&self) -> Result<Vec<AppliedMigration>> {
use chrono::{DateTime, Utc};
let qt = self.qualified_table();
let sql = format!(
"SELECT version, file, name, checksum, description, batch, applied_at, applied_by, execution_ms \
FROM {qt} ORDER BY version ASC, file ASC"
);
let rows = sqlx::query_as::<
_,
(
i32,
String,
String,
String,
Option<String>,
i32,
DateTime<Utc>,
String,
Option<i32>,
),
>(&sql)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(
|(
version,
file,
name,
checksum,
description,
batch,
applied_at,
applied_by,
execution_ms,
)| {
AppliedMigration {
version: version as u32,
file,
name,
checksum,
description,
batch,
applied_at,
applied_by,
execution_ms,
}
},
)
.collect())
}
async fn apply(&self, migration: &Migration, up_sql: &str, batch: i32) -> Result<()> {
let up_sql = up_sql.to_owned();
let start = std::time::Instant::now();
let mut tx = self.pool.begin().await?;
if let Some(sp) = self.set_search_path_sql() {
sqlx::query(&sp).execute(&mut *tx).await?;
}
{
let conn: &mut PgConnection = &mut tx;
conn.execute(sqlx::raw_sql(&up_sql)).await?;
}
let elapsed_ms = start.elapsed().as_millis() as i32;
let qt = self.qualified_table();
let insert = format!(
"INSERT INTO {qt} (version, file, name, checksum, description, batch, applied_at, applied_by, execution_ms) \
VALUES ($1, $2, $3, $4, $5, $6, now(), current_user, $7)"
);
sqlx::query(&insert)
.bind(migration.version as i32)
.bind(&migration.file)
.bind(&migration.name)
.bind(&migration.checksum)
.bind(migration.why.as_deref())
.bind(batch)
.bind(elapsed_ms)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn revert(&self, applied: &AppliedMigration, down_sql: &str) -> Result<()> {
let down_sql = down_sql.to_owned();
let mut tx = self.pool.begin().await?;
if let Some(sp) = self.set_search_path_sql() {
sqlx::query(&sp).execute(&mut *tx).await?;
}
{
let conn: &mut PgConnection = &mut tx;
conn.execute(sqlx::raw_sql(&down_sql)).await?;
}
let qt = self.qualified_table();
let delete = format!("DELETE FROM {qt} WHERE version = $1 AND file = $2");
sqlx::query(&delete)
.bind(applied.version as i32)
.bind(&applied.file)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_ident() {
assert!(validate_ident("00_schema_migrations").is_ok());
assert!(validate_ident("my_schema").is_ok());
assert!(validate_ident("ABC123").is_ok());
}
#[test]
fn invalid_ident_rejects_special_chars() {
assert!(matches!(
validate_ident("bad-name"),
Err(Error::InvalidIdentifier(_))
));
assert!(matches!(
validate_ident("has space"),
Err(Error::InvalidIdentifier(_))
));
assert!(matches!(
validate_ident(""),
Err(Error::InvalidIdentifier(_))
));
assert!(matches!(
validate_ident("semi;colon"),
Err(Error::InvalidIdentifier(_))
));
}
#[test]
fn leading_digit_allowed() {
assert!(validate_ident("00_schema_migrations").is_ok());
assert!(validate_ident("01_vault").is_ok());
}
#[test]
fn qualified_table_format() {
let with_schema = {
let s = "myschema";
let t = "00_schema_migrations";
format!("\"{s}\".\"{t}\"")
};
assert_eq!(with_schema, "\"myschema\".\"00_schema_migrations\"");
let without_schema = {
let t = "00_schema_migrations";
format!("\"{t}\"")
};
assert_eq!(without_schema, "\"00_schema_migrations\"");
}
}