Skip to main content

renox_core/db/
mod.rs

1//! Database access (SQLite, or PostgreSQL with the `postgres` feature): the
2//! connection pool, raw SQL, models, queries, pagination, migrations and
3//! factories.
4
5pub(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)] // used by `db:check` (370.5)
22pub(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};
54/// sqlx, for what Renox's own API doesn't cover: `Db::sqlite()`, `Db::postgres()`,
55/// `Row::sqlite()`, `Row::postgres()` and `DbError::sqlx()` hand out its types.
56/// sqlx may move to a new version in a minor Renox release; see docs/stability.md.
57pub use sqlx;
58pub use value::{DbValue, ToDbValue};
59
60use crate::{AppState, Config};
61
62/// The timestamp type for `created_at`, `updated_at` and `deleted_at`.
63pub type DateTime = chrono::DateTime<chrono::Utc>;
64
65/// Unix seconds (the queue's and webhooks' columns) as a `DateTime`.
66pub(crate) fn from_unix(seconds: i64) -> DateTime {
67    chrono::DateTime::from_timestamp(seconds, 0).unwrap_or_default()
68}
69
70/// The current time, as stored in timestamps: to the microsecond, which is
71/// what PostgreSQL keeps, so a saved model equals the same row read back.
72pub 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
83/// Opens the pool for `DATABASE_URL`. `postgres://` and `postgresql://` URLs
84/// need the `postgres` feature, `sqlite:` URLs open SQLite, and any other
85/// scheme is refused (so a typo doesn't become a SQLite file).
86///
87/// An in-memory SQLite database (what tests use) is swapped for a fresh
88/// schema in `TEST_DATABASE_URL` when that is set to a PostgreSQL URL (in the
89/// environment or in `.env`), so a test suite can run against PostgreSQL
90/// unchanged.
91pub(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        // A typo such as `postgress://` must not become a SQLite file.
101        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
116/// `TEST_DATABASE_URL` from the environment, else from `.env` in the current
117/// directory (without loading the rest of `.env` into the environment).
118fn 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
125/// `env` when set, else `TEST_DATABASE_URL` in the file `dotenv`.
126fn 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
144/// A throwaway database for checks that run migrations: a fresh schema on
145/// PostgreSQL, an in-memory database on SQLite.
146pub(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
155/// Drops the schema of a database made by [`connect_scratch`] (PostgreSQL only).
156pub(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/// Connects to PostgreSQL; with `fresh_schema`, in a new, empty schema of its
171/// own (named `renox_test_…`, left behind for inspection).
172#[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    // Test suites boot many apps at once; a few connections each keeps them
206    // under the server's limit (100 by default).
207    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
235/// The URL with its password hidden, for error messages.
236fn 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
249/// How long SQLite waits for a lock before answering "database is locked".
250const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
251
252/// Opens a SQLite pool, creating the file and its directory if needed. File
253/// databases use WAL mode; every connection enforces foreign keys.
254async 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    // One busy wait can take the whole `BUSY_TIMEOUT`, so busy errors get that
264    // much on top of the acquire timeout: a long wait still leaves room to
265    // try again (#178).
266    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    // Each connection to `:memory:` is a separate database, so keep exactly one.
285    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        // Connections opened before a migration would read the old schema.
293        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        // The one connection is busy for a moment at most, unless a task
302        // waits for itself (a query through `&db` while its own transaction
303        // is open): fail such a test fast instead of letting it hang.
304        config.database_acquire_timeout.min(Duration::from_secs(2))
305    } else {
306        config.database_acquire_timeout
307    });
308    let pool = if in_memory {
309        // sqlx opens the first connection within `acquire_timeout` too, and on a
310        // busy machine that can take longer than the 2 s above: try again until
311        // the configured timeout has passed.
312        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        // Another process opening or closing the same file can make SQLite
319        // answer "database is locked" at once (#163), or wait in the busy
320        // handler past the pool's deadline.
321        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
329/// Opens a file database once before its pool does, so the file exists and is
330/// in WAL mode. Switching to WAL takes an exclusive lock, so processes opening
331/// a brand-new file together (`serve` and `route:list`, say) contend for it:
332/// SQLite answers some of them "database is locked" at once, and a wait in the
333/// busy handler could outlast the pool's acquire timeout (#163). Busy errors
334/// are retried until `budget` has passed; this one connection has no acquire
335/// deadline. Once the file is in WAL mode, the pool's connections don't need
336/// the exclusive lock.
337async fn prepare_file(options: &SqliteConnectOptions, budget: Duration) -> Result<(), sqlx::Error> {
338    use sqlx::{ConnectOptions, Connection};
339
340    // A short busy wait per try: connections switching a new file to WAL
341    // together can each wait for the others' lock, and with the usual five
342    // seconds the first try alone used up the whole budget (#163).
343    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
348/// Whether SQLite answered `SQLITE_BUSY` or `SQLITE_LOCKED` (or one of their
349/// extended codes).
350fn 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
369/// Runs `open` again, after a short pause, while it fails with an error
370/// `retryable` accepts and `budget` hasn't passed since the first try (but at
371/// least three times); any other result is returned as it is.
372async 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            // At least three tries: one try can wait out a whole busy timeout
386            // (as long as the budget) before it fails (#163).
387            Err(e) if retryable(&e) && (start.elapsed() < budget || tries < 3) => {
388                tracing::debug!("opening the database failed ({e}); trying again");
389                // 25–75 ms, so processes that collided don't collide again.
390                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
398/// Quotes an identifier (the same on SQLite and PostgreSQL), e.g. `order` -> `"order"`.
399pub(crate) fn quote(ident: &str) -> String {
400    format!("\"{}\"", ident.replace('"', "\"\""))
401}
402
403/// Implements sqlx's traits for a type stored as text through `Display` and
404/// `FromStr` (what `#[derive(DbEnum)]` generates), for every database this
405/// build of Renox supports. Chosen when renox-core is compiled, so apps don't
406/// need to know which features it has.
407#[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        // A user without a password still gets the mask; no `@` or no
469        // scheme leaves the URL as it is.
470        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")))); // not a number
482        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    /// The environment wins; else `.env` is read for that one key; a
490    /// missing file or key gives nothing.
491    #[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    /// A file database in directories that don't exist yet: they're made.
514    /// An in-memory one waits at most 2 s for its one connection.
515    #[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    /// Pools opening one brand-new file database at once all open it (#163).
541    #[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    /// A slow first connection doesn't fail an in-memory app's boot (#144).
567    #[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        // Three tries even with no budget left: one try can take a whole busy
591        // timeout, which used to leave no time to try again (#163).
592        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}