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