use thiserror::Error;
#[derive(Error, Debug)]
pub enum ExasolError {
#[error(transparent)]
Connection(#[from] ConnectionError),
#[error(transparent)]
Query(#[from] QueryError),
#[error(transparent)]
Conversion(#[from] ConversionError),
#[error(transparent)]
Transport(#[from] TransportError),
}
#[derive(Error, Debug)]
pub enum ConnectionError {
#[error("Failed to connect to {host}:{port}: {message}")]
ConnectionFailed {
host: String,
port: u16,
message: String,
},
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
#[error("Invalid connection parameter '{parameter}': {message}")]
InvalidParameter { parameter: String, message: String },
#[error("Failed to parse connection string: {0}")]
ParseError(String),
#[error("Connection timeout after {timeout_ms}ms")]
Timeout { timeout_ms: u64 },
#[error("Connection is closed")]
ConnectionClosed,
#[error("TLS error: {0}")]
TlsError(String),
}
#[derive(Error, Debug)]
pub enum QueryError {
#[error("SQL syntax error at position {position}: {message}")]
SyntaxError { position: usize, message: String },
#[error("Query execution failed: {0}")]
ExecutionFailed(String),
#[error("Query timeout after {timeout_ms}ms")]
Timeout { timeout_ms: u64 },
#[error("Invalid query state: {0}")]
InvalidState(String),
#[error("Parameter binding error for parameter {index}: {message}")]
ParameterBindingError { index: usize, message: String },
#[error("Result set not available: {0}")]
NoResultSet(String),
#[error("Transaction error: {0}")]
TransactionError(String),
#[error("Potential SQL injection detected")]
SqlInjectionDetected,
#[error("Prepared statement has been closed")]
StatementClosed,
#[error("Expected row count but received result set")]
UnexpectedResultSet,
}
#[derive(Error, Debug)]
pub enum ConversionError {
#[error("Unsupported Exasol type: {exasol_type}")]
UnsupportedType { exasol_type: String },
#[error("Failed to convert value at row {row}, column {column} ({column_name}) to {target_type}: {message}; value: {value}")]
ValueConversionFailed {
row: usize,
column: usize,
column_name: String,
value: String,
target_type: String,
message: String,
},
#[error("Schema mismatch: {0}")]
SchemaMismatch(String),
#[error("Invalid data format: {0}")]
InvalidFormat(String),
#[error("Numeric overflow at row {row}, column {column} ({column_name}) for {target_type}: {message}; value: {value}")]
NumericOverflow {
row: usize,
column: usize,
column_name: String,
value: String,
target_type: String,
message: String,
},
#[error("Invalid UTF-8 string at row {row}, column {column}")]
InvalidUtf8 { row: usize, column: usize },
#[error("Arrow error: {0}")]
ArrowError(String),
}
#[derive(Debug)]
pub(crate) enum ColumnConversionError {
Value {
row: usize,
reason: String,
overflow: bool,
},
Other(ConversionError),
}
impl ColumnConversionError {
pub(crate) fn invalid_value(row: usize, reason: impl Into<String>) -> Self {
Self::Value {
row,
reason: reason.into(),
overflow: false,
}
}
pub(crate) fn overflow(row: usize, reason: impl Into<String>) -> Self {
Self::Value {
row,
reason: reason.into(),
overflow: true,
}
}
pub(crate) fn into_conversion_error(
self,
column: usize,
column_name: &str,
values: &[&serde_json::Value],
target_type: &arrow::datatypes::DataType,
) -> ConversionError {
match self {
Self::Other(error) => error,
Self::Value {
row,
reason,
overflow,
} => {
let value = values
.get(row)
.map_or(serde_json::Value::Null, |v| (*v).clone())
.to_string();
let column_name = column_name.to_string();
let target_type = target_type.to_string();
if overflow {
ConversionError::NumericOverflow {
row,
column,
column_name,
value,
target_type,
message: reason,
}
} else {
ConversionError::ValueConversionFailed {
row,
column,
column_name,
value,
target_type,
message: reason,
}
}
}
}
}
}
impl From<ConversionError> for ColumnConversionError {
fn from(error: ConversionError) -> Self {
Self::Other(error)
}
}
#[derive(Error, Debug)]
pub enum TransportError {
#[error("WebSocket error: {0}")]
WebSocketError(String),
#[error("Serialization error: {0}")]
SerializationError(String),
#[error("Deserialization error: {0}")]
DeserializationError(String),
#[error("Protocol error: {0}")]
ProtocolError(String),
#[error("Invalid server response: {0}")]
InvalidResponse(String),
#[error("Network I/O error: {0}")]
IoError(String),
#[error("Failed to send message: {0}")]
SendError(String),
#[error("Failed to receive message: {0}")]
ReceiveError(String),
#[error("TLS error: {0}")]
TlsError(String),
}
impl From<arrow::error::ArrowError> for ConversionError {
fn from(err: arrow::error::ArrowError) -> Self {
ConversionError::ArrowError(err.to_string())
}
}
impl From<serde_json::Error> for TransportError {
fn from(err: serde_json::Error) -> Self {
TransportError::SerializationError(err.to_string())
}
}
#[cfg(feature = "websocket")]
impl From<tokio_tungstenite::tungstenite::Error> for TransportError {
fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
TransportError::WebSocketError(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_error_display() {
let err = ConnectionError::ConnectionFailed {
host: "localhost".to_string(),
port: 8563,
message: "Connection refused".to_string(),
};
assert!(err.to_string().contains("localhost"));
assert!(err.to_string().contains("8563"));
}
#[test]
fn test_query_error_display() {
let err = QueryError::SyntaxError {
position: 10,
message: "Unexpected token".to_string(),
};
assert!(err.to_string().contains("position 10"));
}
#[test]
fn test_conversion_error_display() {
let err = ConversionError::ValueConversionFailed {
row: 5,
column: 2,
column_name: "PRICE".to_string(),
value: "\"abc\"".to_string(),
target_type: "Float64".to_string(),
message: "Invalid number format".to_string(),
};
assert!(err.to_string().contains("row 5"));
assert!(err.to_string().contains("column 2"));
}
#[test]
fn column_context_turns_a_value_failure_into_value_conversion_failed() {
let values = [&serde_json::Value::Null, &serde_json::json!("yes")];
let err = ColumnConversionError::invalid_value(1, "Expected boolean")
.into_conversion_error(3, "FLAG", &values, &arrow::datatypes::DataType::Boolean);
match err {
ConversionError::ValueConversionFailed {
row,
column,
column_name,
value,
target_type,
message,
} => {
assert_eq!(
(row, column, column_name.as_str(), message.as_str()),
(1, 3, "FLAG", "Expected boolean")
);
assert_eq!(value, "\"yes\"");
assert_eq!(target_type, "Boolean");
}
other => panic!("expected ValueConversionFailed, got {other:?}"),
}
}
#[test]
fn column_context_turns_an_overflow_into_numeric_overflow() {
let values = [&serde_json::json!("123456")];
let err = ColumnConversionError::overflow(0, "too many digits").into_conversion_error(
0,
"AMOUNT",
&values,
&arrow::datatypes::DataType::Decimal128(5, 2),
);
assert!(matches!(
err,
ConversionError::NumericOverflow { row: 0, column: 0, ref column_name, ref value, ref target_type, ref message }
if column_name == "AMOUNT" && value == "\"123456\"" && target_type == "Decimal128(5, 2)"
&& message == "too many digits"
));
}
#[test]
fn column_context_returns_a_carried_conversion_error_unchanged() {
let err = ColumnConversionError::from(ConversionError::ArrowError("bad precision".into()))
.into_conversion_error(0, "X", &[], &arrow::datatypes::DataType::Boolean);
assert_eq!(err.to_string(), "Arrow error: bad precision");
}
#[test]
fn test_transport_tls_error() {
let err = TransportError::TlsError("Certificate validation failed".to_string());
assert!(err.to_string().contains("TLS error"));
assert!(err.to_string().contains("Certificate validation failed"));
}
#[test]
fn test_statement_closed_error() {
let err = QueryError::StatementClosed;
assert!(err.to_string().contains("closed"));
}
#[test]
fn test_unexpected_result_set_error() {
let err = QueryError::UnexpectedResultSet;
assert!(err.to_string().contains("result set"));
}
#[test]
fn test_conversion_error_from_arrow_error_keeps_the_arrow_message() {
let arrow_error = arrow::error::ArrowError::InvalidArgumentError("bad column".to_string());
let arrow_message = arrow_error.to_string();
let err: ConversionError = arrow_error.into();
assert!(matches!(err, ConversionError::ArrowError(_)));
assert_eq!(err.to_string(), format!("Arrow error: {}", arrow_message));
}
#[test]
fn test_transport_error_from_serde_json_error_keeps_the_parser_message() {
let json_error = serde_json::from_str::<serde_json::Value>("{not json").unwrap_err();
let json_message = json_error.to_string();
let err: TransportError = json_error.into();
assert!(matches!(err, TransportError::SerializationError(_)));
assert_eq!(
err.to_string(),
format!("Serialization error: {}", json_message)
);
}
#[test]
fn test_exasol_error_from_connection_error_is_transparent() {
let err: ExasolError = ConnectionError::ConnectionClosed.into();
assert!(matches!(err, ExasolError::Connection(_)));
assert_eq!(err.to_string(), "Connection is closed");
}
#[test]
fn test_exasol_error_from_query_error_is_transparent() {
let err: ExasolError = QueryError::StatementClosed.into();
assert!(matches!(err, ExasolError::Query(_)));
assert_eq!(err.to_string(), "Prepared statement has been closed");
}
#[test]
fn test_exasol_error_from_conversion_error_is_transparent() {
let err: ExasolError = ConversionError::SchemaMismatch("two columns".to_string()).into();
assert!(matches!(err, ExasolError::Conversion(_)));
assert_eq!(err.to_string(), "Schema mismatch: two columns");
}
#[test]
fn test_exasol_error_from_transport_error_is_transparent() {
let err: ExasolError = TransportError::ProtocolError("bad frame".to_string()).into();
assert!(matches!(err, ExasolError::Transport(_)));
assert_eq!(err.to_string(), "Protocol error: bad frame");
}
#[test]
fn test_every_connection_error_variant_renders_its_context() {
let cases: Vec<(ConnectionError, &str)> = vec![
(
ConnectionError::ConnectionFailed {
host: "db.example".to_string(),
port: 8563,
message: "refused".to_string(),
},
"Failed to connect to db.example:8563: refused",
),
(
ConnectionError::AuthenticationFailed("wrong user".to_string()),
"Authentication failed: wrong user",
),
(
ConnectionError::InvalidParameter {
parameter: "port".to_string(),
message: "not a number".to_string(),
},
"Invalid connection parameter 'port': not a number",
),
(
ConnectionError::ParseError("missing host".to_string()),
"Failed to parse connection string: missing host",
),
(
ConnectionError::Timeout { timeout_ms: 2500 },
"Connection timeout after 2500ms",
),
(ConnectionError::ConnectionClosed, "Connection is closed"),
(
ConnectionError::TlsError("untrusted certificate".to_string()),
"TLS error: untrusted certificate",
),
];
for (err, expected) in cases {
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn test_every_query_error_variant_renders_its_context() {
let cases: Vec<(QueryError, &str)> = vec![
(
QueryError::SyntaxError {
position: 7,
message: "unexpected FROM".to_string(),
},
"SQL syntax error at position 7: unexpected FROM",
),
(
QueryError::ExecutionFailed("table missing".to_string()),
"Query execution failed: table missing",
),
(
QueryError::Timeout { timeout_ms: 30_000 },
"Query timeout after 30000ms",
),
(
QueryError::InvalidState("not prepared".to_string()),
"Invalid query state: not prepared",
),
(
QueryError::ParameterBindingError {
index: 2,
message: "NaN".to_string(),
},
"Parameter binding error for parameter 2: NaN",
),
(
QueryError::NoResultSet("row count only".to_string()),
"Result set not available: row count only",
),
(
QueryError::TransactionError("rollback failed".to_string()),
"Transaction error: rollback failed",
),
(
QueryError::SqlInjectionDetected,
"Potential SQL injection detected",
),
(
QueryError::StatementClosed,
"Prepared statement has been closed",
),
(
QueryError::UnexpectedResultSet,
"Expected row count but received result set",
),
];
for (err, expected) in cases {
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn test_every_conversion_error_variant_renders_its_context() {
let cases: Vec<(ConversionError, &str)> = vec![
(
ConversionError::UnsupportedType {
exasol_type: "XML".to_string(),
},
"Unsupported Exasol type: XML",
),
(
ConversionError::ValueConversionFailed {
row: 3,
column: 4,
column_name: "QTY".to_string(),
value: "\"x\"".to_string(),
target_type: "Int64".to_string(),
message: "not a number".to_string(),
},
"Failed to convert value at row 3, column 4 (QTY) to Int64: not a number; value: \"x\"",
),
(
ConversionError::SchemaMismatch("column count".to_string()),
"Schema mismatch: column count",
),
(
ConversionError::InvalidFormat("Empty decimal string".to_string()),
"Invalid data format: Empty decimal string",
),
(
ConversionError::NumericOverflow {
row: 1,
column: 2,
column_name: "AMOUNT".to_string(),
value: "\"123456\"".to_string(),
target_type: "Decimal128(5, 2)".to_string(),
message: "6 significant digits exceed precision 5".to_string(),
},
"Numeric overflow at row 1, column 2 (AMOUNT) for Decimal128(5, 2): 6 significant digits exceed precision 5; value: \"123456\"",
),
(
ConversionError::InvalidUtf8 { row: 5, column: 6 },
"Invalid UTF-8 string at row 5, column 6",
),
(
ConversionError::ArrowError("length mismatch".to_string()),
"Arrow error: length mismatch",
),
];
for (err, expected) in cases {
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn test_every_transport_error_variant_renders_its_context() {
let cases: Vec<(TransportError, &str)> = vec![
(
TransportError::WebSocketError("handshake".to_string()),
"WebSocket error: handshake",
),
(
TransportError::SerializationError("bad value".to_string()),
"Serialization error: bad value",
),
(
TransportError::DeserializationError("bad frame".to_string()),
"Deserialization error: bad frame",
),
(
TransportError::ProtocolError("unexpected state".to_string()),
"Protocol error: unexpected state",
),
(
TransportError::InvalidResponse("no status".to_string()),
"Invalid server response: no status",
),
(
TransportError::IoError("broken pipe".to_string()),
"Network I/O error: broken pipe",
),
(
TransportError::SendError("closed".to_string()),
"Failed to send message: closed",
),
(
TransportError::ReceiveError("timeout".to_string()),
"Failed to receive message: timeout",
),
(
TransportError::TlsError("unknown ca".to_string()),
"TLS error: unknown ca",
),
];
for (err, expected) in cases {
assert_eq!(err.to_string(), expected);
}
}
}