ReviewOS

also looking at this

stacks/bun-query-builder

fix(orm): scope destructive statements the way reads are scoped

#1116
Merged glennmichael123 wants to merge fix/1111-orm-soft-delete-scope into main
2 files +210 -7
8 days ago

Closes #1111.

delete(), update() and increment() built their WHERE with buildWhereClauses(), guarded by this._wheres.length > 0. Every read path uses composeWhere() — and composeWhere is the only place the soft-delete predicate is added.

So a scope expressed purely as that predicate contributed nothing to a write, and when it was the only scope the statement went out with no WHERE at all:

Post.query().onlyTrashed().delete()   ->   DELETE FROM sd_posts

"Purge the trash" was the exact call that destroyed the live rows.

Measured on a 4-row table with 2 trashed:

callbeforeafter
onlyTrashed().delete()total 0 — live rows gonetotal 2, both live rows intact
onlyTrashed().update({...})all 4 rewritten2 rewritten
onlyTrashed().increment('views', 5)all 4 bumped2 bumped
query().delete()trashed rows deleted tootrashed rows survive, as reads imply
limit(1).delete()every matching row deletedthrows, table intact

The fix is the shared function, not the symptom

All three now go through composeWhere — the same function that scopes a read. Two builders answering "which rows does this query mean?" is the defect; patching each symptom would leave them free to drift apart again. This is the same shape as #1094, where two column-type mappings disagreed.

limit / offset / orderBy

None of the three read those fields, so query().where(...).orderBy('id').limit(1).delete() silently deleted every matching row instead of one. They now throw.

They cannot simply be emitted: LIMIT on an UPDATE/DELETE is not portable — Postgres has no such form, and SQLite needs a non-default compile flag. Given the choice between dropping a caller's cap on the floor and saying so, the second is the only safe one.

Deliberately unchanged

query().where(...).delete() still hard-deletes on a model declaring useSoftDeletes, while the instance-level delete() soft-deletes. I verified the asymmetry (4 rows → 2, rather than 4 with 2 marked) and left it alone: the query builder has no forceDelete(), so making this path soft-delete would leave no way to hard-delete through it at all.

That wants forceDelete() added first, and it is a behaviour change to a documented method — a separate PR, not a rider on this one. Raising it rather than picking silently.

Tests

test/orm.destructive-scope.test.ts — 11 tests, 8 fail without the src change. The three that pass both ways are the invariants: a user filter still applies, an unaffected write still works, and a model without soft deletes behaves exactly as before.

They assert row counts rather than emitted SQL, because the defect was the gap between two WHERE builders — only the resulting table settles it.

Checks

  • typecheck clean; bun test — 2031 pass, 4 skip, 1 fail (stale local bin/qbx CLI > version, pre-existing, rebuilt by CI)
  • pickier reports one brace-style warning at orm.ts:465 — pre-existing, sits at :421 on main and moved only because this PR adds lines above it

🤖 Generated with Claude Code

2 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.