1use cufflink_types::db::{DbError, DbErrorKind, Isolation};
6
7#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Outcome {
31 Ok,
32 Refused,
33 ServerError,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Next {
39 Stay(Phase),
40 Close,
41}
42
43pub 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
55pub 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
68pub 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
92pub 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
104pub 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
126pub 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
135pub 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
143pub 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
151pub 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}