use prax_query::QueryError;
use thiserror::Error;
pub type PgResult<T> = Result<T, PgError>;
#[derive(Error, Debug)]
pub enum PgError {
#[error("pool error: {0}")]
Pool(#[from] deadpool_postgres::PoolError),
#[error("postgres error: {0}")]
Postgres(#[from] tokio_postgres::Error),
#[error("configuration error: {0}")]
Config(String),
#[error("connection error: {0}")]
Connection(String),
#[error("query error: {0}")]
Query(String),
#[error("deserialization error: {0}")]
Deserialization(String),
#[error("type conversion error: {0}")]
TypeConversion(String),
#[error("operation timed out after {0}ms")]
Timeout(u64),
#[error("internal error: {0}")]
Internal(String),
}
impl PgError {
pub fn config(message: impl Into<String>) -> Self {
Self::Config(message.into())
}
pub fn connection(message: impl Into<String>) -> Self {
Self::Connection(message.into())
}
pub fn query(message: impl Into<String>) -> Self {
Self::Query(message.into())
}
pub fn deserialization(message: impl Into<String>) -> Self {
Self::Deserialization(message.into())
}
pub fn type_conversion(message: impl Into<String>) -> Self {
Self::TypeConversion(message.into())
}
pub fn is_connection_error(&self) -> bool {
matches!(self, Self::Pool(_) | Self::Connection(_))
}
pub fn is_timeout(&self) -> bool {
matches!(self, Self::Timeout(_))
}
}
pub(crate) fn classify_sqlstate(
code: Option<&str>,
display: &str,
detail: Option<&str>,
) -> QueryError {
let gate_text = detail.unwrap_or(display);
match code {
Some("23505") | Some("23503") | Some("23514") => {
QueryError::constraint_violation("", display)
}
Some("23502") => QueryError::invalid_input("", display),
Some("0A000") if gate_text.contains("cached plan must not change result type") => {
QueryError::stale_plan(display)
}
_ => QueryError::database(display),
}
}
impl From<PgError> for QueryError {
fn from(err: PgError) -> Self {
match err {
PgError::Pool(e) => QueryError::connection(e.to_string()),
PgError::Postgres(e) => {
let code_str = e.code().map(|c| c.code().to_owned());
let display = e.to_string();
let detail = e.as_db_error().map(|db| db.message().to_owned());
let mapped = classify_sqlstate(code_str.as_deref(), &display, detail.as_deref());
mapped.with_source(e)
}
PgError::Config(msg) => QueryError::connection(msg),
PgError::Connection(msg) => QueryError::connection(msg),
PgError::Query(msg) => QueryError::database(msg),
PgError::Deserialization(msg) => QueryError::serialization(msg),
PgError::TypeConversion(msg) => QueryError::serialization(msg),
PgError::Timeout(ms) => QueryError::timeout(ms),
PgError::Internal(msg) => QueryError::internal(msg),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_creation() {
let err = PgError::config("invalid URL");
assert!(matches!(err, PgError::Config(_)));
let err = PgError::connection("connection refused");
assert!(err.is_connection_error());
let err = PgError::Timeout(5000);
assert!(err.is_timeout());
}
#[test]
fn test_into_query_error() {
let pg_err = PgError::Timeout(1000);
let query_err: QueryError = pg_err.into();
assert!(query_err.is_timeout());
}
#[test]
fn test_classify_constraint_sqlstates() {
use prax_query::ErrorCode;
for code in ["23505", "23503", "23514"] {
let e = classify_sqlstate(Some(code), "boom", None);
assert_eq!(e.code, ErrorCode::UniqueConstraint, "code {code}");
assert!(e.is_constraint_violation(), "code {code}");
}
assert_eq!(
classify_sqlstate(Some("23502"), "boom", None).code,
ErrorCode::InvalidParameter
);
}
#[test]
fn test_classify_stale_cached_plan_gates_on_detail() {
use prax_query::ErrorCode;
let e = classify_sqlstate(
Some("0A000"),
"db error",
Some("cached plan must not change result type"),
);
assert_eq!(e.code, ErrorCode::SerializationFailure);
assert!(e.is_retryable());
let e = classify_sqlstate(
Some("0A000"),
"cached plan must not change result type",
None,
);
assert_eq!(e.code, ErrorCode::SerializationFailure);
}
#[test]
fn test_classify_other_0a000_stays_generic() {
use prax_query::ErrorCode;
let e = classify_sqlstate(Some("0A000"), "db error", Some("cannot insert into a view"));
assert_eq!(e.code, ErrorCode::DatabaseError);
assert!(!e.is_retryable());
}
#[test]
fn test_classify_unknown_sqlstate_is_generic() {
use prax_query::ErrorCode;
assert_eq!(
classify_sqlstate(Some("40P01"), "deadlock-ish", None).code,
ErrorCode::DatabaseError
);
assert_eq!(
classify_sqlstate(None, "no code", None).code,
ErrorCode::DatabaseError
);
}
}