ReviewOS

also looking at this

stacks/bun-query-builder

fix(orm)!: query delete() honours soft deletes and runs delete hooks

#1163
Merged glennmichael123 wants to merge fix/query-delete-hooks into main
6 files +406 -9
9 days ago

Problem

Model.query()…delete() was a bare DELETE on every model and ran no model hooks. On a soft-deletable model, that meant:

CallBeforeAfter
Post.delete(1)marks row 1, runs hooks(unchanged)
Post.where('id', '<', 3).delete()permanently removes the live match, no hooksmarks it, hooks per row
Post.query().delete()permanently removes every live row, no hooksmarks them, hooks per row
Post.withTrashed().where(…).delete()removes live and trashed matchesmarks the live ones; trashed rows keep their deleted_at
Post.onlyTrashed().delete()purges the trash (#1111), no hookssame, with hooks
Tag.where(…).delete() (no soft deletes)removes, no hooksremoves, hooks per row

This is the same inconsistency #1159 fixed for destroy(id), still present on the query path.

Change

  • delete() marks the matching rows on a model with useSoftDeletes and removes them on any other. onlyTrashed().delete() still purges, since marking rows that are already marked would do nothing. The docs in #1162 depend on that.
  • forceDelete() is new on the query builder. It removes the matching rows on any model and follows the scope: the default scope hides trashed rows, and withTrashed() reaches them. It refuses limit()/orderBy() the same way delete() does (#1111).
  • Hooks. A model hook takes the row, so when beforeDelete/afterDelete exist, the query reads the matching rows first and then deletes them by primary key, 500 keys per statement. The hooks and the statement cover the same rows. Every beforeDelete runs before anything is written, so one that throws cancels the whole delete. afterDelete sees trashed() as true after a mark, as it does for instance.delete().
  • No hooks, no read. A model with no delete hooks still sends one statement, so bulk deletes on those models cost what they did before.

docs/delete.md gets a "Deleting with a Model Query" section. It merges cleanly with #1162.

Breaking

On a model with useSoftDeletes, a query delete() now marks rows instead of removing them. Code that relied on the old purge should call forceDelete(). Two tests from #1111 (orm.destructive-scope.test.ts) asserted that purge. I kept what they protect (trashed rows are out of scope, and a user filter still applies) and moved the removal half to forceDelete().

Tests

  • test/orm.query-delete.test.ts: 15 tests covering soft and plain models, with and without hooks. They include a throwing beforeDelete leaving every row intact, a primary key of 0, and 1,200 rows deleted across three chunked statements. 14 fail on main.
  • test/orm.query-delete.pg.test.ts: the same behaviour on live Postgres, on both the bulk path and the hooks path. Marking binds the timestamp ahead of the WHERE parameters, which is only at risk under $n numbering. Fails on main.
  • Compile checks: a query delete() and forceDelete() both resolve to number.

Full suite: 2,302 pass / 0 fail / 10 skip. tsc clean. No new lint warnings (334, same as main).

Not changed

Model.query()…update() and increment() still run no beforeUpdate/afterUpdate hooks. That's the same gap on the write side, so it's worth a separate look if hook parity matters there too.

🤖 Generated with Claude Code

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

Sign in to comment on this pull request.