use async_trait::async_trait;
use sqlx::{postgres::PgConnection, Connection, 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(())
}
fn split_statements(sql: &str) -> Vec<String> {
let mut stmts: Vec<String> = Vec::new();
let mut current = String::new();
let chars: Vec<char> = sql.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
while i < chars.len() && chars[i] != '\n' {
current.push(chars[i]);
i += 1;
}
} else if c == '\'' {
current.push(c);
i += 1;
loop {
if i >= chars.len() {
break;
}
let qc = chars[i];
current.push(qc);
i += 1;
if qc == '\'' {
if i < chars.len() && chars[i] == '\'' {
current.push(chars[i]);
i += 1;
} else {
break;
}
}
}
} else if c == ';' {
let trimmed = current.trim().to_owned();
if !trimmed.is_empty()
&& !trimmed
.lines()
.all(|l| l.trim().is_empty() || l.trim().starts_with("--"))
{
stmts.push(trimmed);
}
current.clear();
i += 1;
} else {
current.push(c);
i += 1;
}
}
let trimmed = current.trim().to_owned();
if !trimmed.is_empty()
&& !trimmed
.lines()
.all(|l| l.trim().is_empty() || l.trim().starts_with("--"))
{
stmts.push(trimmed);
}
stmts
}
#[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 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.to_owned(),
source: e,
})?;
}
for stmt in split_statements(sql) {
sqlx::query(&stmt)
.execute(&mut *tx)
.await
.map_err(|e| Error::SetupFailed {
file: name.to_owned(),
source: e,
})?;
}
tx.commit().await.map_err(|e| Error::SetupFailed {
file: name.to_owned(),
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 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?;
}
for stmt in split_statements(up_sql) {
sqlx::query(&stmt).execute(&mut *tx).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 mut tx = self.pool.begin().await?;
if let Some(sp) = self.set_search_path_sql() {
sqlx::query(&sp).execute(&mut *tx).await?;
}
for stmt in split_statements(down_sql) {
sqlx::query(&stmt).execute(&mut *tx).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\"");
}
#[test]
fn split_single_statement() {
let stmts = split_statements("CREATE TABLE t (id INT)");
assert_eq!(stmts, vec!["CREATE TABLE t (id INT)"]);
}
#[test]
fn split_multiple_statements() {
let sql = "CREATE TABLE a (id INT);\nCREATE TABLE b (id INT);";
let stmts = split_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], "CREATE TABLE a (id INT)");
assert_eq!(stmts[1], "CREATE TABLE b (id INT)");
}
#[test]
fn split_single_quoted_string_with_semicolon_stays_one_statement() {
let sql = "INSERT INTO t (name) VALUES ('hello;world');";
let stmts = split_statements(sql);
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("'hello;world'"));
}
#[test]
fn split_single_quoted_escaped_quote() {
let sql = "INSERT INTO t (name) VALUES ('it''s fine');";
let stmts = split_statements(sql);
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("'it''s fine'"));
}
#[test]
fn split_line_comment_semicolon_does_not_split() {
let sql = "CREATE TABLE t (id INT); -- trailing; comment\nCREATE TABLE u (id INT);";
let stmts = split_statements(sql);
assert_eq!(stmts.len(), 2, "got: {stmts:?}");
}
#[test]
fn split_trailing_statement_without_semicolon() {
let sql = "CREATE TABLE a (id INT);\nCREATE TABLE b (id INT)";
let stmts = split_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[1], "CREATE TABLE b (id INT)");
}
#[test]
fn split_empty_input_returns_empty() {
assert!(split_statements("").is_empty());
}
#[test]
fn split_only_comments_returns_empty() {
let sql = "-- just a comment\n-- another comment";
assert!(
split_statements(sql).is_empty(),
"comment-only input should produce no statements"
);
}
#[test]
fn split_dollar_quote_limitation_documented() {
let sql = "CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $$ BEGIN NULL; END $$;";
let stmts = split_statements(sql);
assert!(
stmts.len() > 1,
"expected dollar-quote limitation to cause a split (got {} stmt(s)): {:?}",
stmts.len(),
stmts
);
}
}