use super::struct_test::{QueryTrace, TraceSink, msg};
use crate::admin::helper::text_cast_type;
use crate::db::config::mask_password;
use crate::db::{ADb, DatabaseConfig, RuniqueDb};
use crate::utils::aliases::StrMap;
use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, DbErr, TransactionTrait};
use sea_query::{Alias, Expr, ExprTrait, Func, Query};
use std::time::{Duration, Instant};
const CLONE_GRACE: Duration = Duration::from_secs(3);
#[async_trait::async_trait]
pub trait TestTransaction: Sized + Send + Sync {
type Config: Send + Sync;
type Connection: Send + Sync;
type Error: std::fmt::Display + Send;
fn load_config(vars: &StrMap) -> Result<Self::Config, Self::Error>;
fn describe(config: &Self::Config) -> String;
async fn connect(
config: &Self::Config,
trace: TraceSink,
) -> Result<Self::Connection, Self::Error>;
async fn begin_test(conn: &Self::Connection) -> Result<Self, Self::Error>;
async fn still_open(&self) -> Result<bool, Self::Error> {
Ok(true)
}
async fn rollback_test(self) -> Result<(), Self::Error>;
}
#[async_trait::async_trait]
impl TestTransaction for ADb {
type Config = DatabaseConfig;
type Connection = DatabaseConnection;
type Error = DbErr;
fn load_config(vars: &StrMap) -> Result<DatabaseConfig, DbErr> {
if !vars.contains_key("DATABASE_URL") && !vars.contains_key("DB_ENGINE") {
return Err(DbErr::Custom(msg("runique_test.no_db_config").into_owned()));
}
let mut config = DatabaseConfig::from_lookup(|key| vars.get(key).cloned())
.map(|builder| builder.build())
.map_err(DbErr::Custom)?;
config.max_connections = 1;
config.min_connections = 1;
Ok(config)
}
fn describe(config: &DatabaseConfig) -> String {
let target = format!("{} — {}", config.engine.name(), mask_password(&config.url));
if config.url.contains(":memory:") || config.url.contains("mode=memory") {
format!("{target} ({})", msg("runique_test.in_memory"))
} else {
target
}
}
async fn connect(
config: &DatabaseConfig,
trace: TraceSink,
) -> Result<DatabaseConnection, DbErr> {
let mut conn = config.connect().await?;
conn.set_metric_callback(move |info| {
trace
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(QueryTrace {
sql: info.statement.sql.clone(),
elapsed: info.elapsed,
failed: info.failed,
expected: false,
});
});
Ok(conn)
}
async fn begin_test(conn: &DatabaseConnection) -> Result<Self, DbErr> {
Ok(ADb::new(RuniqueDb::Txn(conn.begin().await?)))
}
async fn still_open(&self) -> Result<bool, DbErr> {
match self.get_database_backend() {
DbBackend::Postgres => {
let txid = || Expr::expr(Func::cust(Alias::new("txid_current")));
let first = scalar(self, txid()).await;
let second = scalar(self, txid()).await;
match (first, second) {
(Ok(a), Ok(b)) => Ok(a == b),
_ => Ok(true),
}
}
DbBackend::MySql => {
match scalar(self, Expr::custom_keyword(Alias::new("@@in_transaction"))).await {
Ok(v) => Ok(v != "0"),
Err(_) => Ok(true),
}
}
_ => Ok(true),
}
}
async fn rollback_test(self) -> Result<(), DbErr> {
let deadline = Instant::now() + CLONE_GRACE;
while self.is_shared() && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
match self.into_inner() {
Some(RuniqueDb::Txn(txn)) => txn.rollback().await,
Some(RuniqueDb::Conn(_)) => Err(DbErr::Custom(
msg("runique_test.not_a_transaction").into_owned(),
)),
None => Err(DbErr::Custom(
msg("runique_test.clone_outlived").into_owned(),
)),
}
}
}
async fn scalar(db: &ADb, expr: Expr) -> Result<String, DbErr> {
let stmt = Query::select()
.expr(expr.cast_as(Alias::new(text_cast_type(db))))
.to_owned();
db.query_one(&stmt)
.await?
.ok_or_else(|| DbErr::RecordNotFound("SELECT".into()))?
.try_get_by_index::<String>(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_target_names_the_engine_without_the_password() {
let memory = DatabaseConfig::from_url("sqlite://test.db?mode=memory")
.unwrap()
.build();
let shown = <ADb as TestTransaction>::describe(&memory);
assert!(shown.contains("SQLite"), "{shown}");
assert!(shown.contains(&*msg("runique_test.in_memory")), "{shown}");
let remote = DatabaseConfig::from_url("postgres://u:secret@db.example/app")
.unwrap()
.build();
let shown = <ADb as TestTransaction>::describe(&remote);
assert!(
shown.contains("db.example") && !shown.contains("secret"),
"{shown}"
);
assert!(!shown.contains(&*msg("runique_test.in_memory")), "{shown}");
}
}