Skip to main content

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}