Skip to main content

bsql_core/
error.rs

1//! Error types for bsql.
2//!
3//! [`BsqlError`] is the single error type returned by all bsql operations.
4//! It has four variants matching the four failure modes of a database operation:
5//! pool, query execution, data decoding, and initial connection.
6
7use std::borrow::Cow;
8use std::fmt;
9
10/// The error type for all bsql operations.
11///
12/// # Variants
13///
14/// - [`Pool`](BsqlError::Pool) — connection pool exhausted or misconfigured.
15/// - [`Query`](BsqlError::Query) — PostgreSQL rejected the query at runtime
16///   (triggers, RLS policies, constraint violations).
17/// - [`Decode`](BsqlError::Decode) — a column value could not be converted to
18///   the expected Rust type.
19/// - [`Connect`](BsqlError::Connect) — initial connection to PostgreSQL failed.
20#[derive(Debug)]
21pub enum BsqlError {
22    Pool(PoolError),
23    Query(QueryError),
24    Decode(DecodeError),
25    Connect(ConnectError),
26}
27
28/// Connection pool failure.
29#[derive(Debug)]
30pub struct PoolError {
31    pub message: Cow<'static, str>,
32    pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync>>,
33}
34
35/// Query execution failure. Contains the PostgreSQL error code when available.
36#[derive(Debug)]
37pub struct QueryError {
38    pub message: Cow<'static, str>,
39    /// The five-character SQLSTATE code (e.g. `"23505"` for unique violation).
40    pub pg_code: Option<String>,
41    pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync>>,
42}
43
44/// Row/column decoding failure.
45#[derive(Debug)]
46pub struct DecodeError {
47    pub column: String,
48    pub expected: &'static str,
49    pub actual: String,
50}
51
52/// Initial connection failure.
53#[derive(Debug)]
54pub struct ConnectError {
55    pub message: Cow<'static, str>,
56    pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync>>,
57}
58
59/// Convenience alias used throughout bsql.
60pub type BsqlResult<T> = Result<T, BsqlError>;
61
62// --- Display ---
63
64impl fmt::Display for BsqlError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::Pool(e) => write!(f, "pool error: {e}"),
68            Self::Query(e) => write!(f, "query error: {e}"),
69            Self::Decode(e) => write!(f, "decode error: {e}"),
70            Self::Connect(e) => write!(f, "connect error: {e}"),
71        }
72    }
73}
74
75impl fmt::Display for PoolError {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.write_str(&self.message)
78    }
79}
80
81impl fmt::Display for QueryError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match &self.pg_code {
84            Some(code) => write!(f, "[{code}] {}", self.message),
85            None => f.write_str(&self.message),
86        }
87    }
88}
89
90impl fmt::Display for DecodeError {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write!(
93            f,
94            "column \"{}\": expected {}, got {}",
95            self.column, self.expected, self.actual
96        )
97    }
98}
99
100impl fmt::Display for ConnectError {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(&self.message)
103    }
104}
105
106impl std::error::Error for BsqlError {
107    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
108        match self {
109            Self::Pool(e) => e.source(),
110            Self::Query(e) => e.source(),
111            Self::Decode(_) => None,
112            Self::Connect(e) => e.source(),
113        }
114    }
115}
116
117impl std::error::Error for PoolError {
118    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
119        boxed_source(&self.source)
120    }
121}
122
123impl std::error::Error for QueryError {
124    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
125        boxed_source(&self.source)
126    }
127}
128
129impl std::error::Error for DecodeError {}
130
131impl std::error::Error for ConnectError {
132    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
133        boxed_source(&self.source)
134    }
135}
136
137fn boxed_source(
138    src: &Option<Box<dyn std::error::Error + Send + Sync>>,
139) -> Option<&(dyn std::error::Error + 'static)> {
140    src.as_ref()
141        .map(|e| &**e as &(dyn std::error::Error + 'static))
142}
143
144// --- From conversions ---
145
146impl From<tokio_postgres::Error> for BsqlError {
147    fn from(e: tokio_postgres::Error) -> Self {
148        let pg_code = e.code().map(|c| c.code().to_owned());
149        let message = Cow::Owned(e.to_string());
150        BsqlError::Query(QueryError {
151            message,
152            pg_code,
153            source: Some(Box::new(e)),
154        })
155    }
156}
157
158impl From<deadpool_postgres::PoolError> for BsqlError {
159    fn from(e: deadpool_postgres::PoolError) -> Self {
160        let message = Cow::Owned(e.to_string());
161        BsqlError::Pool(PoolError {
162            message,
163            source: Some(Box::new(e)),
164        })
165    }
166}
167
168// --- Constructor helpers ---
169
170impl PoolError {
171    pub fn exhausted() -> BsqlError {
172        BsqlError::Pool(PoolError {
173            message: Cow::Borrowed("pool exhausted: all connections in use"),
174            source: None,
175        })
176    }
177}
178
179impl ConnectError {
180    pub fn create(msg: impl Into<String>) -> BsqlError {
181        BsqlError::Connect(ConnectError {
182            message: Cow::Owned(msg.into()),
183            source: None,
184        })
185    }
186
187    pub fn with_source(
188        msg: impl Into<String>,
189        source: impl std::error::Error + Send + Sync + 'static,
190    ) -> BsqlError {
191        BsqlError::Connect(ConnectError {
192            message: Cow::Owned(msg.into()),
193            source: Some(Box::new(source)),
194        })
195    }
196}
197
198impl QueryError {
199    pub fn row_count(expected: &str, actual: u64) -> BsqlError {
200        BsqlError::Query(QueryError {
201            message: Cow::Owned(format!("expected {expected}, got {actual} rows")),
202            pg_code: None,
203            source: None,
204        })
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use std::error::Error as _;
212
213    #[test]
214    fn pool_error_display() {
215        let e = PoolError::exhausted();
216        assert_eq!(
217            e.to_string(),
218            "pool error: pool exhausted: all connections in use"
219        );
220    }
221
222    #[test]
223    fn query_error_with_code_display() {
224        let e = BsqlError::Query(QueryError {
225            message: Cow::Borrowed("duplicate key"),
226            pg_code: Some("23505".into()),
227            source: None,
228        });
229        assert_eq!(e.to_string(), "query error: [23505] duplicate key");
230    }
231
232    #[test]
233    fn query_error_without_code_display() {
234        let e = QueryError::row_count("exactly 1 row", 0);
235        assert_eq!(
236            e.to_string(),
237            "query error: expected exactly 1 row, got 0 rows"
238        );
239    }
240
241    #[test]
242    fn decode_error_display() {
243        let e = BsqlError::Decode(DecodeError {
244            column: "age".into(),
245            expected: "i32",
246            actual: "text".into(),
247        });
248        assert_eq!(
249            e.to_string(),
250            "decode error: column \"age\": expected i32, got text"
251        );
252    }
253
254    #[test]
255    fn connect_error_display() {
256        let e = ConnectError::create("connection refused");
257        assert_eq!(e.to_string(), "connect error: connection refused");
258    }
259
260    #[test]
261    fn pool_exhausted_uses_borrowed_cow() {
262        let e = PoolError::exhausted();
263        match e {
264            BsqlError::Pool(ref pe) => {
265                assert!(
266                    matches!(pe.message, Cow::Borrowed(_)),
267                    "exhausted() should use Cow::Borrowed for zero-alloc"
268                );
269            }
270            _ => panic!("expected Pool variant"),
271        }
272    }
273
274    #[test]
275    fn connect_error_uses_owned_cow() {
276        let e = ConnectError::create("dynamic message");
277        match e {
278            BsqlError::Connect(ref ce) => {
279                assert!(
280                    matches!(ce.message, Cow::Owned(_)),
281                    "create() with dynamic msg should use Cow::Owned"
282                );
283            }
284            _ => panic!("expected Connect variant"),
285        }
286    }
287
288    #[test]
289    fn query_row_count_uses_owned_cow() {
290        let e = QueryError::row_count("exactly 1 row", 5);
291        match e {
292            BsqlError::Query(ref qe) => {
293                assert!(
294                    matches!(qe.message, Cow::Owned(_)),
295                    "row_count() with formatted msg should use Cow::Owned"
296                );
297            }
298            _ => panic!("expected Query variant"),
299        }
300    }
301
302    #[test]
303    fn pool_error_source_chain() {
304        let e = PoolError::exhausted();
305        // exhausted() has no source
306        assert!(e.source().is_none());
307    }
308
309    #[test]
310    fn connect_error_with_source_chain() {
311        let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
312        let e = ConnectError::with_source("connection failed", inner);
313        assert!(e.source().is_some());
314    }
315
316    #[test]
317    fn decode_error_has_no_source() {
318        let e = BsqlError::Decode(DecodeError {
319            column: "col".into(),
320            expected: "i32",
321            actual: "text".into(),
322        });
323        assert!(e.source().is_none());
324    }
325}