rws_server/test_client/
db.rs1use std::future::{Future, IntoFuture};
54use std::pin::Pin;
55
56use crate::model::{Backend, DbError, DbPool};
57
58pub struct TestDb {
60 pool: DbPool,
61 transactional: bool,
63}
64
65enum Target {
66 #[cfg(feature = "model-sqlite")]
67 Sqlite,
68 #[cfg(feature = "model-postgres")]
69 Postgres(String),
70 #[cfg(feature = "model-mysql")]
71 MySql(String),
72}
73
74#[must_use = "a TestDbBuilder does nothing until it is awaited"]
77pub struct TestDbBuilder {
78 target: Target,
79 migrations: Vec<String>,
80}
81
82impl TestDb {
83 #[cfg(feature = "model-sqlite")]
85 pub fn sqlite() -> TestDbBuilder {
86 TestDbBuilder { target: Target::Sqlite, migrations: Vec::new() }
87 }
88
89 #[cfg(feature = "model-postgres")]
92 pub fn postgres(url: &str) -> TestDbBuilder {
93 TestDbBuilder { target: Target::Postgres(url.to_string()), migrations: Vec::new() }
94 }
95
96 #[cfg(feature = "model-mysql")]
99 pub fn mysql(url: &str) -> TestDbBuilder {
100 TestDbBuilder { target: Target::MySql(url.to_string()), migrations: Vec::new() }
101 }
102
103 #[cfg(feature = "model-postgres")]
120 pub fn postgres_from_env() -> Option<TestDbBuilder> {
121 url_from_env("RWS_TEST_POSTGRES_URL", "postgres://user:password@localhost/app_test").map(|url| TestDb::postgres(&url))
122 }
123
124 #[cfg(feature = "model-mysql")]
127 pub fn mysql_from_env() -> Option<TestDbBuilder> {
128 url_from_env("RWS_TEST_MYSQL_URL", "mysql://user:password@localhost/app_test").map(|url| TestDb::mysql(&url))
129 }
130
131 pub fn pool(&self) -> DbPool {
134 self.pool.clone()
135 }
136
137 pub fn backend(&self) -> Backend {
139 self.pool.backend()
140 }
141
142 pub fn is_transactional(&self) -> bool {
145 self.transactional
146 }
147
148 pub async fn rollback(mut self) -> Result<(), DbError> {
152 let transactional = std::mem::replace(&mut self.transactional, false);
153 if !transactional {
154 return Ok(());
155 }
156 match &self.pool {
157 #[cfg(feature = "model-sqlite")]
158 DbPool::Sqlite(_) => Ok(()),
159 #[cfg(feature = "model-postgres")]
160 DbPool::Postgres(pool) => rollback_and_close(pool).await,
161 #[cfg(feature = "model-mysql")]
162 DbPool::MySql(pool) => rollback_and_close(pool).await,
163 DbPool::Replicated(_) => Ok(()),
164 }
165 }
166}
167
168impl std::ops::Deref for TestDb {
169 type Target = DbPool;
170 fn deref(&self) -> &DbPool {
171 &self.pool
172 }
173}
174
175impl Drop for TestDb {
176 fn drop(&mut self) {
177 if !self.transactional {
178 return;
179 }
180 match &self.pool {
181 #[cfg(feature = "model-sqlite")]
182 DbPool::Sqlite(_) => {}
183 #[cfg(feature = "model-postgres")]
184 DbPool::Postgres(pool) => close_soon(pool.clone()),
185 #[cfg(feature = "model-mysql")]
186 DbPool::MySql(pool) => close_soon(pool.clone()),
187 DbPool::Replicated(_) => {}
188 }
189 }
190}
191
192impl TestDbBuilder {
193 pub fn migrations(mut self, dir: &str) -> Self {
197 self.migrations.push(dir.to_string());
198 self
199 }
200
201 pub async fn build(self) -> Result<TestDb, DbError> {
203 match self.target {
204 #[cfg(feature = "model-sqlite")]
205 Target::Sqlite => {
206 let pool = sqlx::pool::PoolOptions::<sqlx::Sqlite>::new()
209 .max_connections(1)
210 .idle_timeout(None)
211 .max_lifetime(None)
212 .connect("sqlite::memory:")
213 .await
214 .map_err(|e| DbError::new(e.to_string()))?;
215 let pool = DbPool::Sqlite(pool);
216 for dir in &self.migrations {
217 pool.migrate(dir).await?;
218 }
219 Ok(TestDb { pool, transactional: false })
220 }
221 #[cfg(feature = "model-postgres")]
222 Target::Postgres(url) => {
223 let _ = crate::crypto_provider().install_default();
224 let options: sqlx::postgres::PgConnectOptions =
225 url.parse().map_err(|e: sqlx::Error| DbError::new(format!("RWS test database URL: {e}")))?;
226 migrate_once(&url, &self.migrations, || async {
227 let pool = sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
228 .max_connections(1)
229 .connect_with(options.clone())
230 .await
231 .map_err(|e| DbError::new(e.to_string()))?;
232 Ok(DbPool::Postgres(pool))
233 })
234 .await?;
235 let pool = transactional_pool::<sqlx::Postgres>()
236 .connect_with(options)
237 .await
238 .map_err(|e| DbError::new(e.to_string()))?;
239 Ok(TestDb { pool: DbPool::Postgres(pool), transactional: true })
240 }
241 #[cfg(feature = "model-mysql")]
242 Target::MySql(url) => {
243 let _ = crate::crypto_provider().install_default();
244 let options: sqlx::mysql::MySqlConnectOptions =
245 url.parse().map_err(|e: sqlx::Error| DbError::new(format!("RWS test database URL: {e}")))?;
246 migrate_once(&url, &self.migrations, || async {
247 let pool = sqlx::pool::PoolOptions::<sqlx::MySql>::new()
248 .max_connections(1)
249 .connect_with(options.clone())
250 .await
251 .map_err(|e| DbError::new(e.to_string()))?;
252 Ok(DbPool::MySql(pool))
253 })
254 .await?;
255 let pool = transactional_pool::<sqlx::MySql>()
256 .connect_with(options)
257 .await
258 .map_err(|e| DbError::new(e.to_string()))?;
259 Ok(TestDb { pool: DbPool::MySql(pool), transactional: true })
260 }
261 }
262 }
263}
264
265impl IntoFuture for TestDbBuilder {
266 type Output = Result<TestDb, DbError>;
267 type IntoFuture = Pin<Box<dyn Future<Output = Result<TestDb, DbError>> + Send>>;
268
269 fn into_future(self) -> Self::IntoFuture {
270 Box::pin(self.build())
271 }
272}
273
274#[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
275fn url_from_env(name: &str, example: &str) -> Option<String> {
276 match std::env::var(name).map(|v| v.trim().to_string()) {
277 Ok(url) if !url.is_empty() => Some(url),
278 _ => {
279 eprintln!("skipped: {name} is not set (the URL of a database for tests, e.g. {example})");
280 None
281 }
282 }
283}
284
285#[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
290fn transactional_pool<DB: sqlx::Database>() -> sqlx::pool::PoolOptions<DB> {
291 sqlx::pool::PoolOptions::<DB>::new()
292 .max_connections(1)
293 .min_connections(0)
294 .idle_timeout(None)
295 .max_lifetime(None)
296 .after_connect(|conn, _meta| <DB::TransactionManager as sqlx::TransactionManager>::begin(conn, None))
297}
298
299#[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
300async fn rollback_and_close<DB: sqlx::Database>(pool: &sqlx::Pool<DB>) -> Result<(), DbError> {
301 let result = async {
302 let mut conn = pool.acquire().await.map_err(|e| DbError::new(e.to_string()))?;
303 <DB::TransactionManager as sqlx::TransactionManager>::rollback(&mut conn).await.map_err(|e| DbError::new(e.to_string()))
304 }
305 .await;
306 pool.close().await;
307 result
308}
309
310#[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
316fn close_soon<DB: sqlx::Database>(pool: sqlx::Pool<DB>) {
317 drop(pool.close());
318 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
319 runtime.spawn(async move { pool.close().await });
320 }
321}
322
323#[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
327async fn migrate_once<F, Fut>(url: &str, dirs: &[String], connect: F) -> Result<(), DbError>
328where
329 F: FnOnce() -> Fut,
330 Fut: Future<Output = Result<DbPool, DbError>>,
331{
332 use std::collections::HashSet;
333 use std::sync::OnceLock;
334 static DONE: OnceLock<tokio::sync::Mutex<HashSet<String>>> = OnceLock::new();
335 if dirs.is_empty() {
336 return Ok(());
337 }
338 let mut done = DONE.get_or_init(Default::default).lock().await;
339 let keys: Vec<String> = dirs.iter().map(|dir| format!("{url}\n{dir}")).collect();
340 if keys.iter().all(|key| done.contains(key)) {
341 return Ok(());
342 }
343 let pool = connect().await?;
344 let mut result = Ok(());
345 for (dir, key) in dirs.iter().zip(keys) {
346 if done.contains(&key) {
347 continue;
348 }
349 if let Err(e) = pool.migrate(dir).await {
350 result = Err(e);
351 break;
352 }
353 done.insert(key);
354 }
355 pool.close().await;
356 result
357}