also looking at this
fix(client): apply the filters that follow returning(), instead of discarding them
#1115
2 files
+262
-44
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.
| @@ -7115,26 +7115,47 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7115 | 7115 | return this |
| 7116 | 7116 | }, |
| 7117 | 7117 | returning(...cols) { |
| 7118 | const retText = `${sqlText} RETURNING ${cols.join(', ')}` | |
| 7119 | const q = params.length > 0 | |
| 7120 | ? _sql.unsafe(retText, params) | |
| 7121 | : _sql.unsafe(retText) | |
| 7118 | const parent: any = this | |
| 7119 | // Render at execute time, not here. | |
| 7120 | // | |
| 7121 | // This used to freeze the statement text immediately and return an | |
| 7122 | // object whose where/andWhere/orWhere/orderBy/limit/offset were | |
| 7123 | // literally `() => obj` — so every filter applied after returning() | |
| 7124 | // was discarded and the UPDATE ran against the whole table, silently, | |
| 7125 | // while `.executeTakeFirst()` handed back a row it had never filtered | |
| 7126 | // to. `returning()` is typed as SelectQueryBuilder, which declares all | |
| 7127 | // of those, so the broken order type-checks. See #1110. | |
| 7128 | // | |
| 7129 | // Deferring also fixes the aliasing form, where the returning handle | |
| 7130 | // is held while the parent gains a predicate. | |
| 7131 | const retText = () => `${sqlText} RETURNING ${cols.join(', ')}` | |
| 7132 | const build = () => (params.length > 0 ? _sql.unsafe(retText(), params) : _sql.unsafe(retText())) | |
| 7122 | 7133 | const runFirst = async () => { |
| 7123 | const rows = await runWithHooks<any[]>(q, 'update') | |
| 7134 | const rows = await runWithHooks<any[]>(build(), 'update') | |
| 7124 | 7135 | return Array.isArray(rows) ? rows[0] : rows |
| 7125 | 7136 | } |
| 7137 | // Not expressible on this builder. Silently ignoring them is what | |
| 7138 | // made a `.limit(1)` destructive write hit every row. | |
| 7139 | const unsupported = (name: string) => (): never => { | |
| 7140 | throw new TypeError(`[query-builder] updateTable.returning(...).${name}() is not supported — an UPDATE cannot be ordered or limited through this builder. Apply the filter with where() instead.`) | |
| 7141 | } | |
| 7126 | 7142 | const obj: any = { |
| 7127 | where: () => obj, | |
| 7128 | andWhere: () => obj, | |
| 7129 | orWhere: () => obj, | |
| 7130 | orderBy: () => obj, | |
| 7131 | limit: () => obj, | |
| 7132 | offset: () => obj, | |
| 7133 | toSQL: () => makeExecutableQuery(q, retText) as any, | |
| 7134 | execute: () => runWithHooks<any[]>(q, 'update'), | |
| 7143 | // Delegate to the builder that owns the statement, then keep | |
| 7144 | // chaining from here. Consecutive predicates join with AND, which | |
| 7145 | // is what andWhere means. | |
| 7146 | where: (...args: any[]) => { parent.where(...args); return obj }, | |
| 7147 | andWhere: (...args: any[]) => { parent.where(...args); return obj }, | |
| 7148 | whereNull: (...args: any[]) => { parent.whereNull(...args); return obj }, | |
| 7149 | whereNotNull: (...args: any[]) => { parent.whereNotNull(...args); return obj }, | |
| 7150 | orWhere: unsupported('orWhere'), | |
| 7151 | orderBy: unsupported('orderBy'), | |
| 7152 | limit: unsupported('limit'), | |
| 7153 | offset: unsupported('offset'), | |
| 7154 | toSQL: () => makeExecutableQuery(build(), retText()) as any, | |
| 7155 | execute: () => runWithHooks<any[]>(build(), 'update'), | |
| 7135 | 7156 | // returning() is typed as SelectQueryBuilder — expose the |
| 7136 | 7157 | // row-fetching methods so `.returning('id').first()` works. |
| 7137 | get: () => runWithHooks<any[]>(q, 'update'), | |
| 7158 | get: () => runWithHooks<any[]>(build(), 'update'), | |
| 7138 | 7159 | first: runFirst, |
| 7139 | 7160 | executeTakeFirst: runFirst, |
| 7140 | 7161 | async firstOrFail() { |
| @@ -7175,19 +7196,20 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7175 | 7196 | return { numUpdatedRows: result } |
| 7176 | 7197 | }, |
| 7177 | 7198 | returningAll() { |
| 7178 | const retAllText = `${sqlText} RETURNING *` | |
| 7179 | const q = params.length > 0 | |
| 7180 | ? _sql.unsafe(retAllText, params) | |
| 7181 | : _sql.unsafe(retAllText) | |
| 7199 | // Deferred for the same reason as returning(): holding this handle | |
| 7200 | // while the parent gains a predicate must not execute the statement | |
| 7201 | // as it stood beforehand. See #1110. | |
| 7202 | const retAllText = () => `${sqlText} RETURNING *` | |
| 7203 | const build = () => (params.length > 0 ? _sql.unsafe(retAllText(), params) : _sql.unsafe(retAllText())) | |
| 7182 | 7204 | return { |
| 7183 | toSQL: () => makeExecutableQuery(q, retAllText) as any, | |
| 7184 | execute: () => runWithHooks<any[]>(q, 'update'), | |
| 7205 | toSQL: () => makeExecutableQuery(build(), retAllText()) as any, | |
| 7206 | execute: () => runWithHooks<any[]>(build(), 'update'), | |
| 7185 | 7207 | async executeTakeFirst() { |
| 7186 | const result = await runWithHooks<any[]>(q, 'update') | |
| 7208 | const result = await runWithHooks<any[]>(build(), 'update') | |
| 7187 | 7209 | return Array.isArray(result) ? result[0] : result |
| 7188 | 7210 | }, |
| 7189 | 7211 | async executeTakeFirstOrThrow() { |
| 7190 | const result = await runWithHooks<any[]>(q, 'update') | |
| 7212 | const result = await runWithHooks<any[]>(build(), 'update') | |
| 7191 | 7213 | const first = Array.isArray(result) ? result[0] : result |
| 7192 | 7214 | if (!first) |
| 7193 | 7215 | throw new Error('Update with RETURNING failed') |
| @@ -7337,25 +7359,46 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7337 | 7359 | return this |
| 7338 | 7360 | }, |
| 7339 | 7361 | returning(...cols) { |
| 7340 | const retText = `${sqlText} RETURNING ${cols.join(', ')}` | |
| 7341 | const q = delParams.length > 0 | |
| 7342 | ? _sql.unsafe(retText, delParams) | |
| 7343 | : _sql.unsafe(retText) | |
| 7362 | const parent: any = this | |
| 7363 | // Rendered at execute time. The frozen-text form, with the no-op | |
| 7364 | // filter stubs below it, meant `.returning('id').where({ id: 1 })` | |
| 7365 | // emptied the table and returned every deleted row as though the | |
| 7366 | // filter had applied. See #1110. | |
| 7367 | const retText = () => `${sqlText} RETURNING ${cols.join(', ')}` | |
| 7368 | const build = () => (delParams.length > 0 ? _sql.unsafe(retText(), delParams) : _sql.unsafe(retText())) | |
| 7369 | // Fire the same delete hooks execute() does. Without this, adding | |
| 7370 | // `.returning(...)` to a delete skipped beforeDelete/afterDelete | |
| 7371 | // entirely, so an application-level delete guard — the usual reason | |
| 7372 | // to implement beforeDelete at all — could be walked straight past. | |
| 7373 | const runDelete = async (): Promise<any[]> => { | |
| 7374 | await activeHooks()?.beforeDelete?.({ table: String(table), where: whereCondition }) | |
| 7375 | const rows = await runWithHooks<any[]>(build(), 'delete') | |
| 7376 | try { | |
| 7377 | await activeHooks()?.afterDelete?.({ table: String(table), where: whereCondition, result: rows }) | |
| 7378 | } | |
| 7379 | catch {} | |
| 7380 | return rows | |
| 7381 | } | |
| 7344 | 7382 | const runFirst = async () => { |
| 7345 | const rows = await runWithHooks<any[]>(q, 'delete') | |
| 7383 | const rows = await runDelete() | |
| 7346 | 7384 | return Array.isArray(rows) ? rows[0] : rows |
| 7347 | 7385 | } |
| 7386 | const unsupported = (name: string) => (): never => { | |
| 7387 | throw new TypeError(`[query-builder] deleteFrom.returning(...).${name}() is not supported — a DELETE cannot be ordered or limited through this builder. Narrow it with where() instead.`) | |
| 7388 | } | |
| 7348 | 7389 | const obj: any = { |
| 7349 | where: () => obj, | |
| 7350 | andWhere: () => obj, | |
| 7351 | orWhere: () => obj, | |
| 7352 | orderBy: () => obj, | |
| 7353 | limit: () => obj, | |
| 7354 | offset: () => obj, | |
| 7355 | toSQL: () => makeExecutableQuery(q, retText) as any, | |
| 7356 | execute: () => runWithHooks<any[]>(q, 'delete'), | |
| 7390 | where: (...args: any[]) => { parent.where(...args); return obj }, | |
| 7391 | andWhere: (...args: any[]) => { parent.where(...args); return obj }, | |
| 7392 | whereNull: (...args: any[]) => { parent.whereNull(...args); return obj }, | |
| 7393 | whereNotNull: (...args: any[]) => { parent.whereNotNull(...args); return obj }, | |
| 7394 | orWhere: unsupported('orWhere'), | |
| 7395 | orderBy: unsupported('orderBy'), | |
| 7396 | limit: unsupported('limit'), | |
| 7397 | offset: unsupported('offset'), | |
| 7398 | toSQL: () => makeExecutableQuery(build(), retText()) as any, | |
| 7399 | execute: () => runDelete(), | |
| 7357 | 7400 | // returning() is typed as SelectQueryBuilder — expose row fetchers. |
| 7358 | get: () => runWithHooks<any[]>(q, 'delete'), | |
| 7401 | get: () => runDelete(), | |
| 7359 | 7402 | first: runFirst, |
| 7360 | 7403 | executeTakeFirst: runFirst, |
| 7361 | 7404 | async firstOrFail() { |
| @@ -7404,19 +7447,18 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7404 | 7447 | return { numDeletedRows: result } |
| 7405 | 7448 | }, |
| 7406 | 7449 | returningAll() { |
| 7407 | const retAllText = `${sqlText} RETURNING *` | |
| 7408 | const q = delParams.length > 0 | |
| 7409 | ? _sql.unsafe(retAllText, delParams) | |
| 7410 | : _sql.unsafe(retAllText) | |
| 7450 | // Deferred, as returning() is — see #1110. | |
| 7451 | const retAllText = () => `${sqlText} RETURNING *` | |
| 7452 | const build = () => (delParams.length > 0 ? _sql.unsafe(retAllText(), delParams) : _sql.unsafe(retAllText())) | |
| 7411 | 7453 | return { |
| 7412 | toSQL: () => makeExecutableQuery(q, retAllText) as any, | |
| 7413 | execute: () => runWithHooks<any[]>(q, 'delete'), | |
| 7454 | toSQL: () => makeExecutableQuery(build(), retAllText()) as any, | |
| 7455 | execute: () => runWithHooks<any[]>(build(), 'delete'), | |
| 7414 | 7456 | async executeTakeFirst() { |
| 7415 | const result = await runWithHooks<any[]>(q, 'delete') | |
| 7457 | const result = await runWithHooks<any[]>(build(), 'delete') | |
| 7416 | 7458 | return Array.isArray(result) ? result[0] : result |
| 7417 | 7459 | }, |
| 7418 | 7460 | async executeTakeFirstOrThrow() { |
| 7419 | const result = await runWithHooks<any[]>(q, 'delete') | |
| 7461 | const result = await runWithHooks<any[]>(build(), 'delete') | |
| 7420 | 7462 | const first = Array.isArray(result) ? result[0] : result |
| 7421 | 7463 | if (!first) |
| 7422 | 7464 | throw new Error('Delete with RETURNING failed') |
| @@ -0,0 +1,176 @@ | ||
| 1 | /** | |
| 2 | * A filter applied after returning() must reach the statement. | |
| 3 | * stacksjs/bun-query-builder#1110. | |
| 4 | * | |
| 5 | * `returning()` froze the statement text at call time and handed back an | |
| 6 | * object whose filter methods were literally no-ops: | |
| 7 | * | |
| 8 | * { where: () => obj, andWhere: () => obj, orWhere: () => obj, | |
| 9 | * orderBy: () => obj, limit: () => obj, offset: () => obj } | |
| 10 | * | |
| 11 | * so every filter expressed after it was discarded and the unfiltered | |
| 12 | * statement ran: | |
| 13 | * | |
| 14 | * updateTable(t).set(v).returning('id').where({ id: 1 }) -> rewrote all rows | |
| 15 | * deleteFrom(t).returning('id').where({ id: 1 }) -> emptied the table | |
| 16 | * | |
| 17 | * Three things made it worse than a dropped predicate: | |
| 18 | * | |
| 19 | * - it REPORTED SUCCESS for work it never scoped. `.where('id','=',99) | |
| 20 | * .executeTakeFirst()` returned a row that cannot match 99, while rewriting | |
| 21 | * everything. | |
| 22 | * - the types invite it. `returning()` is declared to return | |
| 23 | * SelectQueryBuilder, which declares where/andWhere/orWhere/orderBy/limit, | |
| 24 | * so the broken order type-checks under --strict. | |
| 25 | * - on the delete side it skipped beforeDelete/afterDelete, so adding | |
| 26 | * `.returning(...)` walked straight past an application delete guard. | |
| 27 | * | |
| 28 | * The fix defers rendering to execute time and delegates the filters to the | |
| 29 | * builder that owns the statement. Methods that genuinely cannot be expressed | |
| 30 | * on a write (orderBy/limit/offset/orWhere) now throw rather than being | |
| 31 | * ignored — silently ignoring `limit(1)` is what turned a one-row delete into | |
| 32 | * a whole-table one. | |
| 33 | */ | |
| 34 | ||
| 35 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 36 | import { tmpdir } from 'node:os' | |
| 37 | import { join } from 'node:path' | |
| 38 | import { Database } from 'bun:sqlite' | |
| 39 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 40 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src' | |
| 41 | import { config, setConfig } from '../src/config' | |
| 42 | import { resetConnection } from '../src/db' | |
| 43 | ||
| 44 | const MODELS = { | |
| 45 | t: { columns: { id: { type: 'integer', isPrimaryKey: true }, name: { type: 'string' } } }, | |
| 46 | } as any | |
| 47 | ||
| 48 | let dir: string | |
| 49 | let dbPath: string | |
| 50 | let snapshot: { dialect: string, database: Record<string, unknown>, hooks: unknown } | |
| 51 | ||
| 52 | function qb(): any { | |
| 53 | return createQueryBuilder({ | |
| 54 | schema: buildDatabaseSchema(MODELS), | |
| 55 | meta: buildSchemaMeta(MODELS), | |
| 56 | autoMigration: { enabled: false } as any, | |
| 57 | }) | |
| 58 | } | |
| 59 | ||
| 60 | function rows(): Array<{ id: number, name: string }> { | |
| 61 | const probe = new Database(dbPath) | |
| 62 | const out = probe.query('SELECT id, name FROM t ORDER BY id').all() as any[] | |
| 63 | probe.close() | |
| 64 | return out | |
| 65 | } | |
| 66 | ||
| 67 | beforeEach(() => { | |
| 68 | snapshot = { dialect: config.dialect, database: { ...config.database }, hooks: (config as any).hooks } | |
| 69 | dir = mkdtempSync(join(tmpdir(), 'qb-1110-')) | |
| 70 | dbPath = join(dir, 'ret.sqlite') | |
| 71 | const seed = new Database(dbPath, { create: true }) | |
| 72 | seed.run('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)') | |
| 73 | seed.run(`INSERT INTO t (id, name) VALUES (1,'a'),(2,'b'),(3,'c'),(4,'d')`) | |
| 74 | seed.close() | |
| 75 | setConfig({ dialect: 'sqlite', database: { database: dbPath } } as any) | |
| 76 | resetConnection() | |
| 77 | }) | |
| 78 | ||
| 79 | afterEach(() => { | |
| 80 | config.dialect = snapshot.dialect as any | |
| 81 | for (const k of Object.keys(config.database)) delete (config.database as any)[k] | |
| 82 | Object.assign(config.database, snapshot.database) | |
| 83 | ;(config as any).hooks = snapshot.hooks | |
| 84 | resetConnection() | |
| 85 | rmSync(dir, { recursive: true, force: true }) | |
| 86 | }) | |
| 87 | ||
| 88 | describe('UPDATE: a filter after returning() applies (#1110)', () => { | |
| 89 | it('rewrites only the matching row', async () => { | |
| 90 | const returned = await qb().updateTable('t').set({ name: 'X' }).returning('id').where({ id: 1 }).execute() | |
| 91 | ||
| 92 | expect(returned).toEqual([{ id: 1 }]) | |
| 93 | expect(rows().filter(r => r.name === 'X').map(r => r.id)).toEqual([1]) | |
| 94 | expect(rows()).toHaveLength(4) | |
| 95 | }) | |
| 96 | ||
| 97 | it('does not report a row it never matched', async () => { | |
| 98 | // The sharp edge: this used to return { id: 1 } while rewriting all four. | |
| 99 | const first = await qb().updateTable('t').set({ name: 'Y' }).returning('id').where('id', '=', 99).executeTakeFirst() | |
| 100 | ||
| 101 | expect(first).toBeUndefined() | |
| 102 | expect(rows().filter(r => r.name === 'Y')).toHaveLength(0) | |
| 103 | }) | |
| 104 | ||
| 105 | it('still works in the other order', async () => { | |
| 106 | const returned = await qb().updateTable('t').set({ name: 'Z' }).where({ id: 1 }).returning('id').execute() | |
| 107 | expect(returned).toEqual([{ id: 1 }]) | |
| 108 | expect(rows().filter(r => r.name === 'Z').map(r => r.id)).toEqual([1]) | |
| 109 | }) | |
| 110 | ||
| 111 | it('andWhere and whereNull reach the statement too', async () => { | |
| 112 | const returned = await qb().updateTable('t').set({ name: 'W' }) | |
| 113 | .returning('id').where({ id: 2 }).andWhere('name', '=', 'b') | |
| 114 | .execute() | |
| 115 | ||
| 116 | expect(returned).toEqual([{ id: 2 }]) | |
| 117 | expect(rows().filter(r => r.name === 'W').map(r => r.id)).toEqual([2]) | |
| 118 | }) | |
| 119 | }) | |
| 120 | ||
| 121 | describe('DELETE: a filter after returning() applies (#1110)', () => { | |
| 122 | it('removes only the matching row', async () => { | |
| 123 | const returned = await qb().deleteFrom('t').returning('id').where({ id: 1 }).execute() | |
| 124 | ||
| 125 | expect(returned).toEqual([{ id: 1 }]) | |
| 126 | expect(rows().map(r => r.id)).toEqual([2, 3, 4]) | |
| 127 | }) | |
| 128 | ||
| 129 | it('fires beforeDelete, so a delete guard cannot be walked past', async () => { | |
| 130 | setConfig({ hooks: { beforeDelete: () => { throw new Error('BLOCKED by guard') } } } as any) | |
| 131 | ||
| 132 | await expect(qb().deleteFrom('t').returning('id').where({ id: 1 }).execute()) | |
| 133 | .rejects.toThrow(/BLOCKED by guard/) | |
| 134 | ||
| 135 | expect(rows()).toHaveLength(4) | |
| 136 | }) | |
| 137 | }) | |
| 138 | ||
| 139 | describe('the returning handle is not a snapshot (#1110)', () => { | |
| 140 | it('picks up a predicate added to the parent after it was taken', async () => { | |
| 141 | const builder = qb().deleteFrom('t') | |
| 142 | const handle = builder.returning('id') | |
| 143 | builder.where({ id: 2 }) | |
| 144 | ||
| 145 | expect(await handle.execute()).toEqual([{ id: 2 }]) | |
| 146 | expect(rows().map(r => r.id)).toEqual([1, 3, 4]) | |
| 147 | }) | |
| 148 | ||
| 149 | it('applies to returningAll() as well', async () => { | |
| 150 | const builder = qb().deleteFrom('t') | |
| 151 | const handle = builder.returningAll() | |
| 152 | builder.where({ id: 3 }) | |
| 153 | ||
| 154 | const returned = await handle.execute() | |
| 155 | expect(returned.map((r: any) => r.id)).toEqual([3]) | |
| 156 | expect(rows().map(r => r.id)).toEqual([1, 2, 4]) | |
| 157 | }) | |
| 158 | }) | |
| 159 | ||
| 160 | describe('what a write cannot express now says so (#1110)', () => { | |
| 161 | // Silently ignoring these is what let `.limit(1)` on a delete remove | |
| 162 | // everything while reading like it removed one row. | |
| 163 | for (const method of ['orderBy', 'limit', 'offset', 'orWhere'] as const) { | |
| 164 | it(`deleteFrom.returning(...).${method}() throws instead of being ignored`, () => { | |
| 165 | expect(() => (qb().deleteFrom('t').returning('id') as any)[method]('id')) | |
| 166 | .toThrow(/not supported/) | |
| 167 | expect(rows()).toHaveLength(4) | |
| 168 | }) | |
| 169 | ||
| 170 | it(`updateTable.returning(...).${method}() throws instead of being ignored`, () => { | |
| 171 | expect(() => (qb().updateTable('t').set({ name: 'Q' }).returning('id') as any)[method]('id')) | |
| 172 | .toThrow(/not supported/) | |
| 173 | expect(rows().filter(r => r.name === 'Q')).toHaveLength(0) | |
| 174 | }) | |
| 175 | } | |
| 176 | }) | |