also looking at this
fix(orm): scope destructive statements the way reads are scoped
#1116
2 files
+210
-7
| @@ -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 | ||
| 22 | import { Database } from 'bun:sqlite' | |
| 23 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 24 | import { clearModelRegistry, configureOrm, createModel, createTableFromModel } from '../src' | |
| 25 | ||
| 26 | let db: Database | |
| 27 | let Post: any | |
| 28 | ||
| 29 | beforeEach(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 | ||
| 53 | afterEach(() => { | |
| 54 | clearModelRegistry() | |
| 55 | }) | |
| 56 | ||
| 57 | const total = (): number => (db.query('SELECT count(*) c FROM sd_posts').get() as any).c | |
| 58 | const alive = (): number => (db.query('SELECT count(*) c FROM sd_posts WHERE deleted_at IS NULL').get() as any).c | |
| 59 | const titles = (): string[] => (db.query('SELECT title FROM sd_posts ORDER BY title').all() as any[]).map(r => r.title) | |
| 60 | ||
| 61 | describe('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 | ||
| 89 | describe('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 | ||
| 113 | describe('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 | ||
| 140 | describe('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 | }) | |