use crate::dialect::{DatabaseDialect, DialectKind};
use crate::error::{Result, WaypointError};
#[cfg(feature = "postgres")]
use fastrand;
#[cfg(feature = "postgres")]
use tokio_postgres::Client;
#[cfg(feature = "postgres")]
use crate::config::SslMode;
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
pub fn validate_identifier(name: &str) -> Result<()> {
if name.is_empty() {
return Err(WaypointError::ConfigError(
"Identifier cannot be empty".to_string(),
));
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(WaypointError::ConfigError(format!(
"Identifier '{}' contains invalid characters. Only [a-zA-Z0-9_] are allowed.",
name
)));
}
Ok(())
}
pub enum DbClient {
#[cfg(feature = "postgres")]
Postgres(Client),
#[cfg(feature = "mysql")]
Mysql(mysql_async::Pool),
}
impl DbClient {
#[cfg(feature = "postgres")]
pub fn with_postgres(client: Client) -> Self {
DbClient::Postgres(client)
}
#[cfg(feature = "mysql")]
pub fn with_mysql(pool: mysql_async::Pool) -> Self {
DbClient::Mysql(pool)
}
pub fn dialect_kind(&self) -> DialectKind {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(_) => DialectKind::Postgres,
#[cfg(feature = "mysql")]
DbClient::Mysql(_) => DialectKind::Mysql,
}
}
pub fn dialect(&self) -> &'static dyn DatabaseDialect {
#[cfg(feature = "postgres")]
static PG: crate::dialect::postgres::PostgresDialect =
crate::dialect::postgres::PostgresDialect;
#[cfg(feature = "mysql")]
static MY: crate::dialect::mysql::MysqlDialect = crate::dialect::mysql::MysqlDialect;
match self.dialect_kind() {
#[cfg(feature = "postgres")]
DialectKind::Postgres => &PG,
#[cfg(not(feature = "postgres"))]
DialectKind::Postgres => {
panic!("PostgreSQL connection without `postgres` feature compiled in")
}
#[cfg(feature = "mysql")]
DialectKind::Mysql => &MY,
#[cfg(not(feature = "mysql"))]
DialectKind::Mysql => {
panic!("MySQL connection without `mysql` feature compiled in")
}
}
}
#[cfg(feature = "postgres")]
pub fn as_postgres(&self) -> Result<&Client> {
match self {
DbClient::Postgres(c) => Ok(c),
#[cfg(feature = "mysql")]
DbClient::Mysql(_) => Err(WaypointError::ConfigError(
"This operation is not yet implemented for MySQL".into(),
)),
}
}
#[cfg(feature = "mysql")]
pub fn as_mysql(&self) -> Result<&mysql_async::Pool> {
match self {
DbClient::Mysql(p) => Ok(p),
#[cfg(feature = "postgres")]
DbClient::Postgres(_) => Err(WaypointError::ConfigError(
"This operation requires a MySQL connection".into(),
)),
}
}
pub async fn check_connection(&self) -> Result<()> {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(c) => check_connection(c).await,
#[cfg(feature = "mysql")]
DbClient::Mysql(pool) => {
use mysql_async::prelude::*;
let mut conn =
pool.get_conn()
.await
.map_err(|e| WaypointError::ConnectionLost {
operation: "health check".into(),
detail: e.to_string(),
})?;
conn.query_drop("DO 0")
.await
.map_err(|e| WaypointError::ConnectionLost {
operation: "health check".into(),
detail: e.to_string(),
})?;
Ok(())
}
}
}
pub async fn acquire_lock(&self, table_name: &str) -> Result<()> {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(c) => acquire_advisory_lock(c, table_name).await,
#[cfg(feature = "mysql")]
DbClient::Mysql(pool) => {
use mysql_async::prelude::*;
let key = mysql_lock_key(table_name);
let mut conn = pool.get_conn().await?;
let acquired: Option<i64> = conn
.exec_first("SELECT GET_LOCK(?, -1)", (key.clone(),))
.await?;
match acquired {
Some(1) => Ok(()),
_ => Err(WaypointError::LockError(format!(
"Failed to acquire MySQL named lock {}",
key
))),
}
}
}
}
pub async fn acquire_lock_with_timeout(
&self,
table_name: &str,
timeout_secs: u32,
) -> Result<()> {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(c) => {
acquire_advisory_lock_with_timeout(c, table_name, timeout_secs).await
}
#[cfg(feature = "mysql")]
DbClient::Mysql(pool) => {
use mysql_async::prelude::*;
let key = mysql_lock_key(table_name);
let mut conn = pool.get_conn().await?;
let acquired: Option<i64> = conn
.exec_first("SELECT GET_LOCK(?, ?)", (key.clone(), timeout_secs as i64))
.await?;
match acquired {
Some(1) => Ok(()),
Some(0) => Err(WaypointError::LockError(format!(
"Timed out waiting for MySQL named lock {} after {}s",
key, timeout_secs
))),
_ => Err(WaypointError::LockError(format!(
"Failed to acquire MySQL named lock {} (NULL result)",
key
))),
}
}
}
}
pub async fn release_lock(&self, table_name: &str) -> Result<()> {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(c) => release_advisory_lock(c, table_name).await,
#[cfg(feature = "mysql")]
DbClient::Mysql(pool) => {
use mysql_async::prelude::*;
let key = mysql_lock_key(table_name);
let mut conn = pool.get_conn().await?;
conn.exec_drop("SELECT RELEASE_LOCK(?)", (key,)).await?;
Ok(())
}
}
}
pub async fn current_user(&self) -> Result<String> {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(c) => get_current_user(c).await,
#[cfg(feature = "mysql")]
DbClient::Mysql(pool) => {
use mysql_async::prelude::*;
let mut conn = pool.get_conn().await?;
let user: Option<String> = conn.query_first("SELECT CURRENT_USER()").await?;
user.ok_or_else(|| {
WaypointError::ConfigError("CURRENT_USER() returned no rows".into())
})
}
}
}
pub async fn current_database(&self) -> Result<String> {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(c) => get_current_database(c).await,
#[cfg(feature = "mysql")]
DbClient::Mysql(pool) => {
use mysql_async::prelude::*;
let mut conn = pool.get_conn().await?;
let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await?;
match db.flatten() {
Some(name) => Ok(name),
None => Err(WaypointError::ConfigError(
"MySQL connection has no current database (none selected in URL)".into(),
)),
}
}
}
}
pub async fn resolve_schema(&self, configured: &str) -> Result<String> {
match self.dialect_kind() {
DialectKind::Postgres => Ok(configured.to_string()),
DialectKind::Mysql => {
if configured == "public" {
self.current_database().await
} else {
Ok(configured.to_string())
}
}
}
}
pub async fn execute_raw(&self, sql: &str) -> Result<i32> {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(c) => execute_raw(c, sql).await,
#[cfg(feature = "mysql")]
DbClient::Mysql(pool) => {
use mysql_async::prelude::*;
let start = std::time::Instant::now();
let mut conn = pool.get_conn().await?;
for stmt in crate::sql_parser::split_mysql_statements(sql) {
conn.query_drop(&stmt).await?;
}
Ok(start.elapsed().as_millis() as i32)
}
}
}
pub async fn execute_in_transaction(&self, sql: &str) -> Result<i32> {
match self {
#[cfg(feature = "postgres")]
DbClient::Postgres(c) => execute_in_transaction(c, sql).await,
#[cfg(feature = "mysql")]
DbClient::Mysql(_) => self.execute_raw(sql).await,
}
}
}
#[cfg(feature = "mysql")]
fn mysql_lock_key(table_name: &str) -> String {
let mut k = format!("waypoint_{}", table_name);
if k.len() > 64 {
k.truncate(64);
}
k
}
#[cfg(feature = "postgres")]
fn make_rustls_config() -> rustls::ClientConfig {
let root_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(root_store)
.with_no_client_auth()
}
#[cfg(feature = "postgres")]
fn is_permanent_error(e: &tokio_postgres::Error) -> bool {
if let Some(db_err) = e.as_db_error() {
let code = db_err.code().code();
return code == "28P01" || code == "28000";
}
false
}
pub fn inject_keepalive(conn_string: &str, keepalive_secs: u32) -> String {
if keepalive_secs == 0 {
return conn_string.to_string();
}
let lower = conn_string.to_lowercase();
if lower.contains("keepalives") {
return conn_string.to_string();
}
let params = format!("keepalives=1&keepalives_idle={}", keepalive_secs);
if conn_string.starts_with("postgres://") || conn_string.starts_with("postgresql://") {
if conn_string.contains('?') {
format!("{}&{}", conn_string, params)
} else {
format!("{}?{}", conn_string, params)
}
} else {
format!(
"{} keepalives=1 keepalives_idle={}",
conn_string, keepalive_secs
)
}
}
#[cfg(feature = "postgres")]
fn spawn_connection_task<F>(connection: F)
where
F: std::future::Future<Output = std::result::Result<(), tokio_postgres::Error>>
+ Send
+ 'static,
{
tokio::spawn(async move {
if let Err(e) = connection.await {
log::error!("Database connection error: {}", e);
}
});
}
#[cfg(feature = "postgres")]
async fn connect_once(
conn_string: &str,
ssl_mode: &SslMode,
connect_timeout_secs: u32,
) -> std::result::Result<Client, tokio_postgres::Error> {
let connect_fut = async {
match ssl_mode {
SslMode::Disable => {
let (client, connection) =
tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
spawn_connection_task(connection);
Ok(client)
}
SslMode::Require => {
let tls_config = make_rustls_config();
let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
let (client, connection) = tokio_postgres::connect(conn_string, tls).await?;
spawn_connection_task(connection);
Ok(client)
}
SslMode::Prefer => {
let tls_config = make_rustls_config();
let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
match tokio_postgres::connect(conn_string, tls).await {
Ok((client, connection)) => {
spawn_connection_task(connection);
Ok(client)
}
Err(_) => {
log::debug!("TLS connection failed, falling back to plaintext");
let (client, connection) =
tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
spawn_connection_task(connection);
Ok(client)
}
}
}
}
};
if connect_timeout_secs > 0 {
match tokio::time::timeout(
std::time::Duration::from_secs(connect_timeout_secs as u64),
connect_fut,
)
.await
{
Ok(result) => result,
Err(_) => Err(tokio_postgres::Error::__private_api_timeout()),
}
} else {
connect_fut.await
}
}
#[cfg(feature = "postgres")]
pub async fn connect(conn_string: &str) -> Result<Client> {
connect_with_config(conn_string, &SslMode::Prefer, 0, 30, 0).await
}
#[cfg(feature = "postgres")]
pub async fn connect_with_config(
conn_string: &str,
ssl_mode: &SslMode,
retries: u32,
connect_timeout_secs: u32,
statement_timeout_secs: u32,
) -> Result<Client> {
connect_with_full_config(
conn_string,
ssl_mode,
retries,
connect_timeout_secs,
statement_timeout_secs,
120,
)
.await
}
#[cfg(feature = "postgres")]
pub async fn connect_with_full_config(
conn_string: &str,
ssl_mode: &SslMode,
retries: u32,
connect_timeout_secs: u32,
statement_timeout_secs: u32,
keepalive_secs: u32,
) -> Result<Client> {
let conn_string = inject_keepalive(conn_string, keepalive_secs);
let mut last_err = None;
for attempt in 0..=retries {
if attempt > 0 {
let base_delay = std::cmp::min(1u64 << attempt, 30);
let jitter_ms = fastrand::u64(0..1000);
let delay = std::time::Duration::from_secs(base_delay)
+ std::time::Duration::from_millis(jitter_ms);
log::info!(
"Connection attempt failed, retrying; attempt={}, max_attempts={}, delay_ms={}",
attempt + 1,
retries + 1,
delay.as_millis() as u64
);
tokio::time::sleep(delay).await;
}
match connect_once(&conn_string, ssl_mode, connect_timeout_secs).await {
Ok(client) => {
if attempt > 0 {
log::info!(
"Connected successfully after retry; attempt={}, max_attempts={}",
attempt + 1,
retries + 1
);
}
if statement_timeout_secs > 0 {
let timeout_sql =
format!("SET statement_timeout = '{}s'", statement_timeout_secs);
client.batch_execute(&timeout_sql).await?;
}
return Ok(client);
}
Err(e) => {
if is_permanent_error(&e) {
log::error!("Permanent connection error, not retrying: {}", e);
return Err(WaypointError::DatabaseError(e));
}
last_err = Some(e);
}
}
}
Err(WaypointError::DatabaseError(last_err.unwrap()))
}
#[cfg(feature = "postgres")]
pub async fn acquire_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
let lock_id = advisory_lock_id(table_name);
log::info!(
"Acquiring advisory lock; lock_id={}, table={}",
lock_id,
table_name
);
client
.execute("SELECT pg_advisory_lock($1)", &[&lock_id])
.await
.map_err(|e| WaypointError::LockError(format!("Failed to acquire advisory lock: {}", e)))?;
Ok(())
}
#[cfg(feature = "postgres")]
pub async fn acquire_advisory_lock_with_timeout(
client: &Client,
table_name: &str,
timeout_secs: u32,
) -> Result<()> {
let lock_id = advisory_lock_id(table_name);
log::info!(
"Trying to acquire advisory lock with timeout; lock_id={}, table={}, timeout_secs={}",
lock_id,
table_name,
timeout_secs
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs as u64);
loop {
let row = client
.query_one("SELECT pg_try_advisory_lock($1)", &[&lock_id])
.await
.map_err(|e| WaypointError::LockError(format!("Failed to try advisory lock: {}", e)))?;
let acquired: bool = row.get(0);
if acquired {
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(WaypointError::LockError(format!(
"Timed out waiting for advisory lock after {}s (table: {}). Another migration may be running.",
timeout_secs, table_name
)));
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
#[cfg(feature = "postgres")]
pub async fn release_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
let lock_id = advisory_lock_id(table_name);
log::info!(
"Releasing advisory lock; lock_id={}, table={}",
lock_id,
table_name
);
client
.execute("SELECT pg_advisory_unlock($1)", &[&lock_id])
.await
.map_err(|e| WaypointError::LockError(format!("Failed to release advisory lock: {}", e)))?;
Ok(())
}
pub fn advisory_lock_id(table_name: &str) -> i64 {
crc32fast::hash(table_name.as_bytes()) as i64
}
#[cfg(feature = "postgres")]
pub async fn get_current_user(client: &Client) -> Result<String> {
let row = client.query_one("SELECT current_user", &[]).await?;
Ok(row.get::<_, String>(0))
}
#[cfg(feature = "postgres")]
pub async fn get_current_database(client: &Client) -> Result<String> {
let row = client.query_one("SELECT current_database()", &[]).await?;
Ok(row.get::<_, String>(0))
}
#[cfg(feature = "postgres")]
pub async fn execute_in_transaction(client: &Client, sql: &str) -> Result<i32> {
let start = std::time::Instant::now();
client.batch_execute("BEGIN").await?;
match client.batch_execute(sql).await {
Ok(()) => {
client.batch_execute("COMMIT").await?;
}
Err(e) => {
if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
log::warn!("Failed to rollback transaction: {}", rollback_err);
}
return Err(WaypointError::DatabaseError(e));
}
}
let elapsed = start.elapsed().as_millis() as i32;
Ok(elapsed)
}
#[cfg(feature = "postgres")]
pub async fn execute_raw(client: &Client, sql: &str) -> Result<i32> {
let start = std::time::Instant::now();
client.batch_execute(sql).await?;
let elapsed = start.elapsed().as_millis() as i32;
Ok(elapsed)
}
pub fn is_transient_error(e: &WaypointError) -> bool {
match e {
#[cfg(feature = "postgres")]
WaypointError::DatabaseError(pg_err) => {
if pg_err.is_closed() {
return true;
}
if let Some(db_err) = pg_err.as_db_error() {
let code = db_err.code().code();
return matches!(
code,
"57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006"
);
}
let msg = pg_err.to_string().to_lowercase();
msg.contains("connection reset")
|| msg.contains("broken pipe")
|| msg.contains("connection closed")
|| msg.contains("unexpected eof")
}
#[cfg(feature = "mysql")]
WaypointError::MysqlError(my_err) => {
let msg = my_err.to_string().to_lowercase();
msg.contains("connection reset")
|| msg.contains("broken pipe")
|| msg.contains("connection closed")
|| msg.contains("server has gone away")
|| msg.contains("lost connection")
|| msg.contains("io error")
}
WaypointError::ConnectionLost { .. } => true,
_ => false,
}
}
#[cfg(feature = "postgres")]
pub async fn check_connection(client: &Client) -> Result<()> {
client
.simple_query("")
.await
.map_err(|e| WaypointError::ConnectionLost {
operation: "health check".to_string(),
detail: e.to_string(),
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inject_keepalive_url_style() {
let result = inject_keepalive("postgres://user:pass@localhost/db", 120);
assert_eq!(
result,
"postgres://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
);
}
#[test]
fn test_inject_keepalive_url_with_existing_params() {
let result = inject_keepalive("postgres://user:pass@localhost/db?sslmode=require", 60);
assert_eq!(
result,
"postgres://user:pass@localhost/db?sslmode=require&keepalives=1&keepalives_idle=60"
);
}
#[test]
fn test_inject_keepalive_kv_style() {
let result = inject_keepalive("host=localhost port=5432 user=admin dbname=mydb", 90);
assert_eq!(
result,
"host=localhost port=5432 user=admin dbname=mydb keepalives=1 keepalives_idle=90"
);
}
#[test]
fn test_inject_keepalive_zero_disables() {
let result = inject_keepalive("postgres://user:pass@localhost/db", 0);
assert_eq!(result, "postgres://user:pass@localhost/db");
}
#[test]
fn test_inject_keepalive_already_present() {
let result = inject_keepalive("postgres://user:pass@localhost/db?keepalives=1", 120);
assert_eq!(result, "postgres://user:pass@localhost/db?keepalives=1");
}
#[test]
fn test_transient_error_connection_lost() {
let err = WaypointError::ConnectionLost {
operation: "test".to_string(),
detail: "gone".to_string(),
};
assert!(is_transient_error(&err));
}
#[test]
fn test_transient_error_config_is_not_transient() {
let err = WaypointError::ConfigError("bad config".to_string());
assert!(!is_transient_error(&err));
}
#[test]
fn test_transient_error_migration_failed_is_not_transient() {
let err = WaypointError::MigrationFailed {
script: "V1__test.sql".to_string(),
reason: "syntax error".to_string(),
};
assert!(!is_transient_error(&err));
}
#[test]
fn test_advisory_lock_id_stability() {
let id1 = advisory_lock_id("waypoint_schema_history");
let id2 = advisory_lock_id("waypoint_schema_history");
assert_eq!(id1, id2);
let id3 = advisory_lock_id("other_table");
assert_ne!(id1, id3);
}
#[test]
fn test_transient_error_lock_error_is_not_transient() {
let err = WaypointError::LockError("lock failed".to_string());
assert!(!is_transient_error(&err));
}
#[test]
fn test_transient_error_io_error_is_not_transient() {
let err = WaypointError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file not found",
));
assert!(!is_transient_error(&err));
}
#[test]
fn test_validate_identifier_valid() {
assert!(validate_identifier("users").is_ok());
assert!(validate_identifier("my_table").is_ok());
assert!(validate_identifier("Table123").is_ok());
assert!(validate_identifier("a").is_ok());
}
#[test]
fn test_validate_identifier_invalid() {
assert!(validate_identifier("").is_err());
assert!(validate_identifier("my-table").is_err());
assert!(validate_identifier("my table").is_err());
assert!(validate_identifier("table.name").is_err());
assert!(validate_identifier("table;drop").is_err());
}
#[test]
fn test_quote_ident_simple() {
assert_eq!(quote_ident("users"), "\"users\"");
}
#[test]
fn test_quote_ident_embedded_quotes() {
assert_eq!(quote_ident("my\"table"), "\"my\"\"table\"");
}
#[test]
fn test_quote_ident_empty() {
assert_eq!(quote_ident(""), "\"\"");
}
#[test]
fn test_inject_keepalive_postgresql_prefix() {
let result = inject_keepalive("postgresql://user:pass@localhost/db", 120);
assert_eq!(
result,
"postgresql://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
);
}
}