ReviewOS

also looking at this

stacks/bun-query-builder

fix(client): choose WHERE vs AND from builder state, not a text search

#1118
Merged glennmichael123 wants to merge fix/1113-where-keyword-state into main
2 files +235 -27
packages/bun-query-builder/test/client.where-keyword-state.test.tsadded+180-0
Changes to packages/bun-query-builder/test/client.where-keyword-state.test.ts
@@ -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
28import { mkdtempSync, rmSync } from 'node:fs'
29import { tmpdir } from 'node:os'
30import { join } from 'node:path'
31import { Database } from 'bun:sqlite'
32import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
33import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder, raw } from '../src'
34import { config, setConfig } from '../src/config'
35import { 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.
39const 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
61let dir: string
62let dbPath: string
63let snapshot: { dialect: string, database: Record<string, unknown> }
64
65function qb(): any {
66 return createQueryBuilder({
67 schema: buildDatabaseSchema(MODELS),
68 meta: buildSchemaMeta(MODELS),
69 autoMigration: { enabled: false } as any,
70 })
71}
72
73function 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
80beforeEach(() => {
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
97afterEach(() => {
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
105describe('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
131describe('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
152describe('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})