Skip to content

Builder surface for user-supplied write conditions (update_if) #1167

Description

@carllerche

Problem

Toasty carries a Condition on stmt::Update and stmt::Delete, and the engine compiles it against every driver — the DynamoDB ConditionExpression path, the PostgreSQL CTE plan, and the SQLite/MySQL read-modify-write plan. #[version] is the only thing that produces one. Users have no way to write a conditional write of their own, so a check like "only publish if still in draft" has to be done as a read followed by a write, which is not atomic, or squeezed into the filter, which reports "no rows matched" instead of a distinguishable conflict.

A condition on a versioned model also has no defined composition rule. AND is the obvious answer, but the builder has no place to express the user's half, so it has never been settled.

Proposed solution

This issue proposes solving the problem, not the API below. The sketch is here
to make the problem concrete — it is one shape among several, and picking one
needs API exploration and a design doc before any implementation.

A builder method taking a predicate over the model's fields, producing the statement condition:

doc.update()
    .if_(|d| d.state().eq("draft"))
    .state("published")
    .exec(&mut db)
    .await?;

On a versioned model the user's condition is ANDed with the version condition Toasty attaches, so a stale instance fails even when the user's predicate holds. A failed condition returns Error::condition_failed, the same error a version conflict produces — which raises the question of whether callers need to tell the two apart, and if so, how.

Open points:

  • Naming and placement: .update_if(...) wrapping the whole update, versus a .if_(...) step on the existing builder, versus a condition argument to .exec().
  • Which expressions are allowed. The condition is evaluated by the driver, so it is limited to what every backend can express over the row's own columns — no subqueries, no cross-row references.
  • Whether query-based updates (multi-row) accept a condition, and what "the condition failed" means when it held for some matched rows and not others. The existing plans already compute matched-versus-conditioned counts per row, so the mechanism exists; the user-facing semantics do not.
  • The same surface for delete().

Alternatives considered

  • Status quo — conditions stay internal to #[version]. Keeps the API small, but leaves engine machinery that already works on all four drivers unreachable from user code, and forces read-then-write for every domain-level guard.
  • Push the predicate into the filter. Works for query-based writes today, but a failed guard is indistinguishable from a missing row, and it does not apply to instance writes at all.
  • Expose stmt::Condition directly. No new design work, but it puts an internal AST type in the public API and gives no compile-time checking that the expression references the model's own columns.

Scope

Medium — touches public API or one subsystem

Additional context

Carried over from the open questions of the #[version] design doc, where it blocked deciding the composition rule for versioned writes. update_if does not exist anywhere in the tree.

The original design also weighed, and rejected, replacing the #[version] attribute with a call-site .update_if(|u| u.version_eq(v)) — an attribute is a schema fact, and a per-call opt-in means every caller has to remember it on every write. That argument is about replacing the attribute; it does not apply to adding a general condition builder alongside it.

Overlaps with #1166 (conditional query-based writes on a caller-supplied version); if_version would be sugar over this surface, so the two should be decided together.

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-featureA new feature request or missing use case

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions