Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error Types

EsRepo generates four per-entity error types and a column enum. For an entity called User, the macro produces:

TypeUsed by
UserColumnEnum of indexed columns (e.g. Id, Name, Email)
UserCreateErrorcreate, create_all
UserModifyErrorupdate, update_all, delete
UserFindErrorfind_by_*, maybe_find_by_*
UserQueryErrorfind_all, list_by_*, list_for_*, list_for_filters

UserCreateError

pub enum UserCreateError {
    Sqlx(sqlx::Error),
    ConstraintViolation {
        column: Option<UserColumn>,
        value: Option<String>,
        inner: sqlx::Error,
    },
    ConcurrentModification,
    HydrationError(EntityHydrationError),
    PostPersistHookError(/* only if post_persist_hook configured */),
    PostHydrateError(/* only if post_hydrate_hook configured */),
}

PostPersistHookError and PostHydrateError are only present when the corresponding hook is configured. PostPersistHookError wraps sqlx::Error by default, or a custom error type if configured via post_persist_hook(error = "..."). See Hooks for details.

Handling constraint violations

When a create or create_all operation violates a unique, foreign key, or check constraint, the error is returned as ConstraintViolation rather than a raw Sqlx error. (NOT NULL and exclusion violations are not classified and remain Sqlx.) For unique violations, the column field identifies which column caused the violation and the value field contains the conflicting value extracted from the PostgreSQL error detail. For foreign key and check violations — or unique constraints not recognized as belonging to one of the entity’s columns — column and value are None; use the typed violated_constraint() helper (or the raw constraint_name()) to identify the constraint instead.

Security note: value (and the duplicate_value() helper) contains attacker-influenced input that was rejected by a unique constraint and is frequently PII (e.g. an email address). Do not propagate it to untrusted API clients — a caller can probe which values already exist (user enumeration) — and be aware it may end up in logs via the error’s Display/Debug output. At trust boundaries, prefer the boolean helpers was_duplicate() / was_duplicate_by(column) and map the error to a neutral client-facing message.

let result = users.create(new_user).await;
match result {
    Ok(user) => { /* success */ }
    // Column-agnostic check
    Err(e) if e.was_duplicate() => {
        println!("some unique constraint violated");
    }
    Err(e) => return Err(e.into()),
}

// Or check a specific column:
match result {
    Ok(user) => { /* success */ }
    Err(e) if e.was_duplicate_by(UserColumn::Email) => {
        let value = e.duplicate_value(); // Option<&str>
        println!("email {} already taken", value.unwrap_or("unknown"));
    }
    Err(e) => return Err(e.into()),
}

The was_duplicate() / was_duplicate_by(column) helpers only fire for unique violations; was_foreign_key_violation() and was_check_violation() cover the other classified kinds.

Typed constraints: UserConstraint

Alongside the column enum, the macro derives a UserConstraint enum with one variant per constraint on the entity’s table known at compile time: the declared columns’ unique constraints (convention names like users_email_key / users_pkey) plus every unique, foreign key, and check constraint discoverable from the migrations directory (the same catalog that drives list_for_filters specialization). Variant names strip the table prefix — entries_account_not_account_set_fkey on table entries becomes EntryConstraint::AccountNotAccountSetFkey.

This makes dispatching on a hand-written foreign-key or check constraint typo-proof — no string matching at the call site, and a renamed constraint in a migration surfaces as a compile error instead of a silently dead match arm:

match result {
    Ok(entry) => { /* success */ }
    Err(e) if e.violated_constraint() == Some(EntryConstraint::AccountNotAccountSetFkey) => {
        return Err(AppError::EntryTargetsAccountSet);
    }
    Err(e) => return Err(e.into()),
}

Each variant knows its raw name (constraint.name() / Display) and kind (constraint.kind()ConstraintKind::{Unique, ForeignKey, Check}). Constraints created outside discoverable migrations can’t be typed; fall back to constraint_name() for those:

Err(e) if e.constraint_name() == Some("added_at_runtime_fkey") => { /* ... */ }

The macro maps PostgreSQL constraint names to columns automatically. It uses the convention {table}_{column}_key for unique constraints and {table}_pkey for the primary key, and additionally derives the real names of any named single-column unique index from your migrations (the same index catalog that drives list_for_filters specialization — see list_for_filters). So a CREATE UNIQUE INDEX idx_unique_email ON users (email) in a migration is mapped to the email column with no extra annotation — as long as the migrations directory is discoverable (crate-local migrations/, an ancestor migrations/ up to the repo root, or ES_ENTITY_MIGRATIONS_DIR). A composite UNIQUE (a, b) is mapped to its last key column (b) — the discriminating column, with the leading columns acting as its scope — so a UNIQUE (partner_id, name) violation reports the name column.

Concurrent modification

When optimistic concurrency control detects a conflict (duplicate event sequence), the error is ConcurrentModification:

if e.was_concurrent_modification() {
    // retry the operation
}

UserModifyError

UserModifyError has the same structure as UserCreateError (minus HydrationError and PostHydrateError) and is returned by update, update_all, and delete. PostPersistHookError is only present when post_persist_hook is configured. It provides the same was_duplicate, was_duplicate_by, duplicate_value, and was_concurrent_modification helpers.

Nested entity errors

For aggregates with nested entities (e.g. Order containing OrderItems), CreateError and ModifyError include additional variants wrapping the child’s errors. The duplicate_value and was_concurrent_modification helpers cascade into nested errors automatically:

// If a nested OrderItem creation triggers a constraint violation,
// duplicate_value() still returns the conflicting value:
let val = err.duplicate_value(); // cascades into nested variants

The was_duplicate_by helper does not cascade because nested entities have a different column enum. To check which nested column was violated, match the nested variant directly:

match err {
    OrderModifyError::OrderItemsCreate(item_err)
        if item_err.was_duplicate_by(OrderItemColumn::Sku) =>
    {
        let val = item_err.duplicate_value();
    }
    _ => return Err(err.into()),
}

UserFindError

pub enum UserFindError {
    Sqlx(sqlx::Error),
    NotFound { entity: &'static str, column: Option<UserColumn>, value: String },
    HydrationError(EntityHydrationError),
    PostHydrateError(/* only if post_hydrate_hook configured */),
}

The NotFound variant is returned by find_by_* methods when no matching row exists. It includes the entity name, the column searched (as the UserColumn enum), and the value that was not found.

Checking for not-found

let result = users.find_by_id(some_id).await;
match result {
    Ok(user) => { /* found */ }
    Err(e) if e.was_not_found() => {
        println!("user not found");
    }
    Err(e) => return Err(e.into()),
}

Matching on a specific column

Use was_not_found_by to check which column was searched, or pattern-match directly on the NotFound variant for full control:

// Helper method
if e.was_not_found_by(UserColumn::Email) {
    let value = e.not_found_value(); // Option<&str>
    println!("no user with email {}", value.unwrap_or("unknown"));
}

// Pattern matching for custom error conversion
impl From<UserFindError> for AppError {
    fn from(error: UserFindError) -> Self {
        match error {
            UserFindError::NotFound {
                column: Some(UserColumn::Id),
                value,
                ..
            } => Self::UserNotFoundById(value),
            UserFindError::NotFound {
                column: Some(UserColumn::Email),
                value,
                ..
            } => Self::UserNotFoundByEmail(value),
            other => Self::Internal(other.into()),
        }
    }
}

Use maybe_find_by_* to get Ok(None) instead of an error when the entity doesn’t exist.

UserQueryError

pub enum UserQueryError {
    Sqlx(sqlx::Error),
    HydrationError(EntityHydrationError),
    CursorDestructureError(CursorDestructureError),
    PostHydrateError(/* only if post_hydrate_hook configured */),
}

Returned by paginated list operations (list_by_*, list_for_*, list_for_filters). The CursorDestructureError variant occurs when a pagination cursor cannot be decoded.