fletch-orm 1.0.0

Lightweight database toolkit built on SQLx
Documentation
//! Error types for fletch database operations.

use sqlx::error::DatabaseError;

/// Errors that can occur during database operations.
#[derive(Debug, thiserror::Error)]
pub enum FletchError {
    /// The requested row was not found.
    #[error("not found")]
    NotFound,

    /// A unique or primary-key constraint was violated.
    #[error("unique violation: {0}")]
    UniqueViolation(String),

    /// A database error occurred.
    #[error("database error: {0}")]
    Database(String),

    /// The connection pool is closed.
    #[error("pool closed")]
    PoolClosed,

    /// Failed to encode a value for the database.
    #[error("encode error: {0}")]
    Encode(String),

    /// Failed to decode a value from the database.
    #[error("decode error: {0}")]
    Decode(String),

    /// A configuration error occurred.
    #[error("configuration error: {0}")]
    Configuration(String),
}

impl FletchError {
    /// Return true when `err` represents a unique or primary-key constraint violation.
    pub fn is_unique_violation(err: &Self) -> bool {
        match err {
            Self::UniqueViolation(_) => true,
            Self::Database(msg) => is_unique_violation_message(msg),
            _ => false,
        }
    }

    /// Convert from a `sqlx::Error` into a `FletchError`.
    pub fn from_sqlx(err: sqlx::Error) -> Self {
        match err {
            sqlx::Error::RowNotFound => Self::NotFound,
            sqlx::Error::PoolClosed => Self::PoolClosed,
            sqlx::Error::Configuration(e) => Self::Configuration(e.to_string()),
            sqlx::Error::ColumnDecode { source, .. } => Self::Decode(source.to_string()),
            sqlx::Error::Database(db) => {
                let msg = db.to_string();
                if is_sqlx_database_unique_violation(db.as_ref()) {
                    Self::UniqueViolation(msg)
                } else {
                    Self::Database(msg)
                }
            }
            other => Self::Database(other.to_string()),
        }
    }
}

impl From<sqlx::Error> for FletchError {
    fn from(err: sqlx::Error) -> Self {
        Self::from_sqlx(err)
    }
}

/// Return true when `db_err` represents a unique or primary-key constraint violation.
fn is_sqlx_database_unique_violation(db_err: &dyn DatabaseError) -> bool {
    if db_err.is_unique_violation() {
        return true;
    }

    if db_err
        .code()
        .is_some_and(|code| matches!(code.as_ref(), "2067" | "23505" | "1062"))
    {
        return true;
    }

    is_unique_violation_message(db_err.message())
}

/// Return true when `msg` looks like a unique or primary-key constraint violation.
fn is_unique_violation_message(msg: &str) -> bool {
    msg.contains("UNIQUE constraint failed")
        || msg.contains("duplicate key value violates unique constraint")
        || msg.contains("Duplicate entry")
        || msg.contains("23505")
        || msg.contains("1062")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn not_found_displays_message() {
        let err = FletchError::NotFound;
        assert_eq!(err.to_string(), "not found");
    }

    #[test]
    fn unique_violation_displays_message() {
        let err = FletchError::UniqueViolation("duplicate key".into());
        assert_eq!(err.to_string(), "unique violation: duplicate key");
    }

    #[test]
    fn database_displays_message() {
        let err = FletchError::Database("connection refused".into());
        assert_eq!(err.to_string(), "database error: connection refused");
    }

    #[test]
    fn pool_closed_displays_message() {
        let err = FletchError::PoolClosed;
        assert_eq!(err.to_string(), "pool closed");
    }

    #[test]
    fn encode_displays_message() {
        let err = FletchError::Encode("invalid type".into());
        assert_eq!(err.to_string(), "encode error: invalid type");
    }

    #[test]
    fn decode_displays_message() {
        let err = FletchError::Decode("unexpected null".into());
        assert_eq!(err.to_string(), "decode error: unexpected null");
    }

    #[test]
    fn configuration_displays_message() {
        let err = FletchError::Configuration("bad url".into());
        assert_eq!(err.to_string(), "configuration error: bad url");
    }

    #[test]
    fn fletch_error_is_send_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<FletchError>();
    }

    #[test]
    fn from_sqlx_row_not_found() {
        let err = FletchError::from(sqlx::Error::RowNotFound);
        assert!(matches!(err, FletchError::NotFound));
    }

    #[test]
    fn from_sqlx_pool_closed() {
        let err = FletchError::from(sqlx::Error::PoolClosed);
        assert!(matches!(err, FletchError::PoolClosed));
    }

    #[test]
    fn is_unique_violation_detects_variant() {
        let err = FletchError::UniqueViolation("duplicate".into());
        assert!(FletchError::is_unique_violation(&err));
    }

    #[test]
    fn is_unique_violation_detects_database_message_fallback() {
        let err = FletchError::Database(
            "error returned from database: duplicate key value violates unique constraint".into(),
        );
        assert!(FletchError::is_unique_violation(&err));
    }

    #[tokio::test]
    async fn from_sqlx_sqlite_unique_violation_uses_code() {
        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
        sqlx::query("CREATE TABLE users (email TEXT UNIQUE NOT NULL)")
            .execute(&pool)
            .await
            .unwrap();
        sqlx::query("INSERT INTO users (email) VALUES ('a@example.com')")
            .execute(&pool)
            .await
            .unwrap();

        let sqlx_err = sqlx::query("INSERT INTO users (email) VALUES ('a@example.com')")
            .execute(&pool)
            .await
            .unwrap_err();

        let err = FletchError::from_sqlx(sqlx_err);
        assert!(matches!(err, FletchError::UniqueViolation(_)));
        assert!(FletchError::is_unique_violation(&err));
    }
}