1use prax_query::QueryError;
4use thiserror::Error;
5
6pub type PgResult<T> = Result<T, PgError>;
8
9#[derive(Error, Debug)]
11pub enum PgError {
12 #[error("pool error: {0}")]
14 Pool(#[from] deadpool_postgres::PoolError),
15
16 #[error("postgres error: {0}")]
18 Postgres(#[from] tokio_postgres::Error),
19
20 #[error("configuration error: {0}")]
22 Config(String),
23
24 #[error("connection error: {0}")]
26 Connection(String),
27
28 #[error("query error: {0}")]
30 Query(String),
31
32 #[error("deserialization error: {0}")]
34 Deserialization(String),
35
36 #[error("type conversion error: {0}")]
38 TypeConversion(String),
39
40 #[error("operation timed out after {0}ms")]
42 Timeout(u64),
43
44 #[error("internal error: {0}")]
46 Internal(String),
47}
48
49impl PgError {
50 pub fn config(message: impl Into<String>) -> Self {
52 Self::Config(message.into())
53 }
54
55 pub fn connection(message: impl Into<String>) -> Self {
57 Self::Connection(message.into())
58 }
59
60 pub fn query(message: impl Into<String>) -> Self {
62 Self::Query(message.into())
63 }
64
65 pub fn deserialization(message: impl Into<String>) -> Self {
67 Self::Deserialization(message.into())
68 }
69
70 pub fn type_conversion(message: impl Into<String>) -> Self {
72 Self::TypeConversion(message.into())
73 }
74
75 pub fn is_connection_error(&self) -> bool {
77 matches!(self, Self::Pool(_) | Self::Connection(_))
78 }
79
80 pub fn is_timeout(&self) -> bool {
82 matches!(self, Self::Timeout(_))
83 }
84}
85
86pub(crate) fn classify_sqlstate(
112 code: Option<&str>,
113 display: &str,
114 detail: Option<&str>,
115) -> QueryError {
116 let gate_text = detail.unwrap_or(display);
117 match code {
118 Some("23505") | Some("23503") | Some("23514") => {
120 QueryError::constraint_violation("", display)
121 }
122 Some("23502") => QueryError::invalid_input("", display),
124 Some("0A000") if gate_text.contains("cached plan must not change result type") => {
128 QueryError::stale_plan(display)
129 }
130 _ => QueryError::database(display),
131 }
132}
133
134impl From<PgError> for QueryError {
135 fn from(err: PgError) -> Self {
136 match err {
137 PgError::Pool(e) => QueryError::connection(e.to_string()),
138 PgError::Postgres(e) => {
139 let code_str = e.code().map(|c| c.code().to_owned());
144 let display = e.to_string();
145 let detail = e.as_db_error().map(|db| db.message().to_owned());
146 let mapped = classify_sqlstate(code_str.as_deref(), &display, detail.as_deref());
147 mapped.with_source(e)
148 }
149 PgError::Config(msg) => QueryError::connection(msg),
150 PgError::Connection(msg) => QueryError::connection(msg),
151 PgError::Query(msg) => QueryError::database(msg),
152 PgError::Deserialization(msg) => QueryError::serialization(msg),
153 PgError::TypeConversion(msg) => QueryError::serialization(msg),
154 PgError::Timeout(ms) => QueryError::timeout(ms),
155 PgError::Internal(msg) => QueryError::internal(msg),
156 }
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn test_error_creation() {
166 let err = PgError::config("invalid URL");
167 assert!(matches!(err, PgError::Config(_)));
168
169 let err = PgError::connection("connection refused");
170 assert!(err.is_connection_error());
171
172 let err = PgError::Timeout(5000);
173 assert!(err.is_timeout());
174 }
175
176 #[test]
177 fn test_into_query_error() {
178 let pg_err = PgError::Timeout(1000);
179 let query_err: QueryError = pg_err.into();
180 assert!(query_err.is_timeout());
181 }
182
183 #[test]
184 fn test_classify_constraint_sqlstates() {
185 use prax_query::ErrorCode;
186 for code in ["23505", "23503", "23514"] {
188 let e = classify_sqlstate(Some(code), "boom", None);
189 assert_eq!(e.code, ErrorCode::UniqueConstraint, "code {code}");
190 assert!(e.is_constraint_violation(), "code {code}");
191 }
192 assert_eq!(
194 classify_sqlstate(Some("23502"), "boom", None).code,
195 ErrorCode::InvalidParameter
196 );
197 }
198
199 #[test]
200 fn test_classify_stale_cached_plan_gates_on_detail() {
201 use prax_query::ErrorCode;
202 let e = classify_sqlstate(
205 Some("0A000"),
206 "db error",
207 Some("cached plan must not change result type"),
208 );
209 assert_eq!(e.code, ErrorCode::SerializationFailure);
210 assert!(e.is_retryable());
211
212 let e = classify_sqlstate(
215 Some("0A000"),
216 "cached plan must not change result type",
217 None,
218 );
219 assert_eq!(e.code, ErrorCode::SerializationFailure);
220 }
221
222 #[test]
223 fn test_classify_other_0a000_stays_generic() {
224 use prax_query::ErrorCode;
225 let e = classify_sqlstate(Some("0A000"), "db error", Some("cannot insert into a view"));
229 assert_eq!(e.code, ErrorCode::DatabaseError);
230 assert!(!e.is_retryable());
231 }
232
233 #[test]
234 fn test_classify_unknown_sqlstate_is_generic() {
235 use prax_query::ErrorCode;
236 assert_eq!(
237 classify_sqlstate(Some("40P01"), "deadlock-ish", None).code,
238 ErrorCode::DatabaseError
239 );
240 assert_eq!(
241 classify_sqlstate(None, "no code", None).code,
242 ErrorCode::DatabaseError
243 );
244 }
245}