use cufflink_types::db::{DbError, DbErrorKind, Isolation};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Open,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlotOp {
Statement,
Savepoint,
Release,
RollbackTo,
Commit,
Rollback,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Ok,
Refused,
ServerError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Next {
Stay(Phase),
Close,
}
pub fn admit(phase: Phase, op: SlotOp) -> Result<(), DbErrorKind> {
match (phase, op) {
(Phase::Open, _)
| (Phase::Failed, SlotOp::RollbackTo | SlotOp::Rollback | SlotOp::Commit) => Ok(()),
(Phase::Failed, SlotOp::Statement | SlotOp::Savepoint | SlotOp::Release) => {
Err(DbErrorKind::InFailedTransaction)
}
}
}
pub fn next(phase: Phase, op: SlotOp, outcome: Outcome) -> Next {
match (op, outcome) {
(SlotOp::Commit | SlotOp::Rollback, _) => Next::Close,
(_, Outcome::Refused) => Next::Stay(phase),
(_, Outcome::ServerError) => Next::Stay(Phase::Failed),
(SlotOp::RollbackTo, Outcome::Ok) => Next::Stay(Phase::Open),
(_, Outcome::Ok) => Next::Stay(phase),
}
}
pub fn valid_savepoint(name: &str) -> bool {
name.strip_prefix("sp_")
.is_some_and(|n| (1..=3).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_digit()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SavepointOp {
Create,
Release,
RollbackTo,
}
impl SavepointOp {
pub fn slot_op(self) -> SlotOp {
match self {
SavepointOp::Create => SlotOp::Savepoint,
SavepointOp::Release => SlotOp::Release,
SavepointOp::RollbackTo => SlotOp::RollbackTo,
}
}
}
pub fn savepoint_sql(op: SavepointOp, name: &str, known: Option<usize>) -> Option<String> {
match (op, known) {
(SavepointOp::Create, _) => Some(format!("SAVEPOINT {name}")),
(SavepointOp::Release, Some(_)) => Some(format!("RELEASE SAVEPOINT {name}")),
(SavepointOp::RollbackTo, Some(_)) => Some(format!("ROLLBACK TO SAVEPOINT {name}")),
(_, None) => None,
}
}
pub fn savepoints_after(mut stack: Vec<String>, op: SavepointOp, name: &str) -> Vec<String> {
let known = stack.iter().rposition(|s| s == name);
match (op, known) {
(SavepointOp::Create, _) => stack.push(name.to_string()),
(SavepointOp::Release, Some(at)) => stack.truncate(at),
(SavepointOp::RollbackTo, Some(at)) => stack.truncate(at + 1),
(_, None) => {}
}
stack
}
pub fn isolation_sql(isolation: Isolation) -> &'static str {
match isolation {
Isolation::ReadCommitted => "READ COMMITTED",
Isolation::RepeatableRead => "REPEATABLE READ",
Isolation::Serializable => "SERIALIZABLE",
}
}
pub fn transaction_control_refused() -> DbError {
DbError::new(
DbErrorKind::NotSupported,
"transaction control is refused in db_call statements; use begin, commit, \
rollback and the savepoint operations",
)
}
pub fn parameters_outside_transaction() -> DbError {
DbError::new(
DbErrorKind::NotSupported,
"a statement that cannot run inside a transaction takes no parameters",
)
}
pub fn in_failed_transaction(kind: DbErrorKind) -> DbError {
DbError::new(
kind,
"the transaction was aborted by an earlier error; roll back, or roll back to a savepoint, first",
)
}
pub fn rolled_back_by_commit() -> DbError {
DbError::new(
DbErrorKind::InFailedTransaction,
"the transaction was aborted by an earlier error; COMMIT rolled it back",
)
}
pub fn transaction_not_found(tx: u32) -> DbError {
DbError::new(
DbErrorKind::TransactionNotFound,
format!("no open transaction {tx}"),
)
}
pub fn transaction_limit(max_open: usize) -> DbError {
DbError::new(
DbErrorKind::TransactionLimit,
format!("at most {max_open} transactions may be open at once"),
)
}
pub fn invalid_savepoint(name: &str) -> DbError {
DbError::new(
DbErrorKind::InvalidParameter,
format!("invalid savepoint name {name:?}"),
)
}
pub fn unknown_savepoint(name: &str, tx: u32) -> DbError {
DbError::new(
DbErrorKind::InvalidParameter,
format!("no savepoint {name} in transaction {tx}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refusals_name_their_kind() {
for (refusal, kind) in [
(transaction_control_refused(), DbErrorKind::NotSupported),
(parameters_outside_transaction(), DbErrorKind::NotSupported),
(
in_failed_transaction(DbErrorKind::InFailedTransaction),
DbErrorKind::InFailedTransaction,
),
(rolled_back_by_commit(), DbErrorKind::InFailedTransaction),
(transaction_not_found(7), DbErrorKind::TransactionNotFound),
(transaction_limit(2), DbErrorKind::TransactionLimit),
(invalid_savepoint("x"), DbErrorKind::InvalidParameter),
(unknown_savepoint("sp_1", 7), DbErrorKind::InvalidParameter),
] {
assert_eq!(refusal.kind, kind, "{refusal}");
assert!(refusal.code.is_none(), "{refusal}");
}
assert_eq!(transaction_not_found(7).message, "no open transaction 7");
assert_eq!(
transaction_limit(2).message,
"at most 2 transactions may be open at once"
);
assert_eq!(
unknown_savepoint("sp_1", 7).message,
"no savepoint sp_1 in transaction 7"
);
}
const PHASES: [Phase; 2] = [Phase::Open, Phase::Failed];
const OPS: [SlotOp; 6] = [
SlotOp::Statement,
SlotOp::Savepoint,
SlotOp::Release,
SlotOp::RollbackTo,
SlotOp::Commit,
SlotOp::Rollback,
];
const OUTCOMES: [Outcome; 3] = [Outcome::Ok, Outcome::Refused, Outcome::ServerError];
#[test]
fn an_open_transaction_admits_everything() {
for op in OPS {
assert_eq!(admit(Phase::Open, op), Ok(()), "{op:?}");
}
}
#[test]
fn a_failed_transaction_admits_only_the_ways_out() {
for (op, admitted) in [
(SlotOp::Statement, false),
(SlotOp::Savepoint, false),
(SlotOp::Release, false),
(SlotOp::RollbackTo, true),
(SlotOp::Commit, true),
(SlotOp::Rollback, true),
] {
assert_eq!(
admit(Phase::Failed, op),
if admitted {
Ok(())
} else {
Err(DbErrorKind::InFailedTransaction)
},
"{op:?}"
);
}
}
#[test]
fn transitions_cover_every_phase_op_and_outcome() {
for phase in PHASES {
for op in OPS {
for outcome in OUTCOMES {
let expected = match (op, outcome) {
(SlotOp::Commit | SlotOp::Rollback, _) => Next::Close,
(_, Outcome::Refused) => Next::Stay(phase),
(_, Outcome::ServerError) => Next::Stay(Phase::Failed),
(SlotOp::RollbackTo, Outcome::Ok) => Next::Stay(Phase::Open),
(_, Outcome::Ok) => Next::Stay(phase),
};
assert_eq!(
next(phase, op, outcome),
expected,
"{phase:?} {op:?} {outcome:?}"
);
}
}
}
assert_eq!(
next(Phase::Failed, SlotOp::RollbackTo, Outcome::Ok),
Next::Stay(Phase::Open),
"savepoint recovery"
);
assert_eq!(
next(Phase::Open, SlotOp::Statement, Outcome::ServerError),
Next::Stay(Phase::Failed)
);
assert_eq!(
next(Phase::Open, SlotOp::Statement, Outcome::Refused),
Next::Stay(Phase::Open),
"a parameter the host refused never reached the server"
);
}
#[test]
fn savepoint_names_are_the_sdk_shape_only() {
for name in ["sp_0", "sp_1", "sp_12", "sp_999"] {
assert!(valid_savepoint(name), "{name}");
}
for name in [
"sp_",
"sp_1000",
"sp_a",
"sp_1; DROP TABLE x",
"SP_1",
"x_1",
"",
] {
assert!(!valid_savepoint(name), "{name}");
}
}
#[test]
fn savepoint_sql_refuses_unknown_names_before_the_server_sees_them() {
assert_eq!(
savepoint_sql(SavepointOp::Create, "sp_1", None).as_deref(),
Some("SAVEPOINT sp_1")
);
assert_eq!(
savepoint_sql(SavepointOp::Release, "sp_1", Some(0)).as_deref(),
Some("RELEASE SAVEPOINT sp_1")
);
assert_eq!(
savepoint_sql(SavepointOp::RollbackTo, "sp_1", Some(0)).as_deref(),
Some("ROLLBACK TO SAVEPOINT sp_1")
);
assert_eq!(savepoint_sql(SavepointOp::Release, "sp_1", None), None);
assert_eq!(savepoint_sql(SavepointOp::RollbackTo, "sp_1", None), None);
}
#[test]
fn the_savepoint_stack_follows_postgres() {
let stack = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
let three = stack(&["sp_0", "sp_1", "sp_2"]);
assert_eq!(
savepoints_after(stack(&["sp_0"]), SavepointOp::Create, "sp_1"),
stack(&["sp_0", "sp_1"])
);
assert_eq!(
savepoints_after(three.clone(), SavepointOp::Release, "sp_1"),
stack(&["sp_0"])
);
assert_eq!(
savepoints_after(three.clone(), SavepointOp::RollbackTo, "sp_1"),
stack(&["sp_0", "sp_1"])
);
assert_eq!(
savepoints_after(stack(&["sp_0", "sp_0"]), SavepointOp::Release, "sp_0"),
stack(&["sp_0"]),
"the most recent of two same-named savepoints"
);
}
#[test]
fn isolation_levels_render_as_sql() {
assert_eq!(isolation_sql(Isolation::ReadCommitted), "READ COMMITTED");
assert_eq!(isolation_sql(Isolation::RepeatableRead), "REPEATABLE READ");
assert_eq!(isolation_sql(Isolation::Serializable), "SERIALIZABLE");
}
}