use sqlx::error::DatabaseError;
use sqlx::postgres::{PgDatabaseError, PgErrorPosition};
use thiserror::Error;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DbErrorInfo {
pub schema: Option<String>,
pub table: Option<String>,
pub column: Option<String>,
pub datatype: Option<String>,
pub constraint: Option<String>,
pub where_: Option<String>,
pub position: Option<DbErrorPosition>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DbErrorPosition {
Original(usize),
Internal {
position: usize,
query: String,
},
}
impl DbErrorInfo {
fn from_sqlx_database_error(db: &(dyn DatabaseError + 'static)) -> Self {
if let Some(pg) = db.try_downcast_ref::<PgDatabaseError>() {
return Self::from_sqlx_pg_error(pg);
}
Self {
table: db.table().map(ToOwned::to_owned),
constraint: db.constraint().map(ToOwned::to_owned),
..Self::default()
}
}
fn from_sqlx_pg_error(db: &PgDatabaseError) -> Self {
Self {
schema: db.schema().map(ToOwned::to_owned),
table: db.table().map(ToOwned::to_owned),
column: db.column().map(ToOwned::to_owned),
datatype: db.data_type().map(ToOwned::to_owned),
constraint: db.constraint().map(ToOwned::to_owned),
where_: db.r#where().map(ToOwned::to_owned),
position: db.position().map(DbErrorPosition::from),
}
}
}
impl From<PgErrorPosition<'_>> for DbErrorPosition {
fn from(position: PgErrorPosition<'_>) -> Self {
match position {
PgErrorPosition::Original(position) => Self::Original(position),
PgErrorPosition::Internal { position, query } => Self::Internal {
position,
query: query.to_owned(),
},
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConstraintError {
pub detail: Option<String>,
pub info: DbErrorInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct PgFailure {
pub message: String,
pub detail: Option<String>,
pub hint: Option<String>,
pub info: DbErrorInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct DatabaseFailure {
pub code: String,
pub message: String,
pub detail: Option<String>,
pub hint: Option<String>,
pub info: DbErrorInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct OperatorError {
pub field: String,
pub operator: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SearchValueError {
pub field: String,
pub expected: &'static str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct WriteTargetError {
pub statement: &'static str,
pub source_kind: &'static str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CteShapeError {
pub name: String,
pub message: &'static str,
}
impl ConstraintError {
pub fn new(detail: Option<String>, info: DbErrorInfo) -> Self {
Self { detail, info }
}
}
impl PgFailure {
pub fn new(
message: impl Into<String>,
detail: Option<String>,
hint: Option<String>,
info: DbErrorInfo,
) -> Self {
Self {
message: message.into(),
detail,
hint,
info,
}
}
}
impl DatabaseFailure {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
detail: None,
hint: None,
info: DbErrorInfo::default(),
}
}
pub fn detail(mut self, detail: Option<String>) -> Self {
self.detail = detail;
self
}
pub fn hint(mut self, hint: Option<String>) -> Self {
self.hint = hint;
self
}
pub fn constraint(mut self, constraint: Option<String>) -> Self {
self.info.constraint = constraint;
self
}
pub fn table(mut self, table: Option<String>) -> Self {
self.info.table = table;
self
}
pub fn column(mut self, column: Option<String>) -> Self {
self.info.column = column;
self
}
pub fn info(mut self, info: DbErrorInfo) -> Self {
self.info = info;
self
}
}
impl OperatorError {
pub fn new(field: impl Into<String>, operator: impl Into<String>) -> Self {
Self {
field: field.into(),
operator: operator.into(),
}
}
}
impl SearchValueError {
pub fn new(field: impl Into<String>, expected: &'static str) -> Self {
Self {
field: field.into(),
expected,
}
}
}
impl WriteTargetError {
pub fn new(statement: &'static str, source_kind: &'static str) -> Self {
Self {
statement,
source_kind,
}
}
}
impl CteShapeError {
pub fn new(name: impl Into<String>, message: &'static str) -> Self {
Self {
name: name.into(),
message,
}
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
#[error("{statement} fetch requires an explicit RETURNING clause")]
WriteWithoutReturning {
statement: &'static str,
},
#[error("query returned no rows")]
NotFound,
#[error("unique violation{}", constraint_suffix(&.0.info.constraint))]
UniqueViolation(Box<ConstraintError>),
#[error("foreign key violation{}", constraint_suffix(&.0.info.constraint))]
ForeignKeyViolation(Box<ConstraintError>),
#[error("restrict violation{}", constraint_suffix(&.0.info.constraint))]
RestrictViolation(Box<ConstraintError>),
#[error("not null violation{}", column_suffix(&.0.column))]
NotNullViolation(Box<DbErrorInfo>),
#[error("check violation{}", constraint_suffix(&.0.info.constraint))]
CheckViolation(Box<ConstraintError>),
#[error("exclusion violation{}", constraint_suffix(&.0.info.constraint))]
ExclusionViolation(Box<ConstraintError>),
#[error("serialization failure: {}", .0.message)]
SerializationFailure(Box<PgFailure>),
#[error("deadlock detected: {}", .0.message)]
DeadlockDetected(Box<PgFailure>),
#[error("lock not available: {}", .0.message)]
LockNotAvailable(Box<PgFailure>),
#[error("query canceled: {}", .0.message)]
QueryCanceled(Box<PgFailure>),
#[error("insufficient privilege: {}", .0.message)]
InsufficientPrivilege(Box<DatabaseFailure>),
#[error("database error ({}): {}", .0.code, .0.message)]
Database(Box<DatabaseFailure>),
#[error("connection error: {0}")]
Connection(#[source] Box<sqlx::Error>),
#[error("sqlx error: {0}")]
Sqlx(Box<sqlx::Error>),
#[error("transaction rollback failed after error: {error}; rollback error: {rollback}")]
TransactionRollbackFailed {
error: Box<Error>,
rollback: Box<Error>,
},
#[error("parameter encode error: {0}")]
Encode(String),
#[error("raw SQL fragment has {placeholders} placeholders but {binds} bind values")]
RawBindMismatch {
placeholders: usize,
binds: usize,
},
#[error("operator `{}` is not supported for field `{}`", .0.operator, .0.field)]
InvalidOperator(Box<OperatorError>),
#[error("aggregate modifier `{modifier}` can only be applied to aggregate expressions")]
InvalidAggregateModifier {
modifier: &'static str,
},
#[error("field `{field}` is not sortable")]
InvalidSort {
field: String,
},
#[error("unknown search field `{field}`")]
InvalidSearchField {
field: String,
},
#[error("search field `{field}` is not exposed to JSON requests")]
SearchFieldNotExposed {
field: String,
},
#[error("operator `{}` is not supported for search field `{}`", .0.operator, .0.field)]
InvalidSearchOperator(Box<OperatorError>),
#[error("invalid JSON value for search field `{}`; expected {}", .0.field, .0.expected)]
InvalidSearchValue(Box<SearchValueError>),
#[error("empty search logical expression `{logical}`")]
EmptySearchLogical {
logical: &'static str,
},
#[error("empty logical expression `{logical}`")]
EmptyLogical {
logical: &'static str,
},
#[error("row comparison requires the same arity, got {left} and {right}")]
InvalidRowShape {
left: usize,
right: usize,
},
#[error("{} target must be a table or view source, got {}", .0.statement, .0.source_kind)]
InvalidWriteTarget(Box<WriteTargetError>),
#[error("{statement} statement requires at least one assignment")]
EmptyAssignments {
statement: &'static str,
},
#[error("{statement} statement requires at least one column")]
EmptyColumns {
statement: &'static str,
},
#[error("invalid insert shape: {message}")]
InvalidInsertShape {
message: &'static str,
},
#[error("invalid select shape: {message}")]
InvalidSelectShape {
message: &'static str,
},
#[error("invalid merge shape: {message}")]
InvalidMergeShape {
message: &'static str,
},
#[error("invalid CTE `{}`: {}", .0.name, .0.message)]
InvalidCteShape(Box<CteShapeError>),
#[error("delete without filter is not allowed")]
DeleteWithoutFilter,
}
fn constraint_suffix(constraint: &Option<String>) -> String {
constraint
.as_ref()
.map(|name| format!(" on constraint \"{name}\""))
.unwrap_or_default()
}
fn column_suffix(column: &Option<String>) -> String {
column
.as_ref()
.map(|name| format!(" on column \"{name}\""))
.unwrap_or_default()
}
impl From<sqlx::Error> for Error {
fn from(error: sqlx::Error) -> Self {
match error {
sqlx::Error::RowNotFound => Self::NotFound,
sqlx::Error::Database(db) => Self::from_sqlx_database_error(&*db),
error @ (sqlx::Error::Io(_)
| sqlx::Error::Tls(_)
| sqlx::Error::PoolTimedOut
| sqlx::Error::PoolClosed
| sqlx::Error::WorkerCrashed) => Self::Connection(Box::new(error)),
other => Self::Sqlx(Box::new(other)),
}
}
}
impl Error {
pub(crate) fn invalid_operator(field: impl Into<String>, operator: impl Into<String>) -> Self {
Self::InvalidOperator(Box::new(OperatorError::new(field, operator)))
}
pub(crate) fn invalid_search_operator(
field: impl Into<String>,
operator: impl Into<String>,
) -> Self {
Self::InvalidSearchOperator(Box::new(OperatorError::new(field, operator)))
}
pub(crate) fn invalid_search_value(field: impl Into<String>, expected: &'static str) -> Self {
Self::InvalidSearchValue(Box::new(SearchValueError::new(field, expected)))
}
pub(crate) fn invalid_write_target(statement: &'static str, source_kind: &'static str) -> Self {
Self::InvalidWriteTarget(Box::new(WriteTargetError::new(statement, source_kind)))
}
pub(crate) fn invalid_cte_shape(name: impl Into<String>, message: &'static str) -> Self {
Self::InvalidCteShape(Box::new(CteShapeError::new(name, message)))
}
fn from_sqlx_database_error(db: &(dyn DatabaseError + 'static)) -> Self {
let pg = db.try_downcast_ref::<PgDatabaseError>();
let code = db
.code()
.map(|code| code.into_owned())
.unwrap_or_else(|| "unknown".to_owned());
let message = db.message().to_owned();
let detail = pg.and_then(PgDatabaseError::detail).map(ToOwned::to_owned);
let hint = pg.and_then(PgDatabaseError::hint).map(ToOwned::to_owned);
let info = DbErrorInfo::from_sqlx_database_error(db);
match code.as_str() {
"23505" => Self::UniqueViolation(Box::new(ConstraintError::new(detail, info))),
"23503" => Self::ForeignKeyViolation(Box::new(ConstraintError::new(detail, info))),
"23001" => Self::RestrictViolation(Box::new(ConstraintError::new(detail, info))),
"23502" => Self::NotNullViolation(Box::new(info)),
"23514" => Self::CheckViolation(Box::new(ConstraintError::new(detail, info))),
"23P01" => Self::ExclusionViolation(Box::new(ConstraintError::new(detail, info))),
"40001" => {
Self::SerializationFailure(Box::new(PgFailure::new(message, detail, hint, info)))
}
"40P01" => {
Self::DeadlockDetected(Box::new(PgFailure::new(message, detail, hint, info)))
}
"55P03" => {
Self::LockNotAvailable(Box::new(PgFailure::new(message, detail, hint, info)))
}
"57014" => Self::QueryCanceled(Box::new(PgFailure::new(message, detail, hint, info))),
"42501" => Self::InsufficientPrivilege(Box::new(
DatabaseFailure::new(code, message)
.detail(detail)
.hint(hint)
.info(info),
)),
_ => Self::Database(Box::new(
DatabaseFailure::new(code, message)
.detail(detail)
.hint(hint)
.info(info),
)),
}
}
pub fn is_retryable(&self) -> bool {
matches!(self.code(), Some("40001" | "40P01"))
}
pub fn is_connection(&self) -> bool {
matches!(self, Self::Connection(_))
}
pub fn code(&self) -> Option<&str> {
match self {
Self::UniqueViolation(_) => Some("23505"),
Self::ForeignKeyViolation(_) => Some("23503"),
Self::RestrictViolation(_) => Some("23001"),
Self::NotNullViolation(_) => Some("23502"),
Self::CheckViolation(_) => Some("23514"),
Self::ExclusionViolation(_) => Some("23P01"),
Self::SerializationFailure(_) => Some("40001"),
Self::DeadlockDetected(_) => Some("40P01"),
Self::LockNotAvailable(_) => Some("55P03"),
Self::InsufficientPrivilege(_) => Some("42501"),
Self::QueryCanceled(_) => Some("57014"),
Self::Database(err) => Some(&err.code),
_ => None,
}
}
pub fn constraint_name(&self) -> Option<&str> {
self.db_error_info()
.and_then(|info| info.constraint.as_deref())
}
pub fn column_name(&self) -> Option<&str> {
self.db_error_info().and_then(|info| info.column.as_deref())
}
pub fn detail(&self) -> Option<&str> {
match self {
Self::UniqueViolation(err)
| Self::ForeignKeyViolation(err)
| Self::RestrictViolation(err)
| Self::CheckViolation(err)
| Self::ExclusionViolation(err) => err.detail.as_deref(),
Self::SerializationFailure(err)
| Self::DeadlockDetected(err)
| Self::LockNotAvailable(err)
| Self::QueryCanceled(err) => err.detail.as_deref(),
Self::InsufficientPrivilege(err) | Self::Database(err) => err.detail.as_deref(),
_ => None,
}
}
pub fn hint(&self) -> Option<&str> {
match self {
Self::SerializationFailure(err)
| Self::DeadlockDetected(err)
| Self::LockNotAvailable(err)
| Self::QueryCanceled(err) => err.hint.as_deref(),
Self::InsufficientPrivilege(err) | Self::Database(err) => err.hint.as_deref(),
_ => None,
}
}
fn db_error_info(&self) -> Option<&DbErrorInfo> {
match self {
Self::UniqueViolation(err)
| Self::ForeignKeyViolation(err)
| Self::RestrictViolation(err)
| Self::CheckViolation(err)
| Self::ExclusionViolation(err) => Some(&err.info),
Self::NotNullViolation(info) => Some(info),
Self::SerializationFailure(err)
| Self::DeadlockDetected(err)
| Self::LockNotAvailable(err)
| Self::QueryCanceled(err) => Some(&err.info),
Self::InsufficientPrivilege(err) | Self::Database(err) => Some(&err.info),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::{ConstraintError, DatabaseFailure, DbErrorInfo, DbErrorPosition, Error, PgFailure};
#[test]
fn error_size_stays_small_for_application_results() {
assert!(std::mem::size_of::<Error>() <= 32);
}
#[test]
fn structured_error_helpers_expose_common_api_boundary_fields() {
let error = Error::Database(Box::new(DatabaseFailure {
code: "23505".to_owned(),
message: "duplicate key value violates unique constraint".to_owned(),
detail: Some("Key (email)=(ada@example.com) already exists.".to_owned()),
hint: Some("Use another email.".to_owned()),
info: DbErrorInfo {
constraint: Some("users_email_key".to_owned()),
table: Some("users".to_owned()),
column: Some("email".to_owned()),
schema: Some("public".to_owned()),
datatype: Some("text".to_owned()),
where_: Some("SQL statement".to_owned()),
position: Some(DbErrorPosition::Original(42)),
..DbErrorInfo::default()
},
}));
assert_eq!(error.code(), Some("23505"));
assert_eq!(error.constraint_name(), Some("users_email_key"));
assert_eq!(error.column_name(), Some("email"));
assert_eq!(
error.detail(),
Some("Key (email)=(ada@example.com) already exists.")
);
assert_eq!(error.hint(), Some("Use another email."));
let Error::Database(err) = error else {
panic!("expected database error");
};
assert_eq!(err.info.table.as_deref(), Some("users"));
assert_eq!(err.info.schema.as_deref(), Some("public"));
assert_eq!(err.info.datatype.as_deref(), Some("text"));
assert_eq!(err.info.where_.as_deref(), Some("SQL statement"));
assert_eq!(err.info.position, Some(DbErrorPosition::Original(42)));
}
#[test]
fn specialized_error_helpers_share_db_error_info() {
let error = Error::UniqueViolation(Box::new(ConstraintError {
detail: None,
info: DbErrorInfo {
constraint: Some("users_email_key".to_owned()),
table: Some("users".to_owned()),
column: Some("email".to_owned()),
..DbErrorInfo::default()
},
}));
assert_eq!(error.code(), Some("23505"));
assert_eq!(error.constraint_name(), Some("users_email_key"));
assert_eq!(error.column_name(), Some("email"));
}
#[test]
fn retryable_errors_exclude_unknown_connection_outcomes() {
assert!(
Error::SerializationFailure(Box::new(PgFailure {
message: "could not serialize access".to_owned(),
detail: None,
hint: None,
info: DbErrorInfo::default(),
}))
.is_retryable()
);
assert!(
Error::DeadlockDetected(Box::new(PgFailure {
message: "deadlock detected".to_owned(),
detail: None,
hint: None,
info: DbErrorInfo::default(),
}))
.is_retryable()
);
assert!(!Error::from(sqlx::Error::PoolClosed).is_retryable());
for code in ["57P01", "57P02", "57P03"] {
assert!(
!Error::Database(Box::new(DatabaseFailure::new(
code,
"transient server failure"
)))
.is_retryable(),
"{code} must not imply safe transaction replay"
);
}
assert!(
!Error::QueryCanceled(Box::new(PgFailure {
message: "canceling statement due to user request".to_owned(),
detail: None,
hint: None,
info: DbErrorInfo::default(),
}))
.is_retryable()
);
assert!(
!Error::NotNullViolation(Box::new(DbErrorInfo {
column: Some("email".to_owned()),
..DbErrorInfo::default()
}))
.is_retryable()
);
}
#[test]
fn lock_not_available_exposes_sqlstate_without_global_retry_policy() {
let error = Error::LockNotAvailable(Box::new(PgFailure {
message: "could not obtain lock on row in relation".to_owned(),
detail: Some("row is already locked".to_owned()),
hint: Some("retry later".to_owned()),
info: DbErrorInfo::default(),
}));
assert_eq!(error.code(), Some("55P03"));
assert_eq!(error.detail(), Some("row is already locked"));
assert_eq!(error.hint(), Some("retry later"));
assert!(!error.is_retryable());
}
#[test]
fn connection_like_sqlx_errors_map_to_connection_variant() {
let pool_timed_out = Error::from(sqlx::Error::PoolTimedOut);
assert!(matches!(pool_timed_out, Error::Connection(_)));
assert!(pool_timed_out.is_connection());
assert!(!pool_timed_out.is_retryable());
let io = Error::from(sqlx::Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"connection reset",
)));
assert!(matches!(io, Error::Connection(_)));
assert!(io.is_connection());
assert!(!io.is_retryable());
assert!(!Error::NotFound.is_connection());
let Error::Connection(source) = io else {
unreachable!()
};
assert!(matches!(*source, sqlx::Error::Io(_)));
}
}