use std::sync::OnceLock;
use crate::sql::Pool;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Backend {
Postgres,
MySql,
Sqlite,
}
impl Backend {
#[must_use]
pub fn env_var(self) -> Option<&'static str> {
match self {
Backend::Postgres => Some("DATABASE_URL"),
Backend::MySql => Some("MYSQL_TEST_URL"),
Backend::Sqlite => None,
}
}
#[must_use]
pub fn dialect_name(self) -> &'static str {
match self {
Backend::Postgres => "postgres",
Backend::MySql => "mysql",
Backend::Sqlite => "sqlite",
}
}
pub async fn pool(self) -> Option<Pool> {
match self {
#[cfg(feature = "postgres")]
Backend::Postgres => {
let url = std::env::var("DATABASE_URL").ok()?;
assert_scheme(
self,
"DATABASE_URL",
&url,
&["postgres://", "postgresql://"],
);
Some(
Pool::connect(&url).await.unwrap_or_else(|e| {
panic!("DATABASE_URL is set but unreachable ({url}): {e}")
}),
)
}
#[cfg(feature = "mysql")]
Backend::MySql => {
let url = std::env::var("MYSQL_TEST_URL").ok()?;
assert_scheme(self, "MYSQL_TEST_URL", &url, &["mysql://", "mariadb://"]);
Some(Pool::connect(&url).await.unwrap_or_else(|e| {
panic!("MYSQL_TEST_URL is set but unreachable ({url}): {e}")
}))
}
#[cfg(feature = "sqlite")]
Backend::Sqlite => Some(
Pool::connect("sqlite::memory:")
.await
.expect("in-memory SQLite is always available"),
),
#[allow(unreachable_patterns)]
_ => None,
}
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
fn assert_scheme(backend: Backend, var: &str, url: &str, expected: &[&str]) {
assert!(
expected.iter().any(|p| url.starts_with(p)),
"{var} is set to `{url}`, which is not a {} URL — expected one of {expected:?}.\n\n\
`Pool::connect` dispatches on the scheme, so this would have run every \
{} arm against a different engine and reported `ok`. Point {var} at a real \
{} server, or unset it to skip that arm.",
backend.dialect_name(),
backend.dialect_name(),
backend.dialect_name(),
);
}
#[must_use]
pub fn live_lock() -> &'static tokio::sync::Mutex<()> {
static M: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
M.get_or_init(|| tokio::sync::Mutex::new(()))
}
pub async fn drop_table(pool: &Pool, table: &str) {
let d = pool.dialect();
let sql = format!("DROP TABLE IF EXISTS {}", d.quote_ident(table));
crate::sql::raw_execute_pool(pool, &sql, Vec::new())
.await
.unwrap_or_else(|e| panic!("dropping {table} on {}: {e}", d.name()));
}
pub async fn fresh_table<M: crate::core::Model>(pool: &Pool) {
drop_table(pool, M::SCHEMA.table).await;
crate::testkit::create_tables_for::<M>(pool)
.await
.unwrap_or_else(|e| {
panic!(
"creating {} on {}: {e}",
M::SCHEMA.table,
pool.dialect().name()
)
});
}
#[cfg(feature = "sqlite")]
pub async fn sqlite_file_pool() -> Pool {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let mut path = std::env::temp_dir();
path.push(format!(
"rustango-testkit-{}-{}.db",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
for suffix in ["", "-wal", "-shm"] {
let mut p = path.clone().into_os_string();
p.push(suffix);
let _ = std::fs::remove_file(std::path::PathBuf::from(p));
}
let url = format!("sqlite://{}?mode=rwc", path.display());
Pool::connect(&url)
.await
.unwrap_or_else(|e| panic!("file-backed SQLite at {url}: {e}"))
}
#[macro_export]
macro_rules! by_dialect {
($pool:expr,
postgres => $pg:expr, because $pgw:expr,
mysql => $my:expr, because $myw:expr,
sqlite => $sq:expr, because $sqw:expr $(,)?
) => {{
match $pool.dialect().name() {
"postgres" => $crate::testkit::matrix::Divergence {
value: $pg,
why: $pgw,
},
"mysql" => $crate::testkit::matrix::Divergence {
value: $my,
why: $myw,
},
"sqlite" => $crate::testkit::matrix::Divergence {
value: $sq,
why: $sqw,
},
other => panic!(
"by_dialect!: unknown dialect `{other}`. A backend was added \
without teaching this macro about it, so every shared test \
body is silently missing an expectation for it."
),
}
}};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Divergence<T> {
pub value: T,
pub why: &'static str,
}
impl<T> Divergence<T> {
pub fn into_value(self) -> T {
self.value
}
}
#[macro_export]
macro_rules! tri_dialect_test {
(model: $model:ty, scenarios: [ $($name:ident),* $(,)? ] $(,)?) => {
$crate::tri_dialect_test!(@build
sqlite_pool = $crate::testkit::matrix::Backend::Sqlite.pool(),
setup = $crate::testkit::matrix::fresh_table::<$model>,
scenarios = [$($name),*]);
};
(model: $model:ty, sqlite: file, scenarios: [ $($name:ident),* $(,)? ] $(,)?) => {
$crate::tri_dialect_test!(@build
sqlite_pool = $crate::testkit::matrix::sqlite_file_pool(),
setup = $crate::testkit::matrix::fresh_table::<$model>,
scenarios = [$($name),*]);
};
(setup: $setup:path, scenarios: [ $($name:ident),* $(,)? ] $(,)?) => {
$crate::tri_dialect_test!(@build
sqlite_pool = $crate::testkit::matrix::Backend::Sqlite.pool(),
setup = $setup,
scenarios = [$($name),*]);
};
(setup: $setup:path, sqlite: file, scenarios: [ $($name:ident),* $(,)? ] $(,)?) => {
$crate::tri_dialect_test!(@build
sqlite_pool = $crate::testkit::matrix::sqlite_file_pool(),
setup = $setup,
scenarios = [$($name),*]);
};
(@build
sqlite_pool = $sqlite_pool:expr,
setup = $setup:expr,
scenarios = [ $($name:ident),* ]
) => {
#[cfg(feature = "postgres")]
mod tri_postgres {
use super::*;
$(
#[tokio::test]
async fn $name() {
let _guard = $crate::testkit::matrix::live_lock().lock().await;
let Some(pool) =
$crate::testkit::matrix::Backend::Postgres.pool().await
else {
eprintln!(
"DATABASE_URL not set — skipping the Postgres arm of `{}`",
stringify!($name)
);
return;
};
($setup)(&pool).await;
super::$name(&pool).await;
}
)*
}
#[cfg(feature = "mysql")]
mod tri_mysql {
use super::*;
$(
#[tokio::test]
async fn $name() {
let _guard = $crate::testkit::matrix::live_lock().lock().await;
let Some(pool) =
$crate::testkit::matrix::Backend::MySql.pool().await
else {
eprintln!(
"MYSQL_TEST_URL not set — skipping the MySQL arm of `{}`",
stringify!($name)
);
return;
};
($setup)(&pool).await;
super::$name(&pool).await;
}
)*
}
#[cfg(feature = "sqlite")]
mod tri_sqlite {
use super::*;
$(
#[tokio::test]
async fn $name() {
let pool = $sqlite_pool.await;
let Some(pool) = Into::<Option<$crate::sql::Pool>>::into(
$crate::testkit::matrix::IntoPoolOption(pool)
) else {
unreachable!("SQLite is always available")
};
($setup)(&pool).await;
super::$name(&pool).await;
}
)*
}
};
}
#[doc(hidden)]
pub struct IntoPoolOption<T>(pub T);
impl From<IntoPoolOption<Pool>> for Option<Pool> {
fn from(w: IntoPoolOption<Pool>) -> Self {
Some(w.0)
}
}
impl From<IntoPoolOption<Option<Pool>>> for Option<Pool> {
fn from(w: IntoPoolOption<Option<Pool>>) -> Self {
w.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_backend_reads_its_own_env_var() {
assert_eq!(Backend::Postgres.env_var(), Some("DATABASE_URL"));
assert_eq!(Backend::MySql.env_var(), Some("MYSQL_TEST_URL"));
assert_eq!(
Backend::Sqlite.env_var(),
None,
"SQLite is in-memory and must never gate on configuration"
);
}
#[test]
fn dialect_names_match_the_dialect_impls() {
assert_eq!(Backend::Postgres.dialect_name(), "postgres");
assert_eq!(Backend::MySql.dialect_name(), "mysql");
assert_eq!(Backend::Sqlite.dialect_name(), "sqlite");
}
#[tokio::test]
async fn a_pool_reports_the_name_its_backend_claims() {
let mut checked = 0;
for backend in [Backend::Postgres, Backend::MySql, Backend::Sqlite] {
if let Some(pool) = backend.pool().await {
assert_eq!(
pool.dialect().name(),
backend.dialect_name(),
"{backend:?} opens a pool whose dialect calls itself something else, \
so `by_dialect!` would fall through to its panic arm"
);
checked += 1;
}
}
#[cfg(feature = "sqlite")]
assert!(
checked > 0,
"SQLite is compiled in and needs no configuration, so at least \
one backend must have been checked — a zero here means the \
loop is not reaching `Backend::pool` at all"
);
#[cfg(not(feature = "sqlite"))]
if checked == 0 {
eprintln!(
"no backend configured on this build (sqlite not compiled in, \
no DATABASE_URL / MYSQL_TEST_URL) — nothing to check"
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn by_dialect_selects_the_running_dialects_arm() {
let pool = Backend::Sqlite.pool().await.expect("sqlite");
let picked = crate::by_dialect! { &pool,
postgres => 1, because "pg",
mysql => 2, because "my",
sqlite => 3, because "sq",
};
assert_eq!(picked.value, 3, "should take the SQLite arm");
assert_eq!(
picked.why, "sq",
"the reason must travel with the value — it is what a failing \
assertion prints"
);
}
#[tokio::test]
async fn an_unconfigured_backend_yields_no_pool() {
if std::env::var("MYSQL_TEST_URL").is_ok() {
eprintln!("MYSQL_TEST_URL is set — skipping the unset-path assertion");
return;
}
assert!(
Backend::MySql.pool().await.is_none(),
"an unset env var means `not configured here`, which skips"
);
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(table = "matrix_selftest_row")]
#[rustango(app = "testkit_matrix_selftest")]
#[allow(dead_code)]
#[cfg(feature = "sqlite")]
pub struct Row {
#[rustango(primary_key)]
id: crate::sql::Auto<i64>,
#[rustango(max_length = 32)]
label: String,
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn a_file_backed_pool_is_one_database_across_connections() {
use crate::sql::CounterPool as _;
use std::sync::Arc;
const N: usize = 8;
let pool = sqlite_file_pool().await;
fresh_table::<Row>(&pool).await;
let barrier = Arc::new(tokio::sync::Barrier::new(N));
let mut tasks = Vec::new();
for i in 0..N {
let pool = pool.clone();
let barrier = Arc::clone(&barrier);
tasks.push(tokio::spawn(async move {
let crate::sql::Pool::Sqlite(sq) = &pool else {
unreachable!("sqlite_file_pool returns a SQLite pool")
};
let mut conn = sq.acquire().await.expect("acquire");
barrier.wait().await;
sqlx::query("INSERT INTO matrix_selftest_row (label) VALUES (?)")
.bind(format!("row-{i}"))
.execute(&mut *conn)
.await
.expect("insert on a held connection");
}));
}
for t in tasks {
t.await.expect("writer task");
}
assert_eq!(
Row::objects().count(&pool).await.expect("count"),
N as i64,
"all {N} writers held their own connection at once and must still \
share one database — a short count would mean each connection got \
its own, which is what `:memory:` was assumed to do and measurably \
does not"
);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn fresh_table_creates_a_writable_table_from_the_schema() {
use crate::core::Model as _;
use crate::sql::FetcherPool as _;
let pool = Backend::Sqlite.pool().await.expect("sqlite");
fresh_table::<Row>(&pool).await;
let mut row = Row {
id: crate::sql::Auto::Unset,
label: "hello".into(),
};
row.save_pool(&pool)
.await
.expect("insert into emitted table");
let found: Vec<Row> = Row::objects().fetch(&pool).await.expect("fetch");
assert_eq!(found.len(), 1, "the row written should be the row read");
assert_eq!(found[0].label, "hello");
fresh_table::<Row>(&pool).await;
let after: Vec<Row> = Row::objects().fetch(&pool).await.expect("fetch");
assert!(
after.is_empty(),
"fresh_table must drop the table, not reuse it — a live backend \
keeps tables between runs and a stale one silently inherits \
the previous schema"
);
}
}