Skip to main content

floz_orm/
db.rs

1//! Database connection, transaction management, and the Executor trait.
2//!
3//! ```ignore
4//! use floz::prelude::*;
5//!
6//! let db = Db::connect("postgres://user:pass@localhost/mydb").await?;
7//! let result = db.execute_raw("SELECT 1", vec![]).await?;
8//!
9//! // Transaction
10//! let mut tx = db.begin().await?;
11//! tx.execute_raw("INSERT INTO ...", vec![...]).await?;
12//! tx.commit().await?;
13//! ```
14
15use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
16use tokio::sync::Mutex;
17
18use crate::error::FlozError;
19use crate::value::Value;
20
21// ═══════════════════════════════════════════════════════════════
22// Db — Connection Pool Wrapper
23// ═══════════════════════════════════════════════════════════════
24
25/// A database connection pool wrapping `sqlx::PgPool`.
26///
27/// `Db` is cheap to clone (internally reference-counted) and safe to share
28/// across tokio tasks.
29#[derive(Debug, Clone)]
30pub struct Db {
31    pool: PgPool,
32}
33
34impl Db {
35    /// Connect to a PostgreSQL database.
36    ///
37    /// ```ignore
38    /// let db = Db::connect("postgres://user:pass@localhost/mydb").await?;
39    /// ```
40    pub async fn connect(url: &str) -> Result<Self, FlozError> {
41        let pool = PgPoolOptions::new()
42            .max_connections(10)
43            .connect(url)
44            .await?;
45        Ok(Self { pool })
46    }
47
48    /// Connect using an environment variable, falling back to a default URL.
49    ///
50    /// ```ignore
51    /// let db = Db::connect_env("DATABASE_URL", "postgres://localhost:5432/mydb").await?;
52    /// ```
53    pub async fn connect_env(env_var: &str, default_url: &str) -> Result<Self, FlozError> {
54        let url = std::env::var(env_var).unwrap_or_else(|_| default_url.to_string());
55        Self::connect(&url).await
56    }
57
58    /// Wrap an existing sqlx pool.
59    ///
60    /// Useful for integrating floz into an existing application.
61    pub fn from_pool(pool: PgPool) -> Self {
62        Self { pool }
63    }
64
65    /// Access the underlying sqlx pool (escape hatch).
66    pub fn pool(&self) -> &PgPool {
67        &self.pool
68    }
69
70    /// Begin a new transaction.
71    pub async fn begin(&self) -> Result<Tx, FlozError> {
72        let mut conn = self.pool.acquire().await?;
73        sqlx::query("BEGIN").execute(&mut *conn).await?;
74        Ok(Tx {
75            conn: Mutex::new(conn),
76            committed: false,
77        })
78    }
79}
80
81// ═══════════════════════════════════════════════════════════════
82// Tx — Transaction Wrapper
83// ═══════════════════════════════════════════════════════════════
84
85/// A database transaction.
86///
87/// Wraps a pooled connection with an active transaction.
88/// If `commit()` is not called before the `Tx` is dropped,
89/// the transaction is automatically rolled back when the connection
90/// returns to the pool.
91pub struct Tx {
92    conn: Mutex<sqlx::pool::PoolConnection<sqlx::Postgres>>,
93    committed: bool,
94}
95
96impl Tx {
97    /// Commit the transaction.
98    pub async fn commit(mut self) -> Result<(), FlozError> {
99        let mut conn = self.conn.lock().await;
100        sqlx::query("COMMIT").execute(&mut **conn).await?;
101        self.committed = true;
102        Ok(())
103    }
104
105    /// Explicitly rollback the transaction.
106    pub async fn rollback(self) -> Result<(), FlozError> {
107        let mut conn = self.conn.lock().await;
108        sqlx::query("ROLLBACK").execute(&mut **conn).await?;
109        Ok(())
110    }
111}
112
113// Debug impl without exposing connection internals
114impl std::fmt::Debug for Tx {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("Tx")
117            .field("committed", &self.committed)
118            .finish()
119    }
120}
121
122// ═══════════════════════════════════════════════════════════════
123// Executor Trait
124// ═══════════════════════════════════════════════════════════════
125
126/// The core database execution trait.
127///
128/// All floz DAO and DSL methods accept `&impl Executor`, allowing
129/// seamless switching between connection pools, transactions, and mocks.
130///
131/// ```ignore
132/// // Both work:
133/// let users = User::all(&db).await?;
134/// let users = User::all(&tx).await?;
135/// ```
136pub trait Executor {
137    /// Execute a raw SQL statement, returning the number of affected rows.
138    fn execute_raw(
139        &self,
140        sql: &str,
141        params: Vec<Value>,
142    ) -> impl std::future::Future<Output = Result<u64, FlozError>> + Send;
143
144    /// Fetch all matching rows.
145    fn fetch_all<T>(
146        &self,
147        sql: &str,
148        params: Vec<Value>,
149    ) -> impl std::future::Future<Output = Result<Vec<T>, FlozError>> + Send
150    where
151        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>;
152
153    /// Fetch exactly one row. Returns `FlozError::NotFound` if no rows match.
154    fn fetch_one<T>(
155        &self,
156        sql: &str,
157        params: Vec<Value>,
158    ) -> impl std::future::Future<Output = Result<T, FlozError>> + Send
159    where
160        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>;
161
162    /// Fetch zero or one row.
163    fn fetch_optional<T>(
164        &self,
165        sql: &str,
166        params: Vec<Value>,
167    ) -> impl std::future::Future<Output = Result<Option<T>, FlozError>> + Send
168    where
169        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>;
170}
171
172// ═══════════════════════════════════════════════════════════════
173// Value → sqlx bind
174// ═══════════════════════════════════════════════════════════════
175
176/// Bind a `Vec<Value>` to a `sqlx::query()` builder.
177///
178/// Each `Value` variant is pattern-matched to call `.bind()` with the
179/// concrete type, so sqlx knows the PostgreSQL type OID.
180macro_rules! bind_params {
181    ($query:expr, $params:expr) => {{
182        let mut q = $query;
183        for param in $params {
184            q = match param {
185                // Non-nullable
186                Value::Short(v) => q.bind(v),
187                Value::Int(v) => q.bind(v),
188                Value::BigInt(v) => q.bind(v),
189                Value::Real(v) => q.bind(v),
190                Value::Double(v) => q.bind(v),
191                Value::Bool(v) => q.bind(v),
192                Value::String(v) => q.bind(v),
193                Value::Bytes(v) => q.bind(v),
194                Value::Uuid(v) => q.bind(v),
195                Value::DateTime(v) => q.bind(v),
196                Value::NaiveDateTime(v) => q.bind(v),
197                Value::NaiveDate(v) => q.bind(v),
198                Value::NaiveTime(v) => q.bind(v),
199                Value::Json(v) => q.bind(v),
200                Value::Jsonb(v) => q.bind(v),
201                // Nullable (preserves PG type OID for NULLs)
202                Value::OptionShort(v) => q.bind(v),
203                Value::OptionInt(v) => q.bind(v),
204                Value::OptionBigInt(v) => q.bind(v),
205                Value::OptionReal(v) => q.bind(v),
206                Value::OptionDouble(v) => q.bind(v),
207                Value::OptionBool(v) => q.bind(v),
208                Value::OptionString(v) => q.bind(v),
209                Value::OptionBytes(v) => q.bind(v),
210                Value::OptionUuid(v) => q.bind(v),
211                Value::OptionDateTime(v) => q.bind(v),
212                Value::OptionNaiveDateTime(v) => q.bind(v),
213                Value::OptionNaiveDate(v) => q.bind(v),
214                Value::OptionNaiveTime(v) => q.bind(v),
215                Value::OptionJson(v) => q.bind(v),
216                Value::OptionJsonb(v) => q.bind(v),
217            };
218        }
219        q
220    }};
221}
222
223// ═══════════════════════════════════════════════════════════════
224// impl Executor for Db
225// ═══════════════════════════════════════════════════════════════
226
227impl Executor for Db {
228    async fn execute_raw(&self, sql: &str, params: Vec<Value>) -> Result<u64, FlozError> {
229        let query = bind_params!(sqlx::query(sql), params);
230        let result = query.execute(&self.pool).await?;
231        Ok(result.rows_affected())
232    }
233
234    async fn fetch_all<T>(&self, sql: &str, params: Vec<Value>) -> Result<Vec<T>, FlozError>
235    where
236        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>,
237    {
238        let query = bind_params!(sqlx::query_as::<_, T>(sql), params);
239        let rows = query.fetch_all(&self.pool).await?;
240        Ok(rows)
241    }
242
243    async fn fetch_one<T>(&self, sql: &str, params: Vec<Value>) -> Result<T, FlozError>
244    where
245        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>,
246    {
247        let query = bind_params!(sqlx::query_as::<_, T>(sql), params);
248        let row = query
249            .fetch_optional(&self.pool)
250            .await?
251            .ok_or(FlozError::NotFound)?;
252        Ok(row)
253    }
254
255    async fn fetch_optional<T>(&self, sql: &str, params: Vec<Value>) -> Result<Option<T>, FlozError>
256    where
257        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>,
258    {
259        let query = bind_params!(sqlx::query_as::<_, T>(sql), params);
260        let row = query.fetch_optional(&self.pool).await?;
261        Ok(row)
262    }
263}
264
265// ═══════════════════════════════════════════════════════════════
266// impl Executor for Tx
267// ═══════════════════════════════════════════════════════════════
268
269impl Executor for Tx {
270    async fn execute_raw(&self, sql: &str, params: Vec<Value>) -> Result<u64, FlozError> {
271        let mut conn = self.conn.lock().await;
272        let query = bind_params!(sqlx::query(sql), params);
273        let result = query.execute(&mut **conn).await?;
274        Ok(result.rows_affected())
275    }
276
277    async fn fetch_all<T>(&self, sql: &str, params: Vec<Value>) -> Result<Vec<T>, FlozError>
278    where
279        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>,
280    {
281        let mut conn = self.conn.lock().await;
282        let query = bind_params!(sqlx::query_as::<_, T>(sql), params);
283        let rows = query.fetch_all(&mut **conn).await?;
284        Ok(rows)
285    }
286
287    async fn fetch_one<T>(&self, sql: &str, params: Vec<Value>) -> Result<T, FlozError>
288    where
289        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>,
290    {
291        let mut conn = self.conn.lock().await;
292        let query = bind_params!(sqlx::query_as::<_, T>(sql), params);
293        let row = query
294            .fetch_optional(&mut **conn)
295            .await?
296            .ok_or(FlozError::NotFound)?;
297        Ok(row)
298    }
299
300    async fn fetch_optional<T>(&self, sql: &str, params: Vec<Value>) -> Result<Option<T>, FlozError>
301    where
302        T: Send + Unpin + for<'r> sqlx::FromRow<'r, PgRow>,
303    {
304        let mut conn = self.conn.lock().await;
305        let query = bind_params!(sqlx::query_as::<_, T>(sql), params);
306        let row = query.fetch_optional(&mut **conn).await?;
307        Ok(row)
308    }
309}
310
311// ═══════════════════════════════════════════════════════════════
312// Tests
313// ═══════════════════════════════════════════════════════════════
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    // ── Unit tests (no database needed) ──
320
321    #[test]
322    fn db_from_pool_type_check() {
323        // Just verifying the type signatures compile
324        fn _assert_send<T: Send>() {}
325        fn _assert_sync<T: Sync>() {}
326        _assert_send::<Db>();
327        _assert_sync::<Db>();
328    }
329
330    #[test]
331    fn tx_is_debug() {
332        // Tx should implement Debug
333        fn _assert_debug<T: std::fmt::Debug>() {}
334        _assert_debug::<Tx>();
335    }
336
337    // ── Integration tests (require DATABASE_URL) ──
338
339    /// Get test database URL, or skip the test.
340    fn test_url() -> Option<String> {
341        std::env::var("DATABASE_URL").ok()
342    }
343
344    #[tokio::test]
345    async fn connect_to_postgres() {
346        let Some(url) = test_url() else { return };
347        let db = Db::connect(&url).await.unwrap();
348        assert!(db.pool().size() > 0 || true); // pool may not have connections yet
349    }
350
351    #[tokio::test]
352    async fn execute_raw_select() {
353        let Some(url) = test_url() else { return };
354        let db = Db::connect(&url).await.unwrap();
355
356        // SELECT 1 — should execute without error
357        let affected = db.execute_raw("SELECT 1", vec![]).await.unwrap();
358        // SELECT doesn't affect rows, but shouldn't error
359        assert_eq!(affected, 0);
360    }
361
362    #[tokio::test]
363    async fn execute_with_params() {
364        let Some(url) = test_url() else { return };
365        let db = Db::connect(&url).await.unwrap();
366
367        // Create temp table, insert, verify
368        db.execute_raw("CREATE TEMP TABLE xql_test_params (id INT, name TEXT)", vec![])
369            .await
370            .unwrap();
371
372        let affected = db
373            .execute_raw(
374                "INSERT INTO xql_test_params (id, name) VALUES ($1, $2)",
375                vec![Value::Int(1), Value::String("Alice".into())],
376            )
377            .await
378            .unwrap();
379        assert_eq!(affected, 1);
380    }
381
382    #[tokio::test]
383    async fn fetch_all_rows() {
384        let Some(url) = test_url() else { return };
385        let db = Db::connect(&url).await.unwrap();
386
387        db.execute_raw("CREATE TEMP TABLE xql_test_fetch (id INT, name TEXT)", vec![])
388            .await
389            .unwrap();
390        db.execute_raw(
391            "INSERT INTO xql_test_fetch VALUES (1, 'Alice'), (2, 'Bob')",
392            vec![],
393        )
394        .await
395        .unwrap();
396
397        #[derive(Debug, sqlx::FromRow)]
398        struct Row {
399            id: i32,
400            name: String,
401        }
402
403        let rows: Vec<Row> = db
404            .fetch_all("SELECT id, name FROM xql_test_fetch ORDER BY id", vec![])
405            .await
406            .unwrap();
407        assert_eq!(rows.len(), 2);
408        assert_eq!(rows[0].name, "Alice");
409        assert_eq!(rows[1].name, "Bob");
410    }
411
412    #[tokio::test]
413    async fn fetch_one_found() {
414        let Some(url) = test_url() else { return };
415        let db = Db::connect(&url).await.unwrap();
416
417        db.execute_raw("CREATE TEMP TABLE xql_test_one (id INT, name TEXT)", vec![])
418            .await
419            .unwrap();
420        db.execute_raw("INSERT INTO xql_test_one VALUES (1, 'Alice')", vec![])
421            .await
422            .unwrap();
423
424        #[derive(Debug, sqlx::FromRow)]
425        struct Row {
426            id: i32,
427            name: String,
428        }
429
430        let row: Row = db
431            .fetch_one(
432                "SELECT id, name FROM xql_test_one WHERE id = $1",
433                vec![Value::Int(1)],
434            )
435            .await
436            .unwrap();
437        assert_eq!(row.name, "Alice");
438    }
439
440    #[tokio::test]
441    async fn fetch_one_not_found() {
442        let Some(url) = test_url() else { return };
443        let db = Db::connect(&url).await.unwrap();
444
445        db.execute_raw("CREATE TEMP TABLE xql_test_nf (id INT)", vec![])
446            .await
447            .unwrap();
448
449        #[derive(Debug, sqlx::FromRow)]
450        struct Row {
451            id: i32,
452        }
453
454        let result: Result<Row, _> = db
455            .fetch_one(
456                "SELECT id FROM xql_test_nf WHERE id = $1",
457                vec![Value::Int(999)],
458            )
459            .await;
460        assert!(matches!(result, Err(FlozError::NotFound)));
461    }
462
463    #[tokio::test]
464    async fn fetch_optional_some() {
465        let Some(url) = test_url() else { return };
466        let db = Db::connect(&url).await.unwrap();
467
468        db.execute_raw("CREATE TEMP TABLE xql_test_opt (id INT)", vec![])
469            .await
470            .unwrap();
471        db.execute_raw("INSERT INTO xql_test_opt VALUES (42)", vec![])
472            .await
473            .unwrap();
474
475        #[derive(Debug, sqlx::FromRow)]
476        struct Row {
477            id: i32,
478        }
479
480        let row: Option<Row> = db
481            .fetch_optional("SELECT id FROM xql_test_opt WHERE id = $1", vec![Value::Int(42)])
482            .await
483            .unwrap();
484        assert!(row.is_some());
485        assert_eq!(row.unwrap().id, 42);
486    }
487
488    #[tokio::test]
489    async fn fetch_optional_none() {
490        let Some(url) = test_url() else { return };
491        let db = Db::connect(&url).await.unwrap();
492
493        db.execute_raw("CREATE TEMP TABLE xql_test_opt_n (id INT)", vec![])
494            .await
495            .unwrap();
496
497        #[derive(Debug, sqlx::FromRow)]
498        struct Row {
499            id: i32,
500        }
501
502        let row: Option<Row> = db
503            .fetch_optional(
504                "SELECT id FROM xql_test_opt_n WHERE id = $1",
505                vec![Value::Int(999)],
506            )
507            .await
508            .unwrap();
509        assert!(row.is_none());
510    }
511
512    // ── Transaction tests ──
513
514    #[tokio::test]
515    async fn transaction_commit() {
516        let Some(url) = test_url() else { return };
517        let db = Db::connect(&url).await.unwrap();
518
519        db.execute_raw("CREATE TEMP TABLE xql_test_tx (id INT)", vec![])
520            .await
521            .unwrap();
522
523        let tx = db.begin().await.unwrap();
524        tx.execute_raw(
525            "INSERT INTO xql_test_tx VALUES ($1)",
526            vec![Value::Int(1)],
527        )
528        .await
529        .unwrap();
530        tx.commit().await.unwrap();
531
532        // Row should exist after commit
533        #[derive(Debug, sqlx::FromRow)]
534        struct Row { id: i32 }
535
536        let rows: Vec<Row> = db
537            .fetch_all("SELECT id FROM xql_test_tx", vec![])
538            .await
539            .unwrap();
540        assert_eq!(rows.len(), 1);
541    }
542
543    #[tokio::test]
544    async fn transaction_rollback_on_drop() {
545        let Some(url) = test_url() else { return };
546        let db = Db::connect(&url).await.unwrap();
547
548        db.execute_raw("CREATE TABLE IF NOT EXISTS xql_test_txd (id INT)", vec![])
549            .await
550            .unwrap();
551        // Clean up any previous test data
552        db.execute_raw("DELETE FROM xql_test_txd", vec![])
553            .await
554            .unwrap();
555
556        {
557            let tx = db.begin().await.unwrap();
558            tx.execute_raw(
559                "INSERT INTO xql_test_txd VALUES ($1)",
560                vec![Value::Int(99)],
561            )
562            .await
563            .unwrap();
564            // tx dropped here without commit — should rollback
565        }
566
567        // Row should NOT exist after rollback
568        #[derive(Debug, sqlx::FromRow)]
569        struct Row { id: i32 }
570
571        let rows: Vec<Row> = db
572            .fetch_all("SELECT id FROM xql_test_txd WHERE id = 99", vec![])
573            .await
574            .unwrap();
575        assert_eq!(rows.len(), 0, "Row should not exist after rollback-on-drop");
576
577        // Clean up
578        db.execute_raw("DROP TABLE IF EXISTS xql_test_txd", vec![])
579            .await
580            .unwrap();
581    }
582
583    #[tokio::test]
584    async fn transaction_explicit_rollback() {
585        let Some(url) = test_url() else { return };
586        let db = Db::connect(&url).await.unwrap();
587
588        db.execute_raw("CREATE TEMP TABLE xql_test_rb (id INT)", vec![])
589            .await
590            .unwrap();
591
592        let tx = db.begin().await.unwrap();
593        tx.execute_raw(
594            "INSERT INTO xql_test_rb VALUES ($1)",
595            vec![Value::Int(1)],
596        )
597        .await
598        .unwrap();
599        tx.rollback().await.unwrap();
600
601        #[derive(Debug, sqlx::FromRow)]
602        struct Row { id: i32 }
603
604        let rows: Vec<Row> = db
605            .fetch_all("SELECT id FROM xql_test_rb", vec![])
606            .await
607            .unwrap();
608        assert_eq!(rows.len(), 0);
609    }
610
611    #[tokio::test]
612    async fn transaction_fetch() {
613        let Some(url) = test_url() else { return };
614        let db = Db::connect(&url).await.unwrap();
615
616        db.execute_raw("CREATE TEMP TABLE xql_test_txf (id INT, name TEXT)", vec![])
617            .await
618            .unwrap();
619
620        let tx = db.begin().await.unwrap();
621        tx.execute_raw(
622            "INSERT INTO xql_test_txf VALUES (1, 'Inside Tx')",
623            vec![],
624        )
625        .await
626        .unwrap();
627
628        // Fetch from within the transaction — should see uncommitted data
629        #[derive(Debug, sqlx::FromRow)]
630        struct Row { id: i32, name: String }
631
632        let rows: Vec<Row> = tx
633            .fetch_all("SELECT id, name FROM xql_test_txf", vec![])
634            .await
635            .unwrap();
636        assert_eq!(rows.len(), 1);
637        assert_eq!(rows[0].name, "Inside Tx");
638
639        tx.rollback().await.unwrap();
640    }
641
642    // ── Typed null binding test ──
643
644    #[tokio::test]
645    async fn bind_typed_null() {
646        let Some(url) = test_url() else { return };
647        let db = Db::connect(&url).await.unwrap();
648
649        db.execute_raw(
650            "CREATE TEMP TABLE xql_test_null (id INT, name TEXT)",
651            vec![],
652        )
653        .await
654        .unwrap();
655
656        // Insert with typed NULL — this is the trap from the spec!
657        // Value::OptionString(None) sends TEXT OID, not generic null.
658        let affected = db
659            .execute_raw(
660                "INSERT INTO xql_test_null (id, name) VALUES ($1, $2)",
661                vec![Value::Int(1), Value::OptionString(None)],
662            )
663            .await
664            .unwrap();
665        assert_eq!(affected, 1);
666
667        #[derive(Debug, sqlx::FromRow)]
668        struct Row {
669            id: i32,
670            name: Option<String>,
671        }
672
673        let row: Row = db
674            .fetch_one("SELECT id, name FROM xql_test_null WHERE id = 1", vec![])
675            .await
676            .unwrap();
677        assert_eq!(row.id, 1);
678        assert!(row.name.is_none());
679    }
680}