1use sqlx::error::DatabaseError;
2use sqlx::postgres::{PgDatabaseError, PgErrorPosition};
3use thiserror::Error;
4
5#[derive(Clone, Debug, Default, PartialEq, Eq)]
7#[non_exhaustive]
8pub struct DbErrorInfo {
9 pub schema: Option<String>,
11 pub table: Option<String>,
13 pub column: Option<String>,
15 pub datatype: Option<String>,
17 pub constraint: Option<String>,
19 pub where_: Option<String>,
21 pub position: Option<DbErrorPosition>,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum DbErrorPosition {
28 Original(usize),
30 Internal {
32 position: usize,
34 query: String,
36 },
37}
38
39impl DbErrorInfo {
40 fn from_sqlx_database_error(db: &(dyn DatabaseError + 'static)) -> Self {
41 if let Some(pg) = db.try_downcast_ref::<PgDatabaseError>() {
42 return Self::from_sqlx_pg_error(pg);
43 }
44 Self {
45 table: db.table().map(ToOwned::to_owned),
46 constraint: db.constraint().map(ToOwned::to_owned),
47 ..Self::default()
48 }
49 }
50
51 fn from_sqlx_pg_error(db: &PgDatabaseError) -> Self {
52 Self {
53 schema: db.schema().map(ToOwned::to_owned),
54 table: db.table().map(ToOwned::to_owned),
55 column: db.column().map(ToOwned::to_owned),
56 datatype: db.data_type().map(ToOwned::to_owned),
57 constraint: db.constraint().map(ToOwned::to_owned),
58 where_: db.r#where().map(ToOwned::to_owned),
59 position: db.position().map(DbErrorPosition::from),
60 }
61 }
62}
63
64impl From<PgErrorPosition<'_>> for DbErrorPosition {
65 fn from(position: PgErrorPosition<'_>) -> Self {
66 match position {
67 PgErrorPosition::Original(position) => Self::Original(position),
68 PgErrorPosition::Internal { position, query } => Self::Internal {
69 position,
70 query: query.to_owned(),
71 },
72 }
73 }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct ConstraintError {
80 pub detail: Option<String>,
82 pub info: DbErrorInfo,
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct PgFailure {
90 pub message: String,
92 pub detail: Option<String>,
94 pub hint: Option<String>,
96 pub info: DbErrorInfo,
98}
99
100#[derive(Clone, Debug, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct DatabaseFailure {
104 pub code: String,
106 pub message: String,
108 pub detail: Option<String>,
110 pub hint: Option<String>,
112 pub info: DbErrorInfo,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct OperatorError {
120 pub field: String,
122 pub operator: String,
124}
125
126#[derive(Clone, Debug, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct SearchValueError {
130 pub field: String,
132 pub expected: &'static str,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
138#[non_exhaustive]
139pub struct WriteTargetError {
140 pub statement: &'static str,
142 pub source_kind: &'static str,
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
148#[non_exhaustive]
149pub struct CteShapeError {
150 pub name: String,
152 pub message: &'static str,
154}
155
156impl ConstraintError {
157 pub fn new(detail: Option<String>, info: DbErrorInfo) -> Self {
159 Self { detail, info }
160 }
161}
162
163impl PgFailure {
164 pub fn new(
166 message: impl Into<String>,
167 detail: Option<String>,
168 hint: Option<String>,
169 info: DbErrorInfo,
170 ) -> Self {
171 Self {
172 message: message.into(),
173 detail,
174 hint,
175 info,
176 }
177 }
178}
179
180impl DatabaseFailure {
181 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
183 Self {
184 code: code.into(),
185 message: message.into(),
186 detail: None,
187 hint: None,
188 info: DbErrorInfo::default(),
189 }
190 }
191
192 pub fn detail(mut self, detail: Option<String>) -> Self {
194 self.detail = detail;
195 self
196 }
197
198 pub fn hint(mut self, hint: Option<String>) -> Self {
200 self.hint = hint;
201 self
202 }
203
204 pub fn constraint(mut self, constraint: Option<String>) -> Self {
206 self.info.constraint = constraint;
207 self
208 }
209
210 pub fn table(mut self, table: Option<String>) -> Self {
212 self.info.table = table;
213 self
214 }
215
216 pub fn column(mut self, column: Option<String>) -> Self {
218 self.info.column = column;
219 self
220 }
221
222 pub fn info(mut self, info: DbErrorInfo) -> Self {
224 self.info = info;
225 self
226 }
227}
228
229impl OperatorError {
230 pub fn new(field: impl Into<String>, operator: impl Into<String>) -> Self {
232 Self {
233 field: field.into(),
234 operator: operator.into(),
235 }
236 }
237}
238
239impl SearchValueError {
240 pub fn new(field: impl Into<String>, expected: &'static str) -> Self {
242 Self {
243 field: field.into(),
244 expected,
245 }
246 }
247}
248
249impl WriteTargetError {
250 pub fn new(statement: &'static str, source_kind: &'static str) -> Self {
252 Self {
253 statement,
254 source_kind,
255 }
256 }
257}
258
259impl CteShapeError {
260 pub fn new(name: impl Into<String>, message: &'static str) -> Self {
262 Self {
263 name: name.into(),
264 message,
265 }
266 }
267}
268
269#[derive(Debug, Error)]
278#[non_exhaustive]
279pub enum Error {
280 #[error("{statement} fetch requires an explicit RETURNING clause")]
282 WriteWithoutReturning {
283 statement: &'static str,
285 },
286
287 #[error("query returned no rows")]
289 NotFound,
290
291 #[error("unique violation{}", constraint_suffix(&.0.info.constraint))]
293 UniqueViolation(Box<ConstraintError>),
294
295 #[error("foreign key violation{}", constraint_suffix(&.0.info.constraint))]
297 ForeignKeyViolation(Box<ConstraintError>),
298
299 #[error("restrict violation{}", constraint_suffix(&.0.info.constraint))]
301 RestrictViolation(Box<ConstraintError>),
302
303 #[error("not null violation{}", column_suffix(&.0.column))]
305 NotNullViolation(Box<DbErrorInfo>),
306
307 #[error("check violation{}", constraint_suffix(&.0.info.constraint))]
309 CheckViolation(Box<ConstraintError>),
310
311 #[error("exclusion violation{}", constraint_suffix(&.0.info.constraint))]
313 ExclusionViolation(Box<ConstraintError>),
314
315 #[error("serialization failure: {}", .0.message)]
317 SerializationFailure(Box<PgFailure>),
318
319 #[error("deadlock detected: {}", .0.message)]
321 DeadlockDetected(Box<PgFailure>),
322
323 #[error("lock not available: {}", .0.message)]
325 LockNotAvailable(Box<PgFailure>),
326
327 #[error("query canceled: {}", .0.message)]
329 QueryCanceled(Box<PgFailure>),
330
331 #[error("insufficient privilege: {}", .0.message)]
333 InsufficientPrivilege(Box<DatabaseFailure>),
334
335 #[error("database error ({}): {}", .0.code, .0.message)]
337 Database(Box<DatabaseFailure>),
338
339 #[error("connection error: {0}")]
341 Connection(#[source] Box<sqlx::Error>),
342
343 #[error("sqlx error: {0}")]
345 Sqlx(Box<sqlx::Error>),
346
347 #[error("transaction rollback failed after error: {error}; rollback error: {rollback}")]
349 TransactionRollbackFailed {
350 error: Box<Error>,
352 rollback: Box<Error>,
354 },
355
356 #[error("parameter encode error: {0}")]
358 Encode(String),
359
360 #[error("raw SQL fragment has {placeholders} placeholders but {binds} bind values")]
362 RawBindMismatch {
363 placeholders: usize,
365 binds: usize,
367 },
368
369 #[error("operator `{}` is not supported for field `{}`", .0.operator, .0.field)]
371 InvalidOperator(Box<OperatorError>),
372
373 #[error("aggregate modifier `{modifier}` can only be applied to aggregate expressions")]
375 InvalidAggregateModifier {
376 modifier: &'static str,
378 },
379
380 #[error("field `{field}` is not sortable")]
382 InvalidSort {
383 field: String,
385 },
386
387 #[error("unknown search field `{field}`")]
389 InvalidSearchField {
390 field: String,
392 },
393
394 #[error("search field `{field}` is not exposed to JSON requests")]
396 SearchFieldNotExposed {
397 field: String,
399 },
400
401 #[error("operator `{}` is not supported for search field `{}`", .0.operator, .0.field)]
403 InvalidSearchOperator(Box<OperatorError>),
404
405 #[error("invalid JSON value for search field `{}`; expected {}", .0.field, .0.expected)]
407 InvalidSearchValue(Box<SearchValueError>),
408
409 #[error("empty search logical expression `{logical}`")]
411 EmptySearchLogical {
412 logical: &'static str,
414 },
415
416 #[error("empty logical expression `{logical}`")]
418 EmptyLogical {
419 logical: &'static str,
421 },
422
423 #[error("row comparison requires the same arity, got {left} and {right}")]
425 InvalidRowShape {
426 left: usize,
428 right: usize,
430 },
431
432 #[error("{} target must be a table or view source, got {}", .0.statement, .0.source_kind)]
434 InvalidWriteTarget(Box<WriteTargetError>),
435
436 #[error("{statement} statement requires at least one assignment")]
438 EmptyAssignments {
439 statement: &'static str,
441 },
442
443 #[error("{statement} statement requires at least one column")]
445 EmptyColumns {
446 statement: &'static str,
448 },
449
450 #[error("invalid insert shape: {message}")]
452 InvalidInsertShape {
453 message: &'static str,
455 },
456
457 #[error("invalid select shape: {message}")]
459 InvalidSelectShape {
460 message: &'static str,
462 },
463
464 #[error("invalid merge shape: {message}")]
466 InvalidMergeShape {
467 message: &'static str,
469 },
470
471 #[error("invalid CTE `{}`: {}", .0.name, .0.message)]
473 InvalidCteShape(Box<CteShapeError>),
474
475 #[error("delete without filter is not allowed")]
477 DeleteWithoutFilter,
478}
479
480fn constraint_suffix(constraint: &Option<String>) -> String {
481 constraint
482 .as_ref()
483 .map(|name| format!(" on constraint \"{name}\""))
484 .unwrap_or_default()
485}
486
487fn column_suffix(column: &Option<String>) -> String {
488 column
489 .as_ref()
490 .map(|name| format!(" on column \"{name}\""))
491 .unwrap_or_default()
492}
493
494impl From<sqlx::Error> for Error {
495 fn from(error: sqlx::Error) -> Self {
496 match error {
497 sqlx::Error::RowNotFound => Self::NotFound,
498 sqlx::Error::Database(db) => Self::from_sqlx_database_error(&*db),
499 error @ (sqlx::Error::Io(_)
500 | sqlx::Error::Tls(_)
501 | sqlx::Error::PoolTimedOut
502 | sqlx::Error::PoolClosed
503 | sqlx::Error::WorkerCrashed) => Self::Connection(Box::new(error)),
504 other => Self::Sqlx(Box::new(other)),
505 }
506 }
507}
508
509impl Error {
510 pub(crate) fn invalid_operator(field: impl Into<String>, operator: impl Into<String>) -> Self {
511 Self::InvalidOperator(Box::new(OperatorError::new(field, operator)))
512 }
513
514 pub(crate) fn invalid_search_operator(
515 field: impl Into<String>,
516 operator: impl Into<String>,
517 ) -> Self {
518 Self::InvalidSearchOperator(Box::new(OperatorError::new(field, operator)))
519 }
520
521 pub(crate) fn invalid_search_value(field: impl Into<String>, expected: &'static str) -> Self {
522 Self::InvalidSearchValue(Box::new(SearchValueError::new(field, expected)))
523 }
524
525 pub(crate) fn invalid_write_target(statement: &'static str, source_kind: &'static str) -> Self {
526 Self::InvalidWriteTarget(Box::new(WriteTargetError::new(statement, source_kind)))
527 }
528
529 pub(crate) fn invalid_cte_shape(name: impl Into<String>, message: &'static str) -> Self {
530 Self::InvalidCteShape(Box::new(CteShapeError::new(name, message)))
531 }
532
533 fn from_sqlx_database_error(db: &(dyn DatabaseError + 'static)) -> Self {
534 let pg = db.try_downcast_ref::<PgDatabaseError>();
535 let code = db
536 .code()
537 .map(|code| code.into_owned())
538 .unwrap_or_else(|| "unknown".to_owned());
539 let message = db.message().to_owned();
540 let detail = pg.and_then(PgDatabaseError::detail).map(ToOwned::to_owned);
541 let hint = pg.and_then(PgDatabaseError::hint).map(ToOwned::to_owned);
542 let info = DbErrorInfo::from_sqlx_database_error(db);
543
544 match code.as_str() {
545 "23505" => Self::UniqueViolation(Box::new(ConstraintError::new(detail, info))),
546 "23503" => Self::ForeignKeyViolation(Box::new(ConstraintError::new(detail, info))),
547 "23001" => Self::RestrictViolation(Box::new(ConstraintError::new(detail, info))),
548 "23502" => Self::NotNullViolation(Box::new(info)),
549 "23514" => Self::CheckViolation(Box::new(ConstraintError::new(detail, info))),
550 "23P01" => Self::ExclusionViolation(Box::new(ConstraintError::new(detail, info))),
551 "40001" => {
552 Self::SerializationFailure(Box::new(PgFailure::new(message, detail, hint, info)))
553 }
554 "40P01" => {
555 Self::DeadlockDetected(Box::new(PgFailure::new(message, detail, hint, info)))
556 }
557 "55P03" => {
558 Self::LockNotAvailable(Box::new(PgFailure::new(message, detail, hint, info)))
559 }
560 "57014" => Self::QueryCanceled(Box::new(PgFailure::new(message, detail, hint, info))),
561 "42501" => Self::InsufficientPrivilege(Box::new(
562 DatabaseFailure::new(code, message)
563 .detail(detail)
564 .hint(hint)
565 .info(info),
566 )),
567 _ => Self::Database(Box::new(
568 DatabaseFailure::new(code, message)
569 .detail(detail)
570 .hint(hint)
571 .info(info),
572 )),
573 }
574 }
575
576 pub fn is_retryable(&self) -> bool {
580 matches!(self.code(), Some("40001" | "40P01"))
581 }
582
583 pub fn is_connection(&self) -> bool {
586 matches!(self, Self::Connection(_))
587 }
588
589 pub fn code(&self) -> Option<&str> {
591 match self {
592 Self::UniqueViolation(_) => Some("23505"),
593 Self::ForeignKeyViolation(_) => Some("23503"),
594 Self::RestrictViolation(_) => Some("23001"),
595 Self::NotNullViolation(_) => Some("23502"),
596 Self::CheckViolation(_) => Some("23514"),
597 Self::ExclusionViolation(_) => Some("23P01"),
598 Self::SerializationFailure(_) => Some("40001"),
599 Self::DeadlockDetected(_) => Some("40P01"),
600 Self::LockNotAvailable(_) => Some("55P03"),
601 Self::InsufficientPrivilege(_) => Some("42501"),
602 Self::QueryCanceled(_) => Some("57014"),
603 Self::Database(err) => Some(&err.code),
604 _ => None,
605 }
606 }
607
608 pub fn constraint_name(&self) -> Option<&str> {
610 self.db_error_info()
611 .and_then(|info| info.constraint.as_deref())
612 }
613
614 pub fn column_name(&self) -> Option<&str> {
616 self.db_error_info().and_then(|info| info.column.as_deref())
617 }
618
619 pub fn detail(&self) -> Option<&str> {
621 match self {
622 Self::UniqueViolation(err)
623 | Self::ForeignKeyViolation(err)
624 | Self::RestrictViolation(err)
625 | Self::CheckViolation(err)
626 | Self::ExclusionViolation(err) => err.detail.as_deref(),
627 Self::SerializationFailure(err)
628 | Self::DeadlockDetected(err)
629 | Self::LockNotAvailable(err)
630 | Self::QueryCanceled(err) => err.detail.as_deref(),
631 Self::InsufficientPrivilege(err) | Self::Database(err) => err.detail.as_deref(),
632 _ => None,
633 }
634 }
635
636 pub fn hint(&self) -> Option<&str> {
638 match self {
639 Self::SerializationFailure(err)
640 | Self::DeadlockDetected(err)
641 | Self::LockNotAvailable(err)
642 | Self::QueryCanceled(err) => err.hint.as_deref(),
643 Self::InsufficientPrivilege(err) | Self::Database(err) => err.hint.as_deref(),
644 _ => None,
645 }
646 }
647
648 fn db_error_info(&self) -> Option<&DbErrorInfo> {
649 match self {
650 Self::UniqueViolation(err)
651 | Self::ForeignKeyViolation(err)
652 | Self::RestrictViolation(err)
653 | Self::CheckViolation(err)
654 | Self::ExclusionViolation(err) => Some(&err.info),
655 Self::NotNullViolation(info) => Some(info),
656 Self::SerializationFailure(err)
657 | Self::DeadlockDetected(err)
658 | Self::LockNotAvailable(err)
659 | Self::QueryCanceled(err) => Some(&err.info),
660 Self::InsufficientPrivilege(err) | Self::Database(err) => Some(&err.info),
661 _ => None,
662 }
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::{ConstraintError, DatabaseFailure, DbErrorInfo, DbErrorPosition, Error, PgFailure};
669
670 #[test]
671 fn error_size_stays_small_for_application_results() {
672 assert!(std::mem::size_of::<Error>() <= 32);
673 }
674
675 #[test]
676 fn structured_error_helpers_expose_common_api_boundary_fields() {
677 let error = Error::Database(Box::new(DatabaseFailure {
678 code: "23505".to_owned(),
679 message: "duplicate key value violates unique constraint".to_owned(),
680 detail: Some("Key (email)=(ada@example.com) already exists.".to_owned()),
681 hint: Some("Use another email.".to_owned()),
682 info: DbErrorInfo {
683 constraint: Some("users_email_key".to_owned()),
684 table: Some("users".to_owned()),
685 column: Some("email".to_owned()),
686 schema: Some("public".to_owned()),
687 datatype: Some("text".to_owned()),
688 where_: Some("SQL statement".to_owned()),
689 position: Some(DbErrorPosition::Original(42)),
690 ..DbErrorInfo::default()
691 },
692 }));
693
694 assert_eq!(error.code(), Some("23505"));
695 assert_eq!(error.constraint_name(), Some("users_email_key"));
696 assert_eq!(error.column_name(), Some("email"));
697 assert_eq!(
698 error.detail(),
699 Some("Key (email)=(ada@example.com) already exists.")
700 );
701 assert_eq!(error.hint(), Some("Use another email."));
702
703 let Error::Database(err) = error else {
704 panic!("expected database error");
705 };
706 assert_eq!(err.info.table.as_deref(), Some("users"));
707 assert_eq!(err.info.schema.as_deref(), Some("public"));
708 assert_eq!(err.info.datatype.as_deref(), Some("text"));
709 assert_eq!(err.info.where_.as_deref(), Some("SQL statement"));
710 assert_eq!(err.info.position, Some(DbErrorPosition::Original(42)));
711 }
712
713 #[test]
714 fn specialized_error_helpers_share_db_error_info() {
715 let error = Error::UniqueViolation(Box::new(ConstraintError {
716 detail: None,
717 info: DbErrorInfo {
718 constraint: Some("users_email_key".to_owned()),
719 table: Some("users".to_owned()),
720 column: Some("email".to_owned()),
721 ..DbErrorInfo::default()
722 },
723 }));
724
725 assert_eq!(error.code(), Some("23505"));
726 assert_eq!(error.constraint_name(), Some("users_email_key"));
727 assert_eq!(error.column_name(), Some("email"));
728 }
729
730 #[test]
731 fn retryable_errors_exclude_unknown_connection_outcomes() {
732 assert!(
733 Error::SerializationFailure(Box::new(PgFailure {
734 message: "could not serialize access".to_owned(),
735 detail: None,
736 hint: None,
737 info: DbErrorInfo::default(),
738 }))
739 .is_retryable()
740 );
741 assert!(
742 Error::DeadlockDetected(Box::new(PgFailure {
743 message: "deadlock detected".to_owned(),
744 detail: None,
745 hint: None,
746 info: DbErrorInfo::default(),
747 }))
748 .is_retryable()
749 );
750 assert!(!Error::from(sqlx::Error::PoolClosed).is_retryable());
751 for code in ["57P01", "57P02", "57P03"] {
752 assert!(
753 !Error::Database(Box::new(DatabaseFailure::new(
754 code,
755 "transient server failure"
756 )))
757 .is_retryable(),
758 "{code} must not imply safe transaction replay"
759 );
760 }
761 assert!(
762 !Error::QueryCanceled(Box::new(PgFailure {
763 message: "canceling statement due to user request".to_owned(),
764 detail: None,
765 hint: None,
766 info: DbErrorInfo::default(),
767 }))
768 .is_retryable()
769 );
770 assert!(
771 !Error::NotNullViolation(Box::new(DbErrorInfo {
772 column: Some("email".to_owned()),
773 ..DbErrorInfo::default()
774 }))
775 .is_retryable()
776 );
777 }
778
779 #[test]
780 fn lock_not_available_exposes_sqlstate_without_global_retry_policy() {
781 let error = Error::LockNotAvailable(Box::new(PgFailure {
782 message: "could not obtain lock on row in relation".to_owned(),
783 detail: Some("row is already locked".to_owned()),
784 hint: Some("retry later".to_owned()),
785 info: DbErrorInfo::default(),
786 }));
787
788 assert_eq!(error.code(), Some("55P03"));
789 assert_eq!(error.detail(), Some("row is already locked"));
790 assert_eq!(error.hint(), Some("retry later"));
791 assert!(!error.is_retryable());
792 }
793
794 #[test]
795 fn connection_like_sqlx_errors_map_to_connection_variant() {
796 let pool_timed_out = Error::from(sqlx::Error::PoolTimedOut);
797 assert!(matches!(pool_timed_out, Error::Connection(_)));
798 assert!(pool_timed_out.is_connection());
799 assert!(!pool_timed_out.is_retryable());
800
801 let io = Error::from(sqlx::Error::Io(std::io::Error::new(
802 std::io::ErrorKind::ConnectionReset,
803 "connection reset",
804 )));
805 assert!(matches!(io, Error::Connection(_)));
806 assert!(io.is_connection());
807 assert!(!io.is_retryable());
808 assert!(!Error::NotFound.is_connection());
809 let Error::Connection(source) = io else {
810 unreachable!()
811 };
812 assert!(matches!(*source, sqlx::Error::Io(_)));
813 }
814}