Skip to main content

prax_sqlx/
transaction.rs

1//! Transaction support for SQLx.
2
3use crate::error::{SqlxError, SqlxResult};
4use crate::pool::SqlxPool;
5
6/// Transaction isolation levels.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum IsolationLevel {
9    /// Read uncommitted - lowest isolation.
10    ReadUncommitted,
11    /// Read committed - default for PostgreSQL.
12    ReadCommitted,
13    /// Repeatable read - prevents non-repeatable reads.
14    RepeatableRead,
15    /// Serializable - highest isolation.
16    Serializable,
17}
18
19impl IsolationLevel {
20    /// Convert to SQL string.
21    pub fn as_sql(&self) -> &'static str {
22        match self {
23            IsolationLevel::ReadUncommitted => "READ UNCOMMITTED",
24            IsolationLevel::ReadCommitted => "READ COMMITTED",
25            IsolationLevel::RepeatableRead => "REPEATABLE READ",
26            IsolationLevel::Serializable => "SERIALIZABLE",
27        }
28    }
29}
30
31/// Transaction access mode.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum AccessMode {
34    /// Read and write access.
35    ReadWrite,
36    /// Read-only access.
37    ReadOnly,
38}
39
40impl AccessMode {
41    /// Convert to SQL string.
42    pub fn as_sql(&self) -> &'static str {
43        match self {
44            AccessMode::ReadWrite => "READ WRITE",
45            AccessMode::ReadOnly => "READ ONLY",
46        }
47    }
48}
49
50/// Options for starting a transaction.
51#[derive(Debug, Clone, Default)]
52pub struct TransactionOptions {
53    /// Isolation level.
54    pub isolation_level: Option<IsolationLevel>,
55    /// Access mode.
56    pub access_mode: Option<AccessMode>,
57    /// Whether the transaction is deferrable (PostgreSQL only).
58    pub deferrable: Option<bool>,
59}
60
61impl TransactionOptions {
62    /// Create new default options.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Set the isolation level.
68    pub fn isolation_level(mut self, level: IsolationLevel) -> Self {
69        self.isolation_level = Some(level);
70        self
71    }
72
73    /// Set the access mode.
74    pub fn access_mode(mut self, mode: AccessMode) -> Self {
75        self.access_mode = Some(mode);
76        self
77    }
78
79    /// Set whether the transaction is deferrable.
80    pub fn deferrable(mut self, deferrable: bool) -> Self {
81        self.deferrable = Some(deferrable);
82        self
83    }
84
85    /// Create read-only transaction options.
86    pub fn read_only() -> Self {
87        Self::new().access_mode(AccessMode::ReadOnly)
88    }
89
90    /// Create serializable transaction options.
91    pub fn serializable() -> Self {
92        Self::new().isolation_level(IsolationLevel::Serializable)
93    }
94}
95
96/// Execute a closure within a PostgreSQL transaction.
97///
98/// The closure receives `&mut sqlx::Transaction`; it is committed if the
99/// closure returns `Ok`, and rolled back if it returns `Err` (or dropped,
100/// which aborts server-side, if the closure panics).
101///
102/// ```ignore
103/// with_transaction_pg(&pool, |tx| async move {
104///     sqlx::query("INSERT INTO users (name) VALUES ($1)")
105///         .bind("Alice")
106///         .execute(&mut **tx)
107///         .await?;
108///     Ok(())
109/// })
110/// .await?;
111/// ```
112#[cfg(feature = "postgres")]
113pub async fn with_transaction_pg<F, T>(pool: &sqlx::PgPool, f: F) -> SqlxResult<T>
114where
115    F: for<'c> FnOnce(
116        &'c mut sqlx::Transaction<'static, sqlx::Postgres>,
117    ) -> futures::future::BoxFuture<'c, SqlxResult<T>>,
118{
119    let mut tx = pool.begin().await?;
120    match f(&mut tx).await {
121        Ok(value) => {
122            tx.commit().await?;
123            Ok(value)
124        }
125        Err(err) => {
126            if let Err(rb) = tx.rollback().await {
127                tracing::warn!(error = %rb, "transaction rollback failed; connection drop will abort server-side");
128            }
129            Err(err)
130        }
131    }
132}
133
134/// Execute a closure within a MySQL transaction.
135///
136/// The closure receives `&mut sqlx::Transaction`; it is committed if the
137/// closure returns `Ok`, and rolled back if it returns `Err` (or dropped,
138/// which aborts server-side, if the closure panics).
139#[cfg(feature = "mysql")]
140pub async fn with_transaction_mysql<F, T>(pool: &sqlx::MySqlPool, f: F) -> SqlxResult<T>
141where
142    F: for<'c> FnOnce(
143        &'c mut sqlx::Transaction<'static, sqlx::MySql>,
144    ) -> futures::future::BoxFuture<'c, SqlxResult<T>>,
145{
146    let mut tx = pool.begin().await?;
147    match f(&mut tx).await {
148        Ok(value) => {
149            tx.commit().await?;
150            Ok(value)
151        }
152        Err(err) => {
153            if let Err(rb) = tx.rollback().await {
154                tracing::warn!(error = %rb, "transaction rollback failed; connection drop will abort server-side");
155            }
156            Err(err)
157        }
158    }
159}
160
161/// Execute a closure within a SQLite transaction.
162///
163/// The closure receives `&mut sqlx::Transaction`; it is committed if the
164/// closure returns `Ok`, and rolled back if it returns `Err` (or dropped,
165/// which aborts server-side, if the closure panics).
166#[cfg(feature = "sqlite")]
167pub async fn with_transaction_sqlite<F, T>(pool: &sqlx::SqlitePool, f: F) -> SqlxResult<T>
168where
169    F: for<'c> FnOnce(
170        &'c mut sqlx::Transaction<'static, sqlx::Sqlite>,
171    ) -> futures::future::BoxFuture<'c, SqlxResult<T>>,
172{
173    let mut tx = pool.begin().await?;
174    match f(&mut tx).await {
175        Ok(value) => {
176            tx.commit().await?;
177            Ok(value)
178        }
179        Err(err) => {
180            if let Err(rb) = tx.rollback().await {
181                tracing::warn!(error = %rb, "transaction rollback failed; connection drop will abort server-side");
182            }
183            Err(err)
184        }
185    }
186}
187
188/// Generic transaction wrapper for the SqlxPool enum.
189pub async fn with_transaction<F, T>(pool: &SqlxPool, f: F) -> SqlxResult<T>
190where
191    F: FnOnce(&SqlxPool) -> futures::future::BoxFuture<'_, Result<T, SqlxError>>,
192{
193    // Note: This is a simplified implementation. For proper transaction support,
194    // you would need to pass the actual transaction handle to the closure.
195    // This requires more complex lifetime management.
196    f(pool).await
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_isolation_level_sql() {
205        assert_eq!(IsolationLevel::ReadUncommitted.as_sql(), "READ UNCOMMITTED");
206        assert_eq!(IsolationLevel::ReadCommitted.as_sql(), "READ COMMITTED");
207        assert_eq!(IsolationLevel::RepeatableRead.as_sql(), "REPEATABLE READ");
208        assert_eq!(IsolationLevel::Serializable.as_sql(), "SERIALIZABLE");
209    }
210
211    #[test]
212    fn test_access_mode_sql() {
213        assert_eq!(AccessMode::ReadWrite.as_sql(), "READ WRITE");
214        assert_eq!(AccessMode::ReadOnly.as_sql(), "READ ONLY");
215    }
216
217    #[test]
218    fn test_transaction_options_builder() {
219        let opts = TransactionOptions::new()
220            .isolation_level(IsolationLevel::Serializable)
221            .access_mode(AccessMode::ReadOnly)
222            .deferrable(true);
223
224        assert_eq!(opts.isolation_level, Some(IsolationLevel::Serializable));
225        assert_eq!(opts.access_mode, Some(AccessMode::ReadOnly));
226        assert_eq!(opts.deferrable, Some(true));
227    }
228
229    #[test]
230    fn test_transaction_options_presets() {
231        let read_only = TransactionOptions::read_only();
232        assert_eq!(read_only.access_mode, Some(AccessMode::ReadOnly));
233
234        let serializable = TransactionOptions::serializable();
235        assert_eq!(
236            serializable.isolation_level,
237            Some(IsolationLevel::Serializable)
238        );
239    }
240
241    #[cfg(feature = "sqlite")]
242    #[tokio::test]
243    async fn test_with_transaction_sqlite_commits_on_ok() {
244        // Single connection so the in-memory database is shared between the
245        // transaction and the verification read.
246        let pool = sqlx::sqlite::SqlitePoolOptions::new()
247            .max_connections(1)
248            .connect("sqlite::memory:")
249            .await
250            .expect("in-memory sqlite needs no server");
251        sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
252            .execute(&pool)
253            .await
254            .unwrap();
255
256        with_transaction_sqlite(&pool, |tx| {
257            Box::pin(async move {
258                sqlx::query("INSERT INTO t (name) VALUES ('a')")
259                    .execute(&mut **tx)
260                    .await?;
261                Ok::<_, SqlxError>(())
262            })
263        })
264        .await
265        .expect("transaction commits");
266
267        let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM t")
268            .fetch_one(&pool)
269            .await
270            .unwrap();
271        assert_eq!(count, 1, "committed row must be visible after commit");
272    }
273
274    #[cfg(feature = "sqlite")]
275    #[tokio::test]
276    async fn test_with_transaction_sqlite_rolls_back_on_err() {
277        let pool = sqlx::sqlite::SqlitePoolOptions::new()
278            .max_connections(1)
279            .connect("sqlite::memory:")
280            .await
281            .expect("in-memory sqlite needs no server");
282        sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
283            .execute(&pool)
284            .await
285            .unwrap();
286
287        let result: SqlxResult<()> = with_transaction_sqlite(&pool, |tx| {
288            Box::pin(async move {
289                sqlx::query("INSERT INTO t (name) VALUES ('a')")
290                    .execute(&mut **tx)
291                    .await?;
292                Err(SqlxError::Internal("boom".into()))
293            })
294        })
295        .await;
296        assert!(result.is_err(), "closure error must propagate");
297
298        let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM t")
299            .fetch_one(&pool)
300            .await
301            .unwrap();
302        assert_eq!(count, 0, "rolled-back write must not persist");
303    }
304}