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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/delete.mdmodified+18-0
Changes to docs/delete.md
@@ -149,6 +149,24 @@ await Post.forceDelete(1) // or Post.remove(1)
149149An instance has the same pair: `post.delete()` marks a soft-deletable row,
150150`post.forceDelete()` removes it.
151151
152## Deleting with a Model Query
153
154A model query has the same pair. `delete()` marks the matching rows on a model
155with `useSoftDeletes` and removes them on any other; `forceDelete()` removes
156them either way. Both resolve to the number of rows affected.
157
158```typescript
159await Post.where('views', 0).delete() // marks them
160await Post.where('views', 0).forceDelete() // removes them
161await Post.onlyTrashed().delete() // purges the trash
162```
163
164`beforeDelete` and `afterDelete` run once per row. To give each hook its row,
165the query reads the matching rows first, then deletes them by primary key, so
166the hooks and the delete cover the same rows. Every `beforeDelete` runs before
167anything is written, so one that throws cancels the whole delete. A model with
168no delete hooks skips the read and sends a single statement.
169
152170## Delete with Returning
153171
154172```typescript
packages/bun-query-builder/src/__tests__/type-usage-compile.tsmodified+5-0
Changes to packages/bun-query-builder/src/__tests__/type-usage-compile.ts
@@ -1276,6 +1276,11 @@ async function _defineModelUsage() {
12761276 const removed: boolean = await Trail.remove(1)
12771277 void [purged, removed]
12781278
1279 // A query delete resolves to the rows affected, marked or removed
1280 const marked: number = await Trail.where('distance', '>', 1).delete()
1281 const forced: number = await Trail.query().onlyTrashed().forceDelete()
1282 void [marked, forced]
1283
12791284 // Column names stay narrow
12801285 // @ts-expect-error — unknown column
12811286 Trail.where('altitude', 1)
packages/bun-query-builder/src/orm.tsmodified+78-6
Changes to packages/bun-query-builder/src/orm.ts
@@ -926,6 +926,13 @@ export function getDatabase(): Database {
926926/** The column soft deletes are tracked on (matches the migration + `delete()`). */
927927const SOFT_DELETE_COLUMN = 'deleted_at'
928928
929/**
930 * Keys per statement when a query delete with hooks deletes the rows it read
931 * by key. Far under every dialect's bound-parameter limit (SQLite 32766,
932 * Postgres 65535).
933 */
934const DELETE_KEY_CHUNK = 500
935
929936/**
930937 * Alias prefix for the related table's columns in a belongsToMany SELECT, so a
931938 * same-named pivot column (`id`, `status`, `created_at`, …) can't overwrite the
@@ -3516,23 +3523,88 @@ class ModelQueryBuilder<
35163523 return Number(await this.aggregate('SUM', column as string) ?? 0) || 0
35173524 }
35183525
3526 /**
3527 * Delete the matching rows the way `instance.delete()` would: a model with
3528 * `useSoftDeletes` has them marked, any other has them removed, and
3529 * `beforeDelete`/`afterDelete` run for each row. Resolves to the number of
3530 * rows affected.
3531 *
3532 * This was a bare `DELETE` on every model, with no hooks. On a soft-deletable
3533 * model `Post.where(...).delete()` permanently removed live rows that
3534 * `Post.delete(id)` would have marked.
3535 *
3536 * `onlyTrashed().delete()` still purges the trash (#1111), since marking rows
3537 * that are already marked would do nothing. `forceDelete()` purges any scope.
3538 */
35193539 async delete(): Promise<number> {
35203540 this.assertNoUnappliedClauses('delete')
3541 const mark = softDeletesEnabled(this._definition as ModelDefinition) && this._trashed !== 'only'
3542 return this.deleteMatching(mark)
3543 }
3544
3545 /**
3546 * Remove the matching rows for good, soft deletes or not. Delete hooks run
3547 * for each row. Resolves to the number of rows removed.
3548 */
3549 async forceDelete(): Promise<number> {
3550 this.assertNoUnappliedClauses('forceDelete')
3551 return this.deleteMatching(false)
3552 }
3553
3554 private async deleteMatching(mark: boolean): Promise<number> {
35213555 const exec = getExecutor()
3522 const params: unknown[] = []
3523 let sql = `DELETE FROM ${this._definition.table}`
3556 const table = this._definition.table
3557 const hooks = this._definition.hooks
35243558
35253559 // composeWhere, not buildWhereClauses: the soft-delete predicate is added
35263560 // only by composeWhere, so building the WHERE here meant a scope that
35273561 // exists purely as that predicate contributed nothing to the statement.
35283562 // `onlyTrashed().delete()` — purge the trash — emitted a bare DELETE and
35293563 // removed every row, live ones included. See #1111.
3530 const whereBody = this.composeWhere(params)
3531 if (whereBody) {
3532 sql += ` WHERE ${whereBody}`
3564 const params: unknown[] = []
3565 let whereBody = this.composeWhere(params)
3566 // withTrashed() reaches rows that are already marked. Marking them again
3567 // would move their deleted_at, so they are left as they are.
3568 if (mark && this._trashed === 'include')
3569 whereBody = whereBody ? `(${whereBody}) AND ${SOFT_DELETE_COLUMN} IS NULL` : `${SOFT_DELETE_COLUMN} IS NULL`
3570 const where = whereBody ? ` WHERE ${whereBody}` : ''
3571 const now = mark ? formatNow() : undefined
3572
3573 if (!hooks?.beforeDelete && !hooks?.afterDelete) {
3574 return mark
3575 ? (await exec.run(`UPDATE ${table} SET ${SOFT_DELETE_COLUMN} = ?${where}`, [now, ...params])).changes
3576 : (await exec.run(`DELETE FROM ${table}${where}`, params)).changes
35333577 }
35343578
3535 return (await exec.run(sql, params)).changes
3579 // Hooks take the row, so the rows are read first and then deleted by key:
3580 // the hooks and the statement see the same set. Every beforeDelete runs
3581 // before anything is written, so one that throws stops the whole delete.
3582 const pk = this._definition.primaryKey || 'id'
3583 const rows = await exec.all(`SELECT * FROM ${table}${where}`, params)
3584 const instances = rows.map(row => new ModelInstance<TDef>(this._definition, row as Partial<ModelAttributes<TDef>>, true))
3585 const keys = instances.map(i => (i as any)._attributes[pk] as unknown)
3586 if (keys.some(k => k == null))
3587 throw new Error(`Cannot delete a model without a primary key: '${pk}' is missing from a matching row`)
3588
3589 for (const instance of instances)
3590 await hooks.beforeDelete?.(instance as unknown as ModelHookInstance)
3591
3592 let changes = 0
3593 for (let i = 0; i < keys.length; i += DELETE_KEY_CHUNK) {
3594 const chunk = keys.slice(i, i + DELETE_KEY_CHUNK)
3595 const inList = `${pk} IN (${chunk.map(() => '?').join(', ')})`
3596 changes += mark
3597 ? (await exec.run(`UPDATE ${table} SET ${SOFT_DELETE_COLUMN} = ? WHERE ${inList}`, [now, ...chunk])).changes
3598 : (await exec.run(`DELETE FROM ${table} WHERE ${inList}`, chunk)).changes
3599 }
3600
3601 for (const instance of instances) {
3602 // As instance.delete() does, so trashed() in afterDelete is accurate.
3603 if (mark)
3604 (instance as any)._attributes[SOFT_DELETE_COLUMN] = now
3605 await hooks.afterDelete?.(instance as unknown as ModelHookInstance)
3606 }
3607 return changes
35363608 }
35373609
35383610 async update(data: Partial<Pick<InferModelAttributes<TDef>, FillableKeys<TDef>>>): Promise<number> {
packages/bun-query-builder/test/orm.destructive-scope.test.tsmodified+17-3
Changes to packages/bun-query-builder/test/orm.destructive-scope.test.ts
@@ -57,6 +57,8 @@ afterEach(() => {
5757const total = (): number => (db.query('SELECT count(*) c FROM sd_posts').get() as any).c
5858const alive = (): number => (db.query('SELECT count(*) c FROM sd_posts WHERE deleted_at IS NULL').get() as any).c
5959const titles = (): string[] => (db.query('SELECT title FROM sd_posts ORDER BY title').all() as any[]).map(r => r.title)
60const trashedOn = (date: string): string[] =>
61 (db.query('SELECT title FROM sd_posts WHERE deleted_at = ? ORDER BY title').all(date) as any[]).map(r => r.title)
6062
6163describe('delete() honours the soft-delete scope (#1111)', () => {
6264 it('onlyTrashed().delete() removes the trashed rows and spares the live ones', async () => {
@@ -73,15 +75,27 @@ describe('delete() honours the soft-delete scope (#1111)', () => {
7375 it('an unscoped delete still excludes trashed rows, as reads do', async () => {
7476 await Post.query().delete()
7577
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.
78 // The default scope hides trashed rows, so a delete must not reach them
79 // either — otherwise the same query means two different row sets
80 // depending on whether it reads or writes. On this soft-deletable model
81 // the live rows are marked, and the trashed ones keep their deleted_at.
82 expect(total()).toBe(4)
83 expect(alive()).toBe(0)
84 expect(trashedOn('2026-01-01')).toEqual(['a', 'b'])
85 })
86
87 it('an unscoped forceDelete removes only the rows reads would see', async () => {
88 await Post.query().forceDelete()
89
7990 expect(titles()).toEqual(['a', 'b'])
8091 })
8192
8293 it('a user filter still applies alongside the scope', async () => {
8394 await Post.query().where('title', 'c').delete()
95 expect(total()).toBe(4)
96 expect(alive()).toBe(1)
8497
98 await Post.query().withTrashed().where('title', 'c').forceDelete()
8599 expect(titles()).toEqual(['a', 'b', 'd'])
86100 })
87101})
packages/bun-query-builder/test/orm.query-delete.pg.test.tsadded+107-0
Changes to packages/bun-query-builder/test/orm.query-delete.pg.test.ts
@@ -0,0 +1,107 @@
1/**
2 * `Model.query()…delete()` and `forceDelete()` against a live Postgres — see
3 * orm.query-delete.test.ts for the change. Marking puts the timestamp ahead of
4 * the WHERE bindings, which only `$n` numbering would get wrong, so both the
5 * bulk path (no hooks) and the read-then-delete-by-key path (hooks) run here.
6 *
7 * Runs in a subprocess: an earlier configureOrm() in the same process pins the
8 * model layer to sqlite and would mask the network-driver path.
9 */
10
11import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
12import { tmpdir } from 'node:os'
13import { join, resolve } from 'node:path'
14import process from 'node:process'
15import { describe, expect, it } from 'bun:test'
16import { PG_URL, probePostgres } from './pg'
17
18const pgAvailable = await probePostgres()
19
20describe.skipIf(!pgAvailable)('query delete() and forceDelete() against live Postgres', () => {
21 it('marks on soft-deletable models, removes on forceDelete, runs hooks', () => {
22 const srcEntry = resolve(import.meta.dir, '../src/index.ts')
23 const dir = mkdtempSync(join(tmpdir(), 'qb-qdel-'))
24 const scriptPath = join(dir, 'probe.ts')
25
26 writeFileSync(scriptPath, `
27import { SQL } from 'bun'
28import { setConfig, resetConnection, defineModel, clearModelRegistry } from ${JSON.stringify(srcEntry)}
29
30const URL = ${JSON.stringify(PG_URL)}
31const failures = []
32const check = (label, got, want) => {
33 const g = JSON.stringify(got), w = JSON.stringify(want)
34 if (g !== w) failures.push(label + ': got ' + g + ', want ' + w)
35}
36const attempt = async (label, fn) => {
37 try { return await fn() }
38 catch (e) { failures.push(label + ': threw ' + (e && e.message)); return undefined }
39}
40
41const raw = new SQL(URL)
42const seed = async () => {
43 for (const t of ['_qb_qdel_hooked', '_qb_qdel_plain']) {
44 await raw.unsafe('DROP TABLE IF EXISTS ' + t)
45 await raw.unsafe('CREATE TABLE ' + t + ' (id int primary key, title text, deleted_at timestamptz)')
46 await raw.unsafe('INSERT INTO ' + t + " VALUES (1, 'a', '2020-06-15'), (2, 'b', NULL), (3, 'c', NULL), (4, 'd', NULL)")
47 }
48}
49// title, with '*' when marked and '*2020' when it kept its original date.
50const state = async t => (await raw.unsafe('SELECT title, deleted_at FROM ' + t + ' ORDER BY id'))
51 .map(r => r.deleted_at == null ? r.title : new Date(r.deleted_at).getUTCFullYear() === 2020 ? r.title + '*2020' : r.title + '*')
52
53setConfig({ dialect: 'postgres', database: { url: URL } })
54resetConnection()
55clearModelRegistry()
56const log = []
57const Hooked = defineModel({ name: 'QdelHooked', table: '_qb_qdel_hooked', primaryKey: 'id',
58 traits: { useSoftDeletes: true, useTimestamps: false }, attributes: { title: { type: 'string', fillable: true } },
59 hooks: { beforeDelete: m => { log.push('before:' + m.get('title')) }, afterDelete: m => { log.push('after:' + m.get('title')) } } })
60const Plain = defineModel({ name: 'QdelPlain', table: '_qb_qdel_plain', primaryKey: 'id',
61 traits: { useSoftDeletes: true, useTimestamps: false }, attributes: { title: { type: 'string', fillable: true } } })
62
63for (const [label, M, table] of [['hooks', Hooked, '_qb_qdel_hooked'], ['bulk', Plain, '_qb_qdel_plain']]) {
64 await seed(); log.length = 0
65 // A binding in the WHERE, so the timestamp has to be numbered ahead of it.
66 check(label + ' delete count', await attempt(label + ' delete', () => M.where('title', 'in', ['a', 'b', 'c']).where('id', '>', 1).delete()), 2)
67 check(label + ' delete marks', await state(table), ['a*2020', 'b*', 'c*', 'd'])
68
69 await seed()
70 check(label + ' withTrashed delete', await attempt(label + ' withTrashed', () => M.withTrashed().where('id', '<', 3).delete()), 1)
71 check(label + ' withTrashed keeps old date', await state(table), ['a*2020', 'b*', 'c', 'd'])
72
73 await seed()
74 check(label + ' onlyTrashed purges', await attempt(label + ' onlyTrashed', () => M.onlyTrashed().delete()), 1)
75 check(label + ' onlyTrashed rows', await state(table), ['b', 'c', 'd'])
76
77 await seed()
78 check(label + ' forceDelete count', await attempt(label + ' forceDelete', () => M.where('id', '<', 4).forceDelete()), 2)
79 check(label + ' forceDelete rows', await state(table), ['a*2020', 'd'])
80}
81
82await seed(); log.length = 0
83await attempt('hook order', () => Hooked.where('id', '>', 2).delete())
84check('hooks per row, befores first', log, ['before:c', 'before:d', 'after:c', 'after:d'])
85
86for (const t of ['_qb_qdel_hooked', '_qb_qdel_plain'])
87 await raw.unsafe('DROP TABLE IF EXISTS ' + t)
88await raw.end()
89
90if (failures.length) {
91 console.error('FAILURES:\\n' + failures.join('\\n'))
92 process.exit(1)
93}
94console.log('OK')
95process.exit(0)
96`)
97
98 const proc = Bun.spawnSync({ cmd: ['bun', scriptPath], stdout: 'pipe', stderr: 'pipe', cwd: process.cwd(), env: { ...process.env } })
99 const dec = new TextDecoder()
100 const out = dec.decode(proc.stdout).trim()
101 const err = dec.decode(proc.stderr).trim()
102 rmSync(dir, { recursive: true, force: true })
103
104 expect(proc.exitCode, `query delete pg probe failed.\nstdout: ${out}\nstderr: ${err}`).toBe(0)
105 expect(out).toContain('OK')
106 }, 30_000)
107})
packages/bun-query-builder/test/orm.query-delete.test.tsadded+181-0
Changes to packages/bun-query-builder/test/orm.query-delete.test.ts
@@ -0,0 +1,181 @@
1/**
2 * `Model.query()…delete()` deletes the way `instance.delete()` does, and
3 * `forceDelete()` purges.
4 *
5 * A query delete was a bare `DELETE` on every model and ran no hooks. So on a
6 * soft-deletable model `Post.where(...).delete()` permanently removed live rows
7 * that `Post.delete(id)` would have marked, and neither `beforeDelete` nor
8 * `afterDelete` saw any of them.
9 *
10 * Now a soft-deletable model has the rows marked, hooks run per row, and
11 * `forceDelete()` is the permanent one. `onlyTrashed().delete()` still purges
12 * the trash (#1111).
13 */
14
15import { Database } from 'bun:sqlite'
16import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
17import { clearModelRegistry, configureOrm, createModel } from '../src'
18
19let db: Database
20const log: string[] = []
21
22function model(name: string, table: string, opts: { soft: boolean, hooks: boolean }): any {
23 return createModel({
24 name,
25 table,
26 primaryKey: 'id',
27 traits: { useSoftDeletes: opts.soft, useTimestamps: false },
28 attributes: { title: { type: 'string', fillable: true } },
29 ...(opts.hooks
30 ? {
31 hooks: {
32 beforeDelete: (m: any) => { log.push(`before:${m.get('title')}`) },
33 afterDelete: (m: any) => { log.push(`after:${m.get('title')}${m.trashed?.() ? ' (trashed)' : ''}`) },
34 },
35 }
36 : {}),
37 } as any)
38}
39
40let Post: any // soft deletes, hooks
41let Tag: any // no soft deletes, hooks
42let Note: any // soft deletes, no hooks
43let Log: any // neither
44
45beforeEach(() => {
46 clearModelRegistry()
47 log.length = 0
48 db = new Database(':memory:', { create: true })
49 configureOrm({ database: db })
50 for (const t of ['qd_posts', 'qd_notes'])
51 db.run(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY, title TEXT, deleted_at TEXT)`)
52 for (const t of ['qd_tags', 'qd_logs'])
53 db.run(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY, title TEXT)`)
54 // 'a' is already trashed on the soft-deletable tables; 'b', 'c', 'd' are live.
55 for (const t of ['qd_posts', 'qd_notes'])
56 db.run(`INSERT INTO ${t} VALUES (1, 'a', '2020-01-01'), (2, 'b', NULL), (3, 'c', NULL), (4, 'd', NULL)`)
57 for (const t of ['qd_tags', 'qd_logs'])
58 db.run(`INSERT INTO ${t} VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')`)
59
60 Post = model('QdPost', 'qd_posts', { soft: true, hooks: true })
61 Tag = model('QdTag', 'qd_tags', { soft: false, hooks: true })
62 Note = model('QdNote', 'qd_notes', { soft: true, hooks: false })
63 Log = model('QdLog', 'qd_logs', { soft: false, hooks: false })
64})
65
66afterEach(() => {
67 clearModelRegistry()
68 db.close()
69})
70
71/** Each row as `title` or `title*` when marked, and whether 'a' kept its original date. */
72function state(table: string): string[] {
73 return (db.query(`SELECT * FROM ${table} ORDER BY id`).all() as any[])
74 .map(r => r.deleted_at == null ? r.title : r.deleted_at === '2020-01-01' ? `${r.title}*2020` : `${r.title}*`)
75}
76
77describe('delete() on a soft-deletable model', () => {
78 it('marks the matching rows instead of removing them', async () => {
79 // Was a DELETE: 'b' and 'c' were gone for good.
80 expect(await Post.where('id', '<', 4).delete()).toBe(2)
81 expect(state('qd_posts')).toEqual(['a*2020', 'b*', 'c*', 'd'])
82 })
83
84 it('runs the delete hooks for each row, and afterDelete sees it trashed', async () => {
85 await Post.where('title', 'in', ['b', 'c']).delete()
86 expect(log).toEqual(['before:b', 'before:c', 'after:b (trashed)', 'after:c (trashed)'])
87 })
88
89 it('marks every live row when unscoped, and leaves the trash alone', async () => {
90 expect(await Post.query().delete()).toBe(3)
91 expect(state('qd_posts')).toEqual(['a*2020', 'b*', 'c*', 'd*'])
92 expect(log.filter(l => l.startsWith('before'))).toEqual(['before:b', 'before:c', 'before:d'])
93 })
94
95 it('withTrashed() does not re-stamp rows that are already marked', async () => {
96 expect(await Post.withTrashed().where('id', '<', 3).delete()).toBe(1)
97 expect(state('qd_posts')).toEqual(['a*2020', 'b*', 'c', 'd'])
98 expect(log).toEqual(['before:b', 'after:b (trashed)'])
99 })
100
101 it('onlyTrashed().delete() still purges the trash (#1111)', async () => {
102 expect(await Post.onlyTrashed().delete()).toBe(1)
103 expect(state('qd_posts')).toEqual(['b', 'c', 'd'])
104 expect(log).toEqual(['before:a', 'after:a (trashed)'])
105 })
106
107 it('marks without hooks on a model that has none', async () => {
108 expect(await Note.where('id', '<', 4).delete()).toBe(2)
109 expect(state('qd_notes')).toEqual(['a*2020', 'b*', 'c*', 'd'])
110 })
111})
112
113describe('forceDelete() on a query', () => {
114 it('removes live matches for good on a soft-deletable model', async () => {
115 expect(await Post.where('id', '<', 4).forceDelete()).toBe(2)
116 // The default scope still hides 'a', so it is not reached.
117 expect(state('qd_posts')).toEqual(['a*2020', 'd'])
118 expect(log).toEqual(['before:b', 'before:c', 'after:b', 'after:c'])
119 })
120
121 it('reaches trashed rows under withTrashed()', async () => {
122 expect(await Post.withTrashed().where('id', '<', 3).forceDelete()).toBe(2)
123 expect(state('qd_posts')).toEqual(['c', 'd'])
124 })
125
126 it('removes rows on a model without soft deletes, as delete() does', async () => {
127 expect(await Tag.where('id', '>', 2).forceDelete()).toBe(2)
128 expect(await Tag.where('id', '=', 1).delete()).toBe(1)
129 expect(state('qd_tags')).toEqual(['b'])
130 expect(log).toEqual(['before:c', 'before:d', 'after:c', 'after:d', 'before:a', 'after:a'])
131 })
132
133 it('works without hooks', async () => {
134 expect(await Note.query().forceDelete()).toBe(3)
135 expect(await Log.query().forceDelete()).toBe(4)
136 expect(state('qd_notes')).toEqual(['a*2020'])
137 expect(state('qd_logs')).toEqual([])
138 })
139
140 it('refuses limit() and orderBy() like delete() does (#1111)', async () => {
141 await expect(Post.query().orderBy('id').limit(1).forceDelete()).rejects.toThrow('forceDelete() cannot apply')
142 expect(state('qd_posts')).toEqual(['a*2020', 'b', 'c', 'd'])
143 })
144})
145
146describe('query deletes with hooks', () => {
147 it('a throwing beforeDelete stops the whole delete', async () => {
148 clearModelRegistry()
149 const Guarded = createModel({
150 name: 'QdGuarded',
151 table: 'qd_tags',
152 primaryKey: 'id',
153 attributes: { title: { type: 'string', fillable: true } },
154 hooks: { beforeDelete: (m: any) => { if (m.get('title') === 'c') throw new Error('c is protected') } },
155 } as any) as any
156
157 await expect(Guarded.query().delete()).rejects.toThrow('c is protected')
158 // Every beforeDelete runs before anything is written.
159 expect(state('qd_tags')).toEqual(['a', 'b', 'c', 'd'])
160 })
161
162 it('a query matching nothing runs no hooks', async () => {
163 expect(await Post.where('title', 'zzz').delete()).toBe(0)
164 expect(await Tag.where('title', 'zzz').forceDelete()).toBe(0)
165 expect(log).toEqual([])
166 })
167
168 it('deletes a row whose primary key is 0', async () => {
169 db.run(`INSERT INTO qd_tags VALUES (0, 'zero')`)
170 expect(await Tag.where('title', 'zero').delete()).toBe(1)
171 expect(state('qd_tags')).toEqual(['a', 'b', 'c', 'd'])
172 })
173
174 it('deletes more rows than fit in one statement', async () => {
175 const insert = db.prepare('INSERT INTO qd_tags (id, title) VALUES (?, ?)')
176 db.transaction(() => { for (let i = 100; i < 1300; i++) insert.run(i, `t${i}`) })()
177 expect(await Tag.where('id', '>=', 100).delete()).toBe(1200)
178 expect(state('qd_tags')).toEqual(['a', 'b', 'c', 'd'])
179 expect(log.length).toBe(2400)
180 })
181})