Skip to main content

cufflink_pgcodec/
tx.rs

1//! The rules of an interactive transaction, as a pure state machine: what
2//! an open or failed transaction admits, where it stands afterwards, and
3//! which savepoints it holds. Whatever holds the connection applies them.
4
5use cufflink_types::db::{DbError, DbErrorKind, Isolation};
6
7/// Where an open transaction stands. After a statement error Postgres has
8/// aborted the transaction and only a rollback (to a savepoint or whole)
9/// is accepted.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Phase {
12    Open,
13    Failed,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SlotOp {
18    Statement,
19    Savepoint,
20    Release,
21    RollbackTo,
22    Commit,
23    Rollback,
24}
25
26/// How an operation that ran came back: fine, refused before reaching the
27/// server (the transaction is untouched), or failed on the server (which
28/// aborts the transaction).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Outcome {
31    Ok,
32    Refused,
33    ServerError,
34}
35
36/// Where the slot goes after an operation.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Next {
39    Stay(Phase),
40    Close,
41}
42
43/// Whether `op` may run in `phase`; a failed transaction answers every
44/// statement, savepoint and release with the error Postgres would give.
45pub fn admit(phase: Phase, op: SlotOp) -> Result<(), DbErrorKind> {
46    match (phase, op) {
47        (Phase::Open, _)
48        | (Phase::Failed, SlotOp::RollbackTo | SlotOp::Rollback | SlotOp::Commit) => Ok(()),
49        (Phase::Failed, SlotOp::Statement | SlotOp::Savepoint | SlotOp::Release) => {
50            Err(DbErrorKind::InFailedTransaction)
51        }
52    }
53}
54
55/// The slot's phase after an admitted `op` ran with `outcome`. Commit and
56/// rollback end the transaction whatever happened; a server error inside
57/// it aborts it; a successful rollback to a savepoint recovers it.
58pub fn next(phase: Phase, op: SlotOp, outcome: Outcome) -> Next {
59    match (op, outcome) {
60        (SlotOp::Commit | SlotOp::Rollback, _) => Next::Close,
61        (_, Outcome::Refused) => Next::Stay(phase),
62        (_, Outcome::ServerError) => Next::Stay(Phase::Failed),
63        (SlotOp::RollbackTo, Outcome::Ok) => Next::Stay(Phase::Open),
64        (_, Outcome::Ok) => Next::Stay(phase),
65    }
66}
67
68/// Savepoint names are interpolated into SQL, so only the SDK's own
69/// `sp_{depth}` shape is accepted.
70pub fn valid_savepoint(name: &str) -> bool {
71    name.strip_prefix("sp_")
72        .is_some_and(|n| (1..=3).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_digit()))
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum SavepointOp {
77    Create,
78    Release,
79    RollbackTo,
80}
81
82impl SavepointOp {
83    pub fn slot_op(self) -> SlotOp {
84        match self {
85            SavepointOp::Create => SlotOp::Savepoint,
86            SavepointOp::Release => SlotOp::Release,
87            SavepointOp::RollbackTo => SlotOp::RollbackTo,
88        }
89    }
90}
91
92/// The SQL for a savepoint operation, when the name it refers to exists:
93/// `RELEASE` and `ROLLBACK TO` an unknown savepoint would abort the whole
94/// transaction on the server, so they are refused first.
95pub fn savepoint_sql(op: SavepointOp, name: &str, known: Option<usize>) -> Option<String> {
96    match (op, known) {
97        (SavepointOp::Create, _) => Some(format!("SAVEPOINT {name}")),
98        (SavepointOp::Release, Some(_)) => Some(format!("RELEASE SAVEPOINT {name}")),
99        (SavepointOp::RollbackTo, Some(_)) => Some(format!("ROLLBACK TO SAVEPOINT {name}")),
100        (_, None) => None,
101    }
102}
103
104/// The savepoint stack after a successful operation: `RELEASE` drops the
105/// savepoint and every later one, `ROLLBACK TO` keeps it and drops the later
106/// ones.
107pub fn savepoints_after(mut stack: Vec<String>, op: SavepointOp, name: &str) -> Vec<String> {
108    let known = stack.iter().rposition(|s| s == name);
109    match (op, known) {
110        (SavepointOp::Create, _) => stack.push(name.to_string()),
111        (SavepointOp::Release, Some(at)) => stack.truncate(at),
112        (SavepointOp::RollbackTo, Some(at)) => stack.truncate(at + 1),
113        (_, None) => {}
114    }
115    stack
116}
117
118pub fn isolation_sql(isolation: Isolation) -> &'static str {
119    match isolation {
120        Isolation::ReadCommitted => "READ COMMITTED",
121        Isolation::RepeatableRead => "REPEATABLE READ",
122        Isolation::Serializable => "SERIALIZABLE",
123    }
124}
125
126/// Transaction control inside a statement, which the host owns.
127pub fn transaction_control_refused() -> DbError {
128    DbError::new(
129        DbErrorKind::NotSupported,
130        "transaction control is refused in db_call statements; use begin, commit, \
131         rollback and the savepoint operations",
132    )
133}
134
135/// Parameters on a statement that cannot be prepared inside a transaction.
136pub fn parameters_outside_transaction() -> DbError {
137    DbError::new(
138        DbErrorKind::NotSupported,
139        "a statement that cannot run inside a transaction takes no parameters",
140    )
141}
142
143/// What a failed transaction answers until it is rolled back.
144pub fn in_failed_transaction(kind: DbErrorKind) -> DbError {
145    DbError::new(
146        kind,
147        "the transaction was aborted by an earlier error; roll back, or roll back to a savepoint, first",
148    )
149}
150
151/// A `COMMIT` of a failed transaction, which the server answers by
152/// rolling it back.
153pub fn rolled_back_by_commit() -> DbError {
154    DbError::new(
155        DbErrorKind::InFailedTransaction,
156        "the transaction was aborted by an earlier error; COMMIT rolled it back",
157    )
158}
159
160pub fn transaction_not_found(tx: u32) -> DbError {
161    DbError::new(
162        DbErrorKind::TransactionNotFound,
163        format!("no open transaction {tx}"),
164    )
165}
166
167pub fn transaction_limit(max_open: usize) -> DbError {
168    DbError::new(
169        DbErrorKind::TransactionLimit,
170        format!("at most {max_open} transactions may be open at once"),
171    )
172}
173
174pub fn invalid_savepoint(name: &str) -> DbError {
175    DbError::new(
176        DbErrorKind::InvalidParameter,
177        format!("invalid savepoint name {name:?}"),
178    )
179}
180
181pub fn unknown_savepoint(name: &str, tx: u32) -> DbError {
182    DbError::new(
183        DbErrorKind::InvalidParameter,
184        format!("no savepoint {name} in transaction {tx}"),
185    )
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn refusals_name_their_kind() {
194        for (refusal, kind) in [
195            (transaction_control_refused(), DbErrorKind::NotSupported),
196            (parameters_outside_transaction(), DbErrorKind::NotSupported),
197            (
198                in_failed_transaction(DbErrorKind::InFailedTransaction),
199                DbErrorKind::InFailedTransaction,
200            ),
201            (rolled_back_by_commit(), DbErrorKind::InFailedTransaction),
202            (transaction_not_found(7), DbErrorKind::TransactionNotFound),
203            (transaction_limit(2), DbErrorKind::TransactionLimit),
204            (invalid_savepoint("x"), DbErrorKind::InvalidParameter),
205            (unknown_savepoint("sp_1", 7), DbErrorKind::InvalidParameter),
206        ] {
207            assert_eq!(refusal.kind, kind, "{refusal}");
208            assert!(refusal.code.is_none(), "{refusal}");
209        }
210        assert_eq!(transaction_not_found(7).message, "no open transaction 7");
211        assert_eq!(
212            transaction_limit(2).message,
213            "at most 2 transactions may be open at once"
214        );
215        assert_eq!(
216            unknown_savepoint("sp_1", 7).message,
217            "no savepoint sp_1 in transaction 7"
218        );
219    }
220
221    const PHASES: [Phase; 2] = [Phase::Open, Phase::Failed];
222    const OPS: [SlotOp; 6] = [
223        SlotOp::Statement,
224        SlotOp::Savepoint,
225        SlotOp::Release,
226        SlotOp::RollbackTo,
227        SlotOp::Commit,
228        SlotOp::Rollback,
229    ];
230    const OUTCOMES: [Outcome; 3] = [Outcome::Ok, Outcome::Refused, Outcome::ServerError];
231
232    #[test]
233    fn an_open_transaction_admits_everything() {
234        for op in OPS {
235            assert_eq!(admit(Phase::Open, op), Ok(()), "{op:?}");
236        }
237    }
238
239    #[test]
240    fn a_failed_transaction_admits_only_the_ways_out() {
241        for (op, admitted) in [
242            (SlotOp::Statement, false),
243            (SlotOp::Savepoint, false),
244            (SlotOp::Release, false),
245            (SlotOp::RollbackTo, true),
246            (SlotOp::Commit, true),
247            (SlotOp::Rollback, true),
248        ] {
249            assert_eq!(
250                admit(Phase::Failed, op),
251                if admitted {
252                    Ok(())
253                } else {
254                    Err(DbErrorKind::InFailedTransaction)
255                },
256                "{op:?}"
257            );
258        }
259    }
260
261    #[test]
262    fn transitions_cover_every_phase_op_and_outcome() {
263        for phase in PHASES {
264            for op in OPS {
265                for outcome in OUTCOMES {
266                    let expected = match (op, outcome) {
267                        (SlotOp::Commit | SlotOp::Rollback, _) => Next::Close,
268                        (_, Outcome::Refused) => Next::Stay(phase),
269                        (_, Outcome::ServerError) => Next::Stay(Phase::Failed),
270                        (SlotOp::RollbackTo, Outcome::Ok) => Next::Stay(Phase::Open),
271                        (_, Outcome::Ok) => Next::Stay(phase),
272                    };
273                    assert_eq!(
274                        next(phase, op, outcome),
275                        expected,
276                        "{phase:?} {op:?} {outcome:?}"
277                    );
278                }
279            }
280        }
281        assert_eq!(
282            next(Phase::Failed, SlotOp::RollbackTo, Outcome::Ok),
283            Next::Stay(Phase::Open),
284            "savepoint recovery"
285        );
286        assert_eq!(
287            next(Phase::Open, SlotOp::Statement, Outcome::ServerError),
288            Next::Stay(Phase::Failed)
289        );
290        assert_eq!(
291            next(Phase::Open, SlotOp::Statement, Outcome::Refused),
292            Next::Stay(Phase::Open),
293            "a parameter the host refused never reached the server"
294        );
295    }
296
297    #[test]
298    fn savepoint_names_are_the_sdk_shape_only() {
299        for name in ["sp_0", "sp_1", "sp_12", "sp_999"] {
300            assert!(valid_savepoint(name), "{name}");
301        }
302        for name in [
303            "sp_",
304            "sp_1000",
305            "sp_a",
306            "sp_1; DROP TABLE x",
307            "SP_1",
308            "x_1",
309            "",
310        ] {
311            assert!(!valid_savepoint(name), "{name}");
312        }
313    }
314
315    #[test]
316    fn savepoint_sql_refuses_unknown_names_before_the_server_sees_them() {
317        assert_eq!(
318            savepoint_sql(SavepointOp::Create, "sp_1", None).as_deref(),
319            Some("SAVEPOINT sp_1")
320        );
321        assert_eq!(
322            savepoint_sql(SavepointOp::Release, "sp_1", Some(0)).as_deref(),
323            Some("RELEASE SAVEPOINT sp_1")
324        );
325        assert_eq!(
326            savepoint_sql(SavepointOp::RollbackTo, "sp_1", Some(0)).as_deref(),
327            Some("ROLLBACK TO SAVEPOINT sp_1")
328        );
329        assert_eq!(savepoint_sql(SavepointOp::Release, "sp_1", None), None);
330        assert_eq!(savepoint_sql(SavepointOp::RollbackTo, "sp_1", None), None);
331    }
332
333    #[test]
334    fn the_savepoint_stack_follows_postgres() {
335        let stack = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
336        let three = stack(&["sp_0", "sp_1", "sp_2"]);
337        assert_eq!(
338            savepoints_after(stack(&["sp_0"]), SavepointOp::Create, "sp_1"),
339            stack(&["sp_0", "sp_1"])
340        );
341        assert_eq!(
342            savepoints_after(three.clone(), SavepointOp::Release, "sp_1"),
343            stack(&["sp_0"])
344        );
345        assert_eq!(
346            savepoints_after(three.clone(), SavepointOp::RollbackTo, "sp_1"),
347            stack(&["sp_0", "sp_1"])
348        );
349        assert_eq!(
350            savepoints_after(stack(&["sp_0", "sp_0"]), SavepointOp::Release, "sp_0"),
351            stack(&["sp_0"]),
352            "the most recent of two same-named savepoints"
353        );
354    }
355
356    #[test]
357    fn isolation_levels_render_as_sql() {
358        assert_eq!(isolation_sql(Isolation::ReadCommitted), "READ COMMITTED");
359        assert_eq!(isolation_sql(Isolation::RepeatableRead), "REPEATABLE READ");
360        assert_eq!(isolation_sql(Isolation::Serializable), "SERIALIZABLE");
361    }
362}