also looking at this
fix(client): orWhere now groups, so an earlier where keeps applying (#1083)
#1088
13 files
+1310
-450
| @@ -112,7 +112,12 @@ const adults = await db | ||
| 112 | 112 | .where(['age', '>=', 18]) |
| 113 | 113 | .execute() |
| 114 | 114 | |
| 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. | |
| 116 | 121 | const eligibleUsers = await db |
| 117 | 122 | .selectFrom('users') |
| 118 | 123 | .where({ active: true }) |
| @@ -121,6 +126,16 @@ const eligibleUsers = await db | ||
| 121 | 126 | .orWhere(['role', '=', 'admin']) |
| 122 | 127 | .execute() |
| 123 | 128 | |
| 129 | // "Everything above, OR an admin": | |
| 130 | const 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 | ||
| 124 | 139 | // Dynamic condition building |
| 125 | 140 | async function searchUsers(filters: { name?: string, role?: string, minAge?: number }) { |
| 126 | 141 | let query = db.selectFrom('users') |
| @@ -246,7 +261,7 @@ const averyPrefs = await db | ||
| 246 | 261 | |
| 247 | 262 | - **Use indexes**: Ensure filtered columns have appropriate database indexes |
| 248 | 263 | - **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` | |
| 250 | 265 | - **Dynamic filters**: Build conditions conditionally based on user input or application state |
| 251 | 266 | - **JSON performance**: Be mindful that JSON operations may require special indexes |
| 252 | 267 | |
| @@ -74,6 +74,48 @@ const users = await db | ||
| 74 | 74 | .get() |
| 75 | 75 | ``` |
| 76 | 76 | |
| 77 | #### How `orWhere` groups | |
| 78 | ||
| 79 | A chained `orWhere` groups with the term immediately before it, and that group | |
| 80 | is ANDed into the rest of the chain: | |
| 81 | ||
| 82 | ```typescript | |
| 83 | db.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 | ||
| 91 | In other words `OR` binds **tighter** than `AND` here — the inverse of raw SQL, | |
| 92 | and the reading the chain has when you say it out loud. It also matches what | |
| 93 | `whereAny` already does. | |
| 94 | ||
| 95 | This matters because the other reading fails silently and in the widening | |
| 96 | direction. Under raw SQL precedence the same chain means | |
| 97 | `(status AND title) OR content`, so every row matching `content` comes back | |
| 98 | regardless of its status — a valid query, no warning, and a result set that | |
| 99 | looks like data rather than an error. | |
| 100 | ||
| 101 | To get `(a AND b) OR c`, use `whereGroup`: | |
| 102 | ||
| 103 | ```typescript | |
| 104 | db.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 | ||
| 111 | Two 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 | ||
| 77 | 119 | ## Special Where Methods |
| 78 | 120 | |
| 79 | 121 | ### whereIn / whereNotIn |
| @@ -208,6 +250,21 @@ const users = await db | ||
| 208 | 250 | // Generates: WHERE active = true AND (role = 'admin' OR role = 'moderator') |
| 209 | 251 | ``` |
| 210 | 252 | |
| 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 | |
| 257 | db.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 | ||
| 264 | A callback that adds no conditions **throws**. A group that contributed nothing | |
| 265 | would drop out of the query and leave it matching every row the filter existed | |
| 266 | to exclude, which is the failure this API exists to prevent. | |
| 267 | ||
| 211 | 268 | ## Existence Checks |
| 212 | 269 | |
| 213 | 270 | ```typescript |
packages/bun-query-builder/src/client.tsmodified+503-432
| @@ -10,6 +10,8 @@ import type { DriverConnection } from './db' | ||
| 10 | 10 | import { bunSql, getOrCreateBunSql, resetConnection } from './db' |
| 11 | 11 | import { resolvePivot } from './pivot' |
| 12 | 12 | import { singularizerFor } from './inflect' |
| 13 | import type { WhereTerm } from './sql-fragments' | |
| 14 | import { FALSE_PREDICATE, renderInPredicate, renderWhereTerms } from './sql-fragments' | |
| 13 | 15 | |
| 14 | 16 | export { resetConnection } |
| 15 | 17 | |
| @@ -184,38 +186,6 @@ function renderSelectColumn(col: unknown): string { | ||
| 184 | 186 | ) |
| 185 | 187 | } |
| 186 | 188 | |
| 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 | */ | |
| 196 | const FALSE_PREDICATE = '1 = 0' | |
| 197 | const 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 | */ | |
| 213 | function 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 | ||
| 219 | 189 | // Pre-compiled regex patterns for performance |
| 220 | 190 | const SQL_PATTERNS = { |
| 221 | 191 | SELECT_STAR: /^SELECT\s+\*/i, |
| @@ -887,6 +857,47 @@ export interface BaseSelectQueryBuilder< | ||
| 887 | 857 | */ |
| 888 | 858 | orWhereNotLike: (column: keyof DB[TTable]['columns'] & string, pattern: string, caseSensitive?: boolean) => SelectQueryBuilder<DB, TTable, TSelected, TJoined> |
| 889 | 859 | 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> | |
| 890 | 901 | // where any/all/none on list of columns |
| 891 | 902 | /** |
| 892 | 903 | * # `whereAny` |
| @@ -3216,12 +3227,28 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 3216 | 3227 | ? `SELECT ${columns.join(', ')} FROM ${String(table)}` |
| 3217 | 3228 | : `SELECT * FROM ${String(table)}` |
| 3218 | 3229 | |
| 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 | ||
| 3219 | 3246 | // Lazy building: don't prepare statement until execution |
| 3220 | 3247 | // built is initialized lazily to avoid expensive template tag calls on every query |
| 3221 | 3248 | let built: any = null |
| 3222 | 3249 | const ensureBuilt = () => { |
| 3223 | 3250 | if (built === null) { |
| 3224 | const finalText = reorderSelectClauses(text) | |
| 3251 | const finalText = reorderSelectClauses(currentSql()) | |
| 3225 | 3252 | built = whereParams.length > 0 |
| 3226 | 3253 | ? _sql.unsafe(finalText, whereParams) |
| 3227 | 3254 | : _sql.unsafe(finalText) |
| @@ -3229,17 +3256,168 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 3229 | 3256 | return built |
| 3230 | 3257 | } |
| 3231 | 3258 | |
| 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. | |
| 3232 | 3269 | 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 | |
| 3243 | 3421 | } |
| 3244 | 3422 | |
| 3245 | 3423 | // Append a UNION/UNION ALL (extensible to INTERSECT/EXCEPT) while MERGING |
| @@ -3247,6 +3425,15 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 3247 | 3425 | // ours on Postgres — previously the set-op appended text only, dropping the |
| 3248 | 3426 | // right side's params and colliding `$1`. See stacksjs/bun-query-builder#1029. |
| 3249 | 3427 | 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 | |
| 3250 | 3437 | const st = other.__rawState?.() |
| 3251 | 3438 | if (st) { |
| 3252 | 3439 | const offset = whereParams.length |
| @@ -3371,9 +3558,6 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 3371 | 3558 | joinedTables.add(resolved.pivotTable) |
| 3372 | 3559 | } |
| 3373 | 3560 | |
| 3374 | // Track WHERE conditions and parameters for proper merging | |
| 3375 | const whereConditions: string[] = [] | |
| 3376 | const whereParams: unknown[] = [] | |
| 3377 | 3561 | |
| 3378 | 3562 | // Helper function to add columns to the SELECT clause |
| 3379 | 3563 | const addToSelectClause = (columnsToAdd: string): void => { |
| @@ -4188,11 +4372,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4188 | 4372 | throw new Error(`[query-builder] Unsupported relationship type '${type}' for whereHas`) |
| 4189 | 4373 | } |
| 4190 | 4374 | |
| 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})`) | |
| 4196 | 4379 | |
| 4197 | 4380 | return this as any |
| 4198 | 4381 | }, |
| @@ -4241,11 +4424,8 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4241 | 4424 | throw new Error(`[query-builder] Unsupported relationship type '${type}' for whereDoesntHave`) |
| 4242 | 4425 | } |
| 4243 | 4426 | |
| 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})`) | |
| 4249 | 4429 | |
| 4250 | 4430 | return this as any |
| 4251 | 4431 | }, |
| @@ -4292,11 +4472,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4292 | 4472 | throw new Error(`[query-builder] Unsupported relationship type '${type}' for has`) |
| 4293 | 4473 | } |
| 4294 | 4474 | |
| 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})`) | |
| 4300 | 4479 | |
| 4301 | 4480 | return this as any |
| 4302 | 4481 | }, |
| @@ -4343,11 +4522,8 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4343 | 4522 | throw new Error(`[query-builder] Unsupported relationship type '${type}' for doesntHave`) |
| 4344 | 4523 | } |
| 4345 | 4524 | |
| 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})`) | |
| 4351 | 4527 | |
| 4352 | 4528 | return this as any |
| 4353 | 4529 | }, |
| @@ -4480,17 +4656,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4480 | 4656 | const val = value === undefined ? opOrValue : value |
| 4481 | 4657 | const paramIndex = whereParams.length + 1 |
| 4482 | 4658 | const clause = `${resolved.pivotTable}.${column} ${op} ${getPlaceholder(paramIndex)}` |
| 4483 | whereConditions.push(clause) | |
| 4484 | 4659 | 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) | |
| 4488 | 4661 | if (resolved.hasConfig) |
| 4489 | 4662 | pivotConfigRelations.add(relation) |
| 4490 | 4663 | return this as any |
| 4491 | 4664 | }, |
| 4492 | 4665 | wherePivotIn(relation: string, column: string, values: any[]) { |
| 4493 | if (!meta || !Array.isArray(values) || values.length === 0) | |
| 4666 | if (!meta || !Array.isArray(values)) | |
| 4494 | 4667 | return this as any |
| 4495 | 4668 | const resolved = resolvePivotLocal(relation) |
| 4496 | 4669 | if (!resolved) { |
| @@ -4500,13 +4673,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4500 | 4673 | validateIdentifier(column, 'wherePivotIn (column)') |
| 4501 | 4674 | ensurePivotJoined(resolved) |
| 4502 | 4675 | |
| 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. | |
| 4503 | 4680 | 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) | |
| 4506 | 4682 | 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) | |
| 4510 | 4684 | if (resolved.hasConfig) |
| 4511 | 4685 | pivotConfigRelations.add(relation) |
| 4512 | 4686 | return this as any |
| @@ -4524,11 +4698,8 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4524 | 4698 | |
| 4525 | 4699 | const placeholders = getPlaceholders(values.length, whereParams.length + 1) |
| 4526 | 4700 | const clause = `${resolved.pivotTable}.${column} NOT IN (${placeholders})` |
| 4527 | whereConditions.push(clause) | |
| 4528 | 4701 | 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) | |
| 4532 | 4703 | if (resolved.hasConfig) |
| 4533 | 4704 | pivotConfigRelations.add(relation) |
| 4534 | 4705 | return this as any |
| @@ -4542,11 +4713,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4542 | 4713 | validateIdentifier(resolved.pivotTable, 'wherePivotNull (pivot table)') |
| 4543 | 4714 | validateIdentifier(column, 'wherePivotNull (column)') |
| 4544 | 4715 | 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`) | |
| 4550 | 4717 | if (resolved.hasConfig) |
| 4551 | 4718 | pivotConfigRelations.add(relation) |
| 4552 | 4719 | return this as any |
| @@ -4560,19 +4727,12 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4560 | 4727 | validateIdentifier(resolved.pivotTable, 'wherePivotNotNull (pivot table)') |
| 4561 | 4728 | validateIdentifier(column, 'wherePivotNotNull (column)') |
| 4562 | 4729 | 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`) | |
| 4568 | 4731 | if (resolved.hasConfig) |
| 4569 | 4732 | pivotConfigRelations.add(relation) |
| 4570 | 4733 | return this as any |
| 4571 | 4734 | }, |
| 4572 | 4735 | 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 | ||
| 4576 | 4736 | if (typeof expr === 'string' && op !== undefined) { |
| 4577 | 4737 | // Boundary validation: the column and operator are interpolated |
| 4578 | 4738 | // into SQL text (values stay parameterized). Compile-time types |
| @@ -4590,19 +4750,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4590 | 4750 | if (operator === 'in' || operator === 'not in') { |
| 4591 | 4751 | const values = Array.isArray(value) ? value : [value] |
| 4592 | 4752 | 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) | |
| 4595 | 4754 | whereParams.push(...values) |
| 4596 | text = `${text} ${getWhereKeyword()} ${clause}` | |
| 4597 | built = null | |
| 4755 | pushWhere('AND', clause) | |
| 4598 | 4756 | return this |
| 4599 | 4757 | } |
| 4600 | 4758 | const paramIndex = whereParams.length + 1 |
| 4601 | whereConditions.push(`${String(expr)} ${String(op)} ${getPlaceholder(paramIndex)}`) | |
| 4602 | 4759 | 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)}`) | |
| 4606 | 4761 | return this |
| 4607 | 4762 | } |
| 4608 | 4763 | |
| @@ -4616,17 +4771,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4616 | 4771 | if (operator === 'in' || operator === 'not in') { |
| 4617 | 4772 | const values = Array.isArray(val) ? val : [val] |
| 4618 | 4773 | 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) | |
| 4620 | 4775 | whereParams.push(...values) |
| 4621 | text = `${text} ${getWhereKeyword()} ${colName} ${operator.toUpperCase()} (${placeholders})` | |
| 4622 | built = null | |
| 4776 | pushWhere('AND', clause) | |
| 4623 | 4777 | } |
| 4624 | 4778 | else { |
| 4625 | 4779 | const paramIndex = whereParams.length + 1 |
| 4626 | whereConditions.push(`${colName} ${operator} ${getPlaceholder(paramIndex)}`) | |
| 4627 | 4780 | whereParams.push(val) |
| 4628 | text = `${text} ${getWhereKeyword()} ${colName} ${operator} ${getPlaceholder(paramIndex)}` | |
| 4629 | built = null | |
| 4781 | pushWhere('AND', `${colName} ${operator} ${getPlaceholder(paramIndex)}`) | |
| 4630 | 4782 | } |
| 4631 | 4783 | |
| 4632 | 4784 | return this |
| @@ -4643,30 +4795,27 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4643 | 4795 | const value = whereObject[key] |
| 4644 | 4796 | if (Array.isArray(value)) { |
| 4645 | 4797 | 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)) | |
| 4648 | 4799 | whereParams.push(...value) |
| 4649 | 4800 | } |
| 4650 | 4801 | else { |
| 4651 | 4802 | const paramIndex = whereParams.length + 1 |
| 4652 | 4803 | conditions.push(`${key} = ${getPlaceholder(paramIndex)}`) |
| 4653 | whereConditions.push(`${key} = ${getPlaceholder(paramIndex)}`) | |
| 4654 | 4804 | whereParams.push(value) |
| 4655 | 4805 | } |
| 4656 | 4806 | } |
| 4657 | 4807 | |
| 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]) | |
| 4662 | 4813 | return this |
| 4663 | 4814 | } |
| 4664 | 4815 | |
| 4665 | 4816 | // Handle raw expressions |
| 4666 | 4817 | if (isRawExpression(expr)) { |
| 4667 | whereConditions.push(expr.raw) | |
| 4668 | text = `${text} ${getWhereKeyword()} ${expr.raw}` | |
| 4669 | built = null | |
| 4818 | pushWhere('AND', expr.raw) | |
| 4670 | 4819 | return this |
| 4671 | 4820 | } |
| 4672 | 4821 | |
| @@ -4676,17 +4825,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4676 | 4825 | // A `where` that fails open is a data leak in any application where the |
| 4677 | 4826 | // filter is the access check, and nothing anywhere says it happened. |
| 4678 | 4827 | // |
| 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. | |
| 4683 | 4833 | 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 | |
| 4690 | 4836 | } |
| 4691 | 4837 | |
| 4692 | 4838 | if (expr === undefined || expr === null) { |
| @@ -4705,52 +4851,83 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4705 | 4851 | // where helpers |
| 4706 | 4852 | whereNull(column: string) { |
| 4707 | 4853 | 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`) | |
| 4711 | 4855 | return this |
| 4712 | 4856 | }, |
| 4713 | 4857 | whereNotNull(column: string) { |
| 4714 | 4858 | 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`) | |
| 4718 | 4860 | return this |
| 4719 | 4861 | }, |
| 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 | }, | |
| 4720 | 4872 | whereBetween(column: string, start: any, end: any) { |
| 4721 | 4873 | validateIdentifier(String(column), 'whereBetween(column)') |
| 4722 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4723 | 4874 | // Dialect-aware placeholders: Postgres needs `$n`, not `?` (#1027). |
| 4724 | 4875 | const i = whereParams.length + 1 |
| 4725 | text = `${text} ${keyword} ${String(column)} BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}` | |
| 4726 | 4876 | whereParams.push(start, end) |
| 4727 | built = null | |
| 4877 | pushWhere('AND', `${String(column)} BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}`) | |
| 4728 | 4878 | return this |
| 4729 | 4879 | }, |
| 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 | }, | |
| 4730 | 4887 | 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()})`) | |
| 4734 | 4889 | return this |
| 4735 | 4890 | }, |
| 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 | }, | |
| 4736 | 4914 | whereJsonContains(column: string, json: unknown) { |
| 4737 | 4915 | // Dialect-aware JSON containment. Previously hardcoded Postgres `@>`, |
| 4738 | 4916 | // which is a syntax error on MySQL/SQLite and ignored the configured |
| 4739 | 4917 | // `jsonContainsMode`. See stacksjs/bun-query-builder#1026. |
| 4740 | 4918 | validateIdentifier(String(column), 'whereJsonContains(column)') |
| 4741 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4742 | 4919 | const dialect = config.dialect |
| 4743 | 4920 | const idx = whereParams.length + 1 |
| 4744 | 4921 | if (dialect === 'postgres') { |
| 4745 | 4922 | // operator (`@>`, default) or function (`jsonb_contains`) per config. |
| 4746 | 4923 | if (config.sql?.jsonContainsMode === 'function') |
| 4747 | text += ` ${keyword} jsonb_contains(${column}, ${getPlaceholder(idx)})` | |
| 4924 | pushWhere('AND', `jsonb_contains(${column}, ${getPlaceholder(idx)})`) | |
| 4748 | 4925 | else |
| 4749 | text += ` ${keyword} ${column} @> ${getPlaceholder(idx)}` | |
| 4926 | pushWhere('AND', `${column} @> ${getPlaceholder(idx)}`) | |
| 4750 | 4927 | whereParams.push(JSON.stringify(json)) |
| 4751 | 4928 | } |
| 4752 | 4929 | else if (isMysqlLike(dialect)) { |
| 4753 | text += ` ${keyword} JSON_CONTAINS(${column}, ${getPlaceholder(idx)})` | |
| 4930 | pushWhere('AND', `JSON_CONTAINS(${column}, ${getPlaceholder(idx)})`) | |
| 4754 | 4931 | whereParams.push(JSON.stringify(json)) |
| 4755 | 4932 | } |
| 4756 | 4933 | else { |
| @@ -4760,18 +4937,17 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4760 | 4937 | // value must be present. |
| 4761 | 4938 | if (Array.isArray(json)) { |
| 4762 | 4939 | 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 ')})`) | |
| 4764 | 4941 | for (const v of json) whereParams.push(v as any) |
| 4765 | 4942 | } |
| 4766 | 4943 | else if (json !== null && typeof json === 'object') { |
| 4767 | 4944 | throw new Error('[query-builder] whereJsonContains: object containment is not supported on SQLite — pass a scalar or array, or use whereJsonPath.') |
| 4768 | 4945 | } |
| 4769 | 4946 | 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)})`) | |
| 4771 | 4948 | whereParams.push(json as any) |
| 4772 | 4949 | } |
| 4773 | 4950 | } |
| 4774 | built = null | |
| 4775 | 4951 | return this as any |
| 4776 | 4952 | }, |
| 4777 | 4953 | whereJsonPath(path: string, op: WhereOperator, value: any) { |
| @@ -4790,19 +4966,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4790 | 4966 | throw new TypeError(`[query-builder] whereJsonPath(path): refusing to use '${path}' — contains characters outside the allowed JSON-path set`) |
| 4791 | 4967 | |
| 4792 | 4968 | const dialect = config.dialect |
| 4793 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4794 | 4969 | 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)}`) | |
| 4804 | 4976 | whereParams.push(value) |
| 4805 | built = null | |
| 4806 | 4977 | return this as any |
| 4807 | 4978 | }, |
| 4808 | 4979 | // The LIKE/ILIKE family records the clause in `text` + `whereParams` |
| @@ -4891,46 +5062,36 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4891 | 5062 | // because TRUE genuinely is their identity. The asymmetry is the point, |
| 4892 | 5063 | // not an inconsistency to tidy up. |
| 4893 | 5064 | 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})`) | |
| 4897 | 5066 | return this as any |
| 4898 | 5067 | } |
| 4899 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4900 | 5068 | const idx = whereParams.length + 1 |
| 4901 | 5069 | const conds = cols.map((c, i) => `${c} ${op} ${getPlaceholder(idx + i)}`) |
| 4902 | text += ` ${keyword} (${conds.join(' OR ')})` | |
| 4903 | 5070 | for (let i = 0; i < cols.length; i++) whereParams.push(value) |
| 4904 | built = null | |
| 5071 | pushWhere('AND', `(${conds.join(' OR ')})`) | |
| 4905 | 5072 | return this as any |
| 4906 | 5073 | }, |
| 4907 | 5074 | whereAll(cols: string[], op: WhereOperator, value: any) { |
| 4908 | 5075 | if (cols.length === 0) return this as any |
| 4909 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4910 | 5076 | const idx = whereParams.length + 1 |
| 4911 | 5077 | const conds = cols.map((c, i) => `${c} ${op} ${getPlaceholder(idx + i)}`) |
| 4912 | text += ` ${keyword} (${conds.join(' AND ')})` | |
| 4913 | 5078 | for (let i = 0; i < cols.length; i++) whereParams.push(value) |
| 4914 | built = null | |
| 5079 | pushWhere('AND', `(${conds.join(' AND ')})`) | |
| 4915 | 5080 | return this as any |
| 4916 | 5081 | }, |
| 4917 | 5082 | whereNone(cols: string[], op: WhereOperator, value: any) { |
| 4918 | 5083 | if (cols.length === 0) return this as any |
| 4919 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4920 | 5084 | const idx = whereParams.length + 1 |
| 4921 | 5085 | const conds = cols.map((c, i) => `${c} ${op} ${getPlaceholder(idx + i)}`) |
| 4922 | text += ` ${keyword} NOT (${conds.join(' OR ')})` | |
| 4923 | 5086 | for (let i = 0; i < cols.length; i++) whereParams.push(value) |
| 4924 | built = null | |
| 5087 | pushWhere('AND', `NOT (${conds.join(' OR ')})`) | |
| 4925 | 5088 | return this as any |
| 4926 | 5089 | }, |
| 4927 | 5090 | whereNotBetween(column: string, start: any, end: any) { |
| 4928 | 5091 | validateIdentifier(String(column), 'whereNotBetween(column)') |
| 4929 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4930 | 5092 | const i = whereParams.length + 1 |
| 4931 | text += ` ${keyword} ${column} NOT BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}` | |
| 4932 | 5093 | whereParams.push(start, end) |
| 4933 | built = null | |
| 5094 | pushWhere('AND', `${column} NOT BETWEEN ${getPlaceholder(i)} AND ${getPlaceholder(i + 1)}`) | |
| 4934 | 5095 | return this as any |
| 4935 | 5096 | }, |
| 4936 | 5097 | whereDate(column: string, op: WhereOperator, date: string | Date) { |
| @@ -4944,253 +5105,101 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 4944 | 5105 | : typeof date === 'string' |
| 4945 | 5106 | ? date |
| 4946 | 5107 | : (() => { throw new TypeError(`[query-builder] whereDate(date): expected string or Date, got ${typeof date}`) })() |
| 4947 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4948 | 5108 | const idx = whereParams.length + 1 |
| 4949 | text += ` ${keyword} ${column} ${op} ${getPlaceholder(idx)}` | |
| 4950 | 5109 | whereParams.push(dateString) |
| 4951 | built = null | |
| 5110 | pushWhere('AND', `${column} ${op} ${getPlaceholder(idx)}`) | |
| 4952 | 5111 | return this as any |
| 4953 | 5112 | }, |
| 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. | |
| 4954 | 5116 | 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)')) | |
| 4959 | 5122 | return this as any |
| 4960 | 5123 | }, |
| 4961 | 5124 | whereColumn(left: string, op: WhereOperator, right: string) { |
| 4962 | 5125 | validateIdentifier(left, 'whereColumn(left)') |
| 4963 | 5126 | 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}`) | |
| 4967 | 5128 | return this as any |
| 4968 | 5129 | }, |
| 4969 | 5130 | orWhereColumn(left: string, op: WhereOperator, right: string) { |
| 4970 | 5131 | validateIdentifier(left, 'orWhereColumn(left)') |
| 4971 | 5132 | validateIdentifier(right, 'orWhereColumn(right)') |
| 4972 | text += ` OR ${left} ${op} ${right}` | |
| 4973 | built = null | |
| 5133 | pushWhere('OR', `${left} ${op} ${right}`) | |
| 4974 | 5134 | return this as any |
| 4975 | 5135 | }, |
| 4976 | 5136 | whereIn(column: string, values: any[] | { toSQL: () => any }) { |
| 4977 | 5137 | validateIdentifier(String(column), 'whereIn(column)') |
| 4978 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 4979 | 5138 | if (Array.isArray(values)) { |
| 4980 | 5139 | const placeholders = getPlaceholders(values.length, whereParams.length + 1) |
| 4981 | text += ` ${keyword} ${renderInPredicate(column, values, false, placeholders)}` | |
| 5140 | const clause = renderInPredicate(column, values, false, placeholders) | |
| 4982 | 5141 | whereParams.push(...values) |
| 5142 | pushWhere('AND', clause) | |
| 4983 | 5143 | } |
| 4984 | 5144 | else { |
| 4985 | text += ` ${keyword} ${column} IN (${String((values as any).toSQL())})` | |
| 5145 | pushWhere('AND', `${column} IN (${String((values as any).toSQL())})`) | |
| 4986 | 5146 | } |
| 4987 | built = null | |
| 4988 | 5147 | return this as any |
| 4989 | 5148 | }, |
| 4990 | 5149 | orWhereIn(column: string, values: any[] | { toSQL: () => any }) { |
| 4991 | 5150 | validateIdentifier(String(column), 'orWhereIn(column)') |
| 4992 | 5151 | if (Array.isArray(values)) { |
| 4993 | 5152 | const placeholders = getPlaceholders(values.length, whereParams.length + 1) |
| 4994 | text += ` OR ${renderInPredicate(column, values, false, placeholders)}` | |
| 5153 | const clause = renderInPredicate(column, values, false, placeholders) | |
| 4995 | 5154 | whereParams.push(...values) |
| 5155 | pushWhere('OR', clause) | |
| 4996 | 5156 | } |
| 4997 | 5157 | else { |
| 4998 | text += ` OR ${column} IN (${String((values as any).toSQL())})` | |
| 5158 | pushWhere('OR', `${column} IN (${String((values as any).toSQL())})`) | |
| 4999 | 5159 | } |
| 5000 | built = null | |
| 5001 | 5160 | return this as any |
| 5002 | 5161 | }, |
| 5003 | 5162 | whereNotIn(column: string, values: any[] | { toSQL: () => any }) { |
| 5004 | 5163 | validateIdentifier(String(column), 'whereNotIn(column)') |
| 5005 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 5006 | 5164 | if (Array.isArray(values)) { |
| 5007 | 5165 | const placeholders = getPlaceholders(values.length, whereParams.length + 1) |
| 5008 | text += ` ${keyword} ${renderInPredicate(column, values, true, placeholders)}` | |
| 5166 | const clause = renderInPredicate(column, values, true, placeholders) | |
| 5009 | 5167 | whereParams.push(...values) |
| 5168 | pushWhere('AND', clause) | |
| 5010 | 5169 | } |
| 5011 | 5170 | else { |
| 5012 | text += ` ${keyword} ${column} NOT IN (${String((values as any).toSQL())})` | |
| 5171 | pushWhere('AND', `${column} NOT IN (${String((values as any).toSQL())})`) | |
| 5013 | 5172 | } |
| 5014 | built = null | |
| 5015 | 5173 | return this as any |
| 5016 | 5174 | }, |
| 5017 | 5175 | orWhereNotIn(column: string, values: any[] | { toSQL: () => any }) { |
| 5018 | 5176 | validateIdentifier(String(column), 'orWhereNotIn(column)') |
| 5019 | 5177 | if (Array.isArray(values)) { |
| 5020 | 5178 | const placeholders = getPlaceholders(values.length, whereParams.length + 1) |
| 5021 | text += ` OR ${renderInPredicate(column, values, true, placeholders)}` | |
| 5179 | const clause = renderInPredicate(column, values, true, placeholders) | |
| 5022 | 5180 | whereParams.push(...values) |
| 5181 | pushWhere('OR', clause) | |
| 5023 | 5182 | } |
| 5024 | 5183 | else { |
| 5025 | text += ` OR ${column} NOT IN (${String((values as any).toSQL())})` | |
| 5184 | pushWhere('OR', `${column} NOT IN (${String((values as any).toSQL())})`) | |
| 5026 | 5185 | } |
| 5027 | built = null | |
| 5028 | 5186 | return this as any |
| 5029 | 5187 | }, |
| 5030 | 5188 | whereNested(fragment: any) { |
| 5031 | const keyword = SQL_PATTERNS.WHERE.test(text) ? 'AND' : 'WHERE' | |
| 5032 | 5189 | const inner = fragment.toSQL ? String(fragment.toSQL()) : String(fragment) |
| 5033 | text += ` ${keyword} (${inner})` | |
| 5034 | built = null | |
| 5190 | pushWhere('AND', `(${inner})`) | |
| 5035 | 5191 | return this as any |
| 5036 | 5192 | }, |
| 5037 | 5193 | orWhereNested(fragment: any) { |
| 5038 | 5194 | const inner = fragment.toSQL ? String(fragment.toSQL()) : String(fragment) |
| 5039 | text += ` OR (${inner})` | |
| 5040 | built = null | |
| 5195 | pushWhere('OR', `(${inner})`) | |
| 5041 | 5196 | return this as any |
| 5042 | 5197 | }, |
| 5043 | 5198 | 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) | |
| 5118 | 5200 | }, |
| 5119 | 5201 | 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) | |
| 5194 | 5203 | }, |
| 5195 | 5204 | orderBy(column: string, direction: 'asc' | 'desc' = 'asc') { |
| 5196 | 5205 | // Compose-aware: detect an existing ORDER BY clause and append the |
| @@ -5496,7 +5505,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5496 | 5505 | // text — most toSQL() calls never execute the returned handle. |
| 5497 | 5506 | // ensureBuilt() still runs (memoized) if execute()/values()/raw() |
| 5498 | 5507 | // is actually invoked. |
| 5499 | const sqlText = reorderSelectClauses(text) | |
| 5508 | const sqlText = reorderSelectClauses(currentSql()) | |
| 5500 | 5509 | return { |
| 5501 | 5510 | sql: sqlText, |
| 5502 | 5511 | toString: () => sqlText, |
| @@ -5555,7 +5564,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5555 | 5564 | // so a concurrent INSERT/DELETE can't desync `total` from `data.length`. |
| 5556 | 5565 | // The caller owns the transaction (and thus the isolation level). |
| 5557 | 5566 | if (opts.tx) { |
| 5558 | const baseSql = reorderSelectClauses(text) | |
| 5567 | const baseSql = reorderSelectClauses(currentSql()) | |
| 5559 | 5568 | const baseParams = [...whereParams] |
| 5560 | 5569 | const cRows = await opts.tx.unsafe(`SELECT COUNT(*) as c FROM (${baseSql}) as sub`, baseParams) as any[] |
| 5561 | 5570 | const total = Number(cRows?.[0]?.c ?? 0) |
| @@ -5769,7 +5778,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5769 | 5778 | return (ensureBuilt() as any).simple() |
| 5770 | 5779 | }, |
| 5771 | 5780 | toText() { |
| 5772 | return text | |
| 5781 | return currentSql() | |
| 5773 | 5782 | }, |
| 5774 | 5783 | async get() { |
| 5775 | 5784 | const hooks = activeHooks() |
| @@ -5779,15 +5788,16 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5779 | 5788 | if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !hasQueryHooks) { |
| 5780 | 5789 | const prepareFn = _sql._prepareStatement |
| 5781 | 5790 | if (prepareFn) { |
| 5782 | const stmt = prepareFn(text) | |
| 5791 | const stmt = prepareFn(currentSql()) | |
| 5783 | 5792 | return hydratePivotRows(whereParams.length > 0 ? stmt.all(...whereParams) : stmt.all()) |
| 5784 | 5793 | } |
| 5785 | 5794 | } |
| 5786 | 5795 | |
| 5787 | 5796 | // Build query at execution time (statement will be cached by db-clients.ts) |
| 5797 | const getText = currentSql() | |
| 5788 | 5798 | built = whereParams.length > 0 |
| 5789 | ? _sql.unsafe(text, whereParams) | |
| 5790 | : _sql.unsafe(text) | |
| 5799 | ? _sql.unsafe(getText, whereParams) | |
| 5800 | : _sql.unsafe(getText) | |
| 5791 | 5801 | |
| 5792 | 5802 | // Fast path: no soft-deletes, no cache, no timeout, no signal, no hooks |
| 5793 | 5803 | if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !hasQueryHooks) { |
| @@ -5811,9 +5821,12 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5811 | 5821 | const col = config.softDeletes.column |
| 5812 | 5822 | const tbl = String(table) |
| 5813 | 5823 | 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. | |
| 5816 | 5828 | addWhereText('WHERE', `${String(col)} IS ${onlyTrashed ? 'NOT ' : ''}NULL`) |
| 5829 | finalQuery = ensureBuilt() | |
| 5817 | 5830 | } |
| 5818 | 5831 | } |
| 5819 | 5832 | |
| @@ -5855,7 +5868,8 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5855 | 5868 | if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !fHasQueryHooks) { |
| 5856 | 5869 | const prepareFn = _sql._prepareStatement |
| 5857 | 5870 | 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` | |
| 5859 | 5873 | const stmt = prepareFn(firstText) |
| 5860 | 5874 | const rows = whereParams.length > 0 ? stmt.all(...whereParams) : stmt.all() |
| 5861 | 5875 | return hydratePivotRow(rows[0]) as any |
| @@ -5931,14 +5945,17 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5931 | 5945 | // `rows[0]` silently returns just the first group's count. |
| 5932 | 5946 | // Wrap in a subquery when GROUP BY is present. |
| 5933 | 5947 | // 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) | |
| 5936 | 5953 | let countText: string |
| 5937 | 5954 | 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` | |
| 5939 | 5956 | } |
| 5940 | 5957 | else if (fromIdx !== -1) { |
| 5941 | countText = `SELECT COUNT(*) as c${text.substring(fromIdx)}` | |
| 5958 | countText = `SELECT COUNT(*) as c${src.substring(fromIdx)}` | |
| 5942 | 5959 | } |
| 5943 | 5960 | else { |
| 5944 | 5961 | countText = `SELECT COUNT(*) as c FROM ${table}` |
| @@ -5965,9 +5982,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5965 | 5982 | }, |
| 5966 | 5983 | async avg(column: string) { |
| 5967 | 5984 | // Build optimized AVG query without subquery or helpers |
| 5968 | const fromIdx = text.indexOf(' FROM ') | |
| 5985 | const src = currentSql() | |
| 5986 | const fromIdx = src.indexOf(' FROM ') | |
| 5969 | 5987 | const avgText = fromIdx !== -1 |
| 5970 | ? `SELECT AVG(${column}) as a${text.substring(fromIdx)}` | |
| 5988 | ? `SELECT AVG(${column}) as a${src.substring(fromIdx)}` | |
| 5971 | 5989 | : `SELECT AVG(${column}) as a FROM ${table}` |
| 5972 | 5990 | |
| 5973 | 5991 | // Ultra-fast path |
| @@ -5990,9 +6008,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5990 | 6008 | return Number(row?.a ?? 0) |
| 5991 | 6009 | }, |
| 5992 | 6010 | async sum(column: string) { |
| 5993 | const fromIdx = text.indexOf(' FROM ') | |
| 6011 | const src = currentSql() | |
| 6012 | const fromIdx = src.indexOf(' FROM ') | |
| 5994 | 6013 | const sumText = fromIdx !== -1 |
| 5995 | ? `SELECT SUM(${column}) as s${text.substring(fromIdx)}` | |
| 6014 | ? `SELECT SUM(${column}) as s${src.substring(fromIdx)}` | |
| 5996 | 6015 | : `SELECT SUM(${column}) as s FROM ${table}` |
| 5997 | 6016 | const q = whereParams.length > 0 |
| 5998 | 6017 | ? _sql.unsafe(sumText, whereParams) |
| @@ -6002,9 +6021,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6002 | 6021 | return Number(row?.s ?? 0) |
| 6003 | 6022 | }, |
| 6004 | 6023 | async max(column: string) { |
| 6005 | const fromIdx = text.indexOf(' FROM ') | |
| 6024 | const src = currentSql() | |
| 6025 | const fromIdx = src.indexOf(' FROM ') | |
| 6006 | 6026 | const maxText = fromIdx !== -1 |
| 6007 | ? `SELECT MAX(${column}) as m${text.substring(fromIdx)}` | |
| 6027 | ? `SELECT MAX(${column}) as m${src.substring(fromIdx)}` | |
| 6008 | 6028 | : `SELECT MAX(${column}) as m FROM ${table}` |
| 6009 | 6029 | const q = whereParams.length > 0 |
| 6010 | 6030 | ? _sql.unsafe(maxText, whereParams) |
| @@ -6014,9 +6034,10 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6014 | 6034 | return row?.m |
| 6015 | 6035 | }, |
| 6016 | 6036 | async min(column: string) { |
| 6017 | const fromIdx = text.indexOf(' FROM ') | |
| 6037 | const src = currentSql() | |
| 6038 | const fromIdx = src.indexOf(' FROM ') | |
| 6018 | 6039 | const minText = fromIdx !== -1 |
| 6019 | ? `SELECT MIN(${column}) as m${text.substring(fromIdx)}` | |
| 6040 | ? `SELECT MIN(${column}) as m${src.substring(fromIdx)}` | |
| 6020 | 6041 | : `SELECT MIN(${column}) as m FROM ${table}` |
| 6021 | 6042 | const q = whereParams.length > 0 |
| 6022 | 6043 | ? _sql.unsafe(minText, whereParams) |
| @@ -6066,7 +6087,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6066 | 6087 | // by union()/unionAll() on the other side to merge params and renumber |
| 6067 | 6088 | // placeholders. See stacksjs/bun-query-builder#1029. |
| 6068 | 6089 | __rawState() { |
| 6069 | return { sql: reorderSelectClauses(text), params: [...whereParams] } | |
| 6090 | return { sql: reorderSelectClauses(currentSql()), params: [...whereParams] } | |
| 6070 | 6091 | }, |
| 6071 | 6092 | raw() { |
| 6072 | 6093 | return (ensureBuilt() as any).raw() |
| @@ -6124,20 +6145,13 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6124 | 6145 | return () => receiver |
| 6125 | 6146 | const column = chosen |
| 6126 | 6147 | 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. | |
| 6141 | 6155 | if (Array.isArray(value)) { |
| 6142 | 6156 | const phs = getPlaceholders(value.length, whereParams.length + 1) |
| 6143 | 6157 | addWhereText(isOr ? 'OR' : isAnd ? 'AND' : 'WHERE', `${column} IN (${phs})`) |
| @@ -6232,21 +6246,20 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6232 | 6246 | ?? (Array.isArray(subExec?.values) ? subExec.values : []) |
| 6233 | 6247 | const baseText = `SELECT * FROM (${subText}) AS ${String(alias)}` |
| 6234 | 6248 | |
| 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. | |
| 6240 | 6257 | const appendWhere = ( |
| 6241 | text: string, | |
| 6242 | 6258 | params: any[], |
| 6243 | hasWhere: boolean, | |
| 6244 | 6259 | expr: any, |
| 6245 | 6260 | op: WhereOperator | undefined, |
| 6246 | 6261 | 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[] } => { | |
| 6250 | 6263 | const out = [...params] |
| 6251 | 6264 | const cmp = (col: string, operator: string, val: any): string => { |
| 6252 | 6265 | validateIdentifier(col, 'selectFromSub where(column)') |
| @@ -6262,73 +6275,126 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6262 | 6275 | return `${col} ${o} ${ph}` |
| 6263 | 6276 | } |
| 6264 | 6277 | let clause: string |
| 6265 | if (typeof expr === 'string' && op !== undefined) | |
| 6278 | if (typeof expr === 'string' && op !== undefined) { | |
| 6266 | 6279 | clause = cmp(expr, op, value) |
| 6267 | else if (Array.isArray(expr) && expr.length === 3) | |
| 6280 | } | |
| 6281 | else if (Array.isArray(expr) && expr.length === 3) { | |
| 6268 | 6282 | 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 } | |
| 6274 | 6293 | } |
| 6275 | 6294 | |
| 6276 | 6295 | // 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}` | |
| 6279 | 6303 | 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 | } | |
| 6280 | 6339 | const base: BaseSelectQueryBuilder<DB, any, any, any> = { |
| 6281 | 6340 | 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 | |
| 6283 | 6342 | }, |
| 6284 | 6343 | distinctOn(...columns: any[]) { |
| 6285 | 6344 | 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 | |
| 6287 | 6346 | }, |
| 6288 | 6347 | selectRaw(fragment: any) { |
| 6289 | 6348 | 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 | |
| 6295 | 6354 | }, |
| 6296 | 6355 | 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) | |
| 6299 | 6357 | }, |
| 6300 | 6358 | 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) | |
| 6303 | 6360 | }, |
| 6304 | 6361 | 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) | |
| 6307 | 6373 | }, |
| 6308 | 6374 | orderBy(column: string, direction: 'asc' | 'desc' = 'asc') { |
| 6309 | 6375 | validateIdentifier(String(column), 'selectFromSub.orderBy(column)') |
| 6310 | 6376 | const dir = direction === 'asc' ? 'ASC' : 'DESC' |
| 6311 | 6377 | // 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 | |
| 6316 | 6382 | }, |
| 6317 | 6383 | limit(n: number) { |
| 6318 | 6384 | if (!Number.isInteger(n) || n < 0) |
| 6319 | 6385 | 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 | |
| 6324 | 6390 | }, |
| 6325 | 6391 | offset(n: number) { |
| 6326 | 6392 | if (!Number.isInteger(n) || n < 0) |
| 6327 | 6393 | 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 | |
| 6332 | 6398 | }, |
| 6333 | 6399 | toSQL() { |
| 6334 | 6400 | return makeExecutableQuery(build(), text) as any |
| @@ -6435,8 +6501,13 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6435 | 6501 | whereJsonContains: subqueryNotSupported('whereJsonContains'), |
| 6436 | 6502 | whereJsonPath: subqueryNotSupported('whereJsonPath'), |
| 6437 | 6503 | whereNull: subqueryNotSupported('whereNull'), |
| 6504 | orWhereNull: subqueryNotSupported('orWhereNull'), | |
| 6438 | 6505 | whereNotNull: subqueryNotSupported('whereNotNull'), |
| 6506 | orWhereNotNull: subqueryNotSupported('orWhereNotNull'), | |
| 6439 | 6507 | whereExists: subqueryNotSupported('whereExists'), |
| 6508 | orWhereExists: subqueryNotSupported('orWhereExists'), | |
| 6509 | orWhereBetween: subqueryNotSupported('orWhereBetween'), | |
| 6510 | orWhereRaw: subqueryNotSupported('orWhereRaw'), | |
| 6440 | 6511 | whereJsonDoesntContain: subqueryNotSupported('whereJsonDoesntContain'), |
| 6441 | 6512 | whereJsonContainsKey: subqueryNotSupported('whereJsonContainsKey'), |
| 6442 | 6513 | whereJsonDoesntContainKey: subqueryNotSupported('whereJsonDoesntContainKey'), |
| @@ -6511,7 +6582,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6511 | 6582 | return base |
| 6512 | 6583 | } |
| 6513 | 6584 | |
| 6514 | return makeSub(baseText, subParams) as any | |
| 6585 | return makeSub(baseText, [], subParams) as any | |
| 6515 | 6586 | }, |
| 6516 | 6587 | insertInto<TTable extends keyof DB & string>(table: TTable) { |
| 6517 | 6588 | let built: any |
| @@ -34,6 +34,8 @@ import { getOrCreateBunSql } from './db' | ||
| 34 | 34 | import { applySqliteBootstrapPragmas } from './sqlite-pragmas' |
| 35 | 35 | import { normalizeRelationList } from './relation-utils' |
| 36 | 36 | import { singularizerFor, toTableName as sharedToTableName } from './inflect' |
| 37 | import type { WhereTerm } from './sql-fragments' | |
| 38 | import { renderInPredicate, renderWhereTerms } from './sql-fragments' | |
| 37 | 39 | |
| 38 | 40 | /** |
| 39 | 41 | * Current timestamp formatted for the active dialect. |
| @@ -2494,9 +2496,8 @@ class ModelQueryBuilder< | ||
| 2494 | 2496 | * Shared by buildQuery, count, aggregates, delete, and update. |
| 2495 | 2497 | */ |
| 2496 | 2498 | 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) { | |
| 2500 | 2501 | let clause: string |
| 2501 | 2502 | |
| 2502 | 2503 | if (w.raw) { |
| @@ -2508,8 +2509,11 @@ class ModelQueryBuilder< | ||
| 2508 | 2509 | clause = w.operator === '=' ? `${col} IS NULL` : `${col} IS NOT NULL` |
| 2509 | 2510 | } |
| 2510 | 2511 | 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. | |
| 2511 | 2515 | 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(', ')) | |
| 2513 | 2517 | params.push(...arr) |
| 2514 | 2518 | } |
| 2515 | 2519 | else { |
| @@ -2517,9 +2521,13 @@ class ModelQueryBuilder< | ||
| 2517 | 2521 | params.push(w.value) |
| 2518 | 2522 | } |
| 2519 | 2523 | |
| 2520 | clauses.push(i === 0 ? clause : `${w.boolean.toUpperCase()} ${clause}`) | |
| 2524 | terms.push({ conn: w.boolean === 'or' ? 'OR' : 'AND', sql: clause }) | |
| 2521 | 2525 | } |
| 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) | |
| 2523 | 2531 | } |
| 2524 | 2532 | |
| 2525 | 2533 | /** |
| @@ -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 | */ | |
| 22 | export 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 | */ | |
| 36 | export const FALSE_PREDICATE = '1 = 0' | |
| 37 | export 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 | */ | |
| 61 | export 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 | */ | |
| 95 | export 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 | } | |
| @@ -48,6 +48,15 @@ describe('dynamic whereX/orWhereX/andWhereX methods', () => { | ||
| 48 | 48 | .limit(10) |
| 49 | 49 | |
| 50 | 50 | 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 = ?') | |
| 51 | 60 | }) |
| 52 | 61 | |
| 53 | 62 | it('treats array values as IN and scalars as =', () => { |
| @@ -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 | ||
| 20 | import { SQL } from 'bun' | |
| 21 | import { afterAll, beforeAll, describe, expect, it } from 'bun:test' | |
| 22 | import { buildDatabaseSchema, createQueryBuilder } from '../src' | |
| 23 | ||
| 24 | const 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 | ||
| 37 | let sql: SQL | |
| 38 | ||
| 39 | beforeAll(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 | ||
| 48 | afterAll(async () => { | |
| 49 | await sql?.end() | |
| 50 | }) | |
| 51 | ||
| 52 | const q = () => createQueryBuilder<typeof schema>({ schema, sql }).selectFrom('migrations').selectAll() as any | |
| 53 | ||
| 54 | describe('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 | }) | |
| @@ -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 | ||
| 21 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 22 | import { buildDatabaseSchema, config, createQueryBuilder } from '../src' | |
| 23 | ||
| 24 | const 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 | ||
| 37 | const 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 | */ | |
| 48 | const norm = (s: string): string => s.replace(/\$\d+/g, '?') | |
| 49 | const sqlOf = (build: (b: any) => any): string => norm(String(build(q()).toSQL())) | |
| 50 | ||
| 51 | describe('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 | ||
| 111 | describe('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 | ||
| 135 | describe('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 | ||
| 157 | describe('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 | ||
| 190 | describe('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 | ||
| 211 | describe('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 | ||
| 246 | describe('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 | }) | |
| @@ -117,6 +117,10 @@ describe('query builder - modifiers and raws', () => { | ||
| 117 | 117 | it('date/json helpers compose', () => { |
| 118 | 118 | const q1 = qb().selectFrom('users').whereDate('created_at', '>=', '2024-01-01').toSQL() as any |
| 119 | 119 | 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. | |
| 120 | 124 | const q2 = qb().selectFrom('users').whereJsonContains('meta', ['a']).toSQL() as any |
| 121 | 125 | expect(typeof q2.execute).toBe('function') |
| 122 | 126 | }) |
| @@ -313,6 +317,11 @@ describe('query builder - SQL text for clauses and helpers', () => { | ||
| 313 | 317 | expectTextOutput(s2) |
| 314 | 318 | const s3 = toTextOf(qb().selectFrom('projects').orWhereNested(nested as any) as any) |
| 315 | 319 | 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/) | |
| 316 | 325 | const s4 = toTextOf(qb().selectFrom('users').where(['id', '>', 0]).andWhere(['name', 'like', '%a%']) as any) |
| 317 | 326 | expectTextOutput(s4) |
| 318 | 327 | const s5 = toTextOf(qb().selectFrom('users').where(['id', '>', 0]).orWhere(['name', 'like', '%a%']) as any) |
| @@ -11,6 +11,12 @@ | ||
| 11 | 11 | * |
| 12 | 12 | * Failing closed is the whole point of these tests: an exception is recoverable |
| 13 | 13 | * 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. | |
| 14 | 20 | */ |
| 15 | 21 | |
| 16 | 22 | import { describe, expect, it } from 'bun:test' |
| @@ -21,12 +27,22 @@ function query() { | ||
| 21 | 27 | } |
| 22 | 28 | |
| 23 | 29 | describe('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\(\)/) | |
| 26 | 39 | }) |
| 27 | 40 | |
| 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 = ') | |
| 30 | 46 | }) |
| 31 | 47 | |
| 32 | 48 | it('rejects a missing condition', () => { |
| @@ -23,8 +23,8 @@ | ||
| 23 | 23 | * injected operator cannot be walked back. |
| 24 | 24 | */ |
| 25 | 25 | |
| 26 | import { describe, expect, it } from 'bun:test' | |
| 27 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src' | |
| 26 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 27 | import { buildDatabaseSchema, buildSchemaMeta, config, createQueryBuilder } from '../src' | |
| 28 | 28 | |
| 29 | 29 | function qb() { |
| 30 | 30 | const models = { |
| @@ -44,7 +44,16 @@ function qb() { | ||
| 44 | 44 | }) |
| 45 | 45 | } |
| 46 | 46 | |
| 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. | |
| 47 | 52 | describe('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 | ||
| 48 | 57 | it('renders one placeholder per element, parenthesised', () => { |
| 49 | 58 | const sql = String((qb() as any) |
| 50 | 59 | .updateTable('notifications') |
| @@ -67,7 +76,7 @@ describe('IN on an UPDATE', () => { | ||
| 67 | 76 | .where('id', 'in', [7, 8]) |
| 68 | 77 | .toSQL()) |
| 69 | 78 | |
| 70 | expect(sql).toMatch(/IN \(\$2, \$3\)|IN \(\?, \?\)/) | |
| 79 | expect(sql).toContain('IN ($2, $3)') | |
| 71 | 80 | }) |
| 72 | 81 | |
| 73 | 82 | it('continues an existing WHERE with AND rather than opening a second one', () => { |
| @@ -125,6 +134,10 @@ describe('IN on an UPDATE', () => { | ||
| 125 | 134 | }) |
| 126 | 135 | |
| 127 | 136 | describe('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 | ||
| 128 | 141 | it('renders one placeholder per element', () => { |
| 129 | 142 | const sql = String((qb() as any) |
| 130 | 143 | .deleteFrom('notifications') |
| @@ -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 | ||
| 20 | import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' | |
| 21 | import { configureOrm, createModel, createTableFromModel, getDatabase } from '../src/orm' | |
| 22 | ||
| 23 | const 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 | ||
| 34 | describe('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 | }) | |
| @@ -890,9 +890,12 @@ describe('Query Builder: fluent chaining', () => { | ||
| 890 | 890 | .where({ id: 1 }) |
| 891 | 891 | .orWhere({ email: 'test@test.com' }) |
| 892 | 892 | .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 = ?') | |
| 896 | 899 | }) |
| 897 | 900 | }) |
| 898 | 901 | |