also looking at this
fix(client): choose WHERE vs AND from builder state, not a text search
#1118
2 files
+235
-27
| @@ -0,0 +1,180 @@ | ||
| 1 | /** | |
| 2 | * The WHERE/AND keyword follows builder state, not a text search. | |
| 3 | * stacksjs/bun-query-builder#1113. | |
| 4 | * | |
| 5 | * Both write builders chose their keyword by testing the statement built so | |
| 6 | * far for `/\bWHERE\b/`: | |
| 7 | * | |
| 8 | * const getWhereKeyword = () => SQL_PATTERNS.WHERE.test(sqlText) ? 'AND' : 'WHERE' | |
| 9 | * | |
| 10 | * Plenty of text that is not this builder's predicate satisfies that. A | |
| 11 | * subquery inside `set()`, a table named `where`, a column named `where` — | |
| 12 | * each one made the FIRST predicate come out as `AND`, which fuses it onto | |
| 13 | * whatever preceded it: | |
| 14 | * | |
| 15 | * UPDATE "t" SET "flag" = (SELECT ... WHERE x.id > 3) AND "id" = ? | |
| 16 | * DELETE FROM "where" AND "id" = ? | |
| 17 | * | |
| 18 | * The DELETE is a syntax error. The UPDATE is worse: it parses, the predicate | |
| 19 | * becomes part of the value being written, and the statement has no WHERE, so | |
| 20 | * it rewrites every row. Postgres only rejects it when the SET target's type | |
| 21 | * happens not to be boolean; SQLite takes it as written. | |
| 22 | * | |
| 23 | * That last case is why the UPDATE assertions here seed a non-zero column and | |
| 24 | * check every row. The reported repro writes `0` to rows that already held | |
| 25 | * `0`, so the damage is invisible unless the fixture makes it visible. | |
| 26 | */ | |
| 27 | ||
| 28 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 29 | import { tmpdir } from 'node:os' | |
| 30 | import { join } from 'node:path' | |
| 31 | import { Database } from 'bun:sqlite' | |
| 32 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 33 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder, raw } from '../src' | |
| 34 | import { config, setConfig } from '../src/config' | |
| 35 | import { resetConnection } from '../src/db' | |
| 36 | ||
| 37 | // `where` is a legal identifier in every dialect this builder targets, as long | |
| 38 | // as it is quoted — and the builder does quote it. | |
| 39 | const MODELS = { | |
| 40 | t: { | |
| 41 | columns: { | |
| 42 | id: { type: 'integer', isPrimaryKey: true }, | |
| 43 | name: { type: 'string' }, | |
| 44 | flag: { type: 'integer' }, | |
| 45 | }, | |
| 46 | }, | |
| 47 | where: { | |
| 48 | columns: { | |
| 49 | id: { type: 'integer', isPrimaryKey: true }, | |
| 50 | name: { type: 'string' }, | |
| 51 | }, | |
| 52 | }, | |
| 53 | hasreserved: { | |
| 54 | columns: { | |
| 55 | id: { type: 'integer', isPrimaryKey: true }, | |
| 56 | where: { type: 'string' }, | |
| 57 | }, | |
| 58 | }, | |
| 59 | } as any | |
| 60 | ||
| 61 | let dir: string | |
| 62 | let dbPath: string | |
| 63 | let snapshot: { dialect: string, database: Record<string, unknown> } | |
| 64 | ||
| 65 | function qb(): any { | |
| 66 | return createQueryBuilder({ | |
| 67 | schema: buildDatabaseSchema(MODELS), | |
| 68 | meta: buildSchemaMeta(MODELS), | |
| 69 | autoMigration: { enabled: false } as any, | |
| 70 | }) | |
| 71 | } | |
| 72 | ||
| 73 | function all(table: string): any[] { | |
| 74 | const probe = new Database(dbPath) | |
| 75 | const out = probe.query(`SELECT * FROM "${table}" ORDER BY id`).all() as any[] | |
| 76 | probe.close() | |
| 77 | return out | |
| 78 | } | |
| 79 | ||
| 80 | beforeEach(() => { | |
| 81 | snapshot = { dialect: config.dialect, database: { ...config.database } } | |
| 82 | dir = mkdtempSync(join(tmpdir(), 'qb-1113-')) | |
| 83 | dbPath = join(dir, 'kw.sqlite') | |
| 84 | const seed = new Database(dbPath, { create: true }) | |
| 85 | seed.run('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, flag INTEGER)') | |
| 86 | // Non-zero flags: an UPDATE that reaches every row has to be visible. | |
| 87 | seed.run(`INSERT INTO t (id,name,flag) VALUES (1,'a',7),(2,'b',7),(3,'c',7),(4,'d',7)`) | |
| 88 | seed.run('CREATE TABLE "where" (id INTEGER PRIMARY KEY, name TEXT)') | |
| 89 | seed.run(`INSERT INTO "where" (id,name) VALUES (1,'a'),(2,'b')`) | |
| 90 | seed.run('CREATE TABLE hasreserved (id INTEGER PRIMARY KEY, "where" TEXT)') | |
| 91 | seed.run(`INSERT INTO hasreserved (id,"where") VALUES (1,'a'),(2,'b')`) | |
| 92 | seed.close() | |
| 93 | setConfig({ dialect: 'sqlite', database: { database: dbPath } } as any) | |
| 94 | resetConnection() | |
| 95 | }) | |
| 96 | ||
| 97 | afterEach(() => { | |
| 98 | config.dialect = snapshot.dialect as any | |
| 99 | for (const k of Object.keys(config.database)) delete (config.database as any)[k] | |
| 100 | Object.assign(config.database, snapshot.database) | |
| 101 | resetConnection() | |
| 102 | rmSync(dir, { recursive: true, force: true }) | |
| 103 | }) | |
| 104 | ||
| 105 | describe('a WHERE inside a set() fragment does not steal the keyword (#1113)', () => { | |
| 106 | it('emits WHERE for the predicate, not AND', () => { | |
| 107 | const sql = String(qb() | |
| 108 | .updateTable('t') | |
| 109 | .set({ flag: raw('(SELECT count(*) FROM t x WHERE x.id > 3)') }) | |
| 110 | .where({ id: 1 }) | |
| 111 | .toSQL()) | |
| 112 | ||
| 113 | expect(sql).toContain(') WHERE "id"') | |
| 114 | expect(sql).not.toContain(') AND "id"') | |
| 115 | }) | |
| 116 | ||
| 117 | it('updates only the row the predicate selects', async () => { | |
| 118 | await qb() | |
| 119 | .updateTable('t') | |
| 120 | .set({ flag: raw('(SELECT count(*) FROM t x WHERE x.id > 3)') }) | |
| 121 | .where({ id: 1 }) | |
| 122 | .execute() | |
| 123 | ||
| 124 | // Previously every row was rewritten: the predicate became part of the | |
| 125 | // value, so rows 2-4 were assigned `(1) AND (id = 1)` = 0 and row 1 got 1. | |
| 126 | // Seeding 7 is what makes that visible. | |
| 127 | expect(all('t').map(r => r.flag)).toEqual([1, 7, 7, 7]) | |
| 128 | }) | |
| 129 | }) | |
| 130 | ||
| 131 | describe('an identifier named `where` does not steal the keyword (#1113)', () => { | |
| 132 | it('a table named `where` still gets a WHERE clause on DELETE', async () => { | |
| 133 | const sql = String(qb().deleteFrom('where').where({ id: 1 }).toSQL()) | |
| 134 | expect(sql).toBe('DELETE FROM "where" WHERE "id" = ?') | |
| 135 | ||
| 136 | // It used to emit `DELETE FROM "where" AND "id" = ?` — a syntax error. | |
| 137 | await qb().deleteFrom('where').where({ id: 1 }).execute() | |
| 138 | expect(all('where').map(r => r.id)).toEqual([2]) | |
| 139 | }) | |
| 140 | ||
| 141 | it('a table named `where` still gets a WHERE clause on UPDATE', async () => { | |
| 142 | await qb().updateTable('where').set({ name: 'X' }).where({ id: 1 }).execute() | |
| 143 | expect(all('where').map(r => r.name)).toEqual(['X', 'b']) | |
| 144 | }) | |
| 145 | ||
| 146 | it('a column named `where` in set() does not absorb the predicate', async () => { | |
| 147 | await qb().updateTable('hasreserved').set({ where: 'X' }).where({ id: 1 }).execute() | |
| 148 | expect(all('hasreserved').map(r => r.where)).toEqual(['X', 'b']) | |
| 149 | }) | |
| 150 | }) | |
| 151 | ||
| 152 | describe('chained predicates still join with AND (#1015 stays fixed)', () => { | |
| 153 | it('UPDATE: second where() is AND', () => { | |
| 154 | const sql = String(qb().updateTable('t').set({ name: 'X' }).where('id', '=', 1).where('name', '=', 'a').toSQL()) | |
| 155 | expect(sql).toContain('WHERE "id" = ?') | |
| 156 | expect(sql).toContain('AND "name" = ?') | |
| 157 | }) | |
| 158 | ||
| 159 | it('DELETE: second where() is AND', () => { | |
| 160 | const sql = String(qb().deleteFrom('t').where('id', '=', 1).where('name', '=', 'a').toSQL()) | |
| 161 | expect(sql).toContain('WHERE "id" = ?') | |
| 162 | expect(sql).toContain('AND "name" = ?') | |
| 163 | }) | |
| 164 | ||
| 165 | it('DELETE: whereNull then whereNotNull', () => { | |
| 166 | const sql = String(qb().deleteFrom('t').whereNull('name').whereNotNull('flag').toSQL()) | |
| 167 | expect(sql).toBe('DELETE FROM "t" WHERE "name" IS NULL AND "flag" IS NOT NULL') | |
| 168 | }) | |
| 169 | ||
| 170 | it('UPDATE: a raw fragment first, then a column predicate', () => { | |
| 171 | const sql = String(qb().updateTable('t').set({ name: 'X' }).where(raw('id > 2')).where('name', '=', 'c').toSQL()) | |
| 172 | expect(sql).toContain('WHERE id > 2') | |
| 173 | expect(sql).toContain('AND "name" = ?') | |
| 174 | }) | |
| 175 | ||
| 176 | it('DELETE: a raw fragment carrying its own WHERE is still the first predicate', () => { | |
| 177 | const sql = String(qb().deleteFrom('t').where(raw('id IN (SELECT id FROM t x WHERE x.id > 3)')).whereNotNull('name').toSQL()) | |
| 178 | expect(sql).toBe('DELETE FROM "t" WHERE id IN (SELECT id FROM t x WHERE x.id > 3) AND "name" IS NOT NULL') | |
| 179 | }) | |
| 180 | }) | |