Problem
Model.query()…delete() was a bare DELETE on every model and ran no model hooks. On a soft-deletable model, that meant:
| Call | Before | After |
|---|---|---|
Post.delete(1) | marks row 1, runs hooks | (unchanged) |
Post.where('id', '<', 3).delete() | permanently removes the live match, no hooks | marks it, hooks per row |
Post.query().delete() | permanently removes every live row, no hooks | marks them, hooks per row |
Post.withTrashed().where(…).delete() | removes live and trashed matches | marks the live ones; trashed rows keep their deleted_at |
Post.onlyTrashed().delete() | purges the trash (#1111), no hooks | same, with hooks |
Tag.where(…).delete() (no soft deletes) | removes, no hooks | removes, 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 withuseSoftDeletesand 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, andwithTrashed()reaches them. It refuseslimit()/orderBy()the same waydelete()does (#1111).- Hooks. A model hook takes the row, so when
beforeDelete/afterDeleteexist, 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. EverybeforeDeleteruns before anything is written, so one that throws cancels the whole delete.afterDeleteseestrashed()as true after a mark, as it does forinstance.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 throwingbeforeDeleteleaving every row intact, a primary key of0, and 1,200 rows deleted across three chunked statements. 14 fail onmain.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$nnumbering. Fails onmain.- Compile checks: a query
delete()andforceDelete()both resolve tonumber.
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