also looking at this
fix(client): pick the select builder's keywords from state, not from text
#1126
2 files
+198
-5
| @@ -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 | ||
| 27 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 28 | import { tmpdir } from 'node:os' | |
| 29 | import { join } from 'node:path' | |
| 30 | import { Database } from 'bun:sqlite' | |
| 31 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 32 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder, raw } from '../src' | |
| 33 | import { config, setConfig } from '../src/config' | |
| 34 | import { resetConnection } from '../src/db' | |
| 35 | ||
| 36 | const 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 | ||
| 42 | let dir: string | |
| 43 | let dbPath: string | |
| 44 | let snapshot: { dialect: string, database: Record<string, unknown> } | |
| 45 | ||
| 46 | function qb(): any { | |
| 47 | return createQueryBuilder({ | |
| 48 | schema: buildDatabaseSchema(MODELS), | |
| 49 | meta: buildSchemaMeta(MODELS), | |
| 50 | autoMigration: { enabled: false } as any, | |
| 51 | }) | |
| 52 | } | |
| 53 | ||
| 54 | beforeEach(() => { | |
| 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 | ||
| 70 | afterEach(() => { | |
| 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 | ||
| 78 | describe('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 | ||
| 114 | describe('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 | }) | |