Skip to main content

tablo_test/
db.rs

1//! The database a test runs on: in-memory SQLite, or the one `TABLO_TEST_DATABASE_URL` names,
2//! its tables pushed from the models or made by the app's migrations.
3
4use std::sync::{
5    OnceLock,
6    atomic::{AtomicU32, Ordering},
7};
8
9use toasty::{Db, migration::MigrationSet, schema::ModelSet};
10
11/// The variable naming the database [`test_db`] connects to.
12pub const TEST_DATABASE_URL: &str = "TABLO_TEST_DATABASE_URL";
13
14/// A `Db` holding the tables of `models` and no rows.
15///
16/// The database is in-memory SQLite, fresh for each call, or the one [`TEST_DATABASE_URL`] names,
17/// whose driver the test crate enables through Toasty's feature of the same name. Tests share that
18/// database, so each call's tables carry a prefix of their own. An app whose migrations add what
19/// the models cannot state, such as a foreign key, tests on [`migrated_db`] instead.
20///
21/// # Panics
22///
23/// When the database refuses the connection or the tables.
24pub async fn test_db(models: ModelSet) -> Db {
25    let mut builder = Db::builder();
26    builder.models(models);
27    let url = match std::env::var(TEST_DATABASE_URL) {
28        Ok(url) => {
29            builder.table_name_prefix(&table_prefix());
30            url
31        }
32        Err(_) => "sqlite::memory:".to_string(),
33    };
34    let db = builder.connect(&url).await.expect("connect");
35    db.push_schema().await.expect("push_schema");
36    db
37}
38
39/// A `Db` over `models` whose tables `migrations` made, holding no rows: what the app's
40/// migrations add beside the models, such as a foreign key or a check constraint written by
41/// hand, holds in the test too, where [`test_db`] pushes the models' schema alone.
42///
43/// ```rust,ignore
44/// static MIGRATIONS: MigrationSet = toasty::embed_migrations!();
45///
46/// let db = migrated_db(app::models(), &MIGRATIONS).await;
47/// ```
48///
49/// Migrations are SQL for one database, so the test runs where the app does: the database
50/// [`TEST_DATABASE_URL`] names, else in-memory SQLite, fresh for each call. Migration SQL names
51/// its tables in full, so on a shared database each call gets a namespace of its own instead of a
52/// table prefix: a PostgreSQL schema its connections search, or a MySQL database. Neither is
53/// dropped afterwards, as [`test_db`]'s tables are not.
54///
55/// # Panics
56///
57/// When the database refuses the connection, the namespace or a migration.
58pub async fn migrated_db(models: ModelSet, migrations: &MigrationSet) -> Db {
59    let url = match std::env::var(TEST_DATABASE_URL) {
60        Ok(url) => isolated(models.clone(), &url).await,
61        Err(_) => "sqlite::memory:".to_string(),
62    };
63    let mut builder = Db::builder();
64    builder.models(models);
65    let db = builder.connect(&url).await.expect("connect");
66    migrations.apply(&db).await.expect("apply the migrations");
67    db
68}
69
70/// The URL of a namespace of its own on the shared database `url` names, made on the way: a
71/// PostgreSQL schema the connection searches first, or a MySQL database. Another database, such
72/// as an in-memory one, is fresh for each connection already.
73async fn isolated(models: ModelSet, url: &str) -> String {
74    let (scheme, _) = url.split_once(':').unwrap_or((url, ""));
75    let create = match scheme {
76        "postgres" | "postgresql" => "SCHEMA",
77        "mysql" | "mariadb" => "DATABASE",
78        _ => return url.to_string(),
79    };
80    let name = table_prefix().trim_end_matches('_').to_string();
81    let mut builder = Db::builder();
82    builder.models(models);
83    let mut admin = builder.connect(url).await.expect("connect");
84    toasty::sql::statement(format!("CREATE {create} {name}"))
85        .exec(&mut admin)
86        .await
87        .expect("create the test's namespace");
88    if create == "SCHEMA" {
89        let separator = if url.contains('?') { '&' } else { '?' };
90        format!("{url}{separator}options=-c%20search_path%3D{name}")
91    } else {
92        with_database(url, &name)
93    }
94}
95
96/// `url` naming the database `name` in place of its own.
97fn with_database(url: &str, name: &str) -> String {
98    let (base, query) = match url.split_once('?') {
99        Some((base, query)) => (base, Some(query)),
100        None => (url, None),
101    };
102    let authority_start = base.find("://").map_or(0, |at| at + 3);
103    let path_start = base[authority_start..]
104        .find('/')
105        .map_or(base.len(), |at| authority_start + at);
106    let mut out = format!("{}/{name}", &base[..path_start]);
107    if let Some(query) = query {
108        out.push('?');
109        out.push_str(query);
110    }
111    out
112}
113
114/// A table-name prefix no other call of this run or of an earlier run uses. It stays short:
115/// PostgreSQL truncates an identifier past 63 bytes, so a long prefix could make two index names
116/// collide.
117fn table_prefix() -> String {
118    static RUN: OnceLock<u32> = OnceLock::new();
119    static NEXT: AtomicU32 = AtomicU32::new(0);
120    let run = RUN.get_or_init(|| {
121        let now = std::time::SystemTime::now()
122            .duration_since(std::time::UNIX_EPOCH)
123            .unwrap_or_default();
124        now.as_secs() as u32 ^ now.subsec_nanos()
125    });
126    let call = NEXT.fetch_add(1, Ordering::Relaxed);
127    format!("t{run:08x}{call}_")
128}
129
130#[cfg(test)]
131mod tests {
132    use super::with_database;
133
134    #[test]
135    fn a_mysql_url_names_the_tests_database() {
136        assert_eq!(
137            with_database("mysql://root:pw@localhost:3306/tablo", "t1"),
138            "mysql://root:pw@localhost:3306/t1"
139        );
140        assert_eq!(
141            with_database("mysql://localhost?ssl-mode=disabled", "t1"),
142            "mysql://localhost/t1?ssl-mode=disabled"
143        );
144    }
145}