ReviewOS

also looking at this

stacks/bun-query-builder

fix(client): pick the select builder's keywords from state, not from text

#1126
Merged glennmichael123 wants to merge fix/1120-1122-select-keyword-state into main
2 files +198 -5
packages/bun-query-builder/test/client.select-keyword-state.test.tsadded+150-0
Changes to packages/bun-query-builder/test/client.select-keyword-state.test.ts
@@ -0,0 +1,150 @@
1/**
2 * The select builder's clause keywords follow builder state, not a text scan.
3 * stacksjs/bun-query-builder#1120 and #1122.
4 *
5 * Two sites picked a keyword by searching the statement built so far — the
6 * same defect #1113 fixed in the write builders, in a builder that kept it:
7 *
8 * const kw = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' // :3482
9 * const kw = /\bHAVING\b/i.test(text) ? 'AND' : 'HAVING' // :5617, :5650
10 *
11 * #1120 — the WHERE branch is reached only after a set operator, and by then
12 * `text` holds the LEFT select including its WHERE. So a predicate on the left
13 * was read as one on the right:
14 *
15 * SELECT * FROM a WHERE y = $1 UNION SELECT * FROM b AND x = $2
16 *
17 * #1122 — `text` also holds the select list, so a raw fragment that merely
18 * contains the word `having` made the first real HAVING emit as AND, fusing it
19 * onto the GROUP BY list.
20 *
21 * Both emit invalid SQL rather than running against the wrong rows, so unlike
22 * #1113 these assert on the emitted statement — that is where the defect lives.
23 * The union cases also execute, because a keyword decision that produces valid
24 * SQL can still produce the wrong ROWS, and only running it settles that.
25 */
26
27import { mkdtempSync, rmSync } from 'node:fs'
28import { tmpdir } from 'node:os'
29import { join } from 'node:path'
30import { Database } from 'bun:sqlite'
31import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
32import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder, raw } from '../src'
33import { config, setConfig } from '../src/config'
34import { resetConnection } from '../src/db'
35
36const MODELS = {
37 a: { columns: { id: { type: 'integer', isPrimaryKey: true }, x: { type: 'integer' }, y: { type: 'integer' } } },
38 b: { columns: { id: { type: 'integer', isPrimaryKey: true }, x: { type: 'integer' }, y: { type: 'integer' } } },
39 users: { columns: { id: { type: 'integer', isPrimaryKey: true }, name: { type: 'string' } } },
40} as any
41
42let dir: string
43let dbPath: string
44let snapshot: { dialect: string, database: Record<string, unknown> }
45
46function qb(): any {
47 return createQueryBuilder({
48 schema: buildDatabaseSchema(MODELS),
49 meta: buildSchemaMeta(MODELS),
50 autoMigration: { enabled: false } as any,
51 })
52}
53
54beforeEach(() => {
55 snapshot = { dialect: config.dialect, database: { ...config.database } }
56 dir = mkdtempSync(join(tmpdir(), 'qb-1120-'))
57 dbPath = join(dir, 'sel.sqlite')
58 const seed = new Database(dbPath, { create: true })
59 seed.run('CREATE TABLE a (id INTEGER PRIMARY KEY, x INTEGER, y INTEGER)')
60 seed.run('CREATE TABLE b (id INTEGER PRIMARY KEY, x INTEGER, y INTEGER)')
61 seed.run('INSERT INTO a (id,x,y) VALUES (1,1,2),(2,5,9)')
62 seed.run('INSERT INTO b (id,x,y) VALUES (3,1,4),(4,7,8)')
63 seed.run('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)')
64 seed.run(`INSERT INTO users (id,name) VALUES (1,'a'),(2,'b'),(3,'b')`)
65 seed.close()
66 setConfig({ dialect: 'sqlite', database: { database: dbPath } } as any)
67 resetConnection()
68})
69
70afterEach(() => {
71 config.dialect = snapshot.dialect as any
72 for (const k of Object.keys(config.database)) delete (config.database as any)[k]
73 Object.assign(config.database, snapshot.database)
74 resetConnection()
75 rmSync(dir, { recursive: true, force: true })
76})
77
78describe('a predicate after a set operator (#1120)', () => {
79 it('a WHERE on the left does not make the right one an AND', () => {
80 const sql = String(qb().selectFrom('a').where('y', '=', 2).union(qb().selectFrom('b')).where('x', '=', 1).toSQL())
81
82 // Previously: `... UNION SELECT * FROM b AND x = ?`, which does not parse.
83 expect(sql).toContain('UNION SELECT * FROM b WHERE x = ?')
84 })
85
86 it('and the statement actually runs', async () => {
87 const rows = await qb().selectFrom('a').where('y', '=', 2).union(qb().selectFrom('b')).where('x', '=', 1).execute()
88
89 expect(rows.map((r: any) => r.id).sort()).toEqual([1, 3])
90 })
91
92 it('with no predicate on the left, the right one is still WHERE', () => {
93 const sql = String(qb().selectFrom('a').union(qb().selectFrom('b')).where('x', '=', 1).toSQL())
94 expect(sql).toContain('UNION SELECT * FROM b WHERE x = ?')
95 })
96
97 it('when the RIGHT side has its own predicate, AND is correct and is what is emitted', () => {
98 const sql = String(qb().selectFrom('a').union(qb().selectFrom('b').where('y', '=', 4)).where('x', '=', 1).toSQL())
99
100 expect(sql).toContain('WHERE y = ? AND x = ?')
101 })
102
103 it('intersect behaves the same way', () => {
104 const sql = String(qb().selectFrom('a').where('y', '=', 2).intersect(qb().selectFrom('b')).where('x', '=', 1).toSQL())
105 expect(sql).toContain('INTERSECT SELECT * FROM b WHERE x = ?')
106 })
107
108 it('chained predicates on the tail still join with AND', () => {
109 const sql = String(qb().selectFrom('a').where('y', '=', 2).union(qb().selectFrom('b')).where('x', '=', 1).where('y', '=', 4).toSQL())
110 expect(sql).toContain('UNION SELECT * FROM b WHERE x = ? AND y = ?')
111 })
112})
113
114describe('HAVING is chosen from state, not from the select list (#1122)', () => {
115 it('havingRaw() after a raw fragment containing the word', () => {
116 const sql = String(qb().selectFrom('users').selectRaw(raw("'having a party' as t")).groupBy('id').havingRaw(raw('COUNT(id) > 3')).toSQL())
117
118 // Previously: `... GROUP BY id AND COUNT(id) > 3`.
119 expect(sql).toContain('GROUP BY id HAVING COUNT(id) > 3')
120 })
121
122 it('having() in its array form, same fragment', () => {
123 const sql = String(qb().selectFrom('users').selectRaw(raw("'having a party' as t")).groupBy('id').having(['COUNT(id)', '>', 3]).toSQL())
124
125 expect(sql).toContain('HAVING COUNT(id) > ?')
126 expect(sql).not.toContain('GROUP BY id AND')
127 })
128
129 it('chained having() calls still join with AND (#1034 stays fixed)', () => {
130 const sql = String(qb().selectFrom('users').groupBy('id').having(['COUNT(id)', '>', 1]).having(['COUNT(id)', '<', 9]).toSQL())
131
132 expect(sql).toContain('HAVING COUNT(id) > ?')
133 expect(sql).toContain('AND COUNT(id) < ?')
134 })
135
136 it('having() then havingRaw() joins with AND', () => {
137 const sql = String(qb().selectFrom('users').groupBy('id').having(['COUNT(id)', '>', 1]).havingRaw(raw('COUNT(id) < 9')).toSQL())
138
139 expect(sql).toContain('HAVING COUNT(id) > ?')
140 expect(sql).toContain('AND COUNT(id) < 9')
141 })
142
143 it('a having that appends nothing does not claim the keyword', () => {
144 // The empty-object branch emits no text, so the next call is still the
145 // first HAVING rather than a stray AND.
146 const sql = String(qb().selectFrom('users').groupBy('id').having({}).havingRaw(raw('COUNT(id) > 3')).toSQL())
147
148 expect(sql).toContain('HAVING COUNT(id) > 3')
149 })
150})