use std::fmt;
use thiserror::Error;
pub type QueryResult<T> = Result<T, QueryError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorCode {
RecordNotFound = 1001,
NotUnique = 1002,
InvalidFilter = 1003,
InvalidSelect = 1004,
RequiredFieldMissing = 1005,
UniqueConstraint = 2001,
ForeignKeyConstraint = 2002,
CheckConstraint = 2003,
NotNullConstraint = 2004,
ConnectionFailed = 3001,
PoolExhausted = 3002,
ConnectionTimeout = 3003,
AuthenticationFailed = 3004,
SslError = 3005,
TransactionFailed = 4001,
Deadlock = 4002,
SerializationFailure = 4003,
TransactionClosed = 4004,
QueryTimeout = 5001,
SqlSyntax = 5002,
InvalidParameter = 5003,
QueryTooComplex = 5004,
DatabaseError = 5005,
InvalidDataType = 6001,
SerializationError = 6002,
DeserializationError = 6003,
DataTruncation = 6004,
InvalidConfiguration = 7001,
MissingConfiguration = 7002,
InvalidConnectionString = 7003,
Internal = 9001,
Unknown = 9999,
}
impl ErrorCode {
pub fn code(&self) -> String {
format!("P{}", *self as u16)
}
pub fn description(&self) -> &'static str {
match self {
Self::RecordNotFound => "Record not found",
Self::NotUnique => "Multiple records found",
Self::InvalidFilter => "Invalid filter condition",
Self::InvalidSelect => "Invalid select or include",
Self::RequiredFieldMissing => "Required field missing",
Self::UniqueConstraint => "Unique constraint violation",
Self::ForeignKeyConstraint => "Foreign key constraint violation",
Self::CheckConstraint => "Check constraint violation",
Self::NotNullConstraint => "Not null constraint violation",
Self::ConnectionFailed => "Database connection failed",
Self::PoolExhausted => "Connection pool exhausted",
Self::ConnectionTimeout => "Connection timeout",
Self::AuthenticationFailed => "Authentication failed",
Self::SslError => "SSL/TLS error",
Self::TransactionFailed => "Transaction failed",
Self::Deadlock => "Deadlock detected",
Self::SerializationFailure => "Serialization failure",
Self::TransactionClosed => "Transaction already closed",
Self::QueryTimeout => "Query timeout",
Self::SqlSyntax => "SQL syntax error",
Self::InvalidParameter => "Invalid parameter",
Self::QueryTooComplex => "Query too complex",
Self::DatabaseError => "Database error",
Self::InvalidDataType => "Invalid data type",
Self::SerializationError => "Serialization error",
Self::DeserializationError => "Deserialization error",
Self::DataTruncation => "Data truncation",
Self::InvalidConfiguration => "Invalid configuration",
Self::MissingConfiguration => "Missing configuration",
Self::InvalidConnectionString => "Invalid connection string",
Self::Internal => "Internal error",
Self::Unknown => "Unknown error",
}
}
pub fn docs_url(&self) -> String {
format!("https://prax.rs/docs/errors/{}", self.code())
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.code())
}
}
#[derive(Debug, Clone)]
pub struct Suggestion {
pub text: String,
pub code: Option<String>,
}
impl Suggestion {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
code: None,
}
}
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ErrorContext {
pub operation: Option<String>,
pub model: Option<String>,
pub field: Option<String>,
pub sql: Option<String>,
pub suggestions: Vec<Suggestion>,
pub help: Option<String>,
pub related: Vec<String>,
}
impl ErrorContext {
pub fn new() -> Self {
Self::default()
}
pub fn operation(mut self, op: impl Into<String>) -> Self {
self.operation = Some(op.into());
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
pub fn sql(mut self, sql: impl Into<String>) -> Self {
self.sql = Some(sql.into());
self
}
pub fn suggestion(mut self, suggestion: Suggestion) -> Self {
self.suggestions.push(suggestion);
self
}
pub fn suggest(mut self, text: impl Into<String>) -> Self {
self.suggestions.push(Suggestion::new(text));
self
}
pub fn help(mut self, help: impl Into<String>) -> Self {
self.help = Some(help.into());
self
}
}
#[derive(Error, Debug)]
pub struct QueryError {
pub code: ErrorCode,
pub message: String,
pub context: ErrorContext,
#[source]
pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
impl fmt::Display for QueryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}", self.code.code(), self.message)
}
}
impl QueryError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
context: ErrorContext::default(),
source: None,
}
}
pub fn with_context(mut self, operation: impl Into<String>) -> Self {
self.context.operation = Some(operation.into());
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.context.suggestions.push(Suggestion::new(suggestion));
self
}
pub fn with_code_suggestion(
mut self,
text: impl Into<String>,
code: impl Into<String>,
) -> Self {
self.context
.suggestions
.push(Suggestion::new(text).with_code(code));
self
}
pub fn with_help(mut self, help: impl Into<String>) -> Self {
self.context.help = Some(help.into());
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.context.model = Some(model.into());
self
}
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.context.field = Some(field.into());
self
}
pub fn with_sql(mut self, sql: impl Into<String>) -> Self {
self.context.sql = Some(sql.into());
self
}
pub fn with_source<E: std::error::Error + Send + Sync + 'static>(mut self, source: E) -> Self {
self.source = Some(Box::new(source));
self
}
pub fn not_found(model: impl Into<String>) -> Self {
let model = model.into();
Self::new(
ErrorCode::RecordNotFound,
format!("No {} record found matching the query", model),
)
.with_model(&model)
.with_suggestion(format!("Verify the {} exists before querying", model))
.with_code_suggestion(
"Use findFirst() instead to get None instead of an error",
format!(
"client.{}().find_first().r#where(...).exec().await",
model.to_lowercase()
),
)
}
pub fn not_unique(model: impl Into<String>) -> Self {
let model = model.into();
Self::new(
ErrorCode::NotUnique,
format!("Expected unique {} record but found multiple", model),
)
.with_model(&model)
.with_suggestion("Add more specific filters to narrow down to a single record")
.with_suggestion("Use find_many() if you expect multiple results")
}
pub fn constraint_violation(model: impl Into<String>, message: impl Into<String>) -> Self {
let model = model.into();
let message = message.into();
Self::new(
ErrorCode::UniqueConstraint,
format!("Constraint violation on {}: {}", model, message),
)
.with_model(&model)
}
pub fn unique_violation(model: impl Into<String>, field: impl Into<String>) -> Self {
let model = model.into();
let field = field.into();
Self::new(
ErrorCode::UniqueConstraint,
format!("Unique constraint violated on {}.{}", model, field),
)
.with_model(&model)
.with_field(&field)
.with_suggestion(format!("A record with this {} already exists", field))
.with_code_suggestion(
"Use upsert() to update if exists, create if not",
format!(
"client.{}().upsert()\n .r#where({}::{}::equals(value))\n .create(...)\n .update(...)\n .exec().await",
model.to_lowercase(), model.to_lowercase(), field
),
)
}
pub fn foreign_key_violation(model: impl Into<String>, relation: impl Into<String>) -> Self {
let model = model.into();
let relation = relation.into();
Self::new(
ErrorCode::ForeignKeyConstraint,
format!("Foreign key constraint violated: {} -> {}", model, relation),
)
.with_model(&model)
.with_field(&relation)
.with_suggestion(format!(
"Ensure the related {} record exists before creating this {}",
relation, model
))
.with_suggestion("Check for typos in the relation ID")
}
pub fn not_null_violation(model: impl Into<String>, field: impl Into<String>) -> Self {
let model = model.into();
let field = field.into();
Self::new(
ErrorCode::NotNullConstraint,
format!("Cannot set {}.{} to null - field is required", model, field),
)
.with_model(&model)
.with_field(&field)
.with_suggestion(format!("Provide a value for the {} field", field))
.with_help("Make the field optional in your schema if null should be allowed")
}
pub fn invalid_input(field: impl Into<String>, message: impl Into<String>) -> Self {
let field = field.into();
let message = message.into();
Self::new(
ErrorCode::InvalidParameter,
format!("Invalid input for {}: {}", field, message),
)
.with_field(&field)
}
pub fn connection(message: impl Into<String>) -> Self {
let message = message.into();
Self::new(
ErrorCode::ConnectionFailed,
format!("Connection error: {}", message),
)
.with_suggestion("Check that the database server is running")
.with_suggestion("Verify the connection URL is correct")
.with_suggestion("Check firewall settings allow the connection")
}
pub fn connection_timeout(duration_ms: u64) -> Self {
Self::new(
ErrorCode::ConnectionTimeout,
format!("Connection timed out after {}ms", duration_ms),
)
.with_suggestion("Increase the connect_timeout in your connection string")
.with_suggestion("Check network connectivity to the database server")
.with_code_suggestion(
"Add connect_timeout to your connection URL",
"postgres://user:pass@host/db?connect_timeout=30",
)
}
pub fn pool_exhausted(max_connections: u32) -> Self {
Self::new(
ErrorCode::PoolExhausted,
format!("Connection pool exhausted (max {} connections)", max_connections),
)
.with_suggestion("Increase max_connections in pool configuration")
.with_suggestion("Ensure connections are being released properly")
.with_suggestion("Check for connection leaks in your application")
.with_help("Consider using connection pooling middleware like PgBouncer for high-traffic applications")
}
pub fn authentication_failed(message: impl Into<String>) -> Self {
let message = message.into();
Self::new(
ErrorCode::AuthenticationFailed,
format!("Authentication failed: {}", message),
)
.with_suggestion("Check username and password in connection string")
.with_suggestion("Verify the user has permission to access the database")
.with_suggestion("Check pg_hba.conf (PostgreSQL) or user privileges (MySQL)")
}
pub fn timeout(duration_ms: u64) -> Self {
Self::new(
ErrorCode::QueryTimeout,
format!("Query timed out after {}ms", duration_ms),
)
.with_suggestion("Optimize the query to run faster")
.with_suggestion("Add indexes to improve query performance")
.with_suggestion("Increase the query timeout if the query is expected to be slow")
.with_help("Consider paginating large result sets")
}
pub fn transaction(message: impl Into<String>) -> Self {
let message = message.into();
Self::new(
ErrorCode::TransactionFailed,
format!("Transaction error: {}", message),
)
}
pub fn deadlock() -> Self {
Self::new(
ErrorCode::Deadlock,
"Deadlock detected - transaction was rolled back".to_string(),
)
.with_suggestion("Retry the transaction")
.with_suggestion("Access tables in a consistent order across transactions")
.with_suggestion("Keep transactions short to reduce lock contention")
.with_help("Deadlocks occur when two transactions wait for each other's locks")
}
pub fn sql_syntax(message: impl Into<String>, sql: impl Into<String>) -> Self {
let message = message.into();
let sql = sql.into();
Self::new(
ErrorCode::SqlSyntax,
format!("SQL syntax error: {}", message),
)
.with_sql(&sql)
.with_suggestion("Check the generated SQL for errors")
.with_help("This is likely a bug in Prax - please report it")
}
pub fn serialization(message: impl Into<String>) -> Self {
Self::new(ErrorCode::SerializationError, message.into())
}
pub fn deserialization(message: impl Into<String>) -> Self {
let message = message.into();
Self::new(
ErrorCode::DeserializationError,
format!("Failed to deserialize result: {}", message),
)
.with_suggestion("Check that the model matches the database schema")
.with_suggestion("Ensure data types are compatible")
}
pub fn database(message: impl Into<String>) -> Self {
let message = message.into();
Self::new(ErrorCode::DatabaseError, message)
.with_suggestion("Check the database logs for more details")
}
pub fn internal(message: impl Into<String>) -> Self {
let message = message.into();
Self::new(ErrorCode::Internal, format!("Internal error: {}", message))
.with_help("This is likely a bug in Prax ORM - please report it at https://github.com/pegasusheavy/prax-orm/issues")
}
pub fn unsupported(message: impl Into<String>) -> Self {
let message = message.into();
Self::new(
ErrorCode::InvalidConfiguration,
format!("Unsupported: {}", message),
)
.with_help("This operation is not supported by the current database driver")
}
pub fn is_not_found(&self) -> bool {
self.code == ErrorCode::RecordNotFound
}
pub fn is_constraint_violation(&self) -> bool {
matches!(
self.code,
ErrorCode::UniqueConstraint
| ErrorCode::ForeignKeyConstraint
| ErrorCode::CheckConstraint
| ErrorCode::NotNullConstraint
)
}
pub fn is_timeout(&self) -> bool {
matches!(
self.code,
ErrorCode::QueryTimeout | ErrorCode::ConnectionTimeout
)
}
pub fn is_connection_error(&self) -> bool {
matches!(
self.code,
ErrorCode::ConnectionFailed
| ErrorCode::PoolExhausted
| ErrorCode::ConnectionTimeout
| ErrorCode::AuthenticationFailed
| ErrorCode::SslError
)
}
pub fn is_retryable(&self) -> bool {
matches!(
self.code,
ErrorCode::ConnectionTimeout
| ErrorCode::PoolExhausted
| ErrorCode::QueryTimeout
| ErrorCode::Deadlock
| ErrorCode::SerializationFailure
)
}
pub fn error_code(&self) -> &ErrorCode {
&self.code
}
pub fn docs_url(&self) -> String {
self.code.docs_url()
}
pub fn display_full(&self) -> String {
let mut output = String::new();
output.push_str(&format!("Error [{}]: {}\n", self.code.code(), self.message));
if let Some(ref op) = self.context.operation {
output.push_str(&format!(" → While: {}\n", op));
}
if let Some(ref model) = self.context.model {
output.push_str(&format!(" → Model: {}\n", model));
}
if let Some(ref field) = self.context.field {
output.push_str(&format!(" → Field: {}\n", field));
}
if let Some(ref sql) = self.context.sql {
let sql_display = if sql.len() > 200 {
format!("{}...", &sql[..200])
} else {
sql.clone()
};
output.push_str(&format!(" → SQL: {}\n", sql_display));
}
if !self.context.suggestions.is_empty() {
output.push_str("\nSuggestions:\n");
for (i, suggestion) in self.context.suggestions.iter().enumerate() {
output.push_str(&format!(" {}. {}\n", i + 1, suggestion.text));
if let Some(ref code) = suggestion.code {
output.push_str(&format!(
" ```\n {}\n ```\n",
code.replace('\n', "\n ")
));
}
}
}
if let Some(ref help) = self.context.help {
output.push_str(&format!("\nHelp: {}\n", help));
}
output.push_str(&format!("\nMore info: {}\n", self.docs_url()));
output
}
pub fn display_colored(&self) -> String {
let mut output = String::new();
output.push_str(&format!(
"\x1b[1;31mError [{}]\x1b[0m: \x1b[1m{}\x1b[0m\n",
self.code.code(),
self.message
));
if let Some(ref op) = self.context.operation {
output.push_str(&format!(" \x1b[2m→ While:\x1b[0m {}\n", op));
}
if let Some(ref model) = self.context.model {
output.push_str(&format!(" \x1b[2m→ Model:\x1b[0m {}\n", model));
}
if let Some(ref field) = self.context.field {
output.push_str(&format!(" \x1b[2m→ Field:\x1b[0m {}\n", field));
}
if !self.context.suggestions.is_empty() {
output.push_str("\n\x1b[1;33mSuggestions:\x1b[0m\n");
for (i, suggestion) in self.context.suggestions.iter().enumerate() {
output.push_str(&format!(
" \x1b[33m{}.\x1b[0m {}\n",
i + 1,
suggestion.text
));
if let Some(ref code) = suggestion.code {
output.push_str(&format!(
" \x1b[2m```\x1b[0m\n \x1b[36m{}\x1b[0m\n \x1b[2m```\x1b[0m\n",
code.replace('\n', "\n ")
));
}
}
}
if let Some(ref help) = self.context.help {
output.push_str(&format!("\n\x1b[1;36mHelp:\x1b[0m {}\n", help));
}
output.push_str(&format!(
"\n\x1b[2mMore info:\x1b[0m \x1b[4;34m{}\x1b[0m\n",
self.docs_url()
));
output
}
}
pub trait IntoQueryError {
fn into_query_error(self) -> QueryError;
}
impl<E: std::error::Error + Send + Sync + 'static> IntoQueryError for E {
fn into_query_error(self) -> QueryError {
QueryError::internal(self.to_string()).with_source(self)
}
}
#[macro_export]
macro_rules! query_error {
($code:expr, $msg:expr) => {
$crate::error::QueryError::new($code, $msg)
};
($code:expr, $msg:expr, $($key:ident = $value:expr),+ $(,)?) => {{
let mut err = $crate::error::QueryError::new($code, $msg);
$(
err = err.$key($value);
)+
err
}};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_code_format() {
assert_eq!(ErrorCode::RecordNotFound.code(), "P1001");
assert_eq!(ErrorCode::UniqueConstraint.code(), "P2001");
assert_eq!(ErrorCode::ConnectionFailed.code(), "P3001");
}
#[test]
fn test_not_found_error() {
let err = QueryError::not_found("User");
assert!(err.is_not_found());
assert!(err.message.contains("User"));
assert!(!err.context.suggestions.is_empty());
}
#[test]
fn test_unique_violation_error() {
let err = QueryError::unique_violation("User", "email");
assert!(err.is_constraint_violation());
assert_eq!(err.context.model, Some("User".to_string()));
assert_eq!(err.context.field, Some("email".to_string()));
}
#[test]
fn test_timeout_error() {
let err = QueryError::timeout(5000);
assert!(err.is_timeout());
assert!(err.message.contains("5000"));
}
#[test]
fn test_error_with_context() {
let err = QueryError::not_found("User")
.with_context("Finding user by email")
.with_suggestion("Use a different query method");
assert_eq!(
err.context.operation,
Some("Finding user by email".to_string())
);
assert!(err.context.suggestions.len() >= 2); }
#[test]
fn test_retryable_errors() {
assert!(QueryError::timeout(1000).is_retryable());
assert!(QueryError::deadlock().is_retryable());
assert!(QueryError::pool_exhausted(10).is_retryable());
assert!(!QueryError::not_found("User").is_retryable());
}
#[test]
fn test_connection_errors() {
assert!(QueryError::connection("failed").is_connection_error());
assert!(QueryError::authentication_failed("bad password").is_connection_error());
assert!(QueryError::pool_exhausted(10).is_connection_error());
}
#[test]
fn test_display_full() {
let err = QueryError::unique_violation("User", "email").with_context("Creating new user");
let output = err.display_full();
assert!(output.contains("P2001"));
assert!(output.contains("User"));
assert!(output.contains("email"));
assert!(output.contains("Suggestions"));
}
#[test]
fn test_docs_url() {
let err = QueryError::not_found("User");
assert!(err.docs_url().contains("P1001"));
}
#[test]
fn test_error_macro() {
let err = query_error!(
ErrorCode::InvalidParameter,
"Invalid email format",
with_field = "email",
with_suggestion = "Use a valid email address"
);
assert_eq!(err.code, ErrorCode::InvalidParameter);
assert_eq!(err.context.field, Some("email".to_string()));
}
#[test]
fn test_suggestion_with_code() {
let err = QueryError::not_found("User")
.with_code_suggestion("Try this instead", "client.user().find_first()");
let suggestion = err.context.suggestions.last().unwrap();
assert!(suggestion.code.is_some());
}
}