persistant_sqlx/testdb.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! A fresh PostgreSQL database for one test, in place of `#[sqlx::test]` (ROADMAP row 29, Humboldt).
3//!
4//! ```ignore
5//! let admin = PgBackend::connect(&std::env::var("DATABASE_URL")?).await?;
6//! let db = TestDb::create(&admin).await?; // CREATE DATABASE persistant_test_<unique>
7//! db.migrate(&migrations).await?; // optional: the schema the test needs
8//! let pool = db.backend().pool(); // the sqlx pool, for `sqlx::query!` and the rest
9//! // ... the test ...
10//! db.drop_database().await?; // or just let it go out of scope
11//! ```
12//!
13//! Every [`TestDb`] is its own database, so tests that run at the same time cannot see each other's
14//! rows, and nothing needs `--test-threads=1`. The admin connection needs `CREATEDB`.
15//!
16//! **Cleanup.** [`TestDb::drop_database`] closes the pool and drops the database (`WITH (FORCE)`, so a
17//! connection a test leaked does not block it) and reports an error. A `TestDb` dropped without it, a
18//! test that panicked, say, is dropped the same way from a helper thread with its own runtime, so the
19//! database does not outlive the test binary; a failure there cannot be reported and is ignored (a
20//! later run's databases have different names, so a leftover costs disk, never correctness).
21//!
22//! PostgreSQL only: it is the engine Humboldt tests on. SQLite tests use a temporary file and MySQL a
23//! scratch database ([`crate::MysqlBackend::verify_fresh`]).
24
25use crate::PgBackend;
26use persistant_relational::{MigrateError, MigrateReport, Migration, RelError};
27use sqlx::ConnectOptions;
28use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
29use std::sync::atomic::{AtomicU64, Ordering};
30
31static COUNTER: AtomicU64 = AtomicU64::new(0);
32
33/// A database created for one test and dropped when the test is done.
34#[derive(Debug)]
35pub struct TestDb {
36 admin: PgConnectOptions,
37 name: String,
38 // `None` once dropped.
39 backend: Option<PgBackend>,
40}
41
42fn unique_name() -> String {
43 let nanos = std::time::SystemTime::now()
44 .duration_since(std::time::UNIX_EPOCH)
45 .map_or(0, |d| d.as_nanos());
46 format!(
47 "persistant_test_{}_{}_{}",
48 std::process::id(),
49 nanos,
50 COUNTER.fetch_add(1, Ordering::Relaxed)
51 )
52}
53
54fn failed(what: &str, e: sqlx::Error) -> RelError {
55 RelError::Backend(format!("{what}: {e}"))
56}
57
58impl TestDb {
59 /// Create an empty database on the server `admin` is connected to and connect to it (a pool of up
60 /// to 8 connections, the admin connection's options otherwise).
61 pub async fn create(admin: &PgBackend) -> Result<TestDb, RelError> {
62 let name = unique_name();
63 // The name is made here from digits and a fixed prefix, so it needs no quoting.
64 sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}")))
65 .execute(admin.pool())
66 .await
67 .map_err(|e| failed("create the test database (does the role have CREATEDB?)", e))?;
68 let admin_options = (*admin.pool().connect_options()).clone();
69 let options = admin_options
70 .clone()
71 .database(&name)
72 .disable_statement_logging();
73 let connected = PgPoolOptions::new()
74 .max_connections(8)
75 .connect_with(options)
76 .await;
77 let backend = match connected {
78 Ok(pool) => PgBackend::from_pool(pool).await,
79 Err(e) => Err(failed("connect to the test database", e)),
80 };
81 match backend {
82 Ok(backend) => Ok(TestDb {
83 admin: admin_options.clone(),
84 name,
85 backend: Some(backend),
86 }),
87 Err(e) => {
88 let _ = drop_named(&admin_options, &name).await;
89 Err(e)
90 }
91 }
92 }
93
94 /// The database's name (`persistant_test_...`).
95 pub fn name(&self) -> &str {
96 &self.name
97 }
98
99 /// The backend on the test database. Its [`pool`](PgBackend::pool) is the sqlx pool for
100 /// `sqlx::query!` and anything this layer does not carry.
101 pub fn backend(&self) -> &PgBackend {
102 self.backend
103 .as_ref()
104 .expect("the test database is dropped only by consuming it")
105 }
106
107 /// Apply `migrations` to the test database (see [`PgBackend::migrate`]).
108 pub async fn migrate(&self, migrations: &[Migration]) -> Result<MigrateReport, MigrateError> {
109 self.backend().migrate(migrations).await
110 }
111
112 /// Drop the database now, and report whether that worked. The server ends every connection to it
113 /// (`WITH (FORCE)`), including one the test still holds: the pool is not asked to close first,
114 /// because closing waits for every checked-out connection to come back.
115 pub async fn drop_database(mut self) -> Result<(), RelError> {
116 let dropped = drop_named(&self.admin, &self.name).await;
117 self.backend.take();
118 dropped
119 }
120}
121
122/// Drop `name` over a connection of its own: a pooled connection belongs to the runtime that made the
123/// pool, and the drop path below runs on another one.
124async fn drop_named(admin: &PgConnectOptions, name: &str) -> Result<(), RelError> {
125 let mut conn = admin
126 .connect()
127 .await
128 .map_err(|e| failed("connect to drop the test database", e))?;
129 sqlx::query(sqlx::AssertSqlSafe(format!(
130 "DROP DATABASE IF EXISTS {name} WITH (FORCE)"
131 )))
132 .execute(&mut conn)
133 .await
134 .map(|_| ())
135 .map_err(|e| failed("drop the test database", e))
136}
137
138impl Drop for TestDb {
139 fn drop(&mut self) {
140 // `drop_database` already did it.
141 let Some(backend) = self.backend.take() else {
142 return;
143 };
144 // The pool is not closed here (that needs its runtime, which may be the one blocked in this
145 // call); `WITH (FORCE)` ends its connections on the server, and the pool is dropped with it.
146 drop(backend);
147 let (admin, name) = (self.admin.clone(), self.name.clone());
148 // The test's own runtime may be shutting down (a panic unwinds through here), so the cleanup
149 // gets a thread and a runtime of its own and is waited for: a database dropped after the
150 // test binary exited is a database that was not dropped.
151 let cleanup = std::thread::spawn(move || {
152 let Ok(rt) = tokio::runtime::Builder::new_current_thread()
153 .enable_all()
154 .build()
155 else {
156 return;
157 };
158 rt.block_on(async {
159 let _ = drop_named(&admin, &name).await;
160 });
161 });
162 let _ = cleanup.join();
163 }
164}