Skip to main content

rust_ef/
transaction.rs

1//! Transaction handle abstraction.
2//!
3//! Decouples transaction capabilities from `DbContext` so callers can hold a
4//! typed handle (`Box<dyn ITransaction>`) returned by
5//! `DbContext::begin_transaction()`. The handle exposes `commit` / `rollback`
6//! (which consume it), savepoint operations, isolation level control, and a
7//! `connection()` accessor used internally by `save_changes()` to reuse the
8//! transaction's connection.
9//!
10//! ## Control modes
11//!
12//! - **Scoped** — `ctx.use_transaction(|ctx| Box::pin(async move { ... })).await?`
13//!   or `ctx.transaction(...)`. Registers the handle as ambient; every
14//!   statement issued through the context uses the transaction's connection.
15//!   Commits on `Ok`, rolls back on `Err` / `TxOutcome::Rollback`.
16//! - **Manual** — `let txn = ctx.begin_transaction().await?; ... txn.commit().await?;`
17//!   Returns a **standalone** handle. It is not registered as ambient, so
18//!   queries and `save_changes()` do not join it; use it for raw SQL and
19//!   explicit savepoint/isolation control. Use `ctx.transaction(...)` when the
20//!   whole context should join the transaction.
21//!
22//! Any transaction handle that is dropped without `commit` / `rollback` is
23//! rolled back on a background task, matching the `Dispose`-rolls-back
24//! convention of EF Core / ADO.NET. The rollback is best effort: it requires a
25//! Tokio runtime to be available when the handle is dropped.
26
27use crate::error::{EFError, EFResult};
28use crate::provider::{IAsyncConnection, IsolationLevel};
29use crate::session::{Session, SessionConnection, SharedTxn};
30use std::future::Future;
31use std::pin::Pin;
32use std::sync::Arc;
33
34/// The outcome of a [`DbContext::transaction`](crate::db_context::DbContext::transaction)
35/// closure.
36///
37/// Unlike [`DbContext::use_transaction`](crate::db_context::DbContext::use_transaction),
38/// which commits on `Ok` and rolls back only on `Err`, this type lets a handler
39/// abort the database work and still return a successful business result.
40///
41/// ```rust,ignore
42/// let result = ctx.transaction(|ctx| Box::pin(async move {
43///     // ... database work ...
44///     if !meets_condition {
45///         return Ok(TxOutcome::Rollback(BizResult::Rejected));
46///     }
47///     Ok(TxOutcome::Commit(BizResult::Done))
48/// })).await?;
49/// ```
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum TxOutcome<T> {
52    /// Commit the transaction and return the value.
53    Commit(T),
54    /// Roll the transaction back, detach tracked entities, and return the value.
55    Rollback(T),
56}
57
58/// Transaction handle abstracting the Priority 2 connection-level transaction
59/// methods.
60///
61/// `commit` / `rollback` consume `self: Box<Self>` to make use-after-commit
62/// impossible at the type level. Savepoint and isolation methods take
63/// `&mut self` and return a boxed future borrowing the handle.
64///
65/// `Send + Sync` is required so that `Box<dyn ITransaction>` can be stored in
66/// `DbContext` (which DI registers as `Scoped`, requiring `Send + Sync`).
67pub trait ITransaction: Send + Sync {
68    /// Commits the transaction and consumes the handle.
69    fn commit(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>>;
70
71    /// Rolls back the transaction and consumes the handle.
72    fn rollback(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>>;
73
74    /// Creates a savepoint within the current transaction.
75    fn create_point<'a>(
76        &'a mut self,
77        name: &'a str,
78    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
79
80    /// Releases (commits) a previously created savepoint.
81    fn release_point<'a>(
82        &'a mut self,
83        name: &'a str,
84    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
85
86    /// Rolls back to the named savepoint, preserving the outer transaction.
87    fn rollback_point<'a>(
88        &'a mut self,
89        name: &'a str,
90    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
91
92    /// Sets the isolation level of the current transaction.
93    fn set_isolation<'a>(
94        &'a mut self,
95        level: IsolationLevel,
96    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
97
98    /// Exposes the underlying connection so `save_changes()` can reuse the
99    /// transaction's connection.
100    fn connection(&mut self) -> &mut (dyn IAsyncConnection + Send);
101
102    /// Whether this transaction is scoped to the `DbContext`.
103    ///
104    /// Scoped transactions (`use_transaction` / `transaction`) route queries
105    /// and bulk DML through the transaction connection and get per-save
106    /// savepoints. A manual [`DbContext::begin_transaction`](crate::db_context::DbContext::begin_transaction)
107    /// handle is standalone and keeps the legacy behavior.
108    fn is_scoped(&self) -> bool {
109        false
110    }
111}
112
113/// Where the transaction's connection lives.
114enum TxnState {
115    /// A connection owned outright by the handle (standalone
116    /// `DbTransaction::new`).
117    Owned(Option<Box<dyn IAsyncConnection>>),
118    /// A connection shared with the `DbContext` session, so queries and bulk
119    /// DML inside the transaction observe the same connection.
120    Shared {
121        shared: Arc<SharedTxn>,
122        facade: SessionConnection,
123    },
124}
125
126/// Default `ITransaction` implementation.
127///
128/// In `Owned` mode the connection is held in `Option` so `commit` / `rollback`
129/// can take it out by consuming the handle. In `Shared` mode the connection
130/// lives in the session slot and is taken out on commit / rollback.
131pub struct DbTransaction {
132    state: Option<TxnState>,
133    /// When set, commit / rollback / drop clears the context's active
134    /// transaction so subsequent statements fall back to the pool.
135    session: Option<Arc<Session>>,
136}
137
138impl DbTransaction {
139    pub fn new(conn: Box<dyn IAsyncConnection>) -> Self {
140        Self {
141            state: Some(TxnState::Owned(Some(conn))),
142            session: None,
143        }
144    }
145
146    pub(crate) fn new_shared(
147        shared: Arc<SharedTxn>,
148        facade: SessionConnection,
149        session: Option<Arc<Session>>,
150    ) -> Self {
151        Self {
152            state: Some(TxnState::Shared { shared, facade }),
153            session,
154        }
155    }
156}
157
158impl ITransaction for DbTransaction {
159    fn commit(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>> {
160        Box::pin(async move {
161            let mut this = self;
162            let Some(state) = this.state.take() else {
163                return Err(EFError::transaction("transaction already consumed"));
164            };
165            let result = match state {
166                TxnState::Owned(conn) => match conn {
167                    Some(mut conn) => conn.commit_transaction().await,
168                    None => Err(EFError::transaction("transaction already consumed")),
169                },
170                TxnState::Shared { shared, .. } => match shared.take().await {
171                    Ok(mut conn) => conn.commit_transaction().await,
172                    Err(e) => Err(e),
173                },
174            };
175            if let Some(session) = this.session.as_ref() {
176                session.clear().await;
177            }
178            result
179        })
180    }
181
182    fn rollback(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>> {
183        Box::pin(async move {
184            let mut this = self;
185            let Some(state) = this.state.take() else {
186                return Err(EFError::transaction("transaction already consumed"));
187            };
188            let result = match state {
189                TxnState::Owned(conn) => match conn {
190                    Some(mut conn) => conn.rollback_transaction().await,
191                    None => Err(EFError::transaction("transaction already consumed")),
192                },
193                TxnState::Shared { shared, .. } => match shared.take().await {
194                    Ok(mut conn) => conn.rollback_transaction().await,
195                    Err(e) => Err(e),
196                },
197            };
198            if let Some(session) = this.session.as_ref() {
199                session.clear().await;
200            }
201            result
202        })
203    }
204
205    fn create_point<'a>(
206        &'a mut self,
207        name: &'a str,
208    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
209        Box::pin(async move {
210            let state = self
211                .state
212                .as_mut()
213                .ok_or_else(|| EFError::transaction("transaction already consumed"))?;
214            match state {
215                TxnState::Owned(conn) => {
216                    conn.as_mut()
217                        .ok_or_else(|| EFError::transaction("transaction consumed"))?
218                        .create_savepoint(name)
219                        .await
220                }
221                TxnState::Shared { facade, .. } => facade.create_savepoint(name).await,
222            }
223        })
224    }
225
226    fn release_point<'a>(
227        &'a mut self,
228        name: &'a str,
229    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
230        Box::pin(async move {
231            let state = self
232                .state
233                .as_mut()
234                .ok_or_else(|| EFError::transaction("transaction already consumed"))?;
235            match state {
236                TxnState::Owned(conn) => {
237                    conn.as_mut()
238                        .ok_or_else(|| EFError::transaction("transaction consumed"))?
239                        .release_savepoint(name)
240                        .await
241                }
242                TxnState::Shared { facade, .. } => facade.release_savepoint(name).await,
243            }
244        })
245    }
246
247    fn rollback_point<'a>(
248        &'a mut self,
249        name: &'a str,
250    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
251        Box::pin(async move {
252            let state = self
253                .state
254                .as_mut()
255                .ok_or_else(|| EFError::transaction("transaction already consumed"))?;
256            match state {
257                TxnState::Owned(conn) => {
258                    conn.as_mut()
259                        .ok_or_else(|| EFError::transaction("transaction consumed"))?
260                        .rollback_to_savepoint(name)
261                        .await
262                }
263                TxnState::Shared { facade, .. } => facade.rollback_to_savepoint(name).await,
264            }
265        })
266    }
267
268    fn set_isolation<'a>(
269        &'a mut self,
270        level: IsolationLevel,
271    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
272        Box::pin(async move {
273            let state = self
274                .state
275                .as_mut()
276                .ok_or_else(|| EFError::transaction("transaction already consumed"))?;
277            match state {
278                TxnState::Owned(conn) => {
279                    conn.as_mut()
280                        .ok_or_else(|| EFError::transaction("transaction consumed"))?
281                        .set_transaction_isolation(level)
282                        .await
283                }
284                TxnState::Shared { facade, .. } => facade.set_transaction_isolation(level).await,
285            }
286        })
287    }
288
289    fn connection(&mut self) -> &mut (dyn IAsyncConnection + Send) {
290        match self.state.as_mut().expect("transaction already consumed") {
291            TxnState::Owned(conn) => conn
292                .as_mut()
293                .expect("transaction already consumed")
294                .as_mut(),
295            TxnState::Shared { facade, .. } => facade,
296        }
297    }
298
299    fn is_scoped(&self) -> bool {
300        matches!(&self.state, Some(TxnState::Shared { .. }))
301    }
302}
303
304impl Drop for DbTransaction {
305    fn drop(&mut self) {
306        let state = self.state.take();
307        let session = self.session.take();
308        if let Some(session) = &session {
309            // Detach before the background rollback so new statements do not
310            // join a transaction that is about to be rolled back. This also
311            // covers the cancellation window where commit/rollback already
312            // took the connection out of the handle.
313            session.clear_now();
314        }
315        let Some(state) = state else {
316            return;
317        };
318        let Ok(handle) = tokio::runtime::Handle::try_current() else {
319            return;
320        };
321        handle.spawn(async move {
322            match state {
323                TxnState::Owned(Some(mut conn)) => {
324                    let _ = conn.rollback_transaction().await;
325                }
326                TxnState::Owned(None) => {}
327                TxnState::Shared { shared, .. } => {
328                    if let Ok(mut conn) = shared.take().await {
329                        let _ = conn.rollback_transaction().await;
330                    }
331                }
332            }
333            if let Some(session) = session {
334                session.clear().await;
335            }
336        });
337    }
338}