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.
Problem
Toasty carries a
Conditiononstmt::Updateandstmt::Delete, and the engine compiles it against every driver — the DynamoDBConditionExpressionpath, 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.
ANDis 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:
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:
.update_if(...)wrapping the whole update, versus a.if_(...)step on the existing builder, versus a condition argument to.exec().delete().Alternatives considered
#[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.stmt::Conditiondirectly. 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_ifdoes 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_versionwould be sugar over this surface, so the two should be decided together.