1pub(crate) mod auto_migration;
6mod column;
7mod conn;
8mod encrypted;
9mod error;
10mod factory;
11mod from_row;
12mod json;
13mod key;
14mod migrate;
15mod model;
16mod paginate;
17mod query;
18mod query_log;
19pub mod relations;
20pub mod schema;
21#[allow(dead_code)] pub(crate) mod schema_check;
23pub(crate) mod schema_diff;
24pub(crate) mod schema_sql;
25pub mod search;
26mod value;
27
28use std::str::FromStr;
29use std::time::Duration;
30
31use anyhow::Context;
32use axum::extract::FromRef;
33use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
34
35pub use column::{Col, Comparable, Condition, IntoColumn};
36pub use conn::Conn;
37#[doc(hidden)]
38pub use conn::bounds;
39pub use conn::{Db, Dialect, Executor, FromDb, Row, RowIndex, Sql, Transaction, sql};
40pub(crate) use conn::{RowInner, SchemaEpoch, script};
41pub use encrypted::{Encrypted, Unsealed};
42pub use error::DbError;
43pub use factory::{Factory, FactoryBuilder};
44pub use from_row::FromRow;
45pub use json::Json;
46pub use key::{InvalidUlid, ModelKey, Ulid};
47pub use migrate::{Migration, MigrationStatus};
48pub(crate) use migrate::{Migrator, framework_migration};
49pub use model::{Model, ModelHooks};
50pub use paginate::{CursorPage, Page, Paginated, SimplePage};
51pub use query::{Number, Query};
52pub use query_log::capture_queries;
53pub use schema::{ColumnKind, ColumnType, ModelColumn, ModelIndex};
54pub use sqlx;
58pub use value::{DbValue, ToDbValue};
59
60use crate::{AppState, Config};
61
62pub type DateTime = chrono::DateTime<chrono::Utc>;
64
65pub(crate) fn from_unix(seconds: i64) -> DateTime {
67 chrono::DateTime::from_timestamp(seconds, 0).unwrap_or_default()
68}
69
70pub fn now() -> DateTime {
73 use chrono::SubsecRound;
74 chrono::DateTime::<chrono::Utc>::from(crate::clock::system_now()).trunc_subsecs(6)
75}
76
77impl FromRef<AppState> for Db {
78 fn from_ref(state: &AppState) -> Self {
79 state.db.clone()
80 }
81}
82
83pub(crate) async fn connect(config: &Config) -> anyhow::Result<Db> {
92 let url = &config.database_url;
93 if config.database_pool_size == 0 {
94 anyhow::bail!("DATABASE_POOL_SIZE must be at least 1");
95 }
96 if is_postgres(url) {
97 return connect_postgres(url, config, false).await;
98 }
99 if !url.starts_with("sqlite:") {
100 anyhow::bail!(
102 "DATABASE_URL `{}` must start with sqlite:, postgres:// or postgresql://",
103 redact(url)
104 );
105 }
106 if is_memory(url)
107 && let Some(test_url) = test_database_url().filter(|u| is_postgres(u))
108 {
109 return connect_postgres(&test_url, config, true).await;
110 }
111 let schema = SchemaEpoch::default();
112 let pool = connect_sqlite(config, schema.clone()).await?;
113 Ok(Db::from_sqlite(pool, schema))
114}
115
116fn test_database_url() -> Option<String> {
119 test_database_url_in(
120 std::env::var("TEST_DATABASE_URL").ok(),
121 std::path::Path::new(".env"),
122 )
123}
124
125fn test_database_url_in(env: Option<String>, dotenv: &std::path::Path) -> Option<String> {
127 env.or_else(|| {
128 dotenvy::from_path_iter(dotenv)
129 .ok()?
130 .flatten()
131 .find(|(key, _)| key == "TEST_DATABASE_URL")
132 .map(|(_, url)| url)
133 })
134}
135
136fn is_postgres(url: &str) -> bool {
137 url.starts_with("postgres://") || url.starts_with("postgresql://")
138}
139
140fn is_memory(url: &str) -> bool {
141 url.contains(":memory:") || url.contains("mode=memory")
142}
143
144pub(crate) async fn connect_scratch(config: &Config) -> anyhow::Result<Db> {
147 if is_postgres(&config.database_url) {
148 return connect_postgres(&config.database_url, config, true).await;
149 }
150 let mut scratch = config.clone();
151 scratch.database_url = "sqlite::memory:".into();
152 connect(&scratch).await
153}
154
155pub(crate) async fn drop_scratch(db: &Db) {
157 if db.dialect() != Dialect::Postgres {
158 return;
159 }
160 let Ok(schema) = sql("SELECT current_schema()").scalar::<String>(db).await else {
161 return;
162 };
163 if schema.starts_with("renox_test_") {
164 let _ = sql(format!("DROP SCHEMA {} CASCADE", quote(&schema)))
165 .execute(db)
166 .await;
167 }
168}
169
170#[cfg(feature = "postgres")]
173async fn connect_postgres(url: &str, config: &Config, fresh_schema: bool) -> anyhow::Result<Db> {
174 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
175 use sqlx::{ConnectOptions, Connection};
176
177 let failed = || format!("could not connect to the database at `{}`", redact(url));
178 let mut options = PgConnectOptions::from_str(url).with_context(failed)?;
179 if fresh_schema {
180 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
181 let nanos = std::time::SystemTime::now()
182 .duration_since(std::time::UNIX_EPOCH)
183 .unwrap_or_default()
184 .as_nanos();
185 let schema = format!(
186 "renox_test_{}_{}_{nanos}",
187 std::process::id(),
188 COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
189 );
190 let mut conn = options.connect().await.with_context(failed)?;
191 sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
192 "CREATE SCHEMA {}",
193 quote(&schema)
194 )))
195 .execute(&mut conn)
196 .await
197 .context("could not create a schema for the test")?;
198 conn.close().await.ok();
199 options = options.options([("search_path", schema)]);
200 }
201 if let Some(limit) = config.database_statement_timeout {
202 options = options.options([("statement_timeout", format!("{}ms", limit.as_millis()))]);
203 }
204 let pool_size = config.database_pool_size;
205 let pool_size = if fresh_schema {
208 pool_size.min(3)
209 } else {
210 pool_size
211 };
212 let schema = SchemaEpoch::default();
213 let epoch = schema.clone();
214 let pool = PgPoolOptions::new()
215 .max_connections(pool_size)
216 .acquire_timeout(config.database_acquire_timeout)
217 .before_acquire(move |_, meta| {
218 let fresh = !epoch.is_stale(meta.age);
219 Box::pin(async move { Ok(fresh) })
220 })
221 .connect_with(options)
222 .await
223 .with_context(failed)?;
224 Ok(Db::from_postgres(pool, schema))
225}
226
227#[cfg(not(feature = "postgres"))]
228async fn connect_postgres(_url: &str, _config: &Config, _fresh_schema: bool) -> anyhow::Result<Db> {
229 anyhow::bail!(
230 "the database URL points to PostgreSQL, but this build has no PostgreSQL support; \
231 enable the `postgres` feature of `renox`"
232 )
233}
234
235fn redact(url: &str) -> String {
237 let Some((scheme, rest)) = url.split_once("://") else {
238 return url.to_owned();
239 };
240 match rest.split_once('@') {
241 Some((user, host)) => {
242 let user = user.split_once(':').map_or(user, |(name, _)| name);
243 format!("{scheme}://{user}:***@{host}")
244 }
245 None => url.to_owned(),
246 }
247}
248
249const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
251
252async fn connect_sqlite(config: &Config, schema: SchemaEpoch) -> anyhow::Result<sqlx::SqlitePool> {
255 let url = &config.database_url;
256 let in_memory = is_memory(url);
257
258 let mut options = SqliteConnectOptions::from_str(url)
259 .with_context(|| format!("DATABASE_URL `{url}` is not a valid SQLite URL"))?
260 .create_if_missing(true)
261 .foreign_keys(true)
262 .busy_timeout(BUSY_TIMEOUT);
263 let busy_budget = config.database_acquire_timeout + BUSY_TIMEOUT;
267 if !in_memory {
268 options = options
269 .journal_mode(SqliteJournalMode::Wal)
270 .synchronous(SqliteSynchronous::Normal);
271 if let Some(dir) = options
272 .get_filename()
273 .parent()
274 .filter(|d| !d.as_os_str().is_empty())
275 {
276 std::fs::create_dir_all(dir)
277 .with_context(|| format!("could not create {}", dir.display()))?;
278 }
279 prepare_file(&options, busy_budget).await.with_context(|| {
280 format!("could not open the database at `{url}` (switching it to WAL)")
281 })?;
282 }
283
284 let pool = if in_memory {
286 SqlitePoolOptions::new()
287 .max_connections(1)
288 .min_connections(1)
289 .idle_timeout(None)
290 .max_lifetime(None)
291 } else {
292 SqlitePoolOptions::new()
294 .max_connections(config.database_pool_size)
295 .before_acquire(move |_, meta| {
296 let fresh = !schema.is_stale(meta.age);
297 Box::pin(async move { Ok(fresh) })
298 })
299 }
300 .acquire_timeout(if in_memory {
301 config.database_acquire_timeout.min(Duration::from_secs(2))
305 } else {
306 config.database_acquire_timeout
307 });
308 let pool = if in_memory {
309 let budget = config.database_acquire_timeout;
313 retry_while(budget, is_pool_timeout, || {
314 pool.clone().connect_with(options.clone())
315 })
316 .await
317 } else {
318 retry_while(busy_budget, is_busy_or_pool_timeout, || {
322 pool.clone().connect_with(options.clone())
323 })
324 .await
325 };
326 pool.with_context(|| format!("could not open the database at `{url}`"))
327}
328
329async fn prepare_file(options: &SqliteConnectOptions, budget: Duration) -> Result<(), sqlx::Error> {
338 use sqlx::{ConnectOptions, Connection};
339
340 let options = options.clone().busy_timeout(Duration::from_millis(100));
344 let conn = retry_while(budget, is_busy, || options.connect()).await?;
345 conn.close().await
346}
347
348fn is_busy(error: &sqlx::Error) -> bool {
351 let Some(code) = error
352 .as_database_error()
353 .and_then(|e| e.code())
354 .and_then(|code| code.parse::<i32>().ok())
355 else {
356 return false;
357 };
358 matches!(code & 0xff, 5 | 6)
359}
360
361fn is_busy_or_pool_timeout(error: &sqlx::Error) -> bool {
362 is_busy(error) || is_pool_timeout(error)
363}
364
365fn is_pool_timeout(error: &sqlx::Error) -> bool {
366 matches!(error, sqlx::Error::PoolTimedOut)
367}
368
369async fn retry_while<T, F>(
373 budget: Duration,
374 retryable: fn(&sqlx::Error) -> bool,
375 mut open: impl FnMut() -> F,
376) -> Result<T, sqlx::Error>
377where
378 F: std::future::Future<Output = Result<T, sqlx::Error>>,
379{
380 let start = tokio::time::Instant::now();
381 let mut tries = 0;
382 loop {
383 tries += 1;
384 match open().await {
385 Err(e) if retryable(&e) && (start.elapsed() < budget || tries < 3) => {
388 tracing::debug!("opening the database failed ({e}); trying again");
389 let jitter = u64::from(rand::random::<u8>()) * 50 / 255;
391 tokio::time::sleep(Duration::from_millis(25 + jitter)).await;
392 }
393 result => return result,
394 }
395 }
396}
397
398pub(crate) fn quote(ident: &str) -> String {
400 format!("\"{}\"", ident.replace('"', "\"\""))
401}
402
403#[doc(hidden)]
408#[cfg(not(feature = "postgres"))]
409#[macro_export]
410macro_rules! __db_text_type {
411 ($t:ty) => {
412 $crate::__db_text_type_for!($t, $crate::__sqlx::sqlite::Sqlite);
413 };
414}
415
416#[doc(hidden)]
417#[cfg(feature = "postgres")]
418#[macro_export]
419macro_rules! __db_text_type {
420 ($t:ty) => {
421 $crate::__db_text_type_for!($t, $crate::__sqlx::sqlite::Sqlite);
422 $crate::__db_text_type_for!($t, $crate::__sqlx::postgres::Postgres);
423 };
424}
425
426#[doc(hidden)]
427#[macro_export]
428macro_rules! __db_text_type_for {
429 ($t:ty, $db:ty) => {
430 impl $crate::__sqlx::Type<$db> for $t {
431 fn type_info() -> <$db as $crate::__sqlx::Database>::TypeInfo {
432 <::std::string::String as $crate::__sqlx::Type<$db>>::type_info()
433 }
434
435 fn compatible(ty: &<$db as $crate::__sqlx::Database>::TypeInfo) -> bool {
436 <::std::string::String as $crate::__sqlx::Type<$db>>::compatible(ty)
437 }
438 }
439
440 impl<'r> $crate::__sqlx::Decode<'r, $db> for $t {
441 fn decode(
442 value: <$db as $crate::__sqlx::Database>::ValueRef<'r>,
443 ) -> ::std::result::Result<Self, $crate::__sqlx::error::BoxDynError> {
444 let text = <::std::string::String as $crate::__sqlx::Decode<$db>>::decode(value)?;
445 text.parse::<$t>().map_err(::std::convert::Into::into)
446 }
447 }
448 };
449}
450
451#[cfg(test)]
452mod tests {
453 use std::sync::atomic::{AtomicU32, Ordering};
454 use std::time::Duration;
455
456 use super::{
457 is_busy, is_busy_or_pool_timeout, is_pool_timeout, redact, retry_while,
458 test_database_url_in,
459 };
460 use crate::db::error::fake;
461
462 #[test]
463 fn urls_lose_only_their_password() {
464 assert_eq!(
465 redact("postgres://app:secret@db:5432/app"),
466 "postgres://app:***@db:5432/app"
467 );
468 assert_eq!(redact("postgres://app@db/app"), "postgres://app:***@db/app");
471 assert_eq!(redact("postgres://db/app"), "postgres://db/app");
472 assert_eq!(redact("sqlite:app.db"), "sqlite:app.db");
473 }
474
475 #[test]
476 fn busy_errors_and_pool_timeouts() {
477 for code in ["5", "6", "517", "261"] {
478 assert!(is_busy(&fake::coded(Some(code))), "{code}");
479 }
480 assert!(!is_busy(&fake::coded(Some("19"))));
481 assert!(!is_busy(&fake::coded(Some("40001")))); assert!(!is_busy(&fake::coded(None)));
483 assert!(!is_busy(&sqlx::Error::PoolTimedOut));
484 assert!(is_busy_or_pool_timeout(&sqlx::Error::PoolTimedOut));
485 assert!(is_busy_or_pool_timeout(&fake::coded(Some("5"))));
486 assert!(!is_busy_or_pool_timeout(&sqlx::Error::PoolClosed));
487 }
488
489 #[test]
492 fn the_test_database_url_comes_from_the_environment_or_dotenv() {
493 let dir = tempfile::tempdir().unwrap();
494 let dotenv = dir.path().join(".env");
495 assert_eq!(test_database_url_in(None, &dotenv), None);
496 std::fs::write(
497 &dotenv,
498 "APP_NAME=x\nTEST_DATABASE_URL=postgres://t@localhost/t\n",
499 )
500 .unwrap();
501 assert_eq!(
502 test_database_url_in(None, &dotenv).as_deref(),
503 Some("postgres://t@localhost/t")
504 );
505 assert_eq!(
506 test_database_url_in(Some("postgres://env/e".into()), &dotenv).as_deref(),
507 Some("postgres://env/e")
508 );
509 std::fs::write(&dotenv, "APP_NAME=x\n").unwrap();
510 assert_eq!(test_database_url_in(None, &dotenv), None);
511 }
512
513 #[tokio::test]
516 async fn sqlite_makes_directories_and_caps_the_in_memory_wait() {
517 let dir = tempfile::tempdir().unwrap();
518 let config = crate::Config {
519 database_url: format!("sqlite://{}/a/b/app.db", dir.path().display()),
520 ..crate::Config::default()
521 };
522 let pool = super::connect_sqlite(&config, Default::default())
523 .await
524 .unwrap();
525 assert!(dir.path().join("a/b/app.db").exists());
526 pool.close().await;
527
528 let config = crate::Config {
529 database_url: "sqlite::memory:".into(),
530 database_acquire_timeout: Duration::from_secs(30),
531 ..crate::Config::default()
532 };
533 let pool = super::connect_sqlite(&config, Default::default())
534 .await
535 .unwrap();
536 assert_eq!(pool.options().get_acquire_timeout(), Duration::from_secs(2));
537 assert_eq!(pool.options().get_max_connections(), 1);
538 }
539
540 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
542 async fn pools_opening_a_new_file_together_all_open_it() {
543 for _ in 0..10 {
544 let dir = tempfile::tempdir().unwrap();
545 let config = crate::Config {
546 database_url: format!("sqlite://{}/app.db", dir.path().display()),
547 database_acquire_timeout: Duration::from_secs(5),
548 ..crate::Config::default()
549 };
550 let opening: Vec<_> = (0..8)
551 .map(|_| {
552 let config = config.clone();
553 tokio::spawn(async move {
554 super::connect_sqlite(&config, Default::default())
555 .await
556 .map(|_| ())
557 })
558 })
559 .collect();
560 for task in opening {
561 task.await.unwrap().expect("every pool opens the new file");
562 }
563 }
564 }
565
566 #[tokio::test]
568 async fn opening_retries_pool_timeouts_within_the_budget() {
569 let tries = AtomicU32::new(0);
570 let opened = retry_while(Duration::from_secs(30), is_pool_timeout, || async {
571 match tries.fetch_add(1, Ordering::SeqCst) {
572 0 | 1 => Err(sqlx::Error::PoolTimedOut),
573 _ => Ok("pool"),
574 }
575 })
576 .await;
577 assert_eq!(opened.unwrap(), "pool");
578 assert_eq!(tries.load(Ordering::SeqCst), 3);
579 }
580
581 #[tokio::test]
582 async fn opening_gives_up_after_the_budget_and_on_other_errors() {
583 let tries = AtomicU32::new(0);
584 let opened: Result<(), _> = retry_while(Duration::ZERO, is_pool_timeout, || async {
585 tries.fetch_add(1, Ordering::SeqCst);
586 Err(sqlx::Error::PoolTimedOut)
587 })
588 .await;
589 assert!(matches!(opened, Err(sqlx::Error::PoolTimedOut)));
590 assert_eq!(
593 tries.load(Ordering::SeqCst),
594 3,
595 "three tries, then no retry past the budget"
596 );
597
598 let tries = AtomicU32::new(0);
599 let opened: Result<(), _> =
600 retry_while(Duration::from_secs(30), is_pool_timeout, || async {
601 tries.fetch_add(1, Ordering::SeqCst);
602 Err(sqlx::Error::PoolClosed)
603 })
604 .await;
605 assert!(matches!(opened, Err(sqlx::Error::PoolClosed)));
606 assert_eq!(
607 tries.load(Ordering::SeqCst),
608 1,
609 "other errors aren't retried"
610 );
611 }
612}