also looking at this
fix(orm)!: query delete() honours soft deletes and runs delete hooks
#1163
6 files
+406
-9
| @@ -149,6 +149,24 @@ await Post.forceDelete(1) // or Post.remove(1) | ||
| 149 | 149 | An instance has the same pair: `post.delete()` marks a soft-deletable row, |
| 150 | 150 | `post.forceDelete()` removes it. |
| 151 | 151 | |
| 152 | ## Deleting with a Model Query | |
| 153 | ||
| 154 | A model query has the same pair. `delete()` marks the matching rows on a model | |
| 155 | with `useSoftDeletes` and removes them on any other; `forceDelete()` removes | |
| 156 | them either way. Both resolve to the number of rows affected. | |
| 157 | ||
| 158 | ```typescript | |
| 159 | await Post.where('views', 0).delete() // marks them | |
| 160 | await Post.where('views', 0).forceDelete() // removes them | |
| 161 | await Post.onlyTrashed().delete() // purges the trash | |
| 162 | ``` | |
| 163 | ||
| 164 | `beforeDelete` and `afterDelete` run once per row. To give each hook its row, | |
| 165 | the query reads the matching rows first, then deletes them by primary key, so | |
| 166 | the hooks and the delete cover the same rows. Every `beforeDelete` runs before | |
| 167 | anything is written, so one that throws cancels the whole delete. A model with | |
| 168 | no delete hooks skips the read and sends a single statement. | |
| 169 | ||
| 152 | 170 | ## Delete with Returning |
| 153 | 171 | |
| 154 | 172 | ```typescript |
| @@ -1276,6 +1276,11 @@ async function _defineModelUsage() { | ||
| 1276 | 1276 | const removed: boolean = await Trail.remove(1) |
| 1277 | 1277 | void [purged, removed] |
| 1278 | 1278 | |
| 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 | ||
| 1279 | 1284 | // Column names stay narrow |
| 1280 | 1285 | // @ts-expect-error — unknown column |
| 1281 | 1286 | Trail.where('altitude', 1) |
| @@ -926,6 +926,13 @@ export function getDatabase(): Database { | ||
| 926 | 926 | /** The column soft deletes are tracked on (matches the migration + `delete()`). */ |
| 927 | 927 | const SOFT_DELETE_COLUMN = 'deleted_at' |
| 928 | 928 | |
| 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 | */ | |
| 934 | const DELETE_KEY_CHUNK = 500 | |
| 935 | ||
| 929 | 936 | /** |
| 930 | 937 | * Alias prefix for the related table's columns in a belongsToMany SELECT, so a |
| 931 | 938 | * same-named pivot column (`id`, `status`, `created_at`, …) can't overwrite the |
| @@ -3516,23 +3523,88 @@ class ModelQueryBuilder< | ||
| 3516 | 3523 | return Number(await this.aggregate('SUM', column as string) ?? 0) || 0 |
| 3517 | 3524 | } |
| 3518 | 3525 | |
| 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 | */ | |
| 3519 | 3539 | async delete(): Promise<number> { |
| 3520 | 3540 | 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> { | |
| 3521 | 3555 | 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 | |
| 3524 | 3558 | |
| 3525 | 3559 | // composeWhere, not buildWhereClauses: the soft-delete predicate is added |
| 3526 | 3560 | // only by composeWhere, so building the WHERE here meant a scope that |
| 3527 | 3561 | // exists purely as that predicate contributed nothing to the statement. |
| 3528 | 3562 | // `onlyTrashed().delete()` — purge the trash — emitted a bare DELETE and |
| 3529 | 3563 | // 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 | |
| 3533 | 3577 | } |
| 3534 | 3578 | |
| 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 | |
| 3536 | 3608 | } |
| 3537 | 3609 | |
| 3538 | 3610 | async update(data: Partial<Pick<InferModelAttributes<TDef>, FillableKeys<TDef>>>): Promise<number> { |
| @@ -57,6 +57,8 @@ afterEach(() => { | ||
| 57 | 57 | const total = (): number => (db.query('SELECT count(*) c FROM sd_posts').get() as any).c |
| 58 | 58 | const alive = (): number => (db.query('SELECT count(*) c FROM sd_posts WHERE deleted_at IS NULL').get() as any).c |
| 59 | 59 | const titles = (): string[] => (db.query('SELECT title FROM sd_posts ORDER BY title').all() as any[]).map(r => r.title) |
| 60 | const 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) | |
| 60 | 62 | |
| 61 | 63 | describe('delete() honours the soft-delete scope (#1111)', () => { |
| 62 | 64 | 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)', () => { | ||
| 73 | 75 | it('an unscoped delete still excludes trashed rows, as reads do', async () => { |
| 74 | 76 | await Post.query().delete() |
| 75 | 77 | |
| 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 | ||
| 79 | 90 | expect(titles()).toEqual(['a', 'b']) |
| 80 | 91 | }) |
| 81 | 92 | |
| 82 | 93 | it('a user filter still applies alongside the scope', async () => { |
| 83 | 94 | await Post.query().where('title', 'c').delete() |
| 95 | expect(total()).toBe(4) | |
| 96 | expect(alive()).toBe(1) | |
| 84 | 97 | |
| 98 | await Post.query().withTrashed().where('title', 'c').forceDelete() | |
| 85 | 99 | expect(titles()).toEqual(['a', 'b', 'd']) |
| 86 | 100 | }) |
| 87 | 101 | }) |
| @@ -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 | ||
| 11 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 12 | import { tmpdir } from 'node:os' | |
| 13 | import { join, resolve } from 'node:path' | |
| 14 | import process from 'node:process' | |
| 15 | import { describe, expect, it } from 'bun:test' | |
| 16 | import { PG_URL, probePostgres } from './pg' | |
| 17 | ||
| 18 | const pgAvailable = await probePostgres() | |
| 19 | ||
| 20 | describe.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, ` | |
| 27 | import { SQL } from 'bun' | |
| 28 | import { setConfig, resetConnection, defineModel, clearModelRegistry } from ${JSON.stringify(srcEntry)} | |
| 29 | ||
| 30 | const URL = ${JSON.stringify(PG_URL)} | |
| 31 | const failures = [] | |
| 32 | const 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 | } | |
| 36 | const attempt = async (label, fn) => { | |
| 37 | try { return await fn() } | |
| 38 | catch (e) { failures.push(label + ': threw ' + (e && e.message)); return undefined } | |
| 39 | } | |
| 40 | ||
| 41 | const raw = new SQL(URL) | |
| 42 | const 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. | |
| 50 | const 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 | ||
| 53 | setConfig({ dialect: 'postgres', database: { url: URL } }) | |
| 54 | resetConnection() | |
| 55 | clearModelRegistry() | |
| 56 | const log = [] | |
| 57 | const 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')) } } }) | |
| 60 | const Plain = defineModel({ name: 'QdelPlain', table: '_qb_qdel_plain', primaryKey: 'id', | |
| 61 | traits: { useSoftDeletes: true, useTimestamps: false }, attributes: { title: { type: 'string', fillable: true } } }) | |
| 62 | ||
| 63 | for (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 | ||
| 82 | await seed(); log.length = 0 | |
| 83 | await attempt('hook order', () => Hooked.where('id', '>', 2).delete()) | |
| 84 | check('hooks per row, befores first', log, ['before:c', 'before:d', 'after:c', 'after:d']) | |
| 85 | ||
| 86 | for (const t of ['_qb_qdel_hooked', '_qb_qdel_plain']) | |
| 87 | await raw.unsafe('DROP TABLE IF EXISTS ' + t) | |
| 88 | await raw.end() | |
| 89 | ||
| 90 | if (failures.length) { | |
| 91 | console.error('FAILURES:\\n' + failures.join('\\n')) | |
| 92 | process.exit(1) | |
| 93 | } | |
| 94 | console.log('OK') | |
| 95 | process.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 | }) | |
| @@ -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 | ||
| 15 | import { Database } from 'bun:sqlite' | |
| 16 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 17 | import { clearModelRegistry, configureOrm, createModel } from '../src' | |
| 18 | ||
| 19 | let db: Database | |
| 20 | const log: string[] = [] | |
| 21 | ||
| 22 | function 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 | ||
| 40 | let Post: any // soft deletes, hooks | |
| 41 | let Tag: any // no soft deletes, hooks | |
| 42 | let Note: any // soft deletes, no hooks | |
| 43 | let Log: any // neither | |
| 44 | ||
| 45 | beforeEach(() => { | |
| 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 | ||
| 66 | afterEach(() => { | |
| 67 | clearModelRegistry() | |
| 68 | db.close() | |
| 69 | }) | |
| 70 | ||
| 71 | /** Each row as `title` or `title*` when marked, and whether 'a' kept its original date. */ | |
| 72 | function 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 | ||
| 77 | describe('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 | ||
| 113 | describe('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 | ||
| 146 | describe('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 | }) | |