Skip to main content

minco_sqlx_sqlite/
lib.rs

1//! `SQLx` `SQLite` pools with explicit file-backed versus in-memory behavior.
2#![forbid(unsafe_code)]
3
4use serde::{Deserialize, Serialize};
5pub use sqlx::SqlitePool;
6use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
7use std::{path::Path, str::FromStr, time::Duration};
8use thiserror::Error;
9
10pub mod plugin_adapters;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SqlitePoolConfig {
14    pub url: String,
15    pub max_connections: u32,
16    pub acquire_timeout_seconds: u64,
17}
18
19impl SqlitePoolConfig {
20    pub fn file(path: impl AsRef<Path>) -> Self {
21        Self {
22            url: format!("sqlite://{}", path.as_ref().display()),
23            max_connections: 4,
24            acquire_timeout_seconds: 5,
25        }
26    }
27    pub fn memory() -> Self {
28        Self {
29            url: "sqlite::memory:".into(),
30            max_connections: 1,
31            acquire_timeout_seconds: 5,
32        }
33    }
34    pub fn is_memory(&self) -> bool {
35        self.url == "sqlite::memory:" || self.url.contains("mode=memory")
36    }
37    pub fn validate(&self) -> Result<(), SqliteError> {
38        if self.url.trim().is_empty() {
39            return Err(SqliteError::InvalidConfig("database URL is empty".into()));
40        }
41        if self.max_connections == 0 {
42            return Err(SqliteError::InvalidConfig(
43                "max_connections must be at least 1".into(),
44            ));
45        }
46        if self.is_memory() && self.max_connections != 1 {
47            return Err(SqliteError::InvalidConfig(
48                "in-memory SQLite requires exactly one pooled connection".into(),
49            ));
50        }
51        Ok(())
52    }
53}
54
55pub async fn connect(config: &SqlitePoolConfig) -> Result<SqlitePool, SqliteError> {
56    config.validate()?;
57    let mut options = SqliteConnectOptions::from_str(&config.url)?
58        .create_if_missing(!config.is_memory())
59        .foreign_keys(true)
60        .busy_timeout(Duration::from_secs(config.acquire_timeout_seconds));
61    if !config.is_memory() {
62        options = options.journal_mode(SqliteJournalMode::Wal);
63    }
64    Ok(SqlitePoolOptions::new()
65        .max_connections(config.max_connections)
66        .acquire_timeout(Duration::from_secs(config.acquire_timeout_seconds))
67        .connect_with(options)
68        .await?)
69}
70
71pub async fn migrate(pool: &SqlitePool, path: impl AsRef<Path>) -> Result<(), SqliteError> {
72    let migrator = sqlx::migrate::Migrator::new(path.as_ref()).await?;
73    migrator.run(pool).await?;
74    Ok(())
75}
76
77pub async fn migrate_with_history_table(
78    pool: &SqlitePool,
79    path: impl AsRef<Path>,
80    history_table: &'static str,
81) -> Result<(), SqliteError> {
82    validate_identifier(history_table, "migration history table")?;
83    let mut migrator = sqlx::migrate::Migrator::new(path.as_ref()).await?;
84    migrator.dangerous_set_table_name(history_table);
85    migrator.run(pool).await?;
86    Ok(())
87}
88
89pub async fn ready(pool: &SqlitePool) -> bool {
90    matches!(
91        sqlx::query_scalar::<_, i64>("SELECT 1")
92            .fetch_one(pool)
93            .await,
94        Ok(1)
95    )
96}
97
98fn validate_identifier(value: &str, description: &str) -> Result<(), SqliteError> {
99    let mut bytes = value.bytes();
100    let valid_start = bytes
101        .next()
102        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
103    if !valid_start
104        || value.len() > 63
105        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
106    {
107        return Err(SqliteError::InvalidConfig(format!(
108            "{description} must be a SQLite identifier of at most 63 ASCII characters"
109        )));
110    }
111    Ok(())
112}
113
114#[derive(Debug, Error)]
115pub enum SqliteError {
116    #[error("invalid SQLite configuration: {0}")]
117    InvalidConfig(String),
118    #[error("SQLite error: {0}")]
119    Sqlx(#[from] sqlx::Error),
120    #[error("SQLite migration error: {0}")]
121    Migration(#[from] sqlx::migrate::MigrateError),
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    #[test]
128    fn memory_profile_rejects_multiple_connections() {
129        let mut config = SqlitePoolConfig::memory();
130        config.max_connections = 2;
131        assert!(config.validate().is_err());
132    }
133
134    #[tokio::test]
135    async fn migration_history_table_rejects_dynamic_sql_tokens() {
136        let pool = connect(&SqlitePoolConfig::memory())
137            .await
138            .expect("in-memory pool");
139        let result =
140            migrate_with_history_table(&pool, Path::new("missing"), "_migrations;DROP").await;
141        assert!(matches!(result, Err(SqliteError::InvalidConfig(_))));
142    }
143}