ReviewOS

also looking at this

stacks/bun-query-builder

fix(client): orWhere now groups, so an earlier where keeps applying (#1083)

#1088
Merged glennmichael123 wants to merge fix/or-where-precedence into main
13 files +1310 -450
docs/features/builder.mdmodified+17-2
Changes to docs/features/builder.md
@@ -112,7 +112,12 @@ const adults = await db
112112 .where(['age', '>=', 18])
113113 .execute()
114114
115// Complex conditions with multiple filters
115// Complex conditions with multiple filters.
116//
117// A chained `orWhere` groups with the term immediately before it, so this is
118// active AND age >= 18 AND (country IN (...) OR role = 'admin')
119// If you mean "all three filters, OR an admin", make the conjunction explicit
120// with whereGroup — see the note under Logical grouping below.
116121const eligibleUsers = await db
117122 .selectFrom('users')
118123 .where({ active: true })
@@ -121,6 +126,16 @@ const eligibleUsers = await db
121126 .orWhere(['role', '=', 'admin'])
122127 .execute()
123128
129// "Everything above, OR an admin":
130const eligibleOrAdmin = await db
131 .selectFrom('users')
132 .whereGroup(qb => qb
133 .where({ active: true })
134 .andWhere(['age', '>=', 18])
135 .andWhere(['country', 'in', ['US', 'CA', 'GB']]))
136 .orWhere(['role', '=', 'admin'])
137 .execute()
138
124139// Dynamic condition building
125140async function searchUsers(filters: { name?: string, role?: string, minAge?: number }) {
126141 let query = db.selectFrom('users')
@@ -246,7 +261,7 @@ const averyPrefs = await db
246261
247262- **Use indexes**: Ensure filtered columns have appropriate database indexes
248263- **Parameterized queries**: The builder automatically parameterizes values for SQL injection safety
249- **Logical grouping**: Use `orWhere` and `andWhere` to create clear logical groups
264- **Logical grouping**: A chained `orWhere` groups with the term before it — `.where(a).where(b).orWhere(c)` is `a AND (b OR c)`. Use `whereGroup(cb)` / `orWhereGroup(cb)` when you need the other reading, `(a AND b) OR c`
250265- **Dynamic filters**: Build conditions conditionally based on user input or application state
251266- **JSON performance**: Be mindful that JSON operations may require special indexes
252267
docs/guide/where.mdmodified+57-0
Changes to docs/guide/where.md
@@ -74,6 +74,48 @@ const users = await db
7474 .get()
7575```
7676
77#### How `orWhere` groups
78
79A chained `orWhere` groups with the term immediately before it, and that group
80is ANDed into the rest of the chain:
81
82```typescript
83db.selectFrom('posts')
84 .where('status', '=', 'pending')
85 .where('title', 'like', term)
86 .orWhere('content', 'like', term)
87
88// WHERE status = ? AND (title like ? OR content like ?)
89```
90
91In other words `OR` binds **tighter** than `AND` here — the inverse of raw SQL,
92and the reading the chain has when you say it out loud. It also matches what
93`whereAny` already does.
94
95This matters because the other reading fails silently and in the widening
96direction. Under raw SQL precedence the same chain means
97`(status AND title) OR content`, so every row matching `content` comes back
98regardless of its status — a valid query, no warning, and a result set that
99looks like data rather than an error.
100
101To get `(a AND b) OR c`, use `whereGroup`:
102
103```typescript
104db.selectFrom('posts')
105 .whereGroup(qb => qb.where('status', '=', 'pending').where('title', 'like', term))
106 .orWhere('content', 'like', term)
107
108// WHERE (status = ? AND title like ?) OR content like ?
109```
110
111Two notes:
112
113- An object passed to `where`/`orWhere` is a **single** term, so
114 `.where({ a, b }).orWhere(c)` means `(a AND b) OR c`.
115- A `whereRaw`/`orWhereRaw` fragment is also a single term and is **not**
116 auto-parenthesised. If your fragment contains a top-level `OR`, bracket it
117 yourself.
118
77119## Special Where Methods
78120
79121### whereIn / whereNotIn
@@ -208,6 +250,21 @@ const users = await db
208250// Generates: WHERE active = true AND (role = 'admin' OR role = 'moderator')
209251```
210252
253`whereGroup(callback)` is the same thing under an explicit name, and
254`orWhereGroup(callback)` ORs the group onto the query instead of ANDing it:
255
256```typescript
257db.selectFrom('users')
258 .where('active', '=', true)
259 .orWhereGroup(qb => qb.where('role', '=', 'admin').where('verified', '=', true))
260
261// WHERE active = ? OR (role = ? AND verified = ?)
262```
263
264A callback that adds no conditions **throws**. A group that contributed nothing
265would drop out of the query and leave it matching every row the filter existed
266to exclude, which is the failure this API exists to prevent.
267
211268## Existence Checks
212269
213270```typescript
packages/bun-query-builder/src/client.tsmodified+503-432
Changes to packages/bun-query-builder/src/client.ts
@@ -10,6 +10,8 @@ import type { DriverConnection } from './db'
1010import { bunSql, getOrCreateBunSql, resetConnection } from './db'
1111import { resolvePivot } from './pivot'
1212import { singularizerFor } from './inflect'
13import type { WhereTerm } from './sql-fragments'
14import { FALSE_PREDICATE, renderInPredicate, renderWhereTerms } from './sql-fragments'
1315
1416export { resetConnection }
1517
@@ -184,38 +186,6 @@ function renderSelectColumn(col: unknown): string {
184186 )
185187}
186188
187/**
188 * Constant predicates, for the cases where a collection is empty.
189 *
190 * `1 = 0` and `1 = 1` take no parameters and parse identically on SQLite,
191 * Postgres and MySQL, which is the point: the alternatives are engine-specific.
192 * `IN ()` is the example that motivated these — SQLite parses it and matches
193 * nothing, while Postgres and MySQL reject it as a syntax error, so the same
194 * call site behaved differently depending on where it ran.
195 */
196const FALSE_PREDICATE = '1 = 0'
197const TRUE_PREDICATE = '1 = 1'
198
199/**
200 * Render `col IN (…)` / `col NOT IN (…)`, including the empty case.
201 *
202 * An empty `IN` is FALSE (nothing is a member of the empty set) and an empty
203 * `NOT IN` is TRUE (everything is a non-member). Those constants are not
204 * interchangeable and the mirror is easy to get backwards: writing FALSE for
205 * `NOT IN` would silently drop every row from any query whose exclusion list
206 * happened to come back empty — the same shape of silent, widening-or-narrowing
207 * failure as the `IN ()` bug itself.
208 *
209 * Shared rather than inlined at each call site because there are four of them
210 * (`whereIn`, `orWhereIn`, `whereNotIn`, `orWhereNotIn`) and they have already
211 * drifted once.
212 */
213function renderInPredicate(column: string, values: any[], negated: boolean, placeholders: string): string {
214 if (values.length === 0)
215 return negated ? TRUE_PREDICATE : FALSE_PREDICATE
216 return `${column} ${negated ? 'NOT IN' : 'IN'} (${placeholders})`
217}
218
219189// Pre-compiled regex patterns for performance
220190const SQL_PATTERNS = {
221191 SELECT_STAR: /^SELECT\s+\*/i,
@@ -887,6 +857,47 @@ export interface BaseSelectQueryBuilder<
887857 */
888858 orWhereNotLike: (column: keyof DB[TTable]['columns'] & string, pattern: string, caseSensitive?: boolean) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
889859 orWhereNotILike?: (column: keyof DB[TTable]['columns'] & string, pattern: string) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
860 /**
861 * # `whereGroup`
862 *
863 * A parenthesised group of conditions, ANDed onto the query.
864 *
865 * A chained `orWhere` groups with the term immediately before it, so
866 * `.where(a).where(b).orWhere(c)` means `a AND (b OR c)`. This is the escape
867 * hatch for the other reading — `(a AND b) OR c` — which chaining alone can
868 * no longer express. Throws if the callback adds no conditions, rather than
869 * leaving the query unfiltered.
870 *
871 * @example
872 * ```ts
873 * // (status = 'live' AND role = 'admin') OR owner_id = 7
874 * db.selectFrom('users')
875 * .whereGroup(b => b.where('status', '=', 'live').where('role', '=', 'admin'))
876 * .orWhere('owner_id', '=', 7)
877 * ```
878 */
879 whereGroup: (callback: (builder: SelectQueryBuilder<DB, TTable, TSelected, TJoined>) => unknown) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
880 /**
881 * # `orWhereGroup`
882 *
883 * A parenthesised group of conditions, ORed onto the query.
884 */
885 orWhereGroup: (callback: (builder: SelectQueryBuilder<DB, TTable, TSelected, TJoined>) => unknown) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
886 /** OR-connected counterpart of `whereNull`. */
887 orWhereNull: (column: keyof DB[TTable]['columns'] & string) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
888 /** OR-connected counterpart of `whereNotNull`. */
889 orWhereNotNull: (column: keyof DB[TTable]['columns'] & string) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
890 /** OR-connected counterpart of `whereBetween`. */
891 orWhereBetween: (column: keyof DB[TTable]['columns'] & string, start: any, end: any) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
892 /** OR-connected counterpart of `whereExists`. */
893 orWhereExists: (subquery: { toSQL: () => any }) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
894 /**
895 * OR-connected counterpart of `whereRaw`.
896 *
897 * A raw fragment is one term and is not auto-parenthesised — if it contains a
898 * top-level `OR`, bracket it yourself.
899 */
900 orWhereRaw: (fragment: SqlFragment) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
890901 // where any/all/none on list of columns
891902 /**
892903 * # `whereAny`
@@ -3216,12 +3227,28 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
32163227 ? `SELECT ${columns.join(', ')} FROM ${String(table)}`
32173228 : `SELECT * FROM ${String(table)}`
32183229
3230 // The WHERE clause is a TERM LIST, not text appended to `text`. Each term
3231 // records the connector its caller asked for, and the body is rendered at
3232 // emit time by renderWhereTerms(), which brackets each maximal OR-run with
3233 // the term that opens it.
3234 //
3235 // A `whereConditions: string[]` used to live here with 25 pushes and zero
3236 // reads — a structured representation nobody ever wired to the emit path,
3237 // which is exactly why `orWhere` shipped as flat text and mis-grouped.
3238 // See stacksjs/bun-query-builder#1083.
3239 const whereTerms: WhereTerm[] = []
3240 const whereParams: unknown[] = []
3241 // Set once a set operator closes the current SELECT. Terms pushed after a
3242 // UNION/INTERSECT/EXCEPT belong to the RIGHT-hand SELECT and must render at
3243 // the end of `text`, or their params would bind in the wrong order.
3244 let whereTail = false
3245
32193246 // Lazy building: don't prepare statement until execution
32203247 // built is initialized lazily to avoid expensive template tag calls on every query
32213248 let built: any = null
32223249 const ensureBuilt = () => {
32233250 if (built === null) {
3224 const finalText = reorderSelectClauses(text)
3251 const finalText = reorderSelectClauses(currentSql())
32253252 built = whereParams.length > 0
32263253 ? _sql.unsafe(finalText, whereParams)
32273254 : _sql.unsafe(finalText)
@@ -3229,17 +3256,168 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
32293256 return built
32303257 }
32313258
3259 /** Record one WHERE predicate. The connector is the caller's intent, not a
3260 * position — renderWhereTerms() decides what the first term emits. */
3261 const pushWhere = (conn: 'AND' | 'OR', clause: string) => {
3262 whereTerms.push({ conn, sql: clause })
3263 built = null
3264 }
3265
3266 // Kept so the existing call sites need no edit. Callers pass 'WHERE' to
3267 // mean "a where-type clause" (whereLike/whereExists/whereILike/the dynamic
3268 // whereX proxy all do); only 'OR' is a real disjunction.
32323269 const addWhereText = (prefix: 'WHERE' | 'AND' | 'OR', clause: string) => {
3233 const hasWhere = SQL_PATTERNS.WHERE.test(text)
3234 // When a WHERE already exists the new clause needs a CONNECTOR, not a
3235 // second WHERE keyword. Callers pass 'WHERE' to mean "a where-type
3236 // clause" (whereLike/whereExists/whereILike/dynamic whereX all do); if
3237 // one is chained after an existing WHERE, that must become AND — emitting
3238 // a literal second `WHERE` produced invalid SQL (`WHERE a = ? WHERE b = ?`).
3239 // 'OR'/'AND' connectors pass through unchanged. The first clause is always
3240 // forced to WHERE regardless of the requested prefix.
3241 const p = !hasWhere ? 'WHERE' : (prefix === 'WHERE' ? 'AND' : prefix)
3242 text = `${text} ${p} ${clause}`
3270 pushWhere(prefix === 'OR' ? 'OR' : 'AND', clause)
3271 }
3272
3273 // Index of the first TOP-LEVEL clause that must follow WHERE, or -1.
3274 // Paren-depth scan so a subquery's own ORDER BY/LIMIT doesn't match.
3275 const TAIL_CLAUSE = /\(|\)|\b(?:GROUP\s+BY|HAVING|ORDER\s+BY|LIMIT|OFFSET|WINDOW|UNION|INTERSECT|EXCEPT|FOR\s+UPDATE|FOR\s+SHARE|LOCK\s+IN\s+SHARE\s+MODE)\b/gi
3276 const firstTailIndex = (s: string): number => {
3277 TAIL_CLAUSE.lastIndex = 0
3278 let depth = 0
3279 let m: RegExpExecArray | null
3280 // eslint-disable-next-line no-cond-assign
3281 while ((m = TAIL_CLAUSE.exec(s))) {
3282 if (m[0] === '(') { depth++ }
3283 else if (m[0] === ')') { depth = Math.max(0, depth - 1) }
3284 else if (depth === 0) { return m.index }
3285 }
3286 return -1
3287 }
3288
3289 /**
3290 * `text` with the rendered WHERE spliced in ahead of the first trailing
3291 * clause.
3292 *
3293 * EVERY path that emits executable SQL must go through this. The WHERE no
3294 * longer lives in `text`, so reading `text` directly now yields a query
3295 * with no filter at all — the worst failure mode available here, and the
3296 * reason this function exists rather than the splice being inlined.
3297 *
3298 * Splicing rather than appending also fixes `.where(a).orderBy(c).orWhere(b)`,
3299 * which used to emit `… ORDER BY c ASC OR b` and fail to parse.
3300 */
3301 const currentSql = (): string => {
3302 const body = renderWhereTerms(whereTerms)
3303 if (!body)
3304 return text
3305 if (whereTail) {
3306 const kw = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
3307 return `${text} ${kw} ${body}`
3308 }
3309 const cut = firstTailIndex(text)
3310 return cut >= 0
3311 ? `${text.slice(0, cut)}WHERE ${body} ${text.slice(cut)}`
3312 : `${text} WHERE ${body}`
3313 }
3314
3315 /**
3316 * A parenthesised sub-group: the escape hatch for `(a AND b) OR c`, the one
3317 * shape the new precedence rule can no longer express by chaining alone.
3318 *
3319 * The callback receives a fresh builder over the same table and only its
3320 * WHERE state is harvested — nothing else it touches has any effect.
3321 */
3322 const addWhereGroup = (conn: 'AND' | 'OR', cb: (b: any) => unknown) => {
3323 const sub: any = makeSelect(table as any)
3324 cb(sub)
3325 const st = sub.__whereState?.() as { body: string, params: unknown[] } | undefined
3326 if (!st || !st.body) {
3327 // Fails closed, for the same reason where() throws on a condition it
3328 // does not understand: a group that contributed nothing would leave the
3329 // query matching every row the filter existed to exclude.
3330 throw new TypeError(
3331 '[query-builder] whereGroup(callback): the callback added no conditions. '
3332 + 'Add at least one where()/orWhere() to the builder it receives.',
3333 )
3334 }
3335 const offset = whereParams.length
3336 // The sub-builder numbered its placeholders from $1; on Postgres they
3337 // have to continue ours. `?` dialects need no renumbering.
3338 const body = offset > 0 && config.dialect === 'postgres'
3339 ? st.body.replace(/\$(\d+)/g, (_m: string, n: string) => `$${Number(n) + offset}`)
3340 : st.body
3341 whereParams.push(...st.params)
3342 pushWhere(conn, `(${body})`)
3343 }
3344
3345 /**
3346 * The body of `andWhere` and `orWhere`.
3347 *
3348 * These were two near-identical 75-line copies that differed only in the
3349 * connector they emitted — which is exactly how the OR variant came to be
3350 * the one that mis-grouped. One implementation, connector as a parameter.
3351 */
3352 const addBooleanWhere = (self: any, conn: 'AND' | 'OR', label: string, expr: any, op?: WhereOperator, value?: any) => {
3353 if (typeof expr === 'string' && op !== undefined) {
3354 validateIdentifier(String(expr), `${label}(column)`)
3355 assertSafeWhereOperator(op, `${label}(operator)`)
3356 const paramIndex = whereParams.length + 1
3357 whereParams.push(value)
3358 pushWhere(conn, `${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`)
3359 return self
3360 }
3361
3362 // Array format: ['column', 'op', value]
3363 if (Array.isArray(expr)) {
3364 const [col, rawOp, val] = expr
3365 const colName = String(col)
3366 validateIdentifier(colName, `${label}(column)`)
3367 const operator = assertSafeWhereOperator(rawOp, `${label}(operator)`)
3368
3369 if (operator === 'in' || operator === 'not in') {
3370 const values = Array.isArray(val) ? val : [val]
3371 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
3372 const clause = renderInPredicate(colName, values, operator === 'not in', placeholders)
3373 whereParams.push(...values)
3374 pushWhere(conn, clause)
3375 }
3376 else {
3377 const paramIndex = whereParams.length + 1
3378 whereParams.push(val)
3379 pushWhere(conn, `${colName} ${operator} ${getPlaceholder(paramIndex)}`)
3380 }
3381 return self
3382 }
3383
3384 // Callback: a parenthesised group, same as where(callback)/whereGroup().
3385 if (typeof expr === 'function') {
3386 addWhereGroup(conn, expr)
3387 return self
3388 }
3389
3390 // Object format: { name: 'Alice', age: 25 }
3391 if (expr && typeof expr === 'object' && !('raw' in expr)) {
3392 const conditions: string[] = []
3393 for (const key of Object.keys(expr)) {
3394 validateIdentifier(key, `${label}(column)`)
3395 const v = (expr as any)[key]
3396 if (Array.isArray(v)) {
3397 const placeholders = getPlaceholders(v.length, whereParams.length + 1)
3398 conditions.push(renderInPredicate(key, v, false, placeholders))
3399 whereParams.push(...v)
3400 }
3401 else {
3402 const paramIndex = whereParams.length + 1
3403 conditions.push(`${key} = ${getPlaceholder(paramIndex)}`)
3404 whereParams.push(v)
3405 }
3406 }
3407 // One term. `orWhere({a, b})` means "OR (a AND b)", so the conjunction
3408 // has to be bracketed — un-bracketed it bound only `a` to the OR.
3409 if (conditions.length > 0)
3410 pushWhere(conn, conditions.length > 1 ? `(${conditions.join(' AND ')})` : conditions[0])
3411 return self
3412 }
3413
3414 // Raw expressions
3415 if (expr && typeof (expr as any).raw !== 'undefined') {
3416 pushWhere(conn, (expr as any).raw)
3417 return self
3418 }
3419
3420 return self
32433421 }
32443422
32453423 // Append a UNION/UNION ALL (extensible to INTERSECT/EXCEPT) while MERGING
@@ -3247,6 +3425,15 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
32473425 // ours on Postgres — previously the set-op appended text only, dropping the
32483426 // right side's params and colliding `$1`. See stacksjs/bun-query-builder#1029.
32493427 const appendSetOp = (op: string, other: { toSQL: () => any, __rawState?: () => { sql: string, params: unknown[] } }) => {
3428 // Params are one flat array in push order, so the WHERE text has to
3429 // precede the set operator's params in the emitted string. Materialize it
3430 // into `text` now; terms added after this point belong to the RIGHT-hand
3431 // SELECT and render at the end — which is what the old text-append did.
3432 if (whereTerms.length > 0) {
3433 text = currentSql()
3434 whereTerms.length = 0
3435 }
3436 whereTail = true
32503437 const st = other.__rawState?.()
32513438 if (st) {
32523439 const offset = whereParams.length
@@ -3371,9 +3558,6 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
33713558 joinedTables.add(resolved.pivotTable)
33723559 }
33733560
3374 // Track WHERE conditions and parameters for proper merging
3375 const whereConditions: string[] = []
3376 const whereParams: unknown[] = []
33773561
33783562 // Helper function to add columns to the SELECT clause
33793563 const addToSelectClause = (columnsToAdd: string): void => {
@@ -4188,11 +4372,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
41884372 throw new Error(`[query-builder] Unsupported relationship type '${type}' for whereHas`)
41894373 }
41904374
4191 built = sql`${ensureBuilt()} WHERE EXISTS (${sql([subquerySQL] as any)})`
4192 try {
4193 addWhereText('WHERE', `EXISTS (${subquerySQL})`)
4194 }
4195 catch {}
4375 // The record of truth is the term list; ensureBuilt() renders from it.
4376 // This used to ALSO assign `built = sql`${ensureBuilt()} WHERE ...``, a
4377 // parallel representation carrying an unconditional WHERE keyword. See #1083.
4378 addWhereText('WHERE', `EXISTS (${subquerySQL})`)
41964379
41974380 return this as any
41984381 },
@@ -4241,11 +4424,8 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
42414424 throw new Error(`[query-builder] Unsupported relationship type '${type}' for whereDoesntHave`)
42424425 }
42434426
4244 built = sql`${ensureBuilt()} WHERE NOT EXISTS (${sql([subquerySQL] as any)})`
4245 try {
4246 addWhereText('WHERE', `NOT EXISTS (${subquerySQL})`)
4247 }
4248 catch {}
4427 // See the note in whereHas: the term list is the single record.
4428 addWhereText('WHERE', `NOT EXISTS (${subquerySQL})`)
42494429
42504430 return this as any
42514431 },
@@ -4292,11 +4472,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
42924472 throw new Error(`[query-builder] Unsupported relationship type '${type}' for has`)
42934473 }
42944474
4295 built = sql`${ensureBuilt()} WHERE EXISTS (${sql([subquerySQL] as any)})`
4296 try {
4297 addWhereText('WHERE', `EXISTS (${subquerySQL})`)
4298 }
4299 catch {}
4475 // The record of truth is the term list; ensureBuilt() renders from it.
4476 // This used to ALSO assign `built = sql`${ensureBuilt()} WHERE ...``, a
4477 // parallel representation carrying an unconditional WHERE keyword. See #1083.
4478 addWhereText('WHERE', `EXISTS (${subquerySQL})`)
43004479
43014480 return this as any
43024481 },
@@ -4343,11 +4522,8 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
43434522 throw new Error(`[query-builder] Unsupported relationship type '${type}' for doesntHave`)
43444523 }
43454524
4346 built = sql`${ensureBuilt()} WHERE NOT EXISTS (${sql([subquerySQL] as any)})`
4347 try {
4348 addWhereText('WHERE', `NOT EXISTS (${subquerySQL})`)
4349 }
4350 catch {}
4525 // See the note in whereHas: the term list is the single record.
4526 addWhereText('WHERE', `NOT EXISTS (${subquerySQL})`)
43514527
43524528 return this as any
43534529 },
@@ -4480,17 +4656,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
44804656 const val = value === undefined ? opOrValue : value
44814657 const paramIndex = whereParams.length + 1
44824658 const clause = `${resolved.pivotTable}.${column} ${op} ${getPlaceholder(paramIndex)}`
4483 whereConditions.push(clause)
44844659 whereParams.push(val)
4485 const kw = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4486 text = `${text} ${kw} ${clause}`
4487 built = null
4660 pushWhere('AND', clause)
44884661 if (resolved.hasConfig)
44894662 pivotConfigRelations.add(relation)
44904663 return this as any
44914664 },
44924665 wherePivotIn(relation: string, column: string, values: any[]) {
4493 if (!meta || !Array.isArray(values) || values.length === 0)
4666 if (!meta || !Array.isArray(values))
44944667 return this as any
44954668 const resolved = resolvePivotLocal(relation)
44964669 if (!resolved) {
@@ -4500,13 +4673,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
45004673 validateIdentifier(column, 'wherePivotIn (column)')
45014674 ensurePivotJoined(resolved)
45024675
4676 // An empty list is FALSE, not absent: `return this` here dropped the
4677 // predicate and widened the query to every row it existed to exclude.
4678 // `wherePivotNotIn([])` keeps its no-op below — non-membership in the
4679 // empty set is TRUE, so dropping the term there is correct. See #1083.
45034680 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
4504 const clause = `${resolved.pivotTable}.${column} IN (${placeholders})`
4505 whereConditions.push(clause)
4681 const clause = renderInPredicate(`${resolved.pivotTable}.${column}`, values, false, placeholders)
45064682 whereParams.push(...values)
4507 const kw = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4508 text = `${text} ${kw} ${clause}`
4509 built = null
4683 pushWhere('AND', clause)
45104684 if (resolved.hasConfig)
45114685 pivotConfigRelations.add(relation)
45124686 return this as any
@@ -4524,11 +4698,8 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
45244698
45254699 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
45264700 const clause = `${resolved.pivotTable}.${column} NOT IN (${placeholders})`
4527 whereConditions.push(clause)
45284701 whereParams.push(...values)
4529 const kw = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4530 text = `${text} ${kw} ${clause}`
4531 built = null
4702 pushWhere('AND', clause)
45324703 if (resolved.hasConfig)
45334704 pivotConfigRelations.add(relation)
45344705 return this as any
@@ -4542,11 +4713,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
45424713 validateIdentifier(resolved.pivotTable, 'wherePivotNull (pivot table)')
45434714 validateIdentifier(column, 'wherePivotNull (column)')
45444715 ensurePivotJoined(resolved)
4545 const clause = `${resolved.pivotTable}.${column} IS NULL`
4546 whereConditions.push(clause)
4547 const kw = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4548 text = `${text} ${kw} ${clause}`
4549 built = null
4716 pushWhere('AND', `${resolved.pivotTable}.${column} IS NULL`)
45504717 if (resolved.hasConfig)
45514718 pivotConfigRelations.add(relation)
45524719 return this as any
@@ -4560,19 +4727,12 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
45604727 validateIdentifier(resolved.pivotTable, 'wherePivotNotNull (pivot table)')
45614728 validateIdentifier(column, 'wherePivotNotNull (column)')
45624729 ensurePivotJoined(resolved)
4563 const clause = `${resolved.pivotTable}.${column} IS NOT NULL`
4564 whereConditions.push(clause)
4565 const kw = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4566 text = `${text} ${kw} ${clause}`
4567 built = null
4730 pushWhere('AND', `${resolved.pivotTable}.${column} IS NOT NULL`)
45684731 if (resolved.hasConfig)
45694732 pivotConfigRelations.add(relation)
45704733 return this as any
45714734 },
45724735 where(expr: any, op?: WhereOperator, value?: any) {
4573 // Helper to get the correct keyword (WHERE for first condition, AND for subsequent)
4574 const getWhereKeyword = () => SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4575
45764736 if (typeof expr === 'string' && op !== undefined) {
45774737 // Boundary validation: the column and operator are interpolated
45784738 // into SQL text (values stay parameterized). Compile-time types
@@ -4590,19 +4750,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
45904750 if (operator === 'in' || operator === 'not in') {
45914751 const values = Array.isArray(value) ? value : [value]
45924752 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
4593 const clause = `${String(expr)} ${operator.toUpperCase()} (${placeholders})`
4594 whereConditions.push(clause)
4753 const clause = renderInPredicate(String(expr), values, operator === 'not in', placeholders)
45954754 whereParams.push(...values)
4596 text = `${text} ${getWhereKeyword()} ${clause}`
4597 built = null
4755 pushWhere('AND', clause)
45984756 return this
45994757 }
46004758 const paramIndex = whereParams.length + 1
4601 whereConditions.push(`${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`)
46024759 whereParams.push(value)
4603 // Update built and text immediately
4604 text = `${text} ${getWhereKeyword()} ${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`
4605 built = null
4760 pushWhere('AND', `${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`)
46064761 return this
46074762 }
46084763
@@ -4616,17 +4771,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
46164771 if (operator === 'in' || operator === 'not in') {
46174772 const values = Array.isArray(val) ? val : [val]
46184773 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
4619 whereConditions.push(`${colName} ${operator.toUpperCase()} (${placeholders})`)
4774 const clause = renderInPredicate(colName, values, operator === 'not in', placeholders)
46204775 whereParams.push(...values)
4621 text = `${text} ${getWhereKeyword()} ${colName} ${operator.toUpperCase()} (${placeholders})`
4622 built = null
4776 pushWhere('AND', clause)
46234777 }
46244778 else {
46254779 const paramIndex = whereParams.length + 1
4626 whereConditions.push(`${colName} ${operator} ${getPlaceholder(paramIndex)}`)
46274780 whereParams.push(val)
4628 text = `${text} ${getWhereKeyword()} ${colName} ${operator} ${getPlaceholder(paramIndex)}`
4629 built = null
4781 pushWhere('AND', `${colName} ${operator} ${getPlaceholder(paramIndex)}`)
46304782 }
46314783
46324784 return this
@@ -4643,30 +4795,27 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
46434795 const value = whereObject[key]
46444796 if (Array.isArray(value)) {
46454797 const placeholders = getPlaceholders(value.length, whereParams.length + 1)
4646 conditions.push(`${key} IN (${placeholders})`)
4647 whereConditions.push(`${key} IN (${placeholders})`)
4798 conditions.push(renderInPredicate(key, value, false, placeholders))
46484799 whereParams.push(...value)
46494800 }
46504801 else {
46514802 const paramIndex = whereParams.length + 1
46524803 conditions.push(`${key} = ${getPlaceholder(paramIndex)}`)
4653 whereConditions.push(`${key} = ${getPlaceholder(paramIndex)}`)
46544804 whereParams.push(value)
46554805 }
46564806 }
46574807
4658 if (conditions.length > 0) {
4659 text = `${text} ${getWhereKeyword()} ${conditions.join(' AND ')}`
4660 built = null
4661 }
4808 // One term, not one per key: an object literal is a single
4809 // conjunction, so a later `.orWhere(z)` must OR against the whole of
4810 // it rather than against its last key.
4811 if (conditions.length > 0)
4812 pushWhere('AND', conditions.length > 1 ? `(${conditions.join(' AND ')})` : conditions[0])
46624813 return this
46634814 }
46644815
46654816 // Handle raw expressions
46664817 if (isRawExpression(expr)) {
4667 whereConditions.push(expr.raw)
4668 text = `${text} ${getWhereKeyword()} ${expr.raw}`
4669 built = null
4818 pushWhere('AND', expr.raw)
46704819 return this
46714820 }
46724821
@@ -4676,17 +4825,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
46764825 // A `where` that fails open is a data leak in any application where the
46774826 // filter is the access check, and nothing anywhere says it happened.
46784827 //
4679 // The callback form is the one people actually write, because that is
4680 // how Kysely and Knex express an `OR` group. It is not supported here -
4681 // `whereNested`, `whereAny` and the `orWhere*` helpers are - so it says
4682 // so instead of ignoring it.
4828 // The callback form is the one people actually write, because it is how
4829 // Kysely and Knex express a group, and docs/guide/where.md has shown it
4830 // for longer than the builder has rejected it. It is now a real group —
4831 // see addWhereGroup, which still throws if the callback adds nothing, so
4832 // the fails-closed guarantee is unchanged.
46834833 if (typeof expr === 'function') {
4684 throw new TypeError(
4685 'where(callback) is not supported. This builder has no expression callback, and '
4686 + 'ignoring one silently would leave the query matching every row the filter was '
4687 + 'meant to exclude. Use whereNested(subquery), whereAny(...), or the orWhere* '
4688 + 'helpers to build a grouped condition.',
4689 )
4834 addWhereGroup('AND', expr)
4835 return this
46904836 }
46914837
46924838 if (expr === undefined || expr === null) {
@@ -4705,52 +4851,83 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
47054851 // where helpers
47064852 whereNull(column: string) {
47074853 validateIdentifier(String(column), 'whereNull(column)')
4708 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4709 text = `${text} ${keyword} ${String(column)} IS NULL`
4710 built = null
4854 pushWhere('AND', `${String(column)} IS NULL`)
47114855 return this
47124856 },
47134857 whereNotNull(column: string) {
47144858 validateIdentifier(String(column), 'whereNotNull(column)')
4715 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4716 text = `${text} ${keyword} ${String(column)} IS NOT NULL`
4717 built = null
4859 pushWhere('AND', `${String(column)} IS NOT NULL`)
47184860 return this
47194861 },
4862 orWhereNull(column: string) {
4863 validateIdentifier(String(column), 'orWhereNull(column)')
4864 pushWhere('OR', `${String(column)} IS NULL`)
4865 return this as any
4866 },
4867 orWhereNotNull(column: string) {
4868 validateIdentifier(String(column), 'orWhereNotNull(column)')
4869 pushWhere('OR', `${String(column)} IS NOT NULL`)
4870 return this as any
4871 },
47204872 whereBetween(column: string, start: any, end: any) {
47214873 validateIdentifier(String(column), 'whereBetween(column)')
4722 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
47234874 // Dialect-aware placeholders: Postgres needs `$n`, not `?` (#1027).
47244875 const i = whereParams.length + 1
4725 text = `${text} ${keyword} ${String(column)} BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}`
47264876 whereParams.push(start, end)
4727 built = null
4877 pushWhere('AND', `${String(column)} BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}`)
47284878 return this
47294879 },
4880 orWhereBetween(column: string, start: any, end: any) {
4881 validateIdentifier(String(column), 'orWhereBetween(column)')
4882 const i = whereParams.length + 1
4883 whereParams.push(start, end)
4884 pushWhere('OR', `${String(column)} BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}`)
4885 return this as any
4886 },
47304887 whereExists(subquery: { toSQL: () => any }) {
4731 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4732 text = `${text} ${keyword} EXISTS (${subquery.toSQL()})`
4733 built = null
4888 pushWhere('AND', `EXISTS (${subquery.toSQL()})`)
47344889 return this
47354890 },
4891 orWhereExists(subquery: { toSQL: () => any }) {
4892 pushWhere('OR', `EXISTS (${subquery.toSQL()})`)
4893 return this as any
4894 },
4895 /**
4896 * A parenthesised group of conditions.
4897 *
4898 * This is the escape hatch for `(a AND b) OR c` — the shape that chaining
4899 * alone can no longer express now that a chained `orWhere` groups with the
4900 * term before it. See renderWhereTerms and #1083.
4901 */
4902 whereGroup(cb: (b: any) => unknown) {
4903 addWhereGroup('AND', cb)
4904 return this as any
4905 },
4906 orWhereGroup(cb: (b: any) => unknown) {
4907 addWhereGroup('OR', cb)
4908 return this as any
4909 },
4910 /** Internal: the rendered WHERE body and its params, harvested by whereGroup(). */
4911 __whereState() {
4912 return { body: renderWhereTerms(whereTerms), params: [...whereParams] }
4913 },
47364914 whereJsonContains(column: string, json: unknown) {
47374915 // Dialect-aware JSON containment. Previously hardcoded Postgres `@>`,
47384916 // which is a syntax error on MySQL/SQLite and ignored the configured
47394917 // `jsonContainsMode`. See stacksjs/bun-query-builder#1026.
47404918 validateIdentifier(String(column), 'whereJsonContains(column)')
4741 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
47424919 const dialect = config.dialect
47434920 const idx = whereParams.length + 1
47444921 if (dialect === 'postgres') {
47454922 // operator (`@>`, default) or function (`jsonb_contains`) per config.
47464923 if (config.sql?.jsonContainsMode === 'function')
4747 text += ` ${keyword} jsonb_contains(${column}, ${getPlaceholder(idx)})`
4924 pushWhere('AND', `jsonb_contains(${column}, ${getPlaceholder(idx)})`)
47484925 else
4749 text += ` ${keyword} ${column} @> ${getPlaceholder(idx)}`
4926 pushWhere('AND', `${column} @> ${getPlaceholder(idx)}`)
47504927 whereParams.push(JSON.stringify(json))
47514928 }
47524929 else if (isMysqlLike(dialect)) {
4753 text += ` ${keyword} JSON_CONTAINS(${column}, ${getPlaceholder(idx)})`
4930 pushWhere('AND', `JSON_CONTAINS(${column}, ${getPlaceholder(idx)})`)
47544931 whereParams.push(JSON.stringify(json))
47554932 }
47564933 else {
@@ -4760,18 +4937,17 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
47604937 // value must be present.
47614938 if (Array.isArray(json)) {
47624939 const conds = json.map((_, i) => `EXISTS (SELECT 1 FROM json_each(${column}) WHERE json_each.value = ${getPlaceholder(idx + i)})`)
4763 text += ` ${keyword} (${conds.join(' AND ')})`
4940 pushWhere('AND', `(${conds.join(' AND ')})`)
47644941 for (const v of json) whereParams.push(v as any)
47654942 }
47664943 else if (json !== null && typeof json === 'object') {
47674944 throw new Error('[query-builder] whereJsonContains: object containment is not supported on SQLite — pass a scalar or array, or use whereJsonPath.')
47684945 }
47694946 else {
4770 text += ` ${keyword} EXISTS (SELECT 1 FROM json_each(${column}) WHERE json_each.value = ${getPlaceholder(idx)})`
4947 pushWhere('AND', `EXISTS (SELECT 1 FROM json_each(${column}) WHERE json_each.value = ${getPlaceholder(idx)})`)
47714948 whereParams.push(json as any)
47724949 }
47734950 }
4774 built = null
47754951 return this as any
47764952 },
47774953 whereJsonPath(path: string, op: WhereOperator, value: any) {
@@ -4790,19 +4966,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
47904966 throw new TypeError(`[query-builder] whereJsonPath(path): refusing to use '${path}' — contains characters outside the allowed JSON-path set`)
47914967
47924968 const dialect = config.dialect
4793 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
47944969 const idx = whereParams.length + 1
4795 if (dialect === 'postgres') {
4796 text += ` ${keyword} ${path} ${op} ${getPlaceholder(idx)}`
4797 }
4798 else if (isMysqlLike(dialect)) {
4799 text += ` ${keyword} JSON_EXTRACT(${path}) ${op} ${getPlaceholder(idx)}`
4800 }
4801 else {
4802 text += ` ${keyword} json_extract(${path}) ${op} ${getPlaceholder(idx)}`
4803 }
4970 if (dialect === 'postgres')
4971 pushWhere('AND', `${path} ${op} ${getPlaceholder(idx)}`)
4972 else if (isMysqlLike(dialect))
4973 pushWhere('AND', `JSON_EXTRACT(${path}) ${op} ${getPlaceholder(idx)}`)
4974 else
4975 pushWhere('AND', `json_extract(${path}) ${op} ${getPlaceholder(idx)}`)
48044976 whereParams.push(value)
4805 built = null
48064977 return this as any
48074978 },
48084979 // The LIKE/ILIKE family records the clause in `text` + `whereParams`
@@ -4891,46 +5062,36 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
48915062 // because TRUE genuinely is their identity. The asymmetry is the point,
48925063 // not an inconsistency to tidy up.
48935064 if (cols.length === 0) {
4894 const kw = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4895 text += ` ${kw} (${FALSE_PREDICATE})`
4896 built = null
5065 pushWhere('AND', `(${FALSE_PREDICATE})`)
48975066 return this as any
48985067 }
4899 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
49005068 const idx = whereParams.length + 1
49015069 const conds = cols.map((c, i) => `${c} ${op} ${getPlaceholder(idx + i)}`)
4902 text += ` ${keyword} (${conds.join(' OR ')})`
49035070 for (let i = 0; i < cols.length; i++) whereParams.push(value)
4904 built = null
5071 pushWhere('AND', `(${conds.join(' OR ')})`)
49055072 return this as any
49065073 },
49075074 whereAll(cols: string[], op: WhereOperator, value: any) {
49085075 if (cols.length === 0) return this as any
4909 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
49105076 const idx = whereParams.length + 1
49115077 const conds = cols.map((c, i) => `${c} ${op} ${getPlaceholder(idx + i)}`)
4912 text += ` ${keyword} (${conds.join(' AND ')})`
49135078 for (let i = 0; i < cols.length; i++) whereParams.push(value)
4914 built = null
5079 pushWhere('AND', `(${conds.join(' AND ')})`)
49155080 return this as any
49165081 },
49175082 whereNone(cols: string[], op: WhereOperator, value: any) {
49185083 if (cols.length === 0) return this as any
4919 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
49205084 const idx = whereParams.length + 1
49215085 const conds = cols.map((c, i) => `${c} ${op} ${getPlaceholder(idx + i)}`)
4922 text += ` ${keyword} NOT (${conds.join(' OR ')})`
49235086 for (let i = 0; i < cols.length; i++) whereParams.push(value)
4924 built = null
5087 pushWhere('AND', `NOT (${conds.join(' OR ')})`)
49255088 return this as any
49265089 },
49275090 whereNotBetween(column: string, start: any, end: any) {
49285091 validateIdentifier(String(column), 'whereNotBetween(column)')
4929 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
49305092 const i = whereParams.length + 1
4931 text += ` ${keyword} ${column} NOT BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}`
49325093 whereParams.push(start, end)
4933 built = null
5094 pushWhere('AND', `${column} NOT BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}`)
49345095 return this as any
49355096 },
49365097 whereDate(column: string, op: WhereOperator, date: string | Date) {
@@ -4944,253 +5105,101 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
49445105 : typeof date === 'string'
49455106 ? date
49465107 : (() => { throw new TypeError(`[query-builder] whereDate(date): expected string or Date, got ${typeof date}`) })()
4947 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
49485108 const idx = whereParams.length + 1
4949 text += ` ${keyword} ${column} ${op} ${getPlaceholder(idx)}`
49505109 whereParams.push(dateString)
4951 built = null
5110 pushWhere('AND', `${column} ${op} ${getPlaceholder(idx)}`)
49525111 return this as any
49535112 },
5113 // A raw fragment is ONE term and is deliberately not auto-parenthesised —
5114 // wrapping would churn emitted SQL for no correctness gain. A fragment
5115 // containing a top-level OR must bracket itself; see docs/guide/where.md.
49545116 whereRaw(fragment: any) {
4955 const frag = renderRawFragment(fragment, 'whereRaw(fragment)')
4956 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4957 text += ` ${keyword} ${frag}`
4958 built = null
5117 pushWhere('AND', renderRawFragment(fragment, 'whereRaw(fragment)'))
5118 return this as any
5119 },
5120 orWhereRaw(fragment: any) {
5121 pushWhere('OR', renderRawFragment(fragment, 'orWhereRaw(fragment)'))
49595122 return this as any
49605123 },
49615124 whereColumn(left: string, op: WhereOperator, right: string) {
49625125 validateIdentifier(left, 'whereColumn(left)')
49635126 validateIdentifier(right, 'whereColumn(right)')
4964 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
4965 text += ` ${keyword} ${left} ${op} ${right}`
4966 built = null
5127 pushWhere('AND', `${left} ${op} ${right}`)
49675128 return this as any
49685129 },
49695130 orWhereColumn(left: string, op: WhereOperator, right: string) {
49705131 validateIdentifier(left, 'orWhereColumn(left)')
49715132 validateIdentifier(right, 'orWhereColumn(right)')
4972 text += ` OR ${left} ${op} ${right}`
4973 built = null
5133 pushWhere('OR', `${left} ${op} ${right}`)
49745134 return this as any
49755135 },
49765136 whereIn(column: string, values: any[] | { toSQL: () => any }) {
49775137 validateIdentifier(String(column), 'whereIn(column)')
4978 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
49795138 if (Array.isArray(values)) {
49805139 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
4981 text += ` ${keyword} ${renderInPredicate(column, values, false, placeholders)}`
5140 const clause = renderInPredicate(column, values, false, placeholders)
49825141 whereParams.push(...values)
5142 pushWhere('AND', clause)
49835143 }
49845144 else {
4985 text += ` ${keyword} ${column} IN (${String((values as any).toSQL())})`
5145 pushWhere('AND', `${column} IN (${String((values as any).toSQL())})`)
49865146 }
4987 built = null
49885147 return this as any
49895148 },
49905149 orWhereIn(column: string, values: any[] | { toSQL: () => any }) {
49915150 validateIdentifier(String(column), 'orWhereIn(column)')
49925151 if (Array.isArray(values)) {
49935152 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
4994 text += ` OR ${renderInPredicate(column, values, false, placeholders)}`
5153 const clause = renderInPredicate(column, values, false, placeholders)
49955154 whereParams.push(...values)
5155 pushWhere('OR', clause)
49965156 }
49975157 else {
4998 text += ` OR ${column} IN (${String((values as any).toSQL())})`
5158 pushWhere('OR', `${column} IN (${String((values as any).toSQL())})`)
49995159 }
5000 built = null
50015160 return this as any
50025161 },
50035162 whereNotIn(column: string, values: any[] | { toSQL: () => any }) {
50045163 validateIdentifier(String(column), 'whereNotIn(column)')
5005 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
50065164 if (Array.isArray(values)) {
50075165 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
5008 text += ` ${keyword} ${renderInPredicate(column, values, true, placeholders)}`
5166 const clause = renderInPredicate(column, values, true, placeholders)
50095167 whereParams.push(...values)
5168 pushWhere('AND', clause)
50105169 }
50115170 else {
5012 text += ` ${keyword} ${column} NOT IN (${String((values as any).toSQL())})`
5171 pushWhere('AND', `${column} NOT IN (${String((values as any).toSQL())})`)
50135172 }
5014 built = null
50155173 return this as any
50165174 },
50175175 orWhereNotIn(column: string, values: any[] | { toSQL: () => any }) {
50185176 validateIdentifier(String(column), 'orWhereNotIn(column)')
50195177 if (Array.isArray(values)) {
50205178 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
5021 text += ` OR ${renderInPredicate(column, values, true, placeholders)}`
5179 const clause = renderInPredicate(column, values, true, placeholders)
50225180 whereParams.push(...values)
5181 pushWhere('OR', clause)
50235182 }
50245183 else {
5025 text += ` OR ${column} NOT IN (${String((values as any).toSQL())})`
5184 pushWhere('OR', `${column} NOT IN (${String((values as any).toSQL())})`)
50265185 }
5027 built = null
50285186 return this as any
50295187 },
50305188 whereNested(fragment: any) {
5031 const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE'
50325189 const inner = fragment.toSQL ? String(fragment.toSQL()) : String(fragment)
5033 text += ` ${keyword} (${inner})`
5034 built = null
5190 pushWhere('AND', `(${inner})`)
50355191 return this as any
50365192 },
50375193 orWhereNested(fragment: any) {
50385194 const inner = fragment.toSQL ? String(fragment.toSQL()) : String(fragment)
5039 text += ` OR (${inner})`
5040 built = null
5195 pushWhere('OR', `(${inner})`)
50415196 return this as any
50425197 },
50435198 andWhere(expr: any, op?: WhereOperator, value?: any) {
5044 if (typeof expr === 'string' && op !== undefined) {
5045 validateIdentifier(String(expr), 'andWhere(column)')
5046 assertSafeWhereOperator(op, 'andWhere(operator)')
5047 const paramIndex = whereParams.length + 1
5048 whereConditions.push(`${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`)
5049 whereParams.push(value)
5050 text = `${text} AND ${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`
5051 built = null
5052 return this
5053 }
5054
5055 // Handle array format: ['column', 'op', value]
5056 if (Array.isArray(expr)) {
5057 const [col, op, val] = expr
5058 const colName = String(col)
5059 validateIdentifier(colName, 'andWhere(column)')
5060 const operator = assertSafeWhereOperator(op, 'andWhere(operator)')
5061
5062 if (operator === 'in' || operator === 'not in') {
5063 const values = Array.isArray(val) ? val : [val]
5064 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
5065 whereConditions.push(`${colName} ${operator.toUpperCase()} (${placeholders})`)
5066 whereParams.push(...values)
5067 text = `${text} AND ${colName} ${operator.toUpperCase()} (${placeholders})`
5068 built = null
5069 }
5070 else {
5071 const paramIndex = whereParams.length + 1
5072 whereConditions.push(`${colName} ${operator} ${getPlaceholder(paramIndex)}`)
5073 whereParams.push(val)
5074 text = `${text} AND ${colName} ${operator} ${getPlaceholder(paramIndex)}`
5075 built = null
5076 }
5077
5078 return this
5079 }
5080
5081 // Handle object format: { name: 'Alice', age: 25 }
5082 if (expr && typeof expr === 'object' && !('raw' in expr)) {
5083 const keys = Object.keys(expr)
5084 const conditions: string[] = []
5085
5086 for (const key of keys) {
5087 const value = (expr as any)[key]
5088 if (Array.isArray(value)) {
5089 const placeholders = getPlaceholders(value.length, whereParams.length + 1)
5090 conditions.push(`${key} IN (${placeholders})`)
5091 whereConditions.push(`${key} IN (${placeholders})`)
5092 whereParams.push(...value)
5093 }
5094 else {
5095 const paramIndex = whereParams.length + 1
5096 conditions.push(`${key} = ${getPlaceholder(paramIndex)}`)
5097 whereConditions.push(`${key} = ${getPlaceholder(paramIndex)}`)
5098 whereParams.push(value)
5099 }
5100 }
5101
5102 if (conditions.length > 0) {
5103 text = `${text} AND ${conditions.join(' AND ')}`
5104 built = null
5105 }
5106 return this
5107 }
5108
5109 // Handle raw expressions
5110 if (expr && typeof (expr as any).raw !== 'undefined') {
5111 whereConditions.push((expr as any).raw)
5112 text = `${text} AND ${(expr as any).raw}`
5113 built = null
5114 return this
5115 }
5116
5117 return this
5199 return addBooleanWhere(this, 'AND', 'andWhere', expr, op, value)
51185200 },
51195201 orWhere(expr: any, op?: WhereOperator, value?: any) {
5120 if (typeof expr === 'string' && op !== undefined) {
5121 validateIdentifier(String(expr), 'orWhere(column)')
5122 assertSafeWhereOperator(op, 'orWhere(operator)')
5123 const paramIndex = whereParams.length + 1
5124 whereConditions.push(`OR ${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`)
5125 whereParams.push(value)
5126 text = `${text} OR ${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`
5127 built = null
5128 return this
5129 }
5130
5131 // Handle array format: ['column', 'op', value]
5132 if (Array.isArray(expr)) {
5133 const [col, op, val] = expr
5134 const colName = String(col)
5135 validateIdentifier(colName, 'orWhere(column)')
5136 const operator = assertSafeWhereOperator(op, 'orWhere(operator)')
5137
5138 if (operator === 'in' || operator === 'not in') {
5139 const values = Array.isArray(val) ? val : [val]
5140 const placeholders = getPlaceholders(values.length, whereParams.length + 1)
5141 whereConditions.push(`OR ${colName} ${operator.toUpperCase()} (${placeholders})`)
5142 whereParams.push(...values)
5143 text = `${text} OR ${colName} ${operator.toUpperCase()} (${placeholders})`
5144 built = null
5145 }
5146 else {
5147 const paramIndex = whereParams.length + 1
5148 whereConditions.push(`OR ${colName} ${operator} ${getPlaceholder(paramIndex)}`)
5149 whereParams.push(val)
5150 text = `${text} OR ${colName} ${operator} ${getPlaceholder(paramIndex)}`
5151 built = null
5152 }
5153
5154 return this
5155 }
5156
5157 // Handle object format: { name: 'Alice', age: 25 }
5158 if (expr && typeof expr === 'object' && !('raw' in expr)) {
5159 const keys = Object.keys(expr)
5160 const conditions: string[] = []
5161
5162 for (const key of keys) {
5163 const value = (expr as any)[key]
5164 if (Array.isArray(value)) {
5165 const placeholders = getPlaceholders(value.length, whereParams.length + 1)
5166 conditions.push(`${key} IN (${placeholders})`)
5167 whereConditions.push(`OR ${key} IN (${placeholders})`)
5168 whereParams.push(...value)
5169 }
5170 else {
5171 const paramIndex = whereParams.length + 1
5172 conditions.push(`${key} = ${getPlaceholder(paramIndex)}`)
5173 whereConditions.push(`OR ${key} = ${getPlaceholder(paramIndex)}`)
5174 whereParams.push(value)
5175 }
5176 }
5177
5178 if (conditions.length > 0) {
5179 text = `${text} OR ${conditions.join(' AND ')}`
5180 built = null
5181 }
5182 return this
5183 }
5184
5185 // Handle raw expressions
5186 if (expr && typeof (expr as any).raw !== 'undefined') {
5187 whereConditions.push(`OR ${(expr as any).raw}`)
5188 text = `${text} OR ${(expr as any).raw}`
5189 built = null
5190 return this
5191 }
5192
5193 return this
5202 return addBooleanWhere(this, 'OR', 'orWhere', expr, op, value)
51945203 },
51955204 orderBy(column: string, direction: 'asc' | 'desc' = 'asc') {
51965205 // Compose-aware: detect an existing ORDER BY clause and append the
@@ -5496,7 +5505,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
54965505 // text — most toSQL() calls never execute the returned handle.
54975506 // ensureBuilt() still runs (memoized) if execute()/values()/raw()
54985507 // is actually invoked.
5499 const sqlText = reorderSelectClauses(text)
5508 const sqlText = reorderSelectClauses(currentSql())
55005509 return {
55015510 sql: sqlText,
55025511 toString: () => sqlText,
@@ -5555,7 +5564,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
55555564 // so a concurrent INSERT/DELETE can't desync `total` from `data.length`.
55565565 // The caller owns the transaction (and thus the isolation level).
55575566 if (opts.tx) {
5558 const baseSql = reorderSelectClauses(text)
5567 const baseSql = reorderSelectClauses(currentSql())
55595568 const baseParams = [...whereParams]
55605569 const cRows = await opts.tx.unsafe(`SELECT COUNT(*) as c FROM (${baseSql}) as sub`, baseParams) as any[]
55615570 const total = Number(cRows?.[0]?.c ?? 0)
@@ -5769,7 +5778,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
57695778 return (ensureBuilt() as any).simple()
57705779 },
57715780 toText() {
5772 return text
5781 return currentSql()
57735782 },
57745783 async get() {
57755784 const hooks = activeHooks()
@@ -5779,15 +5788,16 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
57795788 if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !hasQueryHooks) {
57805789 const prepareFn = _sql._prepareStatement
57815790 if (prepareFn) {
5782 const stmt = prepareFn(text)
5791 const stmt = prepareFn(currentSql())
57835792 return hydratePivotRows(whereParams.length > 0 ? stmt.all(...whereParams) : stmt.all())
57845793 }
57855794 }
57865795
57875796 // Build query at execution time (statement will be cached by db-clients.ts)
5797 const getText = currentSql()
57885798 built = whereParams.length > 0
5789 ? _sql.unsafe(text, whereParams)
5790 : _sql.unsafe(text)
5799 ? _sql.unsafe(getText, whereParams)
5800 : _sql.unsafe(getText)
57915801
57925802 // Fast path: no soft-deletes, no cache, no timeout, no signal, no hooks
57935803 if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !hasQueryHooks) {
@@ -5811,9 +5821,12 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
58115821 const col = config.softDeletes.column
58125822 const tbl = String(table)
58135823 const hasCol = schema ? Boolean((schema as any)[tbl]?.columns?.[col]) : true
5814 if (hasCol && !SQL_PATTERNS.DELETED_AT.test(text)) {
5815 finalQuery = sql`${ensureBuilt()} WHERE ${sql(String(col))} IS ${onlyTrashed ? sql`NOT NULL` : sql`NULL`}`
5824 if (hasCol && !SQL_PATTERNS.DELETED_AT.test(currentSql())) {
5825 // Record the term, then rebuild. The previous version composed
5826 // `sql`${ensureBuilt()} WHERE ...`` as well, which appended a second
5827 // unconditional WHERE keyword on top of any filter already present.
58165828 addWhereText('WHERE', `${String(col)} IS ${onlyTrashed ? 'NOT ' : ''}NULL`)
5829 finalQuery = ensureBuilt()
58175830 }
58185831 }
58195832
@@ -5855,7 +5868,8 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
58555868 if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !fHasQueryHooks) {
58565869 const prepareFn = _sql._prepareStatement
58575870 if (prepareFn) {
5858 const firstText = text.includes(' LIMIT ') ? text : `${text} LIMIT 1`
5871 const base = currentSql()
5872 const firstText = base.includes(' LIMIT ') ? base : `${base} LIMIT 1`
58595873 const stmt = prepareFn(firstText)
58605874 const rows = whereParams.length > 0 ? stmt.all(...whereParams) : stmt.all()
58615875 return hydratePivotRow(rows[0]) as any
@@ -5931,14 +5945,17 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
59315945 // `rows[0]` silently returns just the first group's count.
59325946 // Wrap in a subquery when GROUP BY is present.
59335947 // See stacksjs/stacks#1862 #26.
5934 const fromIdx = text.indexOf(' FROM ')
5935 const hasGroupBy = / GROUP BY /i.test(text)
5948 // Rebuild from currentSql(), not `text` — the WHERE lives in the term
5949 // list now, and slicing `text` here would count the UNFILTERED table.
5950 const src = currentSql()
5951 const fromIdx = src.indexOf(' FROM ')
5952 const hasGroupBy = / GROUP BY /i.test(src)
59365953 let countText: string
59375954 if (hasGroupBy) {
5938 countText = `SELECT COUNT(*) as c FROM (${text}) AS _bqb_count_sub`
5955 countText = `SELECT COUNT(*) as c FROM (${src}) AS _bqb_count_sub`
59395956 }
59405957 else if (fromIdx !== -1) {
5941 countText = `SELECT COUNT(*) as c${text.substring(fromIdx)}`
5958 countText = `SELECT COUNT(*) as c${src.substring(fromIdx)}`
59425959 }
59435960 else {
59445961 countText = `SELECT COUNT(*) as c FROM ${table}`
@@ -5965,9 +5982,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
59655982 },
59665983 async avg(column: string) {
59675984 // Build optimized AVG query without subquery or helpers
5968 const fromIdx = text.indexOf(' FROM ')
5985 const src = currentSql()
5986 const fromIdx = src.indexOf(' FROM ')
59695987 const avgText = fromIdx !== -1
5970 ? `SELECT AVG(${column}) as a${text.substring(fromIdx)}`
5988 ? `SELECT AVG(${column}) as a${src.substring(fromIdx)}`
59715989 : `SELECT AVG(${column}) as a FROM ${table}`
59725990
59735991 // Ultra-fast path
@@ -5990,9 +6008,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
59906008 return Number(row?.a ?? 0)
59916009 },
59926010 async sum(column: string) {
5993 const fromIdx = text.indexOf(' FROM ')
6011 const src = currentSql()
6012 const fromIdx = src.indexOf(' FROM ')
59946013 const sumText = fromIdx !== -1
5995 ? `SELECT SUM(${column}) as s${text.substring(fromIdx)}`
6014 ? `SELECT SUM(${column}) as s${src.substring(fromIdx)}`
59966015 : `SELECT SUM(${column}) as s FROM ${table}`
59976016 const q = whereParams.length > 0
59986017 ? _sql.unsafe(sumText, whereParams)
@@ -6002,9 +6021,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
60026021 return Number(row?.s ?? 0)
60036022 },
60046023 async max(column: string) {
6005 const fromIdx = text.indexOf(' FROM ')
6024 const src = currentSql()
6025 const fromIdx = src.indexOf(' FROM ')
60066026 const maxText = fromIdx !== -1
6007 ? `SELECT MAX(${column}) as m${text.substring(fromIdx)}`
6027 ? `SELECT MAX(${column}) as m${src.substring(fromIdx)}`
60086028 : `SELECT MAX(${column}) as m FROM ${table}`
60096029 const q = whereParams.length > 0
60106030 ? _sql.unsafe(maxText, whereParams)
@@ -6014,9 +6034,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
60146034 return row?.m
60156035 },
60166036 async min(column: string) {
6017 const fromIdx = text.indexOf(' FROM ')
6037 const src = currentSql()
6038 const fromIdx = src.indexOf(' FROM ')
60186039 const minText = fromIdx !== -1
6019 ? `SELECT MIN(${column}) as m${text.substring(fromIdx)}`
6040 ? `SELECT MIN(${column}) as m${src.substring(fromIdx)}`
60206041 : `SELECT MIN(${column}) as m FROM ${table}`
60216042 const q = whereParams.length > 0
60226043 ? _sql.unsafe(minText, whereParams)
@@ -6066,7 +6087,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
60666087 // by union()/unionAll() on the other side to merge params and renumber
60676088 // placeholders. See stacksjs/bun-query-builder#1029.
60686089 __rawState() {
6069 return { sql: reorderSelectClauses(text), params: [...whereParams] }
6090 return { sql: reorderSelectClauses(currentSql()), params: [...whereParams] }
60706091 },
60716092 raw() {
60726093 return (ensureBuilt() as any).raw()
@@ -6124,20 +6145,13 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
61246145 return () => receiver
61256146 const column = chosen
61266147 return (value: any) => {
6127 const expr = Array.isArray(value)
6128 ? sql`${sql(column)} IN ${sql(value as any)}`
6129 : sql`${sql(column)} = ${value}`
6130 built = isOr
6131 ? sql`${ensureBuilt()} OR ${expr}`
6132 : isAnd
6133 ? sql`${ensureBuilt()} AND ${expr}`
6134 : sql`${ensureBuilt()} WHERE ${expr}`
6135 // Mirror into text AND whereParams with dialect-aware
6136 // placeholders. The params must be pushed: any later call that
6137 // invalidates `built` (orderBy, limit, ...) rebuilds from
6138 // text+whereParams, and the previous version left the value
6139 // only inside the template-built query — the rebuilt statement
6140 // had a dangling placeholder. Same bug class as #1028.
6148 // Record the term and let ensureBuilt() rebuild from it.
6149 //
6150 // This used to ALSO assign `built = sql\`${ensureBuilt()} OR …\``,
6151 // a second representation of the same predicate that appended a
6152 // bare top-level OR — so the dynamic `orWhereX` proxy mis-grouped
6153 // even where the text path did not. It was the query object
6154 // actually executed. See #1083.
61416155 if (Array.isArray(value)) {
61426156 const phs = getPlaceholders(value.length, whereParams.length + 1)
61436157 addWhereText(isOr ? 'OR' : isAnd ? 'AND' : 'WHERE', `${column} IN (${phs})`)
@@ -6232,21 +6246,20 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
62326246 ?? (Array.isArray(subExec?.values) ? subExec.values : [])
62336247 const baseText = `SELECT * FROM (${subText}) AS ${String(alias)}`
62346248
6235 // Append a WHERE/AND/OR condition, returning the new (text, params).
6236 // `hasWhere` is tracked explicitly rather than regex-tested: the SUBQUERY
6237 // text contains its own `WHERE` inside the parens, so SQL_PATTERNS.WHERE
6238 // would falsely report the OUTER query already has one and emit `AS u AND
6239 // …` (syntax error) for the first outer condition.
6249 // Render one condition into a clause, returning it plus the extended
6250 // params. The connector is NOT applied here: the caller records it as a
6251 // term and renderWhereTerms() decides the shape at emit time, which is
6252 // what stops a chained `orWhere` from mis-grouping (#1083). It also
6253 // retires the `hasWhere` flag this used to thread through every method —
6254 // the flag existed because the SUBQUERY text contains its own `WHERE`
6255 // inside the parens, so regex-testing for one reported the OUTER query as
6256 // already having a WHERE. A term list has no such question to ask.
62406257 const appendWhere = (
6241 text: string,
62426258 params: any[],
6243 hasWhere: boolean,
62446259 expr: any,
62456260 op: WhereOperator | undefined,
62466261 value: any,
6247 connector: 'WHERE' | 'AND' | 'OR',
6248 ): { text: string, params: any[] } => {
6249 const kw = !hasWhere ? 'WHERE' : (connector === 'WHERE' ? 'AND' : connector)
6262 ): { clause: string | null, params: any[] } => {
62506263 const out = [...params]
62516264 const cmp = (col: string, operator: string, val: any): string => {
62526265 validateIdentifier(col, 'selectFromSub where(column)')
@@ -6262,73 +6275,126 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
62626275 return `${col} ${o} ${ph}`
62636276 }
62646277 let clause: string
6265 if (typeof expr === 'string' && op !== undefined)
6278 if (typeof expr === 'string' && op !== undefined) {
62666279 clause = cmp(expr, op, value)
6267 else if (Array.isArray(expr) && expr.length === 3)
6280 }
6281 else if (Array.isArray(expr) && expr.length === 3) {
62686282 clause = cmp(expr[0], expr[1], expr[2])
6269 else if (expr && typeof expr === 'object')
6270 clause = Object.entries(expr).map(([k, v]) => cmp(k, '=', v)).join(' AND ')
6271 else
6272 return { text, params }
6273 return { text: `${text} ${kw} ${clause}`, params: out }
6283 }
6284 else if (expr && typeof expr === 'object') {
6285 const parts = Object.entries(expr).map(([k, v]) => cmp(k, '=', v))
6286 // One term: `orWhere({a, b})` means "OR (a AND b)".
6287 clause = parts.length > 1 ? `(${parts.join(' AND ')})` : parts[0]
6288 }
6289 else {
6290 return { clause: null, params }
6291 }
6292 return { clause, params: out }
62746293 }
62756294
62766295 // Build the base API. makeSub() is the single factory; every mutating
6277 // method returns a fresh builder over the new (text, params, hasWhere).
6278 function makeSub(text: string, params: any[], hasWhere = false): BaseSelectQueryBuilder<DB, any, any, any> {
6296 // method returns a fresh builder over the new (baseText, terms, params,
6297 // tail). `text` is DERIVED — the WHERE is rendered from the term list and
6298 // spliced between the SELECT body and the trailing clauses, so a `where`
6299 // added after an `orderBy` still lands in front of it.
6300 function makeSub(baseText: string, terms: WhereTerm[], params: any[], tail = ''): BaseSelectQueryBuilder<DB, any, any, any> {
6301 const body = renderWhereTerms(terms)
6302 const text = body ? `${baseText} WHERE ${body}${tail}` : `${baseText}${tail}`
62796303 const build = (): any => params.length > 0 ? _sql.unsafe(text, params) : _sql.unsafe(text)
6304 const withTerm = (conn: 'AND' | 'OR', expr: any, op: WhereOperator | undefined, value: any) => {
6305 const r = appendWhere(params, expr, op, value)
6306 return r.clause === null
6307 ? makeSub(baseText, terms, params, tail) as any
6308 : makeSub(baseText, [...terms, { conn, sql: r.clause }], r.params, tail) as any
6309 }
6310 /**
6311 * A parenthesised sub-group. The callback gets a minimal collector that
6312 * records terms and params in the outer builder's numbering, so the
6313 * group's placeholders continue rather than restart.
6314 */
6315 const withGroup = (conn: 'AND' | 'OR', cb: (b: any) => unknown) => {
6316 const gTerms: WhereTerm[] = []
6317 let gParams = [...params]
6318 const add = (c: 'AND' | 'OR') => (expr: any, op?: WhereOperator, value?: any) => {
6319 const r = appendWhere(gParams, expr, op, value)
6320 if (r.clause !== null) {
6321 gTerms.push({ conn: c, sql: r.clause })
6322 gParams = r.params
6323 }
6324 return collector
6325 }
6326 const collector: any = { where: add('AND'), andWhere: add('AND'), orWhere: add('OR') }
6327 cb(collector)
6328 const body = renderWhereTerms(gTerms)
6329 if (!body) {
6330 // Same fails-closed rule as the main builder: a group that
6331 // contributed nothing would leave the query matching everything.
6332 throw new TypeError(
6333 '[query-builder] selectFromSub.whereGroup(callback): the callback added no conditions. '
6334 + 'Add at least one where()/orWhere() to the builder it receives.',
6335 )
6336 }
6337 return makeSub(baseText, [...terms, { conn, sql: `(${body})` }], gParams, tail) as any
6338 }
62806339 const base: BaseSelectQueryBuilder<DB, any, any, any> = {
62816340 distinct() {
6282 return makeSub(text.replace(/^SELECT\s+/i, 'SELECT DISTINCT '), params, hasWhere) as any
6341 return makeSub(baseText.replace(/^SELECT\s+/i, 'SELECT DISTINCT '), terms, params, tail) as any
62836342 },
62846343 distinctOn(...columns: any[]) {
62856344 const cols = columns.map(String).join(', ')
6286 return makeSub(text.replace(/^SELECT\s+/i, `SELECT DISTINCT ON (${cols}) `), params, hasWhere) as any
6345 return makeSub(baseText.replace(/^SELECT\s+/i, `SELECT DISTINCT ON (${cols}) `), terms, params, tail) as any
62876346 },
62886347 selectRaw(fragment: any) {
62896348 const frag = renderRawFragment(fragment, 'selectFromSub.selectRaw(fragment)')
6290 const fromIdx = text.indexOf(' FROM ')
6291 const newText = fromIdx !== -1
6292 ? `${text.slice(0, fromIdx)}, ${frag}${text.slice(fromIdx)}`
6293 : `${text}, ${frag}`
6294 return makeSub(newText, params, hasWhere) as any
6349 const fromIdx = baseText.indexOf(' FROM ')
6350 const newBase = fromIdx !== -1
6351 ? `${baseText.slice(0, fromIdx)}, ${frag}${baseText.slice(fromIdx)}`
6352 : `${baseText}, ${frag}`
6353 return makeSub(newBase, terms, params, tail) as any
62956354 },
62966355 where(expr: any, op?: WhereOperator, value?: any) {
6297 const r = appendWhere(text, params, hasWhere, expr, op, value, 'WHERE')
6298 return makeSub(r.text, r.params, true) as any
6356 return withTerm('AND', expr, op, value)
62996357 },
63006358 andWhere(expr: any, op?: WhereOperator, value?: any) {
6301 const r = appendWhere(text, params, hasWhere, expr, op, value, 'AND')
6302 return makeSub(r.text, r.params, true) as any
6359 return withTerm('AND', expr, op, value)
63036360 },
63046361 orWhere(expr: any, op?: WhereOperator, value?: any) {
6305 const r = appendWhere(text, params, hasWhere, expr, op, value, 'OR')
6306 return makeSub(r.text, r.params, true) as any
6362 return withTerm('OR', expr, op, value)
6363 },
6364 // Supported here because `orWhere` is: now that a chained OR groups
6365 // with the term before it, this is the only way to express the other
6366 // reading. The rest of the where-family keeps the deliberate
6367 // subqueryNotSupported() throw further down.
6368 whereGroup(cb: any) {
6369 return withGroup('AND', cb)
6370 },
6371 orWhereGroup(cb: any) {
6372 return withGroup('OR', cb)
63076373 },
63086374 orderBy(column: string, direction: 'asc' | 'desc' = 'asc') {
63096375 validateIdentifier(String(column), 'selectFromSub.orderBy(column)')
63106376 const dir = direction === 'asc' ? 'ASC' : 'DESC'
63116377 // Compose-aware: a second orderBy appends with a comma.
6312 const newText = SQL_PATTERNS.ORDER_BY.test(text)
6313 ? `${text}, ${column} ${dir}`
6314 : `${text} ORDER BY ${column} ${dir}`
6315 return makeSub(newText, params, hasWhere) as any
6378 const newTail = SQL_PATTERNS.ORDER_BY.test(tail)
6379 ? `${tail}, ${column} ${dir}`
6380 : `${tail} ORDER BY ${column} ${dir}`
6381 return makeSub(baseText, terms, params, newTail) as any
63166382 },
63176383 limit(n: number) {
63186384 if (!Number.isInteger(n) || n < 0)
63196385 throw new TypeError(`[query-builder] selectFromSub.limit(n): expected non-negative integer, got ${n}`)
6320 const newText = SQL_PATTERNS.LIMIT.test(text)
6321 ? text.replace(SQL_PATTERNS.LIMIT, ` LIMIT ${n}`)
6322 : `${text} LIMIT ${n}`
6323 return makeSub(newText, params, hasWhere) as any
6386 const newTail = SQL_PATTERNS.LIMIT.test(tail)
6387 ? tail.replace(SQL_PATTERNS.LIMIT, ` LIMIT ${n}`)
6388 : `${tail} LIMIT ${n}`
6389 return makeSub(baseText, terms, params, newTail) as any
63246390 },
63256391 offset(n: number) {
63266392 if (!Number.isInteger(n) || n < 0)
63276393 throw new TypeError(`[query-builder] selectFromSub.offset(n): expected non-negative integer, got ${n}`)
6328 const newText = SQL_PATTERNS.OFFSET.test(text)
6329 ? text.replace(SQL_PATTERNS.OFFSET, ` OFFSET ${n}`)
6330 : `${text} OFFSET ${n}`
6331 return makeSub(newText, params, hasWhere) as any
6394 const newTail = SQL_PATTERNS.OFFSET.test(tail)
6395 ? tail.replace(SQL_PATTERNS.OFFSET, ` OFFSET ${n}`)
6396 : `${tail} OFFSET ${n}`
6397 return makeSub(baseText, terms, params, newTail) as any
63326398 },
63336399 toSQL() {
63346400 return makeExecutableQuery(build(), text) as any
@@ -6435,8 +6501,13 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
64356501 whereJsonContains: subqueryNotSupported('whereJsonContains'),
64366502 whereJsonPath: subqueryNotSupported('whereJsonPath'),
64376503 whereNull: subqueryNotSupported('whereNull'),
6504 orWhereNull: subqueryNotSupported('orWhereNull'),
64386505 whereNotNull: subqueryNotSupported('whereNotNull'),
6506 orWhereNotNull: subqueryNotSupported('orWhereNotNull'),
64396507 whereExists: subqueryNotSupported('whereExists'),
6508 orWhereExists: subqueryNotSupported('orWhereExists'),
6509 orWhereBetween: subqueryNotSupported('orWhereBetween'),
6510 orWhereRaw: subqueryNotSupported('orWhereRaw'),
64406511 whereJsonDoesntContain: subqueryNotSupported('whereJsonDoesntContain'),
64416512 whereJsonContainsKey: subqueryNotSupported('whereJsonContainsKey'),
64426513 whereJsonDoesntContainKey: subqueryNotSupported('whereJsonDoesntContainKey'),
@@ -6511,7 +6582,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
65116582 return base
65126583 }
65136584
6514 return makeSub(baseText, subParams) as any
6585 return makeSub(baseText, [], subParams) as any
65156586 },
65166587 insertInto<TTable extends keyof DB & string>(table: TTable) {
65176588 let built: any
packages/bun-query-builder/src/orm.tsmodified+14-6
Changes to packages/bun-query-builder/src/orm.ts
@@ -34,6 +34,8 @@ import { getOrCreateBunSql } from './db'
3434import { applySqliteBootstrapPragmas } from './sqlite-pragmas'
3535import { normalizeRelationList } from './relation-utils'
3636import { singularizerFor, toTableName as sharedToTableName } from './inflect'
37import type { WhereTerm } from './sql-fragments'
38import { renderInPredicate, renderWhereTerms } from './sql-fragments'
3739
3840/**
3941 * Current timestamp formatted for the active dialect.
@@ -2494,9 +2496,8 @@ class ModelQueryBuilder<
24942496 * Shared by buildQuery, count, aggregates, delete, and update.
24952497 */
24962498 private buildWhereClauses(params: unknown[]): string {
2497 const clauses: string[] = []
2498 for (let i = 0; i < this._wheres.length; i++) {
2499 const w = this._wheres[i]
2499 const terms: WhereTerm[] = []
2500 for (const w of this._wheres) {
25002501 let clause: string
25012502
25022503 if (w.raw) {
@@ -2508,8 +2509,11 @@ class ModelQueryBuilder<
25082509 clause = w.operator === '=' ? `${col} IS NULL` : `${col} IS NOT NULL`
25092510 }
25102511 else if (w.operator === 'in' || w.operator === 'not in') {
2512 // `IN ()` is a syntax error on Postgres and MySQL while SQLite parses
2513 // it, so the empty case renders as a constant predicate instead — see
2514 // renderInPredicate for why the NOT IN mirror is TRUE, not FALSE.
25112515 const arr = w.value as unknown[]
2512 clause = `${sqlColumn(w.column!)} ${w.operator.toUpperCase()} (${arr.map(() => '?').join(', ')})`
2516 clause = renderInPredicate(sqlColumn(w.column!), arr, w.operator === 'not in', arr.map(() => '?').join(', '))
25132517 params.push(...arr)
25142518 }
25152519 else {
@@ -2517,9 +2521,13 @@ class ModelQueryBuilder<
25172521 params.push(w.value)
25182522 }
25192523
2520 clauses.push(i === 0 ? clause : `${w.boolean.toUpperCase()} ${clause}`)
2524 terms.push({ conn: w.boolean === 'or' ? 'OR' : 'AND', sql: clause })
25212525 }
2522 return clauses.join(' ')
2526 // Was a flat join: `clauses.push(i === 0 ? clause : `${BOOL} ${clause}`)`.
2527 // SQL binds AND tighter than OR, so `.where(a).whereLike(b).orWhereLike(c)`
2528 // meant `(a AND b) OR c` and returned every row `a` was meant to exclude.
2529 // renderWhereTerms brackets each OR-run instead. See #1083.
2530 return renderWhereTerms(terms)
25232531 }
25242532
25252533 /**
packages/bun-query-builder/src/sql-fragments.tsadded+99-0
Changes to packages/bun-query-builder/src/sql-fragments.ts
@@ -0,0 +1,99 @@
1/**
2 * SQL fragment renderers shared by the select builder (`client.ts`) and the ORM
3 * query builder (`orm.ts`).
4 *
5 * They live here rather than in either builder because the two had independent
6 * copies of the same defect: both joined their WHERE terms as flat text, so
7 * `orWhere` produced an ungrouped `OR` and SQL's native precedence silently
8 * re-associated the chain. Two implementations of one rule is how they drifted;
9 * one implementation is how they stop.
10 *
11 * See stacksjs/bun-query-builder#1083.
12 */
13
14/**
15 * A single WHERE predicate plus the connector the CALLER asked for.
16 *
17 * The connector belongs to the term rather than sitting between terms because
18 * the first term's connector is meaningless — nothing precedes it — and
19 * carrying it anyway is what lets a leading `.orWhere(x)` render as `WHERE x`
20 * instead of the `FROM t OR x` syntax error it used to emit.
21 */
22export interface WhereTerm {
23 conn: 'AND' | 'OR'
24 sql: string
25}
26
27/**
28 * Constant predicates, for the cases where a collection is empty.
29 *
30 * `1 = 0` and `1 = 1` take no parameters and parse identically on SQLite,
31 * Postgres and MySQL, which is the point: the alternatives are engine-specific.
32 * `IN ()` is the example that motivated these — SQLite parses it and matches
33 * nothing, while Postgres and MySQL reject it as a syntax error, so the same
34 * call site behaved differently depending on where it ran.
35 */
36export const FALSE_PREDICATE = '1 = 0'
37export const TRUE_PREDICATE = '1 = 1'
38
39/**
40 * Render a WHERE body (without the `WHERE` keyword) from an ordered term list.
41 *
42 * Each MAXIMAL RUN of `OR` terms is bracketed together with the term that opens
43 * it, so a chain groups the way it reads:
44 *
45 * .where(a).where(b).orWhere(c) -> a AND (b OR c)
46 * .where(a).orWhere(b).where(c) -> (a OR b) AND c
47 *
48 * In effect `OR` binds TIGHTER than `AND` in chain order — the inverse of raw
49 * SQL. That is a deliberate choice for a fluent builder, and it is already what
50 * `whereAny`/`whereAll`/`whereNone` and the ORM's `whereGroup` do; `orWhere`
51 * was the odd one out. Previously the terms were concatenated as flat text and
52 * SQL's precedence re-associated `a AND b OR c` to `(a AND b) OR c`, so the
53 * first filter stopped applying to any row the `OR` matched — silently, and in
54 * the direction that returns MORE rows.
55 *
56 * The brackets are omitted when the body has a single top-level piece, so a
57 * plain two-term `.where(a).orWhere(b)` and any all-`AND` chain still render
58 * byte-for-byte as before. The escape hatch for the one shape this rule can no
59 * longer express directly — `(a AND b) OR c` — is `whereGroup(cb)`.
60 */
61export function renderWhereTerms(terms: readonly WhereTerm[]): string {
62 if (terms.length === 0)
63 return ''
64
65 const runs: WhereTerm[][] = []
66 for (const term of terms) {
67 if (term.conn === 'OR' && runs.length > 0)
68 runs[runs.length - 1].push(term)
69 else
70 runs.push([term])
71 }
72
73 return runs
74 .map((run, i) => {
75 const body = run.map(t => t.sql).join(' OR ')
76 // A run only needs brackets when there is something outside it to bind
77 // against. `(a OR b)` alone and `a OR b` alone mean the same thing, and
78 // not adding the parens keeps existing emitted SQL unchanged.
79 const piece = run.length > 1 && runs.length > 1 ? `(${body})` : body
80 return i === 0 ? piece : `${run[0].conn} ${piece}`
81 })
82 .join(' ')
83}
84
85/**
86 * Render `col IN (…)` / `col NOT IN (…)`, including the empty case.
87 *
88 * An empty `IN` is FALSE (nothing is a member of the empty set) and an empty
89 * `NOT IN` is TRUE (everything is a non-member). Those constants are not
90 * interchangeable and the mirror is easy to get backwards: writing FALSE for
91 * `NOT IN` would silently drop every row from any query whose exclusion list
92 * happened to come back empty — the same shape of silent, widening-or-narrowing
93 * failure as the `IN ()` bug itself.
94 */
95export function renderInPredicate(column: string, values: any[], negated: boolean, placeholders: string): string {
96 if (values.length === 0)
97 return negated ? TRUE_PREDICATE : FALSE_PREDICATE
98 return `${column} ${negated ? 'NOT IN' : 'IN'} (${placeholders})`
99}
packages/bun-query-builder/test/client.dynamic-where.test.tsmodified+9-0
Changes to packages/bun-query-builder/test/client.dynamic-where.test.ts
@@ -48,6 +48,15 @@ describe('dynamic whereX/orWhereX/andWhereX methods', () => {
4848 .limit(10)
4949
5050 expect(typeof q.toSQL).toBe('function')
51 // Assert the emitted string, not just that a builder came back. The
52 // dynamic proxy mis-grouped too — `email = ? AND name = ? OR role = ? AND
53 // created_at = ?` — and it did so through a second code path (it also
54 // assigned `built = sql`${ensureBuilt()} OR ...``, which was the query
55 // actually executed). See #1083.
56 // Placeholders normalised: the dialect comes from process-wide config, and
57 // this assertion is about grouping, not about `?` vs `$n`.
58 const s = String(q.toSQL()).replace(/\$\d+/g, '?')
59 expect(s).toContain('WHERE email = ? AND (name = ? OR role = ?) AND created_at = ?')
5160 })
5261
5362 it('treats array values as IN and scalars as =', () => {
packages/bun-query-builder/test/client.or-where-precedence.e2e.test.tsadded+153-0
Changes to packages/bun-query-builder/test/client.or-where-precedence.e2e.test.ts
@@ -0,0 +1,153 @@
1/**
2 * The `orWhere` grouping fix (#1083), measured in rows rather than SQL text.
3 *
4 * Asserting the emitted string proves the shape changed. Only row counts prove
5 * the shape was WRONG — and wrong in the dangerous direction, returning a
6 * superset of what was asked for rather than erroring. Every `it` here carries
7 * the pre-fix number next to the post-fix one, because "10, not 80" is the
8 * whole report and a bare `toBe(10)` loses it.
9 *
10 * Fixture: 118 rows, matching the numbers in the issue.
11 * ids 1-40 create_t{i}_table
12 * ids 41-80 alter_t{i}_table
13 * ids 81-118 create_i{i}_index
14 * batch = id <= 59 ? 1 : 2
15 *
16 * So `%create%` matches 78 rows, `%table%` matches 80, and `id <= 10` matches
17 * 10 — the overlaps are what make the mis-grouping visible.
18 */
19
20import { SQL } from 'bun'
21import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
22import { buildDatabaseSchema, createQueryBuilder } from '../src'
23
24const schema = buildDatabaseSchema({
25 Migration: {
26 name: 'Migration',
27 table: 'migrations',
28 primaryKey: 'id',
29 attributes: {
30 id: { validation: { rule: {} } },
31 migration: { validation: { rule: {} } },
32 batch: { validation: { rule: {} } },
33 },
34 },
35} as any)
36
37let sql: SQL
38
39beforeAll(async () => {
40 sql = new SQL('sqlite://:memory:')
41 await sql.unsafe('CREATE TABLE migrations (id INTEGER PRIMARY KEY, migration TEXT, batch INTEGER)')
42 for (let i = 1; i <= 118; i++) {
43 const name = i <= 40 ? `create_t${i}_table` : i <= 80 ? `alter_t${i}_table` : `create_i${i}_index`
44 await sql.unsafe('INSERT INTO migrations (id, migration, batch) VALUES (?, ?, ?)', [i, name, i <= 59 ? 1 : 2])
45 }
46})
47
48afterAll(async () => {
49 await sql?.end()
50})
51
52const q = () => createQueryBuilder<typeof schema>({ schema, sql }).selectFrom('migrations').selectAll() as any
53
54describe('orWhere grouping, in rows (#1083)', () => {
55 it('the reported chain matches 10 rows, not 118', async () => {
56 const rows = await q()
57 .where('id', '<=', 10)
58 .where('migration', 'like', '%create%')
59 .orWhere('migration', 'like', '%table%')
60 .execute()
61
62 expect(rows.length).toBe(10)
63 // The bounding filter is the thing that used to vanish. Assert it directly:
64 // a count alone would pass for the wrong reason if the fixture changed.
65 expect(rows.every((r: any) => r.id <= 10)).toBe(true)
66 })
67
68 it('a where after an orWhere constrains the whole group', async () => {
69 const rows = await q()
70 .where('id', '<=', 10)
71 .orWhere('migration', 'like', '%table%')
72 .where('batch', '=', 2)
73 .execute()
74
75 // Was 31: the trailing `batch = 2` bound only to the OR's right arm, so
76 // rows 1-10 came back regardless of their batch.
77 expect(rows.length).toBe(21)
78 expect(rows.every((r: any) => r.batch === 2)).toBe(true)
79 })
80
81 it('a five-term chain does not degenerate to the whole table', async () => {
82 const rows = await q()
83 .where('id', '<=', 59)
84 .where('migration', 'like', '%create%')
85 .orWhere('migration', 'like', '%table%')
86 .where('batch', '=', 1)
87 .orWhere('batch', '=', 2)
88 .execute()
89
90 // Was 118 — every row in the table, from a query with five filters on it.
91 expect(rows.length).toBe(59)
92 })
93
94 it('a two-term OR chain still returns both branches', async () => {
95 const rows = await q().where('id', '<=', 10).orWhere('migration', 'like', '%table%').execute()
96
97 // Unchanged by the fix, and that is the point: no churn for the common case.
98 expect(rows.length).toBe(80)
99 })
100
101 it('a leading orWhere runs instead of raising a syntax error', async () => {
102 const rows = await q().orWhere('id', '<=', 10).execute()
103
104 // Was `SELECT * FROM migrations OR id <= ?` — SQLiteError: near "OR".
105 expect(rows.length).toBe(10)
106 })
107
108 it('whereGroup reproduces the pre-fix reading exactly', async () => {
109 const rows = await q()
110 .whereGroup((g: any) => g.where('id', '<=', 10).where('migration', 'like', '%create%'))
111 .orWhere('migration', 'like', '%table%')
112 .execute()
113
114 // 80 is what the broken chain returned. Anyone who was relying on the old
115 // grouping has this as the migration path, so it is pinned to the old
116 // number on purpose.
117 expect(rows.length).toBe(80)
118 })
119
120 it('count/sum/min/max all see the grouped WHERE', async () => {
121 const filtered = () => q()
122 .where('id', '<=', 10)
123 .where('migration', 'like', '%create%')
124 .orWhere('migration', 'like', '%table%')
125
126 // The aggregates rebuild their SQL by slicing from ' FROM ', which is the
127 // cheapest possible tripwire for a missed emit path: if any of them still
128 // read the raw text, they would aggregate over the unfiltered table.
129 expect(await filtered().count()).toBe(10)
130 expect(await filtered().max('id')).toBe(10)
131 expect(await filtered().min('id')).toBe(1)
132 expect(await filtered().sum('id')).toBe(55)
133 })
134
135 it('first() and exists() see the grouped WHERE', async () => {
136 const filtered = () => q().where('id', '>=', 100).orWhere('id', '<=', 2).where('batch', '=', 1)
137
138 expect(await filtered().exists()).toBe(true)
139 const rows = await filtered().execute()
140 expect(rows.length).toBe(2)
141 })
142
143 it('paginate counts and pages the grouped WHERE', async () => {
144 const page = await q()
145 .where('id', '<=', 10)
146 .where('migration', 'like', '%create%')
147 .orWhere('migration', 'like', '%table%')
148 .paginate(4)
149
150 expect(page.meta.total).toBe(10)
151 expect(page.data.map((r: any) => r.id)).toEqual([1, 2, 3, 4])
152 })
153})
packages/bun-query-builder/test/client.or-where-precedence.test.tsadded+279-0
Changes to packages/bun-query-builder/test/client.or-where-precedence.test.ts
@@ -0,0 +1,279 @@
1/**
2 * `orWhere` used to append a bare, ungrouped `OR`.
3 *
4 * SQL binds AND tighter than OR, so `a AND b OR c` parses as `(a AND b) OR c`
5 * and the first filter stops applying to every row `c` matches. Nothing warns;
6 * the query is valid SQL; the result set is a SUPERSET of what was asked for.
7 * Failing toward MORE rows is what made it dangerous — it looks like data
8 * rather than an error. The reported case returned 118 rows where 10 were
9 * intended, on a moderation queue where the extra rows were already-published
10 * content shown as pending.
11 *
12 * The rule now: each maximal run of OR terms is bracketed with the term that
13 * opens it, so OR binds TIGHTER than AND in chain order. See renderWhereTerms
14 * in src/sql-fragments.ts and stacksjs/bun-query-builder#1083.
15 *
16 * The no-churn guarantee matters as much as the fix: chains with no `orWhere`,
17 * two-term OR chains, and all-OR chains must emit exactly what they emitted
18 * before. Those are asserted with `toBe`, not `toContain`.
19 */
20
21import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
22import { buildDatabaseSchema, config, createQueryBuilder } from '../src'
23
24const schema = buildDatabaseSchema({
25 Migration: {
26 name: 'Migration',
27 table: 'migrations',
28 primaryKey: 'id',
29 attributes: {
30 id: { validation: { rule: {} } },
31 migration: { validation: { rule: {} } },
32 batch: { validation: { rule: {} } },
33 },
34 },
35} as any)
36
37const q = () => createQueryBuilder<typeof schema>({ schema }).selectFrom('migrations').selectAll() as any
38
39/**
40 * Placeholders normalised to `?`.
41 *
42 * The dialect comes from process-wide config, so the same assertion sees `$1`
43 * under Postgres and `?` under SQLite depending on what else has run. This
44 * suite is about GROUPING, which is dialect-independent — normalising keeps it
45 * from failing for a reason it does not test. Placeholder NUMBERING is asserted
46 * separately, where it is the actual subject.
47 */
48const norm = (s: string): string => s.replace(/\$\d+/g, '?')
49const sqlOf = (build: (b: any) => any): string => norm(String(build(q()).toSQL()))
50
51describe('orWhere groups with the term before it (#1083)', () => {
52 it('brackets a trailing OR run — the reported chain', () => {
53 const sql = sqlOf(b => b
54 .where('id', '<=', 10)
55 .where('migration', 'like', '%create%')
56 .orWhere('migration', 'like', '%table%'))
57
58 // Was: `WHERE id <= ? AND migration like ? OR migration like ?` (flat),
59 // which meant `(id <= 10 AND create) OR table` and returned 118 of 118.
60 expect(sql).toContain('WHERE id <= ? AND (migration like ? OR migration like ?)')
61 })
62
63 it('brackets a leading OR run against the AND that follows', () => {
64 const sql = sqlOf(b => b
65 .where('id', '<=', 10)
66 .orWhere('migration', 'like', '%table%')
67 .where('batch', '=', 2))
68
69 // The mis-grouping ran in both directions: this used to parse as
70 // `id <= 10 OR (table AND batch = 2)`, so the trailing filter bound only
71 // to the OR's right arm.
72 expect(sql).toContain('WHERE (id <= ? OR migration like ?) AND batch = ?')
73 })
74
75 it('leaves a two-term OR chain byte-identical', () => {
76 // `toBe`, deliberately. A single top-level piece needs no brackets, and
77 // adding them anyway would churn the emitted SQL of every existing
78 // two-term query for no correctness gain.
79 expect(sqlOf(b => b.where('id', '<=', 10).orWhere('batch', '=', 2)))
80 .toBe('SELECT * FROM migrations WHERE id <= ? OR batch = ?')
81 })
82
83 it('leaves an all-OR chain unbracketed', () => {
84 const sql = sqlOf(b => b
85 .where('id', '<=', 10)
86 .orWhere('migration', 'like', '%create%')
87 .orWhere('migration', 'like', '%table%'))
88
89 expect(sql).toBe('SELECT * FROM migrations WHERE id <= ? OR migration like ? OR migration like ?')
90 expect(sql).not.toContain('(')
91 })
92
93 it('leaves an all-AND chain byte-identical', () => {
94 expect(sqlOf(b => b.where('id', '<=', 10).where('batch', '=', 1).where('migration', 'like', '%x%')))
95 .toBe('SELECT * FROM migrations WHERE id <= ? AND batch = ? AND migration like ?')
96 })
97
98 it('brackets every OR run in a longer chain', () => {
99 const sql = sqlOf(b => b
100 .where('id', '<=', 59)
101 .where('migration', 'like', '%create%')
102 .orWhere('migration', 'like', '%table%')
103 .where('batch', '=', 1)
104 .orWhere('batch', '=', 2))
105
106 // Was `a AND b OR c AND d OR e`, which degenerates to the whole table.
107 expect(sql).toContain('WHERE id <= ? AND (migration like ? OR migration like ?) AND (batch = ? OR batch = ?)')
108 })
109})
110
111describe('a leading orWhere emits WHERE, not a dangling OR (#1083)', () => {
112 // Five methods bypassed the connector helper entirely and emitted
113 // `SELECT … FROM migrations OR …` when they were the first predicate —
114 // invalid SQL on every dialect.
115 const leading: Array<[string, (b: any) => any]> = [
116 ['orWhere', b => b.orWhere('id', '<=', 10)],
117 ['orWhereIn', b => b.orWhereIn('id', [1, 2])],
118 ['orWhereNotIn', b => b.orWhereNotIn('id', [1, 2])],
119 ['orWhereColumn', b => b.orWhereColumn('id', '=', 'batch')],
120 ['orWhereNested', b => b.orWhereNested('SELECT 1')],
121 ['orWhereLike', b => b.orWhereLike('migration', '%x%')],
122 ['orWhereNull', b => b.orWhereNull('batch')],
123 ]
124
125 for (const [name, build] of leading) {
126 it(`${name}() as the first predicate`, () => {
127 const sql = sqlOf(build)
128
129 expect(sql).not.toMatch(/FROM\s+migrations\s+OR\b/)
130 expect(sql).toContain('WHERE')
131 })
132 }
133})
134
135describe('the WHERE lands in the right place (#1083)', () => {
136 it('a where added after an orderBy still precedes the ORDER BY', () => {
137 const sql = sqlOf(b => b.where('id', '<=', 10).orderBy('id').orWhere('batch', '=', 2))
138
139 // Was `… WHERE id <= ? ORDER BY id ASC OR batch = ?` — a syntax error.
140 expect(sql.indexOf('WHERE')).toBeLessThan(sql.indexOf('ORDER BY'))
141 expect(sql).toBe('SELECT * FROM migrations WHERE id <= ? OR batch = ? ORDER BY id ASC')
142 })
143
144 it('a where added after a limit still precedes the LIMIT', () => {
145 const sql = sqlOf(b => b.where('id', '<=', 10).limit(5).where('batch', '=', 1))
146
147 expect(sql.indexOf('WHERE')).toBeLessThan(sql.indexOf('LIMIT'))
148 })
149
150 it('keeps params in emitted order across a group', () => {
151 const built = q().where('id', '<=', 10).orWhere('batch', '=', 2).where('id', '>=', 1)
152
153 expect(built.__rawState().params).toEqual([10, 2, 1])
154 })
155})
156
157describe('whereGroup is the escape hatch for the old reading (#1083)', () => {
158 it('expresses (a AND b) OR c', () => {
159 const sql = sqlOf(b => b
160 .whereGroup((g: any) => g.where('id', '<=', 10).where('migration', 'like', '%create%'))
161 .orWhere('migration', 'like', '%table%'))
162
163 expect(sql).toContain('WHERE (id <= ? AND migration like ?) OR migration like ?')
164 })
165
166 it('keeps the group params in emitted order', () => {
167 const built = q()
168 .where('batch', '=', 1)
169 .whereGroup((g: any) => g.where('id', '<=', 10).orWhere('id', '>=', 100))
170
171 expect(norm(String(built.toSQL()))).toContain('WHERE batch = ? AND (id <= ? OR id >= ?)')
172 expect(built.__rawState().params).toEqual([1, 10, 100])
173 })
174
175 it('orWhereGroup ORs the whole group', () => {
176 const sql = sqlOf(b => b
177 .where('batch', '=', 1)
178 .orWhereGroup((g: any) => g.where('id', '<=', 10).where('migration', 'like', '%x%')))
179
180 expect(sql).toContain('WHERE batch = ? OR (id <= ? AND migration like ?)')
181 })
182
183 it('throws rather than widening when the callback adds nothing', () => {
184 // A group that contributed no predicate would silently drop out and leave
185 // the query matching every row it existed to exclude.
186 expect(() => q().whereGroup(() => {})).toThrow(/added no conditions/)
187 })
188})
189
190describe('object and raw forms are one term each (#1083)', () => {
191 it('brackets a multi-key object so a later orWhere binds to all of it', () => {
192 const sql = sqlOf(b => b.where({ batch: 1, id: 2 }).orWhere('migration', 'like', '%x%'))
193
194 // Un-bracketed this was `batch = ? AND id = ? OR migration like ?`,
195 // which drops `batch` — the object's own keys came apart.
196 expect(sql).toContain('WHERE (batch = ? AND id = ?) OR migration like ?')
197 })
198
199 it('leaves a single-key object unbracketed', () => {
200 expect(sqlOf(b => b.where({ batch: 1 })))
201 .toBe('SELECT * FROM migrations WHERE batch = ?')
202 })
203
204 it('treats orWhere({a, b}) as OR (a AND b)', () => {
205 const sql = sqlOf(b => b.where('id', '<=', 10).orWhere({ batch: 1, migration: 'x' }))
206
207 expect(sql).toContain('OR (batch = ? AND migration = ?)')
208 })
209})
210
211describe('whereGroup placeholder numbering, per dialect (#1083)', () => {
212 // The dialect is process-wide config, so it is pinned here rather than
213 // assumed — this is the one assertion in the file that is ABOUT `?` vs `$n`.
214 // Same save/restore shape as client.union-params.test.ts, which pins the
215 // matching invariant for set-operator renumbering.
216 let dialect: string
217 beforeEach(() => { dialect = config.dialect })
218 afterEach(() => { config.dialect = dialect as any })
219
220 it('Postgres: continues the outer numbering into the group', () => {
221 config.dialect = 'postgres' as any
222 const built = q()
223 .where('batch', '=', 1)
224 .whereGroup((g: any) => g.where('id', '<=', 10).orWhere('id', '>=', 100))
225 const sql = String(built.toSQL())
226
227 // The sub-builder numbers its own placeholders from $1. Without the offset
228 // the outer `batch` and the group's first term would BOTH be $1, and every
229 // value after the group would bind shifted by one.
230 expect(sql).toContain('WHERE batch = $1 AND (id <= $2 OR id >= $3)')
231 expect((sql.match(/\$1\b/g) ?? []).length).toBe(1)
232 expect(built.__rawState().params).toEqual([1, 10, 100])
233 })
234
235 it('SQLite: positional `?` needs no renumbering', () => {
236 config.dialect = 'sqlite' as any
237 const built = q()
238 .where('batch', '=', 1)
239 .whereGroup((g: any) => g.where('id', '<=', 10).orWhere('id', '>=', 100))
240
241 expect(String(built.toSQL())).toContain('WHERE batch = ? AND (id <= ? OR id >= ?)')
242 expect(built.__rawState().params).toEqual([1, 10, 100])
243 })
244})
245
246describe('the sub-select builder groups the same way (#1083)', () => {
247 const sub = () => createQueryBuilder<typeof schema>({ schema })
248 .selectFromSub(createQueryBuilder<typeof schema>({ schema }).selectFrom('migrations').selectAll() as any, 'm') as any
249
250 it('brackets a trailing OR run', () => {
251 const sql = norm(String(sub().where('id', '<=', 10).where('batch', '=', 1).orWhere('batch', '=', 2).toSQL()))
252
253 expect(sql).toContain('AND (batch = ? OR batch = ?)')
254 })
255
256 it('places a where added after an orderBy before the ORDER BY', () => {
257 const sql = norm(String(sub().orderBy('id').where('id', '<=', 10).toSQL()))
258
259 // The old builder appended the WHERE to the end of the text, so ordering
260 // first made the query unparseable.
261 expect(sql.indexOf('WHERE')).toBeLessThan(sql.indexOf('ORDER BY'))
262 })
263
264 it('emits WHERE for a leading orWhere', () => {
265 const sql = norm(String(sub().orWhere('id', '<=', 10).toSQL()))
266
267 expect(sql).toContain('WHERE id <= ?')
268 expect(sql).not.toMatch(/AS m\s+OR\b/)
269 })
270
271 it('supports whereGroup for the old reading', () => {
272 const sql = norm(String(sub()
273 .whereGroup((g: any) => g.where('id', '<=', 10).where('batch', '=', 1))
274 .orWhere('batch', '=', 2)
275 .toSQL()))
276
277 expect(sql).toContain('WHERE (id <= ? AND batch = ?) OR batch = ?')
278 })
279})
packages/bun-query-builder/test/client.test.tsmodified+9-0
Changes to packages/bun-query-builder/test/client.test.ts
@@ -117,6 +117,10 @@ describe('query builder - modifiers and raws', () => {
117117 it('date/json helpers compose', () => {
118118 const q1 = qb().selectFrom('users').whereDate('created_at', '>=', '2024-01-01').toSQL() as any
119119 expect(typeof q1.execute).toBe('function')
120 // An ARRAY, not an object: object containment throws on SQLite by design
121 // (there is no json_each equivalent for it), so the object form made this
122 // depend on some earlier test file leaving the process-wide dialect on
123 // postgres. The array form means the same thing on every dialect.
120124 const q2 = qb().selectFrom('users').whereJsonContains('meta', ['a']).toSQL() as any
121125 expect(typeof q2.execute).toBe('function')
122126 })
@@ -313,6 +317,11 @@ describe('query builder - SQL text for clauses and helpers', () => {
313317 expectTextOutput(s2)
314318 const s3 = toTextOf(qb().selectFrom('projects').orWhereNested(nested as any) as any)
315319 expectTextOutput(s3)
320 // An orWhereNested with nothing before it used to emit
321 // `SELECT * FROM projects OR (...)` — invalid SQL that expectTextOutput
322 // waved through, since it only checks the result is a string. See #1083.
323 expect(s3).toContain('WHERE (')
324 expect(s3).not.toMatch(/FROM projects\s+OR\b/)
316325 const s4 = toTextOf(qb().selectFrom('users').where(['id', '>', 0]).andWhere(['name', 'like', '%a%']) as any)
317326 expectTextOutput(s4)
318327 const s5 = toTextOf(qb().selectFrom('users').where(['id', '>', 0]).orWhere(['name', 'like', '%a%']) as any)
packages/bun-query-builder/test/client.where-fails-closed.test.tsmodified+20-4
Changes to packages/bun-query-builder/test/client.where-fails-closed.test.ts
@@ -11,6 +11,12 @@
1111 *
1212 * Failing closed is the whole point of these tests: an exception is recoverable
1313 * and a silently missing predicate is not.
14 *
15 * The callback form is no longer among the rejected shapes — as of #1083 it
16 * builds a real parenthesised group. The guarantee these tests exist to protect
17 * survives that change unaltered: a callback that adds NO conditions still
18 * throws, because a group contributing nothing is the same silent widening by
19 * another route.
1420 */
1521
1622import { describe, expect, it } from 'bun:test'
@@ -21,12 +27,22 @@ function query() {
2127}
2228
2329describe('where() refuses what it cannot apply', () => {
24 it('rejects the expression-callback form rather than dropping it', () => {
25 expect(() => query().where((eb: any) => eb.or([]))).toThrow(/where\(callback\) is not supported/)
30 it('rejects a callback that adds no conditions rather than dropping it', () => {
31 // The failure mode this replaced: the callback ran, contributed nothing,
32 // and the builder came back unfiltered.
33 expect(() => query().where((b: any) => { void b })).toThrow(/added no conditions/)
34 expect(() => query().where(() => true)).toThrow(/added no conditions/)
35 })
36
37 it('names what to do about it, because there is a working form', () => {
38 expect(() => query().where(() => true)).toThrow(/where\(\)\/orWhere\(\)/)
2639 })
2740
28 it('names the alternatives, because there are working ones', () => {
29 expect(() => query().where(() => true)).toThrow(/whereNested|whereAny|orWhere/)
41 it('applies a callback that does add conditions', () => {
42 const sql = String(query().where('id', '=', 1).where((b: any) => b.where('a', '=', 1).orWhere('b', '=', 2)).toSQL())
43
44 expect(sql).toContain('(a = ')
45 expect(sql).toContain(' OR b = ')
3046 })
3147
3248 it('rejects a missing condition', () => {
packages/bun-query-builder/test/client.where-in-writes.test.tsmodified+16-3
Changes to packages/bun-query-builder/test/client.where-in-writes.test.ts
@@ -23,8 +23,8 @@
2323 * injected operator cannot be walked back.
2424 */
2525
26import { describe, expect, it } from 'bun:test'
27import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src'
26import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
27import { buildDatabaseSchema, buildSchemaMeta, config, createQueryBuilder } from '../src'
2828
2929function qb() {
3030 const models = {
@@ -44,7 +44,16 @@ function qb() {
4444 })
4545}
4646
47// The dialect decides whether placeholders are `$n` or `?`, and it lives in
48// process-wide config — so without pinning it here these assertions depended on
49// whichever test file happened to run first. Pinned rather than hedged, because
50// the numbering (`$2, $3`, not `$1, $2`) is exactly what this file is about:
51// an IN that restarted at $1 would update every row in the table.
4752describe('IN on an UPDATE', () => {
53 let dialect: string
54 beforeEach(() => { dialect = config.dialect; config.dialect = 'postgres' as any })
55 afterEach(() => { config.dialect = dialect as any })
56
4857 it('renders one placeholder per element, parenthesised', () => {
4958 const sql = String((qb() as any)
5059 .updateTable('notifications')
@@ -67,7 +76,7 @@ describe('IN on an UPDATE', () => {
6776 .where('id', 'in', [7, 8])
6877 .toSQL())
6978
70 expect(sql).toMatch(/IN \(\$2, \$3\)|IN \(\?, \?\)/)
79 expect(sql).toContain('IN ($2, $3)')
7180 })
7281
7382 it('continues an existing WHERE with AND rather than opening a second one', () => {
@@ -125,6 +134,10 @@ describe('IN on an UPDATE', () => {
125134})
126135
127136describe('IN on a DELETE', () => {
137 let dialect: string
138 beforeEach(() => { dialect = config.dialect; config.dialect = 'postgres' as any })
139 afterEach(() => { config.dialect = dialect as any })
140
128141 it('renders one placeholder per element', () => {
129142 const sql = String((qb() as any)
130143 .deleteFrom('notifications')
packages/bun-query-builder/test/orm.or-where-precedence.test.tsadded+128-0
Changes to packages/bun-query-builder/test/orm.or-where-precedence.test.ts
@@ -0,0 +1,128 @@
1/**
2 * The ORM had the same ungrouped-OR defect as the select builder (#1083),
3 * reached by a different route.
4 *
5 * Unlike the select builder it DID keep a structured term list (`_wheres`, each
6 * with a `boolean: 'and' | 'or'`) — it just rendered it flat:
7 *
8 * clauses.push(i === 0 ? clause : `${w.boolean.toUpperCase()} ${clause}`)
9 *
10 * so the grouping was left to SQL's precedence and `.where(a).whereLike(b)
11 * .orWhereLike(c)` meant `(a AND b) OR c`. Having the right data structure is
12 * not the same as using it; that gap is worth a test of its own, because the
13 * next reader will see `_wheres` and assume this case is covered.
14 *
15 * `update()` and `delete()` share buildWhereClauses, so the mis-grouping was
16 * not read-only — a widened WHERE on a DELETE removes rows the filter existed
17 * to protect. That half is asserted here too.
18 */
19
20import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
21import { configureOrm, createModel, createTableFromModel, getDatabase } from '../src/orm'
22
23const Row = createModel({
24 name: 'PrecRow',
25 table: 'prec_rows',
26 primaryKey: 'id',
27 autoIncrement: true,
28 attributes: {
29 name: { type: 'string', fillable: true },
30 batch: { type: 'integer', fillable: true },
31 },
32} as const)
33
34describe('ORM orWhere grouping (#1083)', () => {
35 beforeAll(() => {
36 configureOrm({ database: ':memory:' })
37 })
38
39 beforeEach(async () => {
40 const db = getDatabase()
41 db.run('DROP TABLE IF EXISTS prec_rows')
42 await createTableFromModel(Row.getDefinition())
43 // 20 rows: ids 1-10 `create_*`, 11-20 `alter_*`; batch 1 for ids <= 5.
44 for (let i = 1; i <= 20; i++)
45 await Row.create({ name: i <= 10 ? `create_${i}_table` : `alter_${i}_table`, batch: i <= 5 ? 1 : 2 })
46 })
47
48 afterAll(() => getDatabase().close())
49
50 it('brackets a trailing OR run', () => {
51 const { sql } = Row.query()
52 .where('batch', 1)
53 .whereLike('name', '%create%')
54 .orWhereLike('name', '%alter%')
55 .toSql()
56
57 // Was `batch = ? AND name like ? OR name like ?`.
58 expect(sql).toContain('batch = ? AND (name like ? OR name like ?)')
59 })
60
61 it('returns the bounded rows, not the superset', async () => {
62 const rows = await Row.query()
63 .where('batch', 1)
64 .whereLike('name', '%create%')
65 .orWhereLike('name', '%alter%')
66 .get()
67
68 // Was 15: every `alter_*` row came back regardless of batch.
69 expect(rows.length).toBe(5)
70 // Number(): `type: 'integer'` currently generates a TEXT column in
71 // createTableFromModel, so this reads back as '1'. Unrelated to #1083 —
72 // coerced rather than asserted on, so this test fails for its own reason
73 // or not at all.
74 expect(rows.every(r => Number(r.get('batch')) === 1)).toBe(true)
75 })
76
77 it('brackets a leading OR run against the AND that follows', async () => {
78 const rows = await Row.query()
79 .where('batch', 1)
80 .orWhereLike('name', '%alter%')
81 .where('id', '<=', 12)
82 .get()
83
84 // `(batch = 1 OR name like '%alter%') AND id <= 12` — ids 1-5 and 11-12.
85 expect(rows.map(r => r.get('id'))).toEqual([1, 2, 3, 4, 5, 11, 12])
86 })
87
88 it('leaves a two-term OR chain unbracketed', () => {
89 const { sql } = Row.query().where('batch', 1).orWhereLike('name', '%alter%').toSql()
90
91 expect(sql).toContain('batch = ? OR name like ?')
92 expect(sql).not.toContain('(')
93 })
94
95 it('does not emit a leading OR when orWhere is the first term', () => {
96 const { sql } = Row.query().orWhere('batch', 1).toSql()
97
98 expect(sql).not.toMatch(/WHERE\s+OR\b/)
99 expect(sql).toContain('WHERE batch = ?')
100 })
101
102 it('whereGroup still emits one pre-parenthesised term', () => {
103 // _addGroup pushes an already-bracketed raw clause; renderWhereTerms must
104 // not wrap it a second time.
105 const { sql } = Row.query()
106 .whereGroup(b => b.where('batch', 1).orWhere('batch', 2))
107 .whereLike('name', '%create%')
108 .toSql()
109
110 expect(sql).toContain('(batch = ? OR batch = ?) AND name like ?')
111 expect(sql).not.toContain('((')
112 })
113
114 it('delete() respects the grouping instead of widening', async () => {
115 // The dangerous half: buildWhereClauses is shared with delete(), so the
116 // flat OR removed rows the filter was meant to protect.
117 await Row.query()
118 .where('batch', 1)
119 .whereLike('name', '%create%')
120 .orWhereLike('name', '%alter%')
121 .delete()
122
123 const left = await Row.query().get()
124 // 15 survive (20 minus the 5 batch-1 create rows). Under the flat OR this
125 // deleted 15 and left 5.
126 expect(left.length).toBe(15)
127 })
128})
packages/bun-query-builder/test/types-and-inference.test.tsmodified+6-3
Changes to packages/bun-query-builder/test/types-and-inference.test.ts
@@ -890,9 +890,12 @@ describe('Query Builder: fluent chaining', () => {
890890 .where({ id: 1 })
891891 .orWhere({ email: 'test@test.com' })
892892 .andWhere({ is_active: true }))
893 expect(s).toContain('WHERE')
894 expect(s).toContain('OR')
895 expect(s).toContain('AND')
893 // Asserted as a whole string, not as three toContain()s for 'WHERE'/'OR'/
894 // 'AND'. This is the test that should have caught #1083: it emitted
895 // `id = ? OR email = ? AND is_active = ?` — `(id) OR (email AND active)` —
896 // and passed, because every keyword it looked for was present.
897 // Placeholders normalised — `?` vs `$n` depends on process-wide config.
898 expect(s.replace(/\$\d+/g, '?')).toContain('WHERE (id = ? OR email = ?) AND is_active = ?')
896899 })
897900})
898901