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
packages/bun-query-builder/src/orm.tsmodified+48-7
Changes to packages/bun-query-builder/src/orm.ts
@@ -2672,6 +2672,33 @@ class ModelQueryBuilder<
26722672 * the soft-delete predicate. User clauses are parenthesised when both are
26732673 * present so a top-level `OR` can't escape the soft-delete filter.
26742674 */
2675 /**
2676 * Refuse a destructive statement that carries clauses it will not emit.
2677 *
2678 * `delete()`, `update()` and `increment()` read none of `_limit`, `_offset`
2679 * or `_orderBy`, so `query().where(...).orderBy('id').limit(1).delete()`
2680 * quietly deleted every matching row rather than one — the caller's cap was
2681 * dropped on the floor. LIMIT on an UPDATE/DELETE is not portable (Postgres
2682 * has no such form), so these cannot simply be emitted; being loud is the
2683 * honest option. See #1111.
2684 */
2685 private assertNoUnappliedClauses(method: string): void {
2686 const ignored: string[] = []
2687 if (this._limit !== undefined)
2688 ignored.push('limit()')
2689 if (this._offset !== undefined)
2690 ignored.push('offset()')
2691 if (this._orderBy.length > 0)
2692 ignored.push('orderBy()')
2693
2694 if (ignored.length > 0) {
2695 throw new TypeError(
2696 `[orm] ${method}() cannot apply ${ignored.join(', ')} — an UPDATE/DELETE takes no ORDER BY or LIMIT on every supported dialect. `
2697 + `Previously these were ignored, so the statement affected every matching row. Narrow it with where() instead.`,
2698 )
2699 }
2700 }
2701
26752702 private composeWhere(params: unknown[]): string {
26762703 const userClause = this._wheres.length > 0 ? this.buildWhereClauses(params) : ''
26772704 const sd = this.softDeleteClause()
@@ -3020,6 +3047,7 @@ class ModelQueryBuilder<
30203047 */
30213048 async increment<K extends NumericColumns<TDef>>(column: K, amount = 1): Promise<number> {
30223049 assertValidIdentifier(column, 'increment(column)')
3050 this.assertNoUnappliedClauses('increment')
30233051 const exec = getExecutor()
30243052 const params: unknown[] = [amount]
30253053
@@ -3031,9 +3059,11 @@ class ModelQueryBuilder<
30313059 params.push(formatNow())
30323060 }
30333061
3034 if (this._wheres.length > 0) {
3035 const clauses = this.buildWhereClauses(params)
3036 sql += ` WHERE ${clauses}`
3062 // See delete(): composeWhere is the only path that adds the soft-delete
3063 // predicate, so a scoped counter bump has to go through it. #1111.
3064 const whereBody = this.composeWhere(params)
3065 if (whereBody) {
3066 sql += ` WHERE ${whereBody}`
30373067 }
30383068
30393069 return (await exec.run(sql, params)).changes
@@ -3185,18 +3215,26 @@ class ModelQueryBuilder<
31853215 }
31863216
31873217 async delete(): Promise<number> {
3218 this.assertNoUnappliedClauses('delete')
31883219 const exec = getExecutor()
31893220 const params: unknown[] = []
31903221 let sql = `DELETE FROM ${this._definition.table}`
31913222
3192 if (this._wheres.length > 0) {
3193 sql += ` WHERE ${this.buildWhereClauses(params)}`
3223 // composeWhere, not buildWhereClauses: the soft-delete predicate is added
3224 // only by composeWhere, so building the WHERE here meant a scope that
3225 // exists purely as that predicate contributed nothing to the statement.
3226 // `onlyTrashed().delete()` — purge the trash — emitted a bare DELETE and
3227 // removed every row, live ones included. See #1111.
3228 const whereBody = this.composeWhere(params)
3229 if (whereBody) {
3230 sql += ` WHERE ${whereBody}`
31943231 }
31953232
31963233 return (await exec.run(sql, params)).changes
31973234 }
31983235
31993236 async update(data: Partial<Pick<InferModelAttributes<TDef>, FillableKeys<TDef>>>): Promise<number> {
3237 this.assertNoUnappliedClauses('update')
32003238 const exec = getExecutor()
32013239 const entries = Object.entries(data)
32023240 const sets = entries.map(([k]) => `${sqlColumn(k)} = ?`).join(', ')
@@ -3208,8 +3246,11 @@ class ModelQueryBuilder<
32083246
32093247 let sql = `UPDATE ${this._definition.table} SET ${sets}${timestampsEnabled(this._definition) ? ', updated_at = ?' : ''}`
32103248
3211 if (this._wheres.length > 0) {
3212 sql += ` WHERE ${this.buildWhereClauses(params)}`
3249 // See delete(): composeWhere is the only path that adds the soft-delete
3250 // predicate, so a scoped update has to go through it. #1111.
3251 const whereBody = this.composeWhere(params)
3252 if (whereBody) {
3253 sql += ` WHERE ${whereBody}`
32133254 }
32143255
32153256 return (await exec.run(sql, params)).changes