Skip to main content

fletch_orm/
pool.rs

1//! Connection pool wrapper around `sqlx::Pool`.
2
3use std::time::Duration;
4
5use sqlx::Database;
6
7use crate::error::FletchError;
8
9/// A connection pool wrapping [`sqlx::Pool`].
10///
11/// Generic over `DB: sqlx::Database` so that the same API works with
12/// SQLite, PostgreSQL, and MySQL while retaining compile-time type safety.
13#[derive(Debug)]
14pub struct Pool<DB: Database> {
15    inner: sqlx::Pool<DB>,
16}
17
18impl<DB: Database> Clone for Pool<DB> {
19    fn clone(&self) -> Self {
20        Self {
21            inner: self.inner.clone(),
22        }
23    }
24}
25
26impl<DB: Database> Pool<DB> {
27    /// Connect to the database at the given URL.
28    ///
29    /// This creates a connection pool with SQLx's default pool options.
30    pub async fn connect(url: &str) -> Result<Self, FletchError> {
31        let pool = sqlx::Pool::<DB>::connect(url).await?;
32        Ok(Self { inner: pool })
33    }
34
35    /// Returns a [`PoolBuilder`] for configuring pool options before connecting.
36    pub fn builder() -> PoolBuilder<DB> {
37        PoolBuilder {
38            options: sqlx::pool::PoolOptions::new(),
39        }
40    }
41
42    /// Close the pool, waiting for all connections to be returned.
43    pub async fn close(&self) {
44        self.inner.close().await;
45    }
46
47    /// Returns a reference to the underlying sqlx pool.
48    ///
49    /// Escape hatch for advanced or raw SQLx access. Prefer fletch CRUD and
50    /// the query builder where possible.
51    pub fn inner(&self) -> &sqlx::Pool<DB> {
52        &self.inner
53    }
54
55    /// Returns the number of connections currently active (including idle).
56    pub fn size(&self) -> u32 {
57        self.inner.size()
58    }
59
60    /// Returns the number of idle connections in the pool.
61    pub fn num_idle(&self) -> usize {
62        self.inner.num_idle()
63    }
64
65    /// Begin a new transaction.
66    pub async fn begin(&self) -> Result<Transaction<'static, DB>, FletchError> {
67        let tx = self.inner.begin().await?;
68        Ok(Transaction { inner: tx })
69    }
70}
71
72/// Builder for configuring a [`Pool`] before connecting.
73///
74/// Created via [`Pool::builder()`].
75pub struct PoolBuilder<DB: Database> {
76    options: sqlx::pool::PoolOptions<DB>,
77}
78
79impl<DB: Database> PoolBuilder<DB> {
80    /// Set the maximum number of connections the pool should maintain.
81    pub fn max_connections(mut self, max: u32) -> Self {
82        self.options = self.options.max_connections(max);
83        self
84    }
85
86    /// Set the minimum number of connections the pool should maintain.
87    pub fn min_connections(mut self, min: u32) -> Self {
88        self.options = self.options.min_connections(min);
89        self
90    }
91
92    /// Set the maximum time a connection can sit idle before being closed.
93    pub fn idle_timeout(mut self, timeout: Duration) -> Self {
94        self.options = self.options.idle_timeout(timeout);
95        self
96    }
97
98    /// Set the maximum lifetime of individual connections.
99    pub fn max_lifetime(mut self, lifetime: Duration) -> Self {
100        self.options = self.options.max_lifetime(lifetime);
101        self
102    }
103
104    /// Set the maximum time to wait when acquiring a connection from the pool.
105    pub fn acquire_timeout(mut self, timeout: Duration) -> Self {
106        self.options = self.options.acquire_timeout(timeout);
107        self
108    }
109
110    /// Connect to the database at the given URL with the configured options.
111    pub async fn connect(self, url: &str) -> Result<Pool<DB>, FletchError> {
112        let pool = self.options.connect(url).await?;
113        Ok(Pool { inner: pool })
114    }
115}
116
117/// Declarative pool configuration.
118///
119/// All fields are optional; unset fields use SQLx defaults.
120/// Can be converted into a [`PoolBuilder`] via [`PoolConfig::into_builder`].
121///
122/// # Environment variables
123///
124/// [`PoolConfig::from_env()`] reads:
125/// - `DATABASE_MAX_CONNECTIONS`
126/// - `DATABASE_MIN_CONNECTIONS`
127/// - `DATABASE_ACQUIRE_TIMEOUT_SECS`
128/// - `DATABASE_IDLE_TIMEOUT_SECS`
129/// - `DATABASE_MAX_LIFETIME_SECS`
130#[derive(Debug, Clone, Default)]
131#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
132pub struct PoolConfig {
133    pub max_connections: Option<u32>,
134    pub min_connections: Option<u32>,
135    pub acquire_timeout_secs: Option<u64>,
136    pub idle_timeout_secs: Option<u64>,
137    pub max_lifetime_secs: Option<u64>,
138}
139
140impl PoolConfig {
141    /// Load pool configuration from environment variables.
142    ///
143    /// Missing or unparseable variables are silently ignored (treated as unset).
144    pub fn from_env() -> Self {
145        Self {
146            max_connections: std::env::var("DATABASE_MAX_CONNECTIONS")
147                .ok()
148                .and_then(|v| v.parse().ok()),
149            min_connections: std::env::var("DATABASE_MIN_CONNECTIONS")
150                .ok()
151                .and_then(|v| v.parse().ok()),
152            acquire_timeout_secs: std::env::var("DATABASE_ACQUIRE_TIMEOUT_SECS")
153                .ok()
154                .and_then(|v| v.parse().ok()),
155            idle_timeout_secs: std::env::var("DATABASE_IDLE_TIMEOUT_SECS")
156                .ok()
157                .and_then(|v| v.parse().ok()),
158            max_lifetime_secs: std::env::var("DATABASE_MAX_LIFETIME_SECS")
159                .ok()
160                .and_then(|v| v.parse().ok()),
161        }
162    }
163
164    /// Convert this config into a [`PoolBuilder`], applying all set fields.
165    pub fn into_builder<DB: Database>(self) -> PoolBuilder<DB> {
166        let mut builder = PoolBuilder {
167            options: sqlx::pool::PoolOptions::new(),
168        };
169        if let Some(max) = self.max_connections {
170            builder = builder.max_connections(max);
171        }
172        if let Some(min) = self.min_connections {
173            builder = builder.min_connections(min);
174        }
175        if let Some(secs) = self.acquire_timeout_secs {
176            builder = builder.acquire_timeout(Duration::from_secs(secs));
177        }
178        if let Some(secs) = self.idle_timeout_secs {
179            builder = builder.idle_timeout(Duration::from_secs(secs));
180        }
181        if let Some(secs) = self.max_lifetime_secs {
182            builder = builder.max_lifetime(Duration::from_secs(secs));
183        }
184        builder
185    }
186}
187
188/// A database transaction with auto-rollback on drop.
189///
190/// If the transaction is dropped without calling [`commit`](Transaction::commit),
191/// it will automatically roll back.
192pub struct Transaction<'a, DB: Database> {
193    inner: sqlx::Transaction<'a, DB>,
194}
195
196impl<'a, DB: Database> Transaction<'a, DB> {
197    /// Commit the transaction.
198    pub async fn commit(self) -> Result<(), FletchError> {
199        self.inner.commit().await?;
200        Ok(())
201    }
202
203    /// Explicitly roll back the transaction.
204    pub async fn rollback(self) -> Result<(), FletchError> {
205        self.inner.rollback().await?;
206        Ok(())
207    }
208
209    /// Returns a mutable reference to the underlying sqlx transaction.
210    ///
211    /// Escape hatch for advanced or raw SQLx access. Prefer fletch CRUD and
212    /// the query builder where possible.
213    pub fn inner_mut(&mut self) -> &mut sqlx::Transaction<'a, DB> {
214        &mut self.inner
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use sqlx::Sqlite;
222
223    #[tokio::test]
224    async fn pool_connect_sqlite_memory() {
225        let pool = Pool::<Sqlite>::connect("sqlite::memory:").await.unwrap();
226        // Verify we can acquire a connection
227        {
228            let _conn = pool.inner().acquire().await.unwrap();
229        }
230        pool.close().await;
231    }
232
233    #[tokio::test]
234    async fn pool_begin_and_commit() {
235        let pool = Pool::<Sqlite>::connect("sqlite::memory:").await.unwrap();
236
237        // Create a table using the pool directly
238        sqlx::query("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
239            .execute(pool.inner())
240            .await
241            .unwrap();
242
243        // Begin a transaction and insert
244        let mut tx = pool.begin().await.unwrap();
245        sqlx::query("INSERT INTO test (id, name) VALUES (1, 'Alice')")
246            .execute(&mut **tx.inner_mut())
247            .await
248            .unwrap();
249        tx.commit().await.unwrap();
250
251        // Verify the row exists
252        let row: (i64, String) = sqlx::query_as("SELECT id, name FROM test WHERE id = 1")
253            .fetch_one(pool.inner())
254            .await
255            .unwrap();
256        assert_eq!(row, (1, "Alice".to_string()));
257
258        pool.close().await;
259    }
260
261    #[tokio::test]
262    async fn transaction_auto_rollback_on_drop() {
263        let pool = Pool::<Sqlite>::connect("sqlite::memory:").await.unwrap();
264
265        sqlx::query("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
266            .execute(pool.inner())
267            .await
268            .unwrap();
269
270        // Begin a transaction, insert, then drop without committing
271        {
272            let mut tx = pool.begin().await.unwrap();
273            sqlx::query("INSERT INTO test (id, name) VALUES (1, 'Alice')")
274                .execute(&mut **tx.inner_mut())
275                .await
276                .unwrap();
277            // tx dropped here without commit -> rollback
278        }
279
280        // Verify the row does NOT exist
281        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test")
282            .fetch_one(pool.inner())
283            .await
284            .unwrap();
285        assert_eq!(count.0, 0);
286
287        pool.close().await;
288    }
289
290    #[tokio::test]
291    async fn transaction_explicit_rollback() {
292        let pool = Pool::<Sqlite>::connect("sqlite::memory:").await.unwrap();
293
294        sqlx::query("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
295            .execute(pool.inner())
296            .await
297            .unwrap();
298
299        let mut tx = pool.begin().await.unwrap();
300        sqlx::query("INSERT INTO test (id, name) VALUES (1, 'Alice')")
301            .execute(&mut **tx.inner_mut())
302            .await
303            .unwrap();
304        tx.rollback().await.unwrap();
305
306        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test")
307            .fetch_one(pool.inner())
308            .await
309            .unwrap();
310        assert_eq!(count.0, 0);
311
312        pool.close().await;
313    }
314
315    #[tokio::test]
316    async fn pool_builder_connects_with_options() {
317        let pool = Pool::<Sqlite>::builder()
318            .max_connections(1)
319            .connect("sqlite::memory:")
320            .await
321            .unwrap();
322
323        // Verify we can acquire a connection
324        let conn = pool.inner().acquire().await.unwrap();
325        drop(conn);
326        pool.close().await;
327    }
328
329    #[test]
330    fn pool_config_default_is_all_none() {
331        let config = PoolConfig::default();
332        assert!(config.max_connections.is_none());
333        assert!(config.min_connections.is_none());
334        assert!(config.acquire_timeout_secs.is_none());
335        assert!(config.idle_timeout_secs.is_none());
336        assert!(config.max_lifetime_secs.is_none());
337    }
338
339    #[test]
340    fn pool_config_into_builder_applies_fields() {
341        let config = PoolConfig {
342            max_connections: Some(20),
343            min_connections: Some(2),
344            acquire_timeout_secs: Some(10),
345            idle_timeout_secs: Some(300),
346            max_lifetime_secs: Some(1800),
347        };
348
349        let builder = config.into_builder::<Sqlite>();
350        // Verify the builder was created (we can't inspect internal options,
351        // but we verify it doesn't panic)
352        let _ = builder;
353    }
354
355    #[tokio::test]
356    async fn pool_config_into_builder_connects() {
357        let config = PoolConfig {
358            max_connections: Some(1),
359            min_connections: None,
360            acquire_timeout_secs: None,
361            idle_timeout_secs: None,
362            max_lifetime_secs: None,
363        };
364
365        let pool = config
366            .into_builder::<Sqlite>()
367            .connect("sqlite::memory:")
368            .await
369            .unwrap();
370
371        let conn = pool.inner().acquire().await.unwrap();
372        drop(conn);
373        pool.close().await;
374    }
375
376    #[tokio::test]
377    async fn pool_introspection() {
378        let pool = Pool::<Sqlite>::builder()
379            .max_connections(5)
380            .connect("sqlite::memory:")
381            .await
382            .unwrap();
383
384        // Acquire a connection to ensure the pool has at least one active connection
385        let conn = pool.inner().acquire().await.unwrap();
386
387        assert!(pool.size() >= 1);
388        // num_idle should be less than size since we hold a connection
389        assert!(pool.num_idle() < usize::try_from(pool.size()).unwrap());
390
391        drop(conn);
392        pool.close().await;
393    }
394}