Skip to main content

rainier_database/
testing.rs

1//! Test doubles — [`MemoryConnection`] and [`fake_database`].
2//!
3//! A repository test should not need a database. This fake records the SQL a
4//! repository emits and replays queued rows, so the behaviour *around* the
5//! query — the hooks, the pagination arithmetic, the not-found handling, the
6//! shard route chosen — is testable with no server, schema or migration.
7//!
8//! It is **not** a SQL engine: it never parses the statements it is given. Use
9//! it to test the layer above the ORM, and a real SQLite executor to test the
10//! SQL itself.
11
12use std::sync::{Arc, Mutex};
13
14use rainier_orm::sea_query::Value;
15use rainier_orm::{Dialect, ExecOutcome, Row, ShardRoute};
16use rainier_support::{BoxFuture, Error, Result};
17
18use crate::connection::{Connection, Database};
19use crate::row::{ColumnRequest, OwnedRow};
20
21/// One recorded statement.
22#[derive(Debug, Clone, PartialEq)]
23pub struct RecordedStatement {
24    /// The SQL that was run.
25    pub sql: String,
26    /// Its bind values.
27    pub params: Vec<Value>,
28    /// The shard it was routed to.
29    pub route: ShardRoute,
30}
31
32/// A [`Connection`] that records statements and replays queued rows.
33#[derive(Default)]
34pub struct MemoryConnection {
35    dialect: Option<Dialect>,
36    shard_family: Option<String>,
37    allocated_id: Option<u64>,
38    recorded: Mutex<Vec<RecordedStatement>>,
39    queued: Mutex<Vec<Vec<OwnedRow>>>,
40    outcome: Mutex<ExecOutcome>,
41    failure: Option<String>,
42}
43
44impl MemoryConnection {
45    /// A connection speaking `dialect` that returns no rows and reports one
46    /// affected row per write.
47    pub fn new(dialect: Dialect) -> Self {
48        Self {
49            dialect: Some(dialect),
50            outcome: Mutex::new(ExecOutcome { rows_affected: 1, last_insert_id: 1 }),
51            ..Self::default()
52        }
53    }
54
55    /// Queue the rows the next query should return.
56    ///
57    /// Answers are consumed in order; once the queue is empty, queries return
58    /// nothing.
59    pub fn returning(self, rows: impl IntoIterator<Item = OwnedRow>) -> Self {
60        self.queued.lock().expect("queue lock poisoned").push(rows.into_iter().collect());
61        self
62    }
63
64    /// Set the outcome every write reports.
65    pub fn with_outcome(self, rows_affected: u64, last_insert_id: i64) -> Self {
66        *self.outcome.lock().expect("outcome lock poisoned") =
67            ExecOutcome { rows_affected, last_insert_id };
68        self
69    }
70
71    /// Present as a sharded connector in the named family.
72    pub fn sharded(mut self, family: impl Into<String>) -> Self {
73        self.shard_family = Some(family.into());
74        self
75    }
76
77    /// Mint this id whenever the repository asks for a shard-encoded key.
78    pub fn allocating(mut self, id: u64) -> Self {
79        self.allocated_id = Some(id);
80        self
81    }
82
83    /// Make every statement fail with `message`.
84    pub fn failing(mut self, message: impl Into<String>) -> Self {
85        self.failure = Some(message.into());
86        self
87    }
88
89    /// Every statement run so far, in order.
90    pub fn recorded(&self) -> Vec<RecordedStatement> {
91        self.recorded.lock().expect("recorded lock poisoned").clone()
92    }
93
94    /// The SQL of every statement run so far.
95    pub fn statements(&self) -> Vec<String> {
96        self.recorded().into_iter().map(|r| r.sql).collect()
97    }
98
99    /// The most recent statement's SQL.
100    pub fn last_statement(&self) -> Option<String> {
101        self.recorded().last().map(|r| r.sql.clone())
102    }
103
104    /// The most recent statement's shard route.
105    pub fn last_route(&self) -> Option<ShardRoute> {
106        self.recorded().last().map(|r| r.route)
107    }
108
109    /// The bind values of every statement run so far.
110    pub fn bindings(&self) -> Vec<Vec<Value>> {
111        self.recorded().into_iter().map(|r| r.params).collect()
112    }
113
114    /// How many statements have run.
115    pub fn statement_count(&self) -> usize {
116        self.recorded.lock().expect("recorded lock poisoned").len()
117    }
118
119    fn record(&self, route: ShardRoute, sql: &str, params: Vec<Value>) -> Result<()> {
120        self.recorded.lock().expect("recorded lock poisoned").push(RecordedStatement {
121            sql: sql.to_string(),
122            params,
123            route,
124        });
125        match &self.failure {
126            Some(message) => Err(Error::internal(message.clone())),
127            None => Ok(()),
128        }
129    }
130
131    fn take_rows(&self) -> Vec<OwnedRow> {
132        let mut queued = self.queued.lock().expect("queue lock poisoned");
133        if queued.is_empty() {
134            return Vec::new();
135        }
136        queued.remove(0)
137    }
138}
139
140impl std::fmt::Debug for MemoryConnection {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        f.debug_struct("MemoryConnection")
143            .field("dialect", &self.dialect)
144            .field("statements", &self.statement_count())
145            .finish()
146    }
147}
148
149impl Connection for MemoryConnection {
150    fn dialect(&self) -> Dialect {
151        self.dialect.unwrap_or(Dialect::Sqlite)
152    }
153
154    fn shard_family(&self) -> Option<String> {
155        self.shard_family.clone()
156    }
157
158    fn allocate_id(&self, _shard_key: u64) -> Option<u64> {
159        self.allocated_id
160    }
161
162    fn fetch<'a>(
163        &'a self,
164        route: ShardRoute,
165        sql: &'a str,
166        params: Vec<Value>,
167        _columns: Vec<ColumnRequest>,
168    ) -> BoxFuture<'a, Result<Vec<OwnedRow>>> {
169        Box::pin(async move {
170            self.record(route, sql, params)?;
171            // Queued rows are handed back as-is rather than re-snapshotted,
172            // so a test can queue exactly the cells it wants a decoder to see.
173            Ok(self.take_rows())
174        })
175    }
176
177    fn execute<'a>(
178        &'a self,
179        route: ShardRoute,
180        sql: &'a str,
181        params: Vec<Value>,
182    ) -> BoxFuture<'a, Result<Vec<ExecOutcome>>> {
183        Box::pin(async move {
184            self.record(route, sql, params)?;
185            Ok(vec![*self.outcome.lock().expect("outcome lock poisoned")])
186        })
187    }
188
189    fn fetch_raw<'a>(
190        &'a self,
191        route: ShardRoute,
192        sql: &'a str,
193        params: Vec<Value>,
194    ) -> BoxFuture<'a, Result<Vec<Box<dyn Row>>>> {
195        Box::pin(async move {
196            self.record(route, sql, params)?;
197            Ok(self.take_rows().into_iter().map(|row| Box::new(row) as Box<dyn Row>).collect())
198        })
199    }
200}
201
202/// A [`Database`] over a [`MemoryConnection`], plus a handle to the connection
203/// for assertions.
204pub fn fake_database(connection: MemoryConnection) -> (Database, Arc<MemoryConnection>) {
205    let connection = Arc::new(connection);
206    (Database::from_arc(connection.clone()), connection)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::statement;
213
214    #[derive(rainier_orm::Entity, Clone, Debug, PartialEq)]
215    #[orm(table = "posts")]
216    struct Post {
217        #[orm(pk, auto_increment)]
218        id: u64,
219        title: String,
220    }
221
222    #[tokio::test]
223    async fn records_the_statement_bindings_and_route() {
224        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
225
226        db.execute(statement::select_by_pk::<Post>(Dialect::Sqlite, 7_i64.into())).await.unwrap();
227
228        let recorded = connection.recorded();
229        assert_eq!(recorded.len(), 1);
230        assert!(recorded[0].sql.contains("posts"));
231        // The key, plus the `LIMIT 1` sea-query parameterises.
232        assert_eq!(recorded[0].params.len(), 2);
233        assert_eq!(recorded[0].params[0], 7_i64.into());
234        assert_eq!(recorded[0].route, ShardRoute::Global);
235    }
236
237    #[tokio::test]
238    async fn queued_rows_are_served_once_each() {
239        let connection = MemoryConnection::new(Dialect::Sqlite)
240            .returning([OwnedRow::new().with("id", 1_u64).with("title", "a")])
241            .returning([
242                OwnedRow::new().with("id", 2_u64).with("title", "b"),
243                OwnedRow::new().with("id", 3_u64).with("title", "c"),
244            ]);
245        let (db, _) = fake_database(connection);
246
247        let first: Vec<Post> =
248            db.fetch_all(statement::select_all::<Post>(Dialect::Sqlite)).await.unwrap();
249        assert_eq!(first.len(), 1);
250
251        let second: Vec<Post> =
252            db.fetch_all(statement::select_all::<Post>(Dialect::Sqlite)).await.unwrap();
253        assert_eq!(second.len(), 2);
254
255        let third: Vec<Post> =
256            db.fetch_all(statement::select_all::<Post>(Dialect::Sqlite)).await.unwrap();
257        assert!(third.is_empty(), "the queue is exhausted");
258    }
259
260    #[tokio::test]
261    async fn write_outcomes_are_configurable() {
262        let (db, _) = fake_database(MemoryConnection::new(Dialect::Sqlite).with_outcome(5, 42));
263        let outcome = db.statement("UPDATE posts SET title = 'x'").await.unwrap();
264
265        assert_eq!(outcome.rows_affected, 5);
266        assert_eq!(outcome.last_insert_id, 42);
267    }
268
269    #[tokio::test]
270    async fn a_failing_connection_still_records_the_attempt() {
271        let (db, connection) =
272            fake_database(MemoryConnection::new(Dialect::Sqlite).failing("boom"));
273
274        assert!(db.statement("SELECT 1").await.is_err());
275        assert_eq!(connection.statement_count(), 1);
276    }
277
278    #[test]
279    fn a_sharded_fake_reports_its_family_and_mints_ids() {
280        let connection = MemoryConnection::new(Dialect::Sqlite).sharded("users").allocating(4242);
281        assert_eq!(connection.shard_family().as_deref(), Some("users"));
282        assert_eq!(connection.allocate_id(1), Some(4242));
283
284        let (db, _) = fake_database(connection);
285        assert!(db.is_sharded());
286    }
287}