Skip to main content

database_mcp_sqlite/
adapter.rs

1//! `SQLite` adapter definition and connection configuration.
2//!
3//! Creates a lazy connection pool via [`SqlitePoolOptions::connect_lazy_with`].
4//! No file I/O happens until the first tool invocation.
5
6use std::time::Duration;
7
8use database_mcp_config::DatabaseConfig;
9use sqlx::SqlitePool;
10use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
11use tracing::info;
12
13/// `SQLite` file-based database adapter.
14///
15/// The connection pool is created with [`SqlitePoolOptions::connect_lazy_with`],
16/// which defers all file I/O until the first query. Connection errors
17/// surface as tool-level errors returned to the MCP client.
18#[derive(Clone)]
19pub struct SqliteAdapter {
20    pub(crate) config: DatabaseConfig,
21    pub(crate) pool: SqlitePool,
22}
23
24impl std::fmt::Debug for SqliteAdapter {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("SqliteAdapter")
27            .field("read_only", &self.config.read_only)
28            .finish_non_exhaustive()
29    }
30}
31
32impl SqliteAdapter {
33    /// Creates a new `SQLite` adapter with a lazy connection pool.
34    ///
35    /// Does **not** open the database file. The pool connects on-demand
36    /// when the first query is executed.
37    #[must_use]
38    pub fn new(config: &DatabaseConfig) -> Self {
39        let pool = pool_options(config).connect_lazy_with(connect_options(config));
40        let name = config.name.as_deref().unwrap_or_default();
41        info!("SQLite lazy connection pool created: {name}");
42        Self {
43            config: config.clone(),
44            pool,
45        }
46    }
47
48    /// Wraps `name` in double quotes for safe use in `SQLite` SQL statements.
49    pub(crate) fn quote_identifier(name: &str) -> String {
50        database_mcp_sql::identifier::quote_identifier(name, '"')
51    }
52}
53
54/// Builds [`SqlitePoolOptions`] with lifecycle defaults from a [`DatabaseConfig`].
55fn pool_options(config: &DatabaseConfig) -> SqlitePoolOptions {
56    let mut opts = SqlitePoolOptions::new()
57        .max_connections(1) // SQLite is a single-writer
58        .min_connections(DatabaseConfig::DEFAULT_MIN_CONNECTIONS)
59        .idle_timeout(Duration::from_secs(DatabaseConfig::DEFAULT_IDLE_TIMEOUT_SECS))
60        .max_lifetime(Duration::from_secs(DatabaseConfig::DEFAULT_MAX_LIFETIME_SECS));
61
62    if let Some(timeout) = config.connection_timeout {
63        opts = opts.acquire_timeout(Duration::from_secs(timeout));
64    }
65
66    opts
67}
68
69/// Builds [`SqliteConnectOptions`] from a [`DatabaseConfig`].
70fn connect_options(config: &DatabaseConfig) -> SqliteConnectOptions {
71    let name = config.name.as_deref().unwrap_or_default();
72    SqliteConnectOptions::new().filename(name)
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use database_mcp_config::DatabaseBackend;
79
80    fn base_config() -> DatabaseConfig {
81        DatabaseConfig {
82            backend: DatabaseBackend::Sqlite,
83            name: Some("test.db".into()),
84            ..DatabaseConfig::default()
85        }
86    }
87
88    #[test]
89    fn pool_options_applies_defaults() {
90        let config = base_config();
91        let opts = pool_options(&config);
92
93        assert_eq!(opts.get_max_connections(), 1, "SQLite must be single-writer");
94        assert_eq!(opts.get_min_connections(), DatabaseConfig::DEFAULT_MIN_CONNECTIONS);
95        assert_eq!(
96            opts.get_idle_timeout(),
97            Some(Duration::from_secs(DatabaseConfig::DEFAULT_IDLE_TIMEOUT_SECS))
98        );
99        assert_eq!(
100            opts.get_max_lifetime(),
101            Some(Duration::from_secs(DatabaseConfig::DEFAULT_MAX_LIFETIME_SECS))
102        );
103    }
104
105    #[test]
106    fn pool_options_applies_connection_timeout() {
107        let config = DatabaseConfig {
108            connection_timeout: Some(7),
109            ..base_config()
110        };
111        let opts = pool_options(&config);
112
113        assert_eq!(opts.get_acquire_timeout(), Duration::from_secs(7));
114    }
115
116    #[test]
117    fn pool_options_without_connection_timeout_uses_sqlx_default() {
118        let config = base_config();
119        let opts = pool_options(&config);
120
121        assert_eq!(opts.get_acquire_timeout(), Duration::from_secs(30));
122    }
123
124    #[test]
125    fn pool_options_ignores_max_pool_size() {
126        let config = DatabaseConfig {
127            max_pool_size: 20,
128            ..base_config()
129        };
130        let opts = pool_options(&config);
131
132        assert_eq!(opts.get_max_connections(), 1, "SQLite must always be single-writer");
133    }
134
135    #[test]
136    fn try_from_sets_filename() {
137        let opts = connect_options(&base_config());
138
139        assert_eq!(opts.get_filename().to_str().expect("valid path"), "test.db");
140    }
141
142    #[test]
143    fn try_from_empty_name_defaults() {
144        let config = DatabaseConfig {
145            name: None,
146            ..base_config()
147        };
148        let opts = connect_options(&config);
149
150        // Empty string filename — validated elsewhere by Config::validate()
151        assert_eq!(opts.get_filename().to_str().expect("valid path"), "");
152    }
153
154    #[tokio::test]
155    async fn new_creates_lazy_pool() {
156        let config = base_config();
157        let adapter = SqliteAdapter::new(&config);
158        // Pool exists but has no active connections (lazy).
159        assert_eq!(adapter.pool.size(), 0);
160    }
161}