use thiserror::Error as ThisError;
#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum Error {
#[error(
"connection error{}: {message}",
sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(),
)]
Connection {
message: String,
#[source]
source: Option<std::io::Error>,
sqlstate: Option<String>,
},
#[error("authentication failed: {0}")]
Authentication(String),
#[error("TLS error: {0}")]
Tls(String),
#[error(
"server error{}: {message}{}{}",
sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(),
detail.as_ref().map(|d| format!("\nDETAIL: {d}")).unwrap_or_default(),
hint.as_ref().map(|h| format!("\nHINT: {h}")).unwrap_or_default(),
)]
Server {
sqlstate: Option<String>,
message: String,
detail: Option<String>,
hint: Option<String>,
},
#[error("protocol error: {0}")]
Protocol(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error(
"connection closed{}: {message}",
sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(),
)]
Closed {
message: String,
sqlstate: Option<String>,
},
#[error("operation timed out: {0}")]
Timeout(String),
#[error(
"operation cancelled{}: {message}",
sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(),
)]
Cancelled {
message: String,
sqlstate: Option<String>,
},
#[error("conversion error: {0}")]
Conversion(String),
#[error("configuration error: {0}")]
Config(String),
#[error("feature not supported: {0}")]
FeatureNotSupported(String),
#[error("invalid name: {0}")]
InvalidName(String),
#[error("invalid table definition: {0}")]
InvalidTableDefinition(String),
#[error("not found: {0}")]
NotFound(String),
#[error("already exists: {0}")]
AlreadyExists(String),
#[error("invalid operation: {0}")]
InvalidOperation(String),
#[error("column {name}: {kind}")]
Column {
name: String,
#[source]
kind: ColumnErrorKind,
},
#[error("column index {idx} out of bounds (row has {column_count} columns)")]
ColumnIndexOutOfBounds {
idx: usize,
column_count: usize,
},
#[error("internal error: {message}")]
Internal {
message: String,
},
}
#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum ColumnErrorKind {
#[error("column not found")]
Missing,
#[error("unexpected NULL")]
Null,
#[error("type mismatch: expected {expected}, got {actual}")]
TypeMismatch {
expected: String,
actual: String,
},
}
impl Error {
pub fn internal(message: impl Into<String>) -> Self {
Error::Internal {
message: message.into(),
}
}
pub fn connection(message: impl Into<String>) -> Self {
Error::Connection {
message: message.into(),
source: None,
sqlstate: None,
}
}
pub fn connection_with_io(message: impl Into<String>, source: std::io::Error) -> Self {
Error::Connection {
message: message.into(),
source: Some(source),
sqlstate: None,
}
}
pub fn connection_with_sqlstate(
message: impl Into<String>,
sqlstate: impl Into<String>,
) -> Self {
Error::Connection {
message: message.into(),
source: None,
sqlstate: Some(sqlstate.into()),
}
}
pub fn server(
sqlstate: Option<String>,
message: impl Into<String>,
detail: Option<String>,
hint: Option<String>,
) -> Self {
Error::Server {
sqlstate,
message: message.into(),
detail,
hint,
}
}
pub fn column(name: impl Into<String>, kind: ColumnErrorKind) -> Self {
Error::Column {
name: name.into(),
kind,
}
}
pub fn column_index_out_of_bounds(idx: usize, column_count: usize) -> Self {
Error::ColumnIndexOutOfBounds { idx, column_count }
}
pub fn authentication(message: impl Into<String>) -> Self {
Error::Authentication(message.into())
}
pub fn tls(message: impl Into<String>) -> Self {
Error::Tls(message.into())
}
pub fn protocol(message: impl Into<String>) -> Self {
Error::Protocol(message.into())
}
pub fn closed(message: impl Into<String>) -> Self {
Error::Closed {
message: message.into(),
sqlstate: None,
}
}
pub fn closed_with_sqlstate(message: impl Into<String>, sqlstate: impl Into<String>) -> Self {
Error::Closed {
message: message.into(),
sqlstate: Some(sqlstate.into()),
}
}
pub fn timeout(message: impl Into<String>) -> Self {
Error::Timeout(message.into())
}
pub fn cancelled(message: impl Into<String>) -> Self {
Error::Cancelled {
message: message.into(),
sqlstate: None,
}
}
pub fn cancelled_with_sqlstate(
message: impl Into<String>,
sqlstate: impl Into<String>,
) -> Self {
Error::Cancelled {
message: message.into(),
sqlstate: Some(sqlstate.into()),
}
}
pub fn conversion(message: impl Into<String>) -> Self {
Error::Conversion(message.into())
}
pub fn config(message: impl Into<String>) -> Self {
Error::Config(message.into())
}
pub fn feature_not_supported(message: impl Into<String>) -> Self {
Error::FeatureNotSupported(message.into())
}
pub fn invalid_name(message: impl Into<String>) -> Self {
Error::InvalidName(message.into())
}
pub fn invalid_table_definition(message: impl Into<String>) -> Self {
Error::InvalidTableDefinition(message.into())
}
pub fn not_found(message: impl Into<String>) -> Self {
Error::NotFound(message.into())
}
pub fn already_exists(message: impl Into<String>) -> Self {
Error::AlreadyExists(message.into())
}
pub fn invalid_operation(message: impl Into<String>) -> Self {
Error::InvalidOperation(message.into())
}
#[must_use]
pub fn message(&self) -> String {
self.to_string()
}
#[must_use]
pub fn sqlstate(&self) -> Option<&str> {
match self {
Error::Server { sqlstate, .. }
| Error::Connection { sqlstate, .. }
| Error::Closed { sqlstate, .. }
| Error::Cancelled { sqlstate, .. } => sqlstate.as_deref(),
_ => None,
}
}
}
impl From<hyperdb_api_core::client::Error> for Error {
fn from(err: hyperdb_api_core::client::Error) -> Self {
use hyperdb_api_core::client::ErrorKind as CoreKind;
let chain = err.to_string();
let kind = err.kind();
let sqlstate = err.sqlstate().map(str::to_string);
let detail = err.detail().map(str::to_string);
let hint = err.hint().map(str::to_string);
let message = err.message().to_string();
match kind {
CoreKind::Connection => Error::Connection {
message: chain,
source: None,
sqlstate,
},
CoreKind::Authentication => Error::Authentication(chain),
CoreKind::Query => Error::Server {
sqlstate,
message,
detail,
hint,
},
CoreKind::Protocol => Error::Protocol(chain),
CoreKind::Io => Error::Connection {
message: chain,
source: None,
sqlstate,
},
CoreKind::Config => Error::Config(chain),
CoreKind::Timeout => Error::Timeout(chain),
CoreKind::Cancelled => Error::Cancelled {
message: chain,
sqlstate,
},
CoreKind::Closed => Error::Closed {
message: chain,
sqlstate,
},
CoreKind::Conversion => Error::Conversion(chain),
CoreKind::FeatureNotSupported => Error::FeatureNotSupported(chain),
CoreKind::Other => Error::Internal { message: chain },
}
}
}
impl From<std::convert::Infallible> for Error {
fn from(_: std::convert::Infallible) -> Self {
unreachable!("Infallible has no values")
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
use hyperdb_api_core::client::{Error as CoreError, ErrorKind as CoreKind};
#[test]
fn server_display_includes_sqlstate_detail_and_hint() {
let err = Error::server(
Some("23505".to_string()),
"duplicate key value violates unique constraint",
Some("Key (id)=(42) already exists.".to_string()),
Some("Choose a different key.".to_string()),
);
let s = err.to_string();
assert!(s.contains("server error (23505)"), "got: {s}");
assert!(
s.contains("duplicate key value violates unique constraint"),
"got: {s}"
);
assert!(
s.contains("\nDETAIL: Key (id)=(42) already exists."),
"got: {s}"
);
assert!(s.contains("\nHINT: Choose a different key."), "got: {s}");
}
#[test]
fn server_display_omits_missing_optional_fields() {
let err = Error::server(None, "syntax error at end of input", None, None);
let s = err.to_string();
assert_eq!(s, "server error: syntax error at end of input");
}
#[test]
fn from_client_error_query_does_not_duplicate_detail() {
let core = CoreError::new_with_details(
CoreKind::Query,
"duplicate key value",
Some("Key (id)=(42) already exists.".to_string()),
Some("Choose a different key.".to_string()),
Some("23505".to_string()),
);
let public: Error = core.into();
let s = public.to_string();
let count = s.matches("Key (id)=(42) already exists.").count();
assert_eq!(count, 1, "detail must appear exactly once; got: {s}");
let hint_count = s.matches("Choose a different key.").count();
assert_eq!(hint_count, 1, "hint must appear exactly once; got: {s}");
assert_eq!(public.sqlstate(), Some("23505"));
}
#[test]
fn from_client_error_exhaustive_over_kinds() {
for kind in [
CoreKind::Connection,
CoreKind::Authentication,
CoreKind::Query,
CoreKind::Protocol,
CoreKind::Io,
CoreKind::Config,
CoreKind::Timeout,
CoreKind::Cancelled,
CoreKind::Closed,
CoreKind::Conversion,
CoreKind::FeatureNotSupported,
CoreKind::Other,
] {
let core = CoreError::new(kind, "test message");
let public: Error = core.into();
assert!(
public.to_string().contains("test message"),
"{kind:?} mapping lost the message: {public}",
);
}
}
#[test]
fn sqlstate_returns_some_for_server_connection_closed_cancelled() {
let server = Error::server(Some("42P04".to_string()), "db exists", None, None);
assert_eq!(server.sqlstate(), Some("42P04"));
let conn = Error::connection_with_sqlstate("connect failed", "08006");
assert_eq!(conn.sqlstate(), Some("08006"));
let closed = Error::closed_with_sqlstate("admin shutdown", "57P01");
assert_eq!(closed.sqlstate(), Some("57P01"));
let cancelled = Error::cancelled_with_sqlstate("user cancel", "57014");
assert_eq!(cancelled.sqlstate(), Some("57014"));
assert_eq!(Error::Conversion("...".into()).sqlstate(), None);
assert_eq!(
Error::Internal {
message: "...".into()
}
.sqlstate(),
None
);
assert_eq!(Error::cancelled("user cancel").sqlstate(), None);
}
#[test]
fn column_display_formats_name_and_kind() {
let err = Error::column("user_id", ColumnErrorKind::Missing);
assert_eq!(err.to_string(), "column user_id: column not found");
let err = Error::column("score", ColumnErrorKind::Null);
assert_eq!(err.to_string(), "column score: unexpected NULL");
let err = Error::column(
"count",
ColumnErrorKind::TypeMismatch {
expected: "i32".into(),
actual: "TEXT".into(),
},
);
assert_eq!(
err.to_string(),
"column count: type mismatch: expected i32, got TEXT"
);
}
#[test]
fn column_index_out_of_bounds_display() {
let err = Error::column_index_out_of_bounds(5, 3);
assert_eq!(
err.to_string(),
"column index 5 out of bounds (row has 3 columns)"
);
}
#[test]
fn connection_display_with_typed_io_source() {
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
let err = Error::connection_with_io("connecting to hyperd", io_err);
let s = err.to_string();
assert!(
s.contains("connection error: connecting to hyperd"),
"got: {s}"
);
use std::error::Error as StdError;
let src = err.source().expect("connection_with_io must expose source");
let io_src: &std::io::Error = src
.downcast_ref::<std::io::Error>()
.expect("source must downcast to io::Error");
assert_eq!(io_src.kind(), std::io::ErrorKind::ConnectionRefused);
}
#[test]
fn internal_constructor_round_trip() {
let err = Error::internal("invariant violated");
assert_eq!(err.to_string(), "internal error: invariant violated");
}
#[test]
fn invalid_operation_constructor_round_trip() {
let err = Error::invalid_operation("cannot mix insert_data with insert_batch");
assert_eq!(
err.to_string(),
"invalid operation: cannot mix insert_data with insert_batch"
);
assert!(matches!(err, Error::InvalidOperation(_)));
}
}