use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatabaseConstraintError {
UniqueViolation,
ForeignKeyViolation,
NotNullViolation,
CheckViolation,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConstraintDetails {
pub table: Option<String>,
pub constraint: Option<String>,
pub columns: Vec<String>,
}
#[derive(Debug)]
pub struct DatabaseError {
constraint: Option<DatabaseConstraintError>,
details: ConstraintDetails,
source: anyhow::Error,
}
impl DatabaseError {
pub fn new(source: anyhow::Error) -> Self {
let constraint = classify_constraint(&source);
let details = if constraint.is_some() { constraint_details(&source) } else { ConstraintDetails::default() };
Self { constraint, details, source }
}
pub fn constraint(&self) -> Option<DatabaseConstraintError> {
self.constraint
}
pub fn constraint_details(&self) -> &ConstraintDetails {
&self.details
}
pub fn original(&self) -> &anyhow::Error {
&self.source
}
pub fn into_original(self) -> anyhow::Error {
self.source
}
}
impl fmt::Display for DatabaseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.source.fmt(formatter)
}
}
impl std::error::Error for DatabaseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.source.as_ref())
}
}
#[derive(Debug)]
pub struct RowDecodeError {
model: &'static str,
}
impl RowDecodeError {
pub fn new(model: &'static str) -> Self {
Self { model }
}
}
impl fmt::Display for RowDecodeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "failed to decode database row as `{}`", self.model)
}
}
impl std::error::Error for RowDecodeError {}
pub fn is_decode_error(error: &anyhow::Error) -> bool {
error.chain().any(|source| source.is::<RowDecodeError>())
}
fn classify_constraint(error: &anyhow::Error) -> Option<DatabaseConstraintError> {
for source in error.chain() {
if let Some(error) = source.downcast_ref::<rusqlite::Error>()
&& let rusqlite::Error::SqliteFailure(code, _) = error
{
return match code.extended_code {
rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE | rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY => {
Some(DatabaseConstraintError::UniqueViolation)
}
rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY => Some(DatabaseConstraintError::ForeignKeyViolation),
rusqlite::ffi::SQLITE_CONSTRAINT_NOTNULL => Some(DatabaseConstraintError::NotNullViolation),
rusqlite::ffi::SQLITE_CONSTRAINT_CHECK => Some(DatabaseConstraintError::CheckViolation),
_ => None,
};
}
if let Some(error) = source.downcast_ref::<tokio_postgres::Error>()
&& let Some(error) = error.as_db_error()
{
return match *error.code() {
tokio_postgres::error::SqlState::UNIQUE_VIOLATION => Some(DatabaseConstraintError::UniqueViolation),
tokio_postgres::error::SqlState::FOREIGN_KEY_VIOLATION => {
Some(DatabaseConstraintError::ForeignKeyViolation)
}
tokio_postgres::error::SqlState::NOT_NULL_VIOLATION => Some(DatabaseConstraintError::NotNullViolation),
tokio_postgres::error::SqlState::CHECK_VIOLATION => Some(DatabaseConstraintError::CheckViolation),
_ => None,
};
}
if let Some(mysql_async::Error::Server(error)) = source.downcast_ref::<mysql_async::Error>() {
return match error.code {
1062 => Some(DatabaseConstraintError::UniqueViolation),
1451 | 1452 => Some(DatabaseConstraintError::ForeignKeyViolation),
1048 | 1364 => Some(DatabaseConstraintError::NotNullViolation),
3819 => Some(DatabaseConstraintError::CheckViolation),
_ => None,
};
}
}
None
}
fn constraint_details(error: &anyhow::Error) -> ConstraintDetails {
for source in error.chain() {
if let Some(rusqlite::Error::SqliteFailure(_, Some(message))) = source.downcast_ref::<rusqlite::Error>() {
return sqlite_constraint_details(message);
}
if let Some(error) = source.downcast_ref::<tokio_postgres::Error>()
&& let Some(error) = error.as_db_error()
{
let mut columns = error.column().map(|column| vec![column.to_string()]).unwrap_or_default();
if columns.is_empty() {
columns = error.detail().map(postgres_detail_columns).unwrap_or_default();
}
return ConstraintDetails {
table: error.table().map(str::to_string),
constraint: error.constraint().map(str::to_string),
columns,
};
}
if let Some(mysql_async::Error::Server(error)) = source.downcast_ref::<mysql_async::Error>() {
return mysql_constraint_details(error.code, &error.message);
}
}
ConstraintDetails::default()
}
fn sqlite_constraint_details(message: &str) -> ConstraintDetails {
let Some((kind, target)) = message.split_once(" constraint failed: ") else {
return ConstraintDetails::default();
};
if kind == "CHECK" {
return ConstraintDetails { constraint: Some(target.trim().to_string()), ..Default::default() };
}
let mut details = ConstraintDetails::default();
for column in target.split(',').map(str::trim) {
match column.rsplit_once('.') {
Some((table, column)) => {
details.table.get_or_insert_with(|| table.to_string());
details.columns.push(column.to_string());
}
None => details.columns.push(column.to_string()),
}
}
details
}
fn postgres_detail_columns(detail: &str) -> Vec<String> {
detail
.strip_prefix("Key (")
.and_then(|rest| rest.split_once(")="))
.map(|(columns, _)| columns.split(',').map(|column| column.trim().trim_matches('"').to_string()).collect())
.unwrap_or_default()
}
fn mysql_constraint_details(code: u16, message: &str) -> ConstraintDetails {
match code {
1062 => {
let key = message.rsplit_once(" for key '").map(|(_, key)| key.trim_end_matches('\''));
match key.and_then(|key| key.split_once('.')) {
Some((table, constraint)) => ConstraintDetails {
table: Some(table.to_string()),
constraint: Some(constraint.to_string()),
columns: Vec::new(),
},
None => ConstraintDetails { constraint: key.map(str::to_string), ..Default::default() },
}
}
1048 | 1364 => ConstraintDetails {
columns: between(message, "'", "'").map(|column| vec![column.to_string()]).unwrap_or_default(),
..Default::default()
},
1451 | 1452 => ConstraintDetails {
table: between(message, "`.`", "`").map(str::to_string),
constraint: between(message, "CONSTRAINT `", "`").map(str::to_string),
columns: between(message, "FOREIGN KEY (", ")")
.map(|columns| columns.split(',').map(|column| column.trim().trim_matches('`').to_string()).collect())
.unwrap_or_default(),
},
3819 => ConstraintDetails { constraint: between(message, "'", "'").map(str::to_string), ..Default::default() },
_ => ConstraintDetails::default(),
}
}
fn between<'a>(message: &'a str, start: &str, end: &str) -> Option<&'a str> {
let (_, rest) = message.split_once(start)?;
rest.split_once(end).map(|(value, _)| value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sqlite_messages_expose_table_and_columns() {
assert_eq!(
sqlite_constraint_details("UNIQUE constraint failed: account.email, account.tenant_id"),
ConstraintDetails {
table: Some("account".to_string()),
constraint: None,
columns: vec!["email".to_string(), "tenant_id".to_string()],
}
);
assert_eq!(
sqlite_constraint_details("CHECK constraint failed: positive_balance").constraint.as_deref(),
Some("positive_balance")
);
assert_eq!(sqlite_constraint_details("FOREIGN KEY constraint failed"), ConstraintDetails::default());
}
#[test]
fn postgres_detail_exposes_unique_columns() {
assert_eq!(postgres_detail_columns("Key (email)=(a@dinoco.rs) already exists."), vec!["email"]);
assert_eq!(
postgres_detail_columns("Key (tenant_id, \"email\")=(1, a) already exists."),
vec!["tenant_id", "email"]
);
}
#[test]
fn mysql_messages_expose_constraint_and_columns() {
let unique =
mysql_constraint_details(1062, "Duplicate entry 'a@dinoco.rs' for key 'account.account_email_key'");
assert_eq!(unique.table.as_deref(), Some("account"));
assert_eq!(unique.constraint.as_deref(), Some("account_email_key"));
let not_null = mysql_constraint_details(1048, "Column 'email' cannot be null");
assert_eq!(not_null.columns, vec!["email"]);
let foreign_key = mysql_constraint_details(
1452,
"Cannot add or update a child row: a foreign key constraint fails (`app`.`session`, CONSTRAINT `session_account_fk` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`))",
);
assert_eq!(foreign_key.table.as_deref(), Some("session"));
assert_eq!(foreign_key.constraint.as_deref(), Some("session_account_fk"));
assert_eq!(foreign_key.columns, vec!["account_id"]);
let check = mysql_constraint_details(3819, "Check constraint 'positive_balance' is violated.");
assert_eq!(check.constraint.as_deref(), Some("positive_balance"));
}
}