chain-builder 3.1.0

A typed, dialect-aware SQL query builder for Rust (PostgreSQL/MySQL/SQLite).
Documentation
# Mapping Errors to HTTP Status

**Problem:** a handler that executes chain-builder queries can fail three
ways — the query was invalid because of *request input* (the client's fault),
invalid because of a *bug in your chain* (your fault), or the *database*
failed. Those need to become 400, 500, and 500/404 respectively, in one place,
instead of ad-hoc matches in every handler.

The split is principled, not heuristic: every
[`BuildError`](../error-handling.md) variant describes a caller mistake, so
the only question is *did the offending value come from the request?* Two
variants can be triggered by end-user input in a typical filter/pagination
API — `InvalidHavingOperator` (an operator string taken from input) and
`OffsetWithoutLimit` (pagination parameters taken from input) — they map to
**400**. Every other build error is a bug in query construction → **500**.
`Error::Sqlx` is a database/driver failure → **500**, with `RowNotFound`
refined to **404** where "no such row" is an expected outcome.

> axum is used for illustration only — it is not a dependency of
> chain-builder. The match arms are the portable part; swap the
> `IntoResponse` glue for your framework's equivalent.

## The `AppError` wrapper

One newtype over `chain_builder::Error`, one `From` impl so `?` works in
every handler, one `IntoResponse` impl holding the whole mapping:

```rust,ignore
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
};
use chain_builder::{BuildError, Error};

struct AppError(Error);

// `?` on any fetch_*/execute/count call lands here.
impl From<Error> for AppError {
    fn from(e: Error) -> Self {
        Self(e)
    }
}

// `?` on a bare try_to_sql()/try_to_sqlx_query() also works:
// BuildError → chain_builder::Error → AppError.
impl From<BuildError> for AppError {
    fn from(e: BuildError) -> Self {
        Self(Error::from(e))
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self.0 {
            // ---- 4XX: the offending value came from the request ----------

            // A filter API let the client pick the operator; the allowlist
            // rejected it. The variant carries the rejected operator, but
            // echo it back with care (it is attacker-controlled text).
            Error::Build(BuildError::InvalidHavingOperator(_)) => (
                StatusCode::BAD_REQUEST,
                "unsupported filter operator".to_owned(),
            ),

            // Pagination params came straight from the request: an offset
            // without a limit is a client error here. If YOUR code computes
            // offsets independently of input, move this arm to the 500 bucket.
            Error::Build(BuildError::OffsetWithoutLimit) => (
                StatusCode::BAD_REQUEST,
                "offset requires a limit".to_owned(),
            ),

            // ---- 404 refinement: expected "no such row" -------------------

            // fetch_one / fetch_scalar on zero rows. Only sensible on
            // lookup-by-id style endpoints; drop this arm if a missing row
            // means a broken invariant instead.
            Error::Sqlx(sqlx::Error::RowNotFound) => {
                (StatusCode::NOT_FOUND, "not found".to_owned())
            }

            // ---- 5XX: programmer errors and infrastructure ----------------

            // Remaining build errors (LockRequiresSelect, EmptyInsert, …) are
            // bugs in query construction, not client input. Log loudly.
            Error::Build(e) => {
                tracing::error!(error = %e, "invalid query construction");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
            }

            // Connection / query / decode failure.
            Error::Sqlx(e) => {
                tracing::error!(error = %e, "database error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
            }

            // Error is #[non_exhaustive]: this wildcard arm is REQUIRED by
            // the compiler outside the chain-builder crate, and it is where
            // any future variant lands — default it to 500, never 200.
            e => {
                tracing::error!(error = %e, "unhandled chain-builder error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
            }
        };
        (status, message).into_response()
    }
}
```

A handler then needs no error code at all:

```rust,ignore
async fn get_user(
    State(pool): State<sqlx::PgPool>,
    Path(id): Path<i64>,
) -> Result<Json<UserRow>, AppError> {
    let user: UserRow = QueryBuilder::<Postgres>::table("users")
        .select(["id", "name", "role"])
        .where_eq("id", id)
        .fetch_one(&pool)        // no row → Error::Sqlx(RowNotFound) → 404
        .await?;
    Ok(Json(user))
}
```

## Notes & caveats

- **Wildcard arms are mandatory, twice.** Both `Error` *and* `BuildError` are
  `#[non_exhaustive]` — any `match` on either, anywhere outside the crate,
  needs a wildcard arm. Above, the inner `BuildError` matching happens inside
  `Error::Build(…)` patterns, and the trailing `e => …` arm covers future
  `Error` variants; the `Error::Build(e)` arm covers future `BuildError`
  variants. Route both wildcards to 500: a variant you did not anticipate is
  by definition not a request error you understood.
- **The 400 set is about *your* API surface.** `InvalidHavingOperator` and
  `OffsetWithoutLimit` are 400 only because those values plausibly arrive
  from a request. If your handlers never expose operators or raw offsets to
  clients, map everything in `Error::Build` to 500 and treat any occurrence
  as a bug.
- **Don't leak internals.** `BuildError`'s `Display` messages name builder
  methods, and `sqlx::Error` can include SQL fragments and table names —
  log them, but send a generic body. The one variant carrying user input,
  `InvalidHavingOperator(String)`, contains attacker-controlled text; if you
  echo it, treat it as untrusted output (encode/escape for the response
  format).
- **404 is a refinement, not a default.** `fetch_optional` returning
  `Ok(None)` is usually the cleaner way to express "missing is fine"; the
  `RowNotFound` arm catches the `fetch_one`/`fetch_scalar` paths.
- **Prefer the `try_*`/`fetch_*` family in handlers.** The panicking
  `to_sql()` path bypasses this whole mapping — it is for static,
  hand-written queries (see the [policy]../error-handling.md).

## Related pages

- [Error Handling]../error-handling.md — the variant table and the 4XX/5XX reasoning
- [Executing with sqlx]../sqlx.md — which helpers return `RowNotFound` vs `Ok(None)`
- [HTTP Filters & Pagination]http-filters-pagination.md — the handler that uses this `AppError`