also looking at this
fix(client): an array in object-form where() means IN on writes too
#1127
2 files
+189
-21
| @@ -0,0 +1,156 @@ | ||
| 1 | /** | |
| 2 | * An array value in object-form where() means IN, on writes as well as reads. | |
| 3 | * stacksjs/bun-query-builder#1114. | |
| 4 | * | |
| 5 | * The select builder has read `where({ id: [3, 2] })` as `id IN (3, 2)` since | |
| 6 | * #1013/#1083. The write builders bound the whole array to a single | |
| 7 | * placeholder, so the same input meant two different things depending on which | |
| 8 | * statement it was attached to: | |
| 9 | * | |
| 10 | * selectFrom(t).where({ id: [3, 2] }) -> rows 2 and 3 | |
| 11 | * deleteFrom(t).where({ id: [3, 2] }) -> deletes nothing | |
| 12 | * | |
| 13 | * This is the opposite direction to #1101 — the write is too NARROW, so it | |
| 14 | * destroys nothing. But it fails silently and reports success, and it fails in | |
| 15 | * exactly the case people write it for: the bulk delete of a set of ids. | |
| 16 | * | |
| 17 | * All three write paths now share one renderer with the same semantics. The | |
| 18 | * mixed case matters most for the shared code: the old per-key index | |
| 19 | * arithmetic (`baseIdx + i + 1`) assumed one parameter per key, which an | |
| 20 | * array breaks. | |
| 21 | */ | |
| 22 | ||
| 23 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 24 | import { tmpdir } from 'node:os' | |
| 25 | import { join } from 'node:path' | |
| 26 | import { Database } from 'bun:sqlite' | |
| 27 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 28 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src' | |
| 29 | import { config, setConfig } from '../src/config' | |
| 30 | import { resetConnection } from '../src/db' | |
| 31 | ||
| 32 | const MODELS = { | |
| 33 | t: { columns: { id: { type: 'integer', isPrimaryKey: true }, name: { type: 'string' }, tenant: { type: 'integer' } } }, | |
| 34 | } as any | |
| 35 | ||
| 36 | let dir: string | |
| 37 | let dbPath: string | |
| 38 | let snapshot: { dialect: string, database: Record<string, unknown> } | |
| 39 | ||
| 40 | function qb(): any { | |
| 41 | return createQueryBuilder({ | |
| 42 | schema: buildDatabaseSchema(MODELS), | |
| 43 | meta: buildSchemaMeta(MODELS), | |
| 44 | autoMigration: { enabled: false } as any, | |
| 45 | }) | |
| 46 | } | |
| 47 | ||
| 48 | function ids(): number[] { | |
| 49 | const probe = new Database(dbPath) | |
| 50 | const out = (probe.query('SELECT id FROM t ORDER BY id').all() as any[]).map(r => r.id) | |
| 51 | probe.close() | |
| 52 | return out | |
| 53 | } | |
| 54 | ||
| 55 | function renamed(): number[] { | |
| 56 | const probe = new Database(dbPath) | |
| 57 | const out = (probe.query(`SELECT id FROM t WHERE name = 'X' ORDER BY id`).all() as any[]).map(r => r.id) | |
| 58 | probe.close() | |
| 59 | return out | |
| 60 | } | |
| 61 | ||
| 62 | beforeEach(() => { | |
| 63 | snapshot = { dialect: config.dialect, database: { ...config.database } } | |
| 64 | dir = mkdtempSync(join(tmpdir(), 'qb-1114-')) | |
| 65 | dbPath = join(dir, 'arr.sqlite') | |
| 66 | const seed = new Database(dbPath, { create: true }) | |
| 67 | seed.run('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, tenant INTEGER)') | |
| 68 | seed.run(`INSERT INTO t (id,name,tenant) VALUES (1,'a',1),(2,'b',1),(3,'c',1),(4,'d',999)`) | |
| 69 | seed.close() | |
| 70 | setConfig({ dialect: 'sqlite', database: { database: dbPath } } as any) | |
| 71 | resetConnection() | |
| 72 | }) | |
| 73 | ||
| 74 | afterEach(() => { | |
| 75 | config.dialect = snapshot.dialect as any | |
| 76 | for (const k of Object.keys(config.database)) delete (config.database as any)[k] | |
| 77 | Object.assign(config.database, snapshot.database) | |
| 78 | resetConnection() | |
| 79 | rmSync(dir, { recursive: true, force: true }) | |
| 80 | }) | |
| 81 | ||
| 82 | describe('reads and writes agree on what an array means (#1114)', () => { | |
| 83 | it('SELECT reads it as IN — the behaviour the writes now match', async () => { | |
| 84 | const rows = await qb().selectFrom('t').select('id').where({ id: [3, 2] }).execute() | |
| 85 | expect(rows.map((r: any) => r.id).sort()).toEqual([2, 3]) | |
| 86 | }) | |
| 87 | ||
| 88 | it('DELETE removes exactly those rows', async () => { | |
| 89 | await qb().deleteFrom('t').where({ id: [3, 2] }).execute() | |
| 90 | ||
| 91 | // Previously: deleted nothing, and reported success. | |
| 92 | expect(ids()).toEqual([1, 4]) | |
| 93 | }) | |
| 94 | ||
| 95 | it('UPDATE rewrites exactly those rows', async () => { | |
| 96 | await qb().updateTable('t').set({ name: 'X' }).where({ id: [3, 2] }).execute() | |
| 97 | expect(renamed()).toEqual([2, 3]) | |
| 98 | }) | |
| 99 | ||
| 100 | it('updateMany does too', async () => { | |
| 101 | await qb().updateMany('t', { id: [3, 2] } as any, { name: 'X' }) | |
| 102 | expect(renamed()).toEqual([2, 3]) | |
| 103 | }) | |
| 104 | }) | |
| 105 | ||
| 106 | describe('an array alongside a scalar binds in the right order (#1114)', () => { | |
| 107 | // The old code computed each parameter index as `baseIdx + i + 1`, one per | |
| 108 | // key. An array contributes several, so every parameter after it was bound | |
| 109 | // to the wrong placeholder. | |
| 110 | it('DELETE with both a list and a scalar', async () => { | |
| 111 | const sql = String(qb().deleteFrom('t').where({ id: [2, 3], tenant: 1 } as any).toSQL()) | |
| 112 | expect(sql).toBe('DELETE FROM "t" WHERE "id" IN (?, ?) AND "tenant" = ?') | |
| 113 | ||
| 114 | await qb().deleteFrom('t').where({ id: [2, 3], tenant: 1 } as any).execute() | |
| 115 | expect(ids()).toEqual([1, 4]) | |
| 116 | }) | |
| 117 | ||
| 118 | it('the scalar still constrains — a row outside the tenant survives', async () => { | |
| 119 | await qb().deleteFrom('t').where({ id: [3, 4], tenant: 1 } as any).execute() | |
| 120 | ||
| 121 | // id 4 is tenant 999, so only 3 goes. | |
| 122 | expect(ids()).toEqual([1, 2, 4]) | |
| 123 | }) | |
| 124 | }) | |
| 125 | ||
| 126 | describe('an empty array matches nothing, never everything (#1114)', () => { | |
| 127 | // The only safe reading on a write: a filter the caller supplied must not | |
| 128 | // widen to "every row" because the collection came back empty. This is what | |
| 129 | // the select builder already does (FALSE_PREDICATE). | |
| 130 | it('DELETE with an empty list deletes nothing', async () => { | |
| 131 | await qb().deleteFrom('t').where({ id: [] } as any).execute() | |
| 132 | expect(ids()).toEqual([1, 2, 3, 4]) | |
| 133 | }) | |
| 134 | ||
| 135 | it('and does not drop the other conditions either', async () => { | |
| 136 | await qb().deleteFrom('t').where({ id: [], tenant: 1 } as any).execute() | |
| 137 | expect(ids()).toEqual([1, 2, 3, 4]) | |
| 138 | }) | |
| 139 | ||
| 140 | it('UPDATE with an empty list rewrites nothing', async () => { | |
| 141 | await qb().updateTable('t').set({ name: 'X' }).where({ id: [] } as any).execute() | |
| 142 | expect(renamed()).toEqual([]) | |
| 143 | }) | |
| 144 | }) | |
| 145 | ||
| 146 | describe('the scalar form is unchanged (#1114)', () => { | |
| 147 | it('DELETE', async () => { | |
| 148 | await qb().deleteFrom('t').where({ id: 2 }).execute() | |
| 149 | expect(ids()).toEqual([1, 3, 4]) | |
| 150 | }) | |
| 151 | ||
| 152 | it('UPDATE with two scalars', async () => { | |
| 153 | await qb().updateTable('t').set({ name: 'X' }).where({ id: 2, tenant: 1 } as any).execute() | |
| 154 | expect(renamed()).toEqual([2]) | |
| 155 | }) | |
| 156 | }) | |