Skip to main content

prax_sqlx/
error.rs

1//! Error types for SQLx operations.
2
3use prax_query::QueryError;
4use thiserror::Error;
5
6/// Result type alias for SQLx operations.
7pub type SqlxResult<T> = Result<T, SqlxError>;
8
9/// Errors that can occur during SQLx operations.
10#[derive(Error, Debug)]
11pub enum SqlxError {
12    /// SQLx database error
13    #[error("Database error: {0}")]
14    Sqlx(#[from] sqlx::Error),
15
16    /// Configuration error
17    #[error("Configuration error: {0}")]
18    Config(String),
19
20    /// Connection error
21    #[error("Connection error: {0}")]
22    Connection(String),
23
24    /// Query execution error
25    #[error("Query error: {0}")]
26    Query(String),
27
28    /// Row deserialization error
29    #[error("Deserialization error: {0}")]
30    Deserialization(String),
31
32    /// Type conversion error
33    #[error("Type conversion error: {0}")]
34    TypeConversion(String),
35
36    /// Pool error
37    #[error("Pool error: {0}")]
38    Pool(String),
39
40    /// Timeout error
41    #[error("Operation timed out after {0}ms")]
42    Timeout(u64),
43
44    /// Migration error
45    #[error("Migration error: {0}")]
46    Migration(String),
47
48    /// Internal error
49    #[error("Internal error: {0}")]
50    Internal(String),
51}
52
53impl From<SqlxError> for QueryError {
54    fn from(err: SqlxError) -> Self {
55        match err {
56            SqlxError::Sqlx(e) => match &e {
57                // Transport/pool failures are connection problems even though
58                // their messages ("error communicating with database") never
59                // contain the word "connection".
60                sqlx::Error::Io(_) | sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => {
61                    QueryError::connection(e.to_string())
62                }
63                sqlx::Error::RowNotFound => QueryError::not_found("row"),
64                // Classify database errors by SQLSTATE code rather than by
65                // substring-matching the message (mirrors prax-postgres).
66                sqlx::Error::Database(db_err) => match db_err.code().as_deref() {
67                    // unique / foreign key / check violation
68                    Some("23505") | Some("23503") | Some("23514") => {
69                        QueryError::constraint_violation("", e.to_string())
70                    }
71                    // not-null violation
72                    Some("23502") => QueryError::invalid_input("", e.to_string()),
73                    // Stale server-side prepared plan after DDL — transient,
74                    // retryable. 0A000 is the shared FEATURE_NOT_SUPPORTED
75                    // class, so gate on the specific cached-plan message from
76                    // the DbError (not the outer Display); other 0A000
77                    // conditions stay terminal.
78                    Some("0A000")
79                        if db_err
80                            .message()
81                            .contains("cached plan must not change result type") =>
82                    {
83                        QueryError::stale_plan(e.to_string())
84                    }
85                    _ => QueryError::database(e.to_string()),
86                },
87                _ => QueryError::database(e.to_string()),
88            },
89            SqlxError::Config(msg) => QueryError::connection(msg),
90            SqlxError::Connection(msg) => QueryError::connection(msg),
91            SqlxError::Query(msg) => QueryError::database(msg),
92            SqlxError::Deserialization(msg) => QueryError::serialization(msg),
93            SqlxError::TypeConversion(msg) => QueryError::serialization(msg),
94            SqlxError::Pool(msg) => QueryError::connection(msg),
95            SqlxError::Timeout(ms) => QueryError::timeout(ms),
96            SqlxError::Migration(msg) => QueryError::database(msg),
97            SqlxError::Internal(msg) => QueryError::internal(msg),
98        }
99    }
100}
101
102impl SqlxError {
103    /// Create a configuration error.
104    pub fn config(msg: impl Into<String>) -> Self {
105        Self::Config(msg.into())
106    }
107
108    /// Create a connection error.
109    pub fn connection(msg: impl Into<String>) -> Self {
110        Self::Connection(msg.into())
111    }
112
113    /// Create a query error.
114    pub fn query(msg: impl Into<String>) -> Self {
115        Self::Query(msg.into())
116    }
117
118    /// Create a pool error.
119    pub fn pool(msg: impl Into<String>) -> Self {
120        Self::Pool(msg.into())
121    }
122
123    /// Create a timeout error.
124    pub fn timeout(ms: u64) -> Self {
125        Self::Timeout(ms)
126    }
127
128    /// Create a type conversion error.
129    pub fn type_conversion(msg: impl Into<String>) -> Self {
130        Self::TypeConversion(msg.into())
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_error_creation() {
140        let err = SqlxError::config("test config error");
141        assert!(matches!(err, SqlxError::Config(_)));
142
143        let err = SqlxError::connection("test connection error");
144        assert!(matches!(err, SqlxError::Connection(_)));
145
146        let err = SqlxError::timeout(5000);
147        assert!(matches!(err, SqlxError::Timeout(5000)));
148    }
149
150    #[test]
151    fn test_error_to_query_error() {
152        let err = SqlxError::connection("connection failed");
153        let query_err: QueryError = err.into();
154        assert!(query_err.to_string().contains("connection"));
155
156        let err = SqlxError::timeout(1000);
157        let query_err: QueryError = err.into();
158        assert!(
159            query_err.to_string().contains("timeout") || query_err.to_string().contains("1000")
160        );
161    }
162
163    /// Minimal `sqlx::error::DatabaseError` implementation so SQLSTATE-code
164    /// classification can be exercised without a live database.
165    #[derive(Debug)]
166    struct MockDbError {
167        message: String,
168        code: Option<String>,
169    }
170
171    impl std::fmt::Display for MockDbError {
172        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173            f.write_str(&self.message)
174        }
175    }
176
177    impl std::error::Error for MockDbError {}
178
179    impl sqlx::error::DatabaseError for MockDbError {
180        fn message(&self) -> &str {
181            &self.message
182        }
183
184        fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
185            self.code.as_deref().map(std::borrow::Cow::Borrowed)
186        }
187
188        fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
189            self
190        }
191
192        fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
193            self
194        }
195
196        fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
197            self
198        }
199
200        fn kind(&self) -> sqlx::error::ErrorKind {
201            sqlx::error::ErrorKind::Other
202        }
203    }
204
205    #[test]
206    fn test_sqlx_database_error_code_mapping() {
207        // SQLSTATE 23505 (unique violation) classifies by code, not message.
208        let db_err = MockDbError {
209            message: "db says no".to_string(),
210            code: Some("23505".to_string()),
211        };
212        let err = SqlxError::from(sqlx::Error::Database(Box::new(db_err)));
213        let query_err: QueryError = err.into();
214        assert_eq!(query_err.code, prax_query::ErrorCode::UniqueConstraint);
215
216        // SQLSTATE 23502 (not-null violation) maps to invalid input.
217        let db_err = MockDbError {
218            message: "db says no".to_string(),
219            code: Some("23502".to_string()),
220        };
221        let err = SqlxError::from(sqlx::Error::Database(Box::new(db_err)));
222        let query_err: QueryError = err.into();
223        assert_eq!(query_err.code, prax_query::ErrorCode::InvalidParameter);
224
225        // An unrecognised SQLSTATE falls back to a generic database error.
226        let db_err = MockDbError {
227            message: "deadlock detected".to_string(),
228            code: Some("40P01".to_string()),
229        };
230        let err = SqlxError::from(sqlx::Error::Database(Box::new(db_err)));
231        let query_err: QueryError = err.into();
232        assert_eq!(query_err.code, prax_query::ErrorCode::DatabaseError);
233    }
234
235    #[test]
236    fn test_sqlx_check_violation_maps_to_constraint() {
237        // SQLSTATE 23514 (check violation) classifies as a constraint error.
238        let db_err = MockDbError {
239            message: "new row violates check constraint".to_string(),
240            code: Some("23514".to_string()),
241        };
242        let err = SqlxError::from(sqlx::Error::Database(Box::new(db_err)));
243        let query_err: QueryError = err.into();
244        assert!(query_err.is_constraint_violation());
245    }
246
247    #[test]
248    fn test_sqlx_stale_cached_plan_is_retryable() {
249        // 0A000 with the cached-plan message is transient and retryable.
250        let db_err = MockDbError {
251            message: "cached plan must not change result type".to_string(),
252            code: Some("0A000".to_string()),
253        };
254        let err = SqlxError::from(sqlx::Error::Database(Box::new(db_err)));
255        let query_err: QueryError = err.into();
256        assert_eq!(query_err.code, prax_query::ErrorCode::SerializationFailure);
257        assert!(query_err.is_retryable());
258    }
259
260    #[test]
261    fn test_sqlx_other_0a000_stays_generic() {
262        // A genuine "feature not supported" 0A000 is terminal.
263        let db_err = MockDbError {
264            message: "cannot insert into a view".to_string(),
265            code: Some("0A000".to_string()),
266        };
267        let err = SqlxError::from(sqlx::Error::Database(Box::new(db_err)));
268        let query_err: QueryError = err.into();
269        assert_eq!(query_err.code, prax_query::ErrorCode::DatabaseError);
270        assert!(!query_err.is_retryable());
271    }
272
273    #[test]
274    fn test_sqlx_io_error_maps_to_connection() {
275        // I/O errors display as "error communicating with database" — no
276        // "connection" substring — but must still classify as connection errors.
277        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
278        let err = SqlxError::from(sqlx::Error::Io(io_err));
279        let query_err: QueryError = err.into();
280        assert_eq!(query_err.code, prax_query::ErrorCode::ConnectionFailed);
281    }
282}