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/test/orm.destructive-scope.test.tsadded+162-0
Changes to packages/bun-query-builder/test/orm.destructive-scope.test.ts
@@ -0,0 +1,162 @@
1/**
2 * The ORM's destructive statements must be scoped the way its reads are.
3 * stacksjs/bun-query-builder#1111.
4 *
5 * `delete()`, `update()` and `increment()` built their WHERE with
6 * `buildWhereClauses()`, guarded by `this._wheres.length > 0`. Every read path
7 * uses `composeWhere()` — and `composeWhere` is the only place the soft-delete
8 * predicate is added.
9 *
10 * So a scope expressed purely as that predicate contributed nothing to a
11 * write, and when it was the ONLY scope the statement went out with no WHERE
12 * at all:
13 *
14 * Post.query().onlyTrashed().delete() -> DELETE FROM posts (every row)
15 *
16 * "Purge the trash" was the exact call that destroyed the live rows.
17 *
18 * These assert row counts rather than emitted SQL. The defect was the gap
19 * between two WHERE builders, so only the resulting table settles it.
20 */
21
22import { Database } from 'bun:sqlite'
23import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
24import { clearModelRegistry, configureOrm, createModel, createTableFromModel } from '../src'
25
26let db: Database
27let Post: any
28
29beforeEach(async () => {
30 clearModelRegistry()
31 db = new Database(':memory:', { create: true })
32 configureOrm({ database: db })
33
34 Post = createModel({
35 name: 'Sdpost',
36 table: 'sd_posts',
37 primaryKey: 'id',
38 autoIncrement: true,
39 traits: { useSoftDeletes: true },
40 attributes: {
41 title: { type: 'string', fillable: true },
42 views: { type: 'integer', fillable: true },
43 },
44 } as any)
45
46 await createTableFromModel(Post.getDefinition())
47 for (const title of ['a', 'b', 'c', 'd'])
48 await Post.create({ title, views: 0 })
49 // 'a' and 'b' are trashed; 'c' and 'd' are live.
50 db.run(`UPDATE sd_posts SET deleted_at = '2026-01-01' WHERE title IN ('a','b')`)
51})
52
53afterEach(() => {
54 clearModelRegistry()
55})
56
57const total = (): number => (db.query('SELECT count(*) c FROM sd_posts').get() as any).c
58const alive = (): number => (db.query('SELECT count(*) c FROM sd_posts WHERE deleted_at IS NULL').get() as any).c
59const titles = (): string[] => (db.query('SELECT title FROM sd_posts ORDER BY title').all() as any[]).map(r => r.title)
60
61describe('delete() honours the soft-delete scope (#1111)', () => {
62 it('onlyTrashed().delete() removes the trashed rows and spares the live ones', async () => {
63 expect([total(), alive()]).toEqual([4, 2])
64
65 await Post.query().onlyTrashed().delete()
66
67 // Previously: total 0 — the live rows went too.
68 expect(total()).toBe(2)
69 expect(alive()).toBe(2)
70 expect(titles()).toEqual(['c', 'd'])
71 })
72
73 it('an unscoped delete still excludes trashed rows, as reads do', async () => {
74 await Post.query().delete()
75
76 // The default scope hides trashed rows, so a hard delete must not reach
77 // them either — otherwise the same query means two different row sets
78 // depending on whether it reads or writes.
79 expect(titles()).toEqual(['a', 'b'])
80 })
81
82 it('a user filter still applies alongside the scope', async () => {
83 await Post.query().where('title', 'c').delete()
84
85 expect(titles()).toEqual(['a', 'b', 'd'])
86 })
87})
88
89describe('update() and increment() honour the scope (#1111)', () => {
90 it('onlyTrashed().update() rewrites only the trashed rows', async () => {
91 await Post.query().onlyTrashed().update({ title: 'PURGED' })
92
93 const purged = (db.query(`SELECT count(*) c FROM sd_posts WHERE title='PURGED'`).get() as any).c
94 expect(purged).toBe(2)
95 expect(total()).toBe(4)
96 })
97
98 it('onlyTrashed().increment() bumps only the trashed rows', async () => {
99 await Post.query().onlyTrashed().increment('views', 5)
100
101 const bumped = (db.query('SELECT count(*) c FROM sd_posts WHERE views = 5').get() as any).c
102 expect(bumped).toBe(2)
103 })
104
105 it('an unscoped update leaves trashed rows alone', async () => {
106 await Post.query().update({ views: 9 })
107
108 const bumped = (db.query('SELECT count(*) c FROM sd_posts WHERE views = 9').get() as any).c
109 expect(bumped).toBe(2)
110 })
111})
112
113describe('clauses a write cannot emit are refused (#1111)', () => {
114 // Silently ignoring limit() is what turned `limit(1).delete()` into a delete
115 // of every matching row. LIMIT on UPDATE/DELETE is not portable — Postgres
116 // has no such form — so being loud is the honest option.
117 it('limit() on a delete throws instead of being dropped', async () => {
118 await expect(Post.query().limit(1).delete()).rejects.toThrow(/cannot apply limit/)
119 expect(total()).toBe(4)
120 })
121
122 it('orderBy() on a delete throws instead of being dropped', async () => {
123 await expect(Post.query().orderBy('id').delete()).rejects.toThrow(/cannot apply .*orderBy/)
124 expect(total()).toBe(4)
125 })
126
127 it('limit() on an update throws instead of being dropped', async () => {
128 await expect(Post.query().limit(1).update({ views: 3 })).rejects.toThrow(/cannot apply limit/)
129 const bumped = (db.query('SELECT count(*) c FROM sd_posts WHERE views = 3').get() as any).c
130 expect(bumped).toBe(0)
131 })
132
133 it('a write with no ordering or limit is unaffected', async () => {
134 await expect(Post.query().where('title', 'c').update({ views: 7 })).resolves.toBeDefined()
135 const bumped = (db.query('SELECT count(*) c FROM sd_posts WHERE views = 7').get() as any).c
136 expect(bumped).toBe(1)
137 })
138})
139
140describe('a model without soft deletes is unchanged (#1111)', () => {
141 it('deletes exactly what the filter selects', async () => {
142 clearModelRegistry()
143 const plain = new Database(':memory:', { create: true })
144 configureOrm({ database: plain })
145
146 const Item = createModel({
147 name: 'Plainitem',
148 table: 'plain_items',
149 primaryKey: 'id',
150 autoIncrement: true,
151 attributes: { name: { type: 'string', fillable: true } },
152 } as any)
153 await createTableFromModel((Item as any).getDefinition())
154 for (const name of ['x', 'y', 'z'])
155 await (Item as any).create({ name })
156
157 await (Item as any).query().where('name', 'y').delete()
158
159 const left = (plain.query('SELECT name FROM plain_items ORDER BY name').all() as any[]).map(r => r.name)
160 expect(left).toEqual(['x', 'z'])
161 })
162})