Skip to main content

siderite_testkit/
database.rs

1//! Throw-away databases for tests: schema setup and transactional isolation.
2
3use siderite_migrations::{
4    MigrationError, MigrationGraph, Migrator, ProjectState, diff, load_dir, schema_editor,
5};
6use siderite_orm::signals::Signals;
7use siderite_orm::{BackendError, Db, ModelMeta, OrmError};
8use std::convert::Infallible;
9use std::future::Future;
10use std::path::Path;
11use thiserror::Error;
12
13/// Failure while preparing or using a [`TestDatabase`].
14#[derive(Debug, Error)]
15#[non_exhaustive]
16pub enum TestDatabaseError {
17    /// The backend could not be opened.
18    #[error(transparent)]
19    Backend(#[from] BackendError),
20    /// A statement or transaction operation failed.
21    #[error(transparent)]
22    Orm(#[from] OrmError),
23    /// Loading or applying migrations failed, or DDL could not be rendered.
24    #[error(transparent)]
25    Migration(#[from] MigrationError),
26}
27
28/// A disposable database for one test.
29///
30/// # Lifetime model
31///
32/// A [`TestDatabase`] owns one [`Db`] handle. [`TestDatabase::sqlite_memory`]
33/// uses a single shared in-memory connection, so the database lives exactly as
34/// long as the last clone of the handle and every clone sees the same data.
35///
36/// [`TestDatabase::isolated`] opens a transaction, hands the closure a `Db`
37/// bound to it and **always rolls back** when the closure returns, so tests
38/// never observe each other's writes. Because an in-memory database has one
39/// connection, the transaction holds it: use only the `Db` given to the
40/// closure inside it (querying the outer handle would wait forever), and do
41/// not keep clones of the transactional `Db` after the closure finishes.
42#[derive(Debug, Clone)]
43pub struct TestDatabase {
44    db: Db,
45}
46
47/// Internal outcome that forces `Db::transaction` to roll back while still
48/// carrying the closure's value out.
49enum Rollback<T> {
50    Done(T),
51    Failed(OrmError),
52}
53
54impl<T> From<OrmError> for Rollback<T> {
55    fn from(err: OrmError) -> Self {
56        Self::Failed(err)
57    }
58}
59
60impl TestDatabase {
61    /// Wrap an existing handle (any backend).
62    pub fn from_db(db: Db) -> Self {
63        Self { db }
64    }
65
66    /// Open a fresh shared in-memory SQLite database (single connection).
67    ///
68    /// # Errors
69    /// [`TestDatabaseError::Backend`] if the pool cannot be created.
70    #[cfg(feature = "sqlite")]
71    pub async fn sqlite_memory() -> Result<Self, TestDatabaseError> {
72        let backend = siderite_backends::sqlite::SqliteBackend::connect("sqlite::memory:").await?;
73        Ok(Self::from_db(Db::new(backend)))
74    }
75
76    /// The underlying handle (cheap to clone).
77    pub fn db(&self) -> &Db {
78        &self.db
79    }
80
81    /// Consume the wrapper and return the handle.
82    pub fn into_db(self) -> Db {
83        self.db
84    }
85
86    /// Attach `signals` so model signals fire for this database. Do this
87    /// before cloning the handle out; it applies to this handle and later
88    /// clones only.
89    #[must_use]
90    pub fn with_signals(self, signals: Signals) -> Self {
91        Self {
92            db: self.db.with_signals(signals),
93        }
94    }
95
96    /// Create the tables (and indexes, constraints, M2M join tables) for
97    /// `models` from their compiled metadata, as `makemigrations` +
98    /// `migrate` would.
99    ///
100    /// # Errors
101    /// [`TestDatabaseError::Migration`] if DDL cannot be rendered for the
102    /// backend, or [`TestDatabaseError::Orm`] if a statement fails.
103    pub async fn with_models(self, models: &[&ModelMeta]) -> Result<Self, TestDatabaseError> {
104        let target = ProjectState::from_metas(models);
105        let operations = diff(&ProjectState::new(), &target)?;
106        let kind = self.db.capabilities().kind;
107        for sql in schema_editor::statements(kind, &ProjectState::new(), &operations)? {
108            self.db.execute_script(&sql).await?;
109        }
110        Ok(self)
111    }
112
113    /// Apply every migration file found in `dir`.
114    ///
115    /// # Errors
116    /// [`TestDatabaseError::Migration`] for unreadable, inconsistent or
117    /// failing migrations.
118    pub async fn with_migrations(self, dir: impl AsRef<Path>) -> Result<Self, TestDatabaseError> {
119        let graph = MigrationGraph::build(load_dir(dir.as_ref())?)?;
120        Migrator::new(&self.db, &graph).migrate(None, false).await?;
121        Ok(self)
122    }
123
124    /// Run `f` inside a transaction that is **always rolled back**, returning
125    /// `f`'s value. See the [`TestDatabase`] docs for the lifetime model.
126    ///
127    /// Panics in `f` also discard the transaction.
128    ///
129    /// # Errors
130    /// [`TestDatabaseError::Orm`] if the transaction cannot begin or roll back.
131    pub async fn isolated<F, Fut, T>(&self, f: F) -> Result<T, TestDatabaseError>
132    where
133        F: FnOnce(Db) -> Fut,
134        Fut: Future<Output = T>,
135    {
136        let outcome = self
137            .db
138            .transaction(|tx| async move { Err::<Infallible, _>(Rollback::Done(f(tx).await)) })
139            .await;
140        match outcome {
141            Err(Rollback::Done(value)) => Ok(value),
142            Err(Rollback::Failed(err)) => Err(err.into()),
143            Ok(never) => match never {},
144        }
145    }
146}
147
148#[cfg(all(test, feature = "sqlite"))]
149mod tests {
150    use super::*;
151    use siderite_migrations::{FieldState, ModelState, SqlType};
152    use siderite_migrations::{Migration, Operation, write_migration};
153    use std::time::{SystemTime, UNIX_EPOCH};
154
155    fn widget_model() -> ModelState {
156        let mut id = FieldState::new("id", "id", SqlType::BigInt);
157        id.primary_key = true;
158        id.auto = true;
159        ModelState {
160            name: "Widget".into(),
161            table: "widgets".into(),
162            fields: vec![id, FieldState::new("label", "label", SqlType::Text)],
163            indexes: Vec::new(),
164            constraints: Vec::new(),
165        }
166    }
167
168    #[tokio::test]
169    async fn with_migrations_applies_files_from_a_directory() {
170        let nanos = SystemTime::now()
171            .duration_since(UNIX_EPOCH)
172            .map_or(0, |d| d.as_nanos());
173        let dir =
174            std::env::temp_dir().join(format!("siderite-testkit-{}-{nanos}", std::process::id()));
175        let migration = Migration::new(
176            "0001_initial",
177            Vec::new(),
178            vec![Operation::CreateModel {
179                model: widget_model(),
180            }],
181            true,
182            Vec::new(),
183        )
184        .unwrap();
185        write_migration(&dir, &migration).unwrap();
186
187        let test = TestDatabase::sqlite_memory()
188            .await
189            .unwrap()
190            .with_migrations(&dir)
191            .await
192            .unwrap();
193        std::fs::remove_dir_all(&dir).unwrap();
194
195        test.db()
196            .raw_execute("INSERT INTO widgets (label) VALUES ('a')", vec![])
197            .await
198            .unwrap();
199        let rows = test
200            .db()
201            .raw_sql("SELECT id FROM widgets", vec![])
202            .await
203            .unwrap();
204        assert_eq!(rows.rows.len(), 1);
205    }
206
207    #[tokio::test]
208    async fn with_migrations_rejects_an_unreadable_directory_entry() {
209        let nanos = SystemTime::now()
210            .duration_since(UNIX_EPOCH)
211            .map_or(0, |d| d.as_nanos());
212        let dir = std::env::temp_dir().join(format!(
213            "siderite-testkit-bad-{}-{nanos}",
214            std::process::id()
215        ));
216        std::fs::create_dir_all(&dir).unwrap();
217        std::fs::write(dir.join("0001_bad.json"), "not json").unwrap();
218        let result = TestDatabase::sqlite_memory()
219            .await
220            .unwrap()
221            .with_migrations(&dir)
222            .await;
223        std::fs::remove_dir_all(&dir).unwrap();
224        assert!(matches!(result, Err(TestDatabaseError::Migration(_))));
225    }
226
227    #[tokio::test]
228    async fn memory_database_is_shared_between_clones() {
229        let test = TestDatabase::sqlite_memory().await.unwrap();
230        test.db()
231            .execute_script("CREATE TABLE t (id INTEGER PRIMARY KEY)")
232            .await
233            .unwrap();
234        let clone = test.clone().into_db();
235        clone
236            .raw_execute("INSERT INTO t (id) VALUES (1)", vec![])
237            .await
238            .unwrap();
239        let rows = test.db().raw_sql("SELECT id FROM t", vec![]).await.unwrap();
240        assert_eq!(rows.rows.len(), 1);
241    }
242
243    #[tokio::test]
244    async fn isolated_rolls_back_and_returns_value() {
245        let test = TestDatabase::sqlite_memory().await.unwrap();
246        test.db()
247            .execute_script("CREATE TABLE t (id INTEGER PRIMARY KEY)")
248            .await
249            .unwrap();
250        let inside = test
251            .isolated(|db| async move {
252                db.raw_execute("INSERT INTO t (id) VALUES (1)", vec![])
253                    .await
254                    .unwrap();
255                assert!(db.in_transaction());
256                db.raw_sql("SELECT id FROM t", vec![])
257                    .await
258                    .unwrap()
259                    .rows
260                    .len()
261            })
262            .await
263            .unwrap();
264        assert_eq!(inside, 1);
265        let after = test.db().raw_sql("SELECT id FROM t", vec![]).await.unwrap();
266        assert!(after.rows.is_empty());
267        // The connection is released: a second isolated run works.
268        test.isolated(|_| async {}).await.unwrap();
269    }
270}