Skip to main content

rqb/
error.rs

1use sqlx::error::DatabaseError;
2use sqlx::postgres::{PgDatabaseError, PgErrorPosition};
3use thiserror::Error;
4
5/// Structured metadata extracted from a Postgres database error.
6#[derive(Clone, Debug, Default, PartialEq, Eq)]
7#[non_exhaustive]
8pub struct DbErrorInfo {
9    /// Schema name reported by Postgres.
10    pub schema: Option<String>,
11    /// Table name reported by Postgres.
12    pub table: Option<String>,
13    /// Column name reported by Postgres.
14    pub column: Option<String>,
15    /// Data type name reported by Postgres.
16    pub datatype: Option<String>,
17    /// Constraint name reported by Postgres.
18    pub constraint: Option<String>,
19    /// Postgres `WHERE` context attached to the error.
20    pub where_: Option<String>,
21    /// Error position in the original or internally rewritten query.
22    pub position: Option<DbErrorPosition>,
23}
24
25/// Position metadata reported by Postgres for a database error.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum DbErrorPosition {
28    /// One-based character position in the original submitted SQL.
29    Original(usize),
30    /// One-based character position in an internally generated query.
31    Internal {
32        /// One-based character position in `query`.
33        position: usize,
34        /// Internal query text reported by Postgres.
35        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/// Payload for Postgres constraint-class errors.
77#[derive(Clone, Debug, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct ConstraintError {
80    /// Postgres detail string.
81    pub detail: Option<String>,
82    /// Additional structured database error metadata.
83    pub info: DbErrorInfo,
84}
85
86/// Payload for Postgres failures without a dedicated constraint or column shape.
87#[derive(Clone, Debug, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct PgFailure {
90    /// Postgres primary error message.
91    pub message: String,
92    /// Postgres detail string.
93    pub detail: Option<String>,
94    /// Postgres hint string.
95    pub hint: Option<String>,
96    /// Additional structured database error metadata.
97    pub info: DbErrorInfo,
98}
99
100/// Payload for mapped or unmapped Postgres database failures.
101#[derive(Clone, Debug, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct DatabaseFailure {
104    /// SQLSTATE error code.
105    pub code: String,
106    /// Database primary error message.
107    pub message: String,
108    /// Database detail string.
109    pub detail: Option<String>,
110    /// Database hint string.
111    pub hint: Option<String>,
112    /// Additional structured database error metadata.
113    pub info: DbErrorInfo,
114}
115
116/// Payload for field/operator validation failures.
117#[derive(Clone, Debug, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct OperatorError {
120    /// Field API name.
121    pub field: String,
122    /// Operator name.
123    pub operator: String,
124}
125
126/// Payload for JSON search value validation failures.
127#[derive(Clone, Debug, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct SearchValueError {
130    /// Field name from the JSON request.
131    pub field: String,
132    /// Human-readable expected value kind.
133    pub expected: &'static str,
134}
135
136/// Payload for write-target validation failures.
137#[derive(Clone, Debug, PartialEq, Eq)]
138#[non_exhaustive]
139pub struct WriteTargetError {
140    /// Statement kind.
141    pub statement: &'static str,
142    /// Source kind that was used as the write target.
143    pub source_kind: &'static str,
144}
145
146/// Payload for CTE shape validation failures.
147#[derive(Clone, Debug, PartialEq, Eq)]
148#[non_exhaustive]
149pub struct CteShapeError {
150    /// CTE name.
151    pub name: String,
152    /// Validation message.
153    pub message: &'static str,
154}
155
156impl ConstraintError {
157    /// Creates a constraint error payload for tests or adapters.
158    pub fn new(detail: Option<String>, info: DbErrorInfo) -> Self {
159        Self { detail, info }
160    }
161}
162
163impl PgFailure {
164    /// Creates a generic Postgres failure payload for tests or adapters.
165    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    /// Creates a database failure payload for tests or adapters.
182    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    /// Sets the Postgres detail string.
193    pub fn detail(mut self, detail: Option<String>) -> Self {
194        self.detail = detail;
195        self
196    }
197
198    /// Sets the Postgres hint string.
199    pub fn hint(mut self, hint: Option<String>) -> Self {
200        self.hint = hint;
201        self
202    }
203
204    /// Sets the reported constraint name.
205    pub fn constraint(mut self, constraint: Option<String>) -> Self {
206        self.info.constraint = constraint;
207        self
208    }
209
210    /// Sets the reported table name.
211    pub fn table(mut self, table: Option<String>) -> Self {
212        self.info.table = table;
213        self
214    }
215
216    /// Sets the reported column name.
217    pub fn column(mut self, column: Option<String>) -> Self {
218        self.info.column = column;
219        self
220    }
221
222    /// Sets structured database error metadata.
223    pub fn info(mut self, info: DbErrorInfo) -> Self {
224        self.info = info;
225        self
226    }
227}
228
229impl OperatorError {
230    /// Creates a field/operator validation payload.
231    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    /// Creates a JSON search value validation payload.
241    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    /// Creates a write-target validation payload.
251    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    /// Creates a CTE shape validation payload.
261    pub fn new(name: impl Into<String>, message: &'static str) -> Self {
262        Self {
263            name: name.into(),
264            message,
265        }
266    }
267}
268
269/// Error type returned by rqb builders and sqlx execution helpers.
270///
271/// Builder/search validation errors are returned before SQL is rendered.
272/// Execution errors preserve sqlx/Postgres detail and map common SQLSTATE
273/// classes to structured variants such as [`Error::UniqueViolation`] and
274/// [`Error::NotNullViolation`]. HTTP adapters typically map search/validation
275/// errors to 400, `NotFound` to 404, constraint conflicts to 409, and other
276/// database failures to 500 unless the application has a narrower policy.
277#[derive(Debug, Error)]
278#[non_exhaustive]
279pub enum Error {
280    /// A modeled write fetch was rejected before execution because RETURNING is absent.
281    #[error("{statement} fetch requires an explicit RETURNING clause")]
282    WriteWithoutReturning {
283        /// Statement kind: INSERT, UPDATE, DELETE, or MERGE.
284        statement: &'static str,
285    },
286
287    /// A `fetch_one`-style operation did not return a row.
288    #[error("query returned no rows")]
289    NotFound,
290
291    /// Postgres unique constraint violation (`23505`).
292    #[error("unique violation{}", constraint_suffix(&.0.info.constraint))]
293    UniqueViolation(Box<ConstraintError>),
294
295    /// Postgres foreign key violation (`23503`).
296    #[error("foreign key violation{}", constraint_suffix(&.0.info.constraint))]
297    ForeignKeyViolation(Box<ConstraintError>),
298
299    /// Postgres restrict violation (`23001`).
300    #[error("restrict violation{}", constraint_suffix(&.0.info.constraint))]
301    RestrictViolation(Box<ConstraintError>),
302
303    /// Postgres not-null violation (`23502`).
304    #[error("not null violation{}", column_suffix(&.0.column))]
305    NotNullViolation(Box<DbErrorInfo>),
306
307    /// Postgres check constraint violation (`23514`).
308    #[error("check violation{}", constraint_suffix(&.0.info.constraint))]
309    CheckViolation(Box<ConstraintError>),
310
311    /// Postgres exclusion constraint violation (`23P01`).
312    #[error("exclusion violation{}", constraint_suffix(&.0.info.constraint))]
313    ExclusionViolation(Box<ConstraintError>),
314
315    /// Postgres serialization failure (`40001`).
316    #[error("serialization failure: {}", .0.message)]
317    SerializationFailure(Box<PgFailure>),
318
319    /// Postgres deadlock detected (`40P01`).
320    #[error("deadlock detected: {}", .0.message)]
321    DeadlockDetected(Box<PgFailure>),
322
323    /// Postgres lock not available (`55P03`).
324    #[error("lock not available: {}", .0.message)]
325    LockNotAvailable(Box<PgFailure>),
326
327    /// Postgres query cancellation (`57014`).
328    #[error("query canceled: {}", .0.message)]
329    QueryCanceled(Box<PgFailure>),
330
331    /// Postgres insufficient privilege error (`42501`).
332    #[error("insufficient privilege: {}", .0.message)]
333    InsufficientPrivilege(Box<DatabaseFailure>),
334
335    /// A database error that is not mapped to a specialized variant.
336    #[error("database error ({}): {}", .0.code, .0.message)]
337    Database(Box<DatabaseFailure>),
338
339    /// Connection-level failure.
340    #[error("connection error: {0}")]
341    Connection(#[source] Box<sqlx::Error>),
342
343    /// Unclassified sqlx error.
344    #[error("sqlx error: {0}")]
345    Sqlx(Box<sqlx::Error>),
346
347    /// A transaction body failed and rollback also failed.
348    #[error("transaction rollback failed after error: {error}; rollback error: {rollback}")]
349    TransactionRollbackFailed {
350        /// Original transaction body error.
351        error: Box<Error>,
352        /// Rollback error.
353        rollback: Box<Error>,
354    },
355
356    /// Failed to encode a bound parameter into Postgres arguments.
357    #[error("parameter encode error: {0}")]
358    Encode(String),
359
360    /// Raw SQL placeholder count does not match supplied bind values.
361    #[error("raw SQL fragment has {placeholders} placeholders but {binds} bind values")]
362    RawBindMismatch {
363        /// Number of `?` placeholders in the raw SQL fragment.
364        placeholders: usize,
365        /// Number of bind values supplied for the fragment.
366        binds: usize,
367    },
368
369    /// A field was used with an operator it does not support.
370    #[error("operator `{}` is not supported for field `{}`", .0.operator, .0.field)]
371    InvalidOperator(Box<OperatorError>),
372
373    /// An aggregate-local modifier was applied to a non-aggregate expression.
374    #[error("aggregate modifier `{modifier}` can only be applied to aggregate expressions")]
375    InvalidAggregateModifier {
376        /// Modifier method name.
377        modifier: &'static str,
378    },
379
380    /// A field without ordering support was used for sorting.
381    #[error("field `{field}` is not sortable")]
382    InvalidSort {
383        /// Field API name.
384        field: String,
385    },
386
387    /// JSON search referenced a field that is not in the source metadata.
388    #[error("unknown search field `{field}`")]
389    InvalidSearchField {
390        /// Field name from the JSON request.
391        field: String,
392    },
393
394    /// JSON search referenced a field that is not exposed to JSON requests.
395    #[error("search field `{field}` is not exposed to JSON requests")]
396    SearchFieldNotExposed {
397        /// Field name from the JSON request.
398        field: String,
399    },
400
401    /// JSON search used an unsupported operator for a field.
402    #[error("operator `{}` is not supported for search field `{}`", .0.operator, .0.field)]
403    InvalidSearchOperator(Box<OperatorError>),
404
405    /// JSON search value did not match the field's expected JSON shape.
406    #[error("invalid JSON value for search field `{}`; expected {}", .0.field, .0.expected)]
407    InvalidSearchValue(Box<SearchValueError>),
408
409    /// JSON search used an empty `and` or `or` group.
410    #[error("empty search logical expression `{logical}`")]
411    EmptySearchLogical {
412        /// Logical operator name.
413        logical: &'static str,
414    },
415
416    /// Builder used an empty `AND` or `OR` group.
417    #[error("empty logical expression `{logical}`")]
418    EmptyLogical {
419        /// Logical operator name.
420        logical: &'static str,
421    },
422
423    /// A row-value comparison used row expressions with different arity.
424    #[error("row comparison requires the same arity, got {left} and {right}")]
425    InvalidRowShape {
426        /// Number of values in the left row expression.
427        left: usize,
428        /// Number of values in the right row expression.
429        right: usize,
430    },
431
432    /// A write statement targeted a source kind that cannot be written to.
433    #[error("{} target must be a table or view source, got {}", .0.statement, .0.source_kind)]
434    InvalidWriteTarget(Box<WriteTargetError>),
435
436    /// A write statement had no assignments.
437    #[error("{statement} statement requires at least one assignment")]
438    EmptyAssignments {
439        /// Statement kind.
440        statement: &'static str,
441    },
442
443    /// A write statement had no target columns.
444    #[error("{statement} statement requires at least one column")]
445    EmptyColumns {
446        /// Statement kind.
447        statement: &'static str,
448    },
449
450    /// Insert builder state was not valid.
451    #[error("invalid insert shape: {message}")]
452    InvalidInsertShape {
453        /// Validation message.
454        message: &'static str,
455    },
456
457    /// Select builder state was not valid.
458    #[error("invalid select shape: {message}")]
459    InvalidSelectShape {
460        /// Validation message.
461        message: &'static str,
462    },
463
464    /// Merge builder state was not valid.
465    #[error("invalid merge shape: {message}")]
466    InvalidMergeShape {
467        /// Validation message.
468        message: &'static str,
469    },
470
471    /// CTE definition or exposed field list was not valid.
472    #[error("invalid CTE `{}`: {}", .0.name, .0.message)]
473    InvalidCteShape(Box<CteShapeError>),
474
475    /// A delete statement was built without a filter.
476    #[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    /// Returns true for serialization failures and deadlocks.
577    /// Retry the whole transaction, including its decisions, with bounded backoff.
578    /// Connection failures are excluded: a lost commit response can have an unknown outcome.
579    pub fn is_retryable(&self) -> bool {
580        matches!(self.code(), Some("40001" | "40P01"))
581    }
582
583    /// Returns true for connection-level failures.
584    /// Classification only; it does not establish whether replaying an operation is safe.
585    pub fn is_connection(&self) -> bool {
586        matches!(self, Self::Connection(_))
587    }
588
589    /// Returns the SQLSTATE code when this error maps to one.
590    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    /// Returns the associated constraint name when available.
609    pub fn constraint_name(&self) -> Option<&str> {
610        self.db_error_info()
611            .and_then(|info| info.constraint.as_deref())
612    }
613
614    /// Returns the associated column name when available.
615    pub fn column_name(&self) -> Option<&str> {
616        self.db_error_info().and_then(|info| info.column.as_deref())
617    }
618
619    /// Returns the database detail message when available.
620    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    /// Returns the database hint message when available.
637    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}