also looking at this
fix(orm)!: accept whereRaw bindings as one array
#1148
10 files
+742
-27
| @@ -67,7 +67,7 @@ const meta = buildSchemaMeta(models) | ||
| 67 | 67 | | `whereNull(col)` | WHERE IS NULL | `.whereNull('deleted_at')` | |
| 68 | 68 | | `whereNotNull(col)` | WHERE IS NOT NULL | `.whereNotNull('email')` | |
| 69 | 69 | | `whereColumn(a, op, b)` | Compare columns | `.whereColumn('a', '=', 'b')` | |
| 70 | | `whereRaw(sql)` | Raw WHERE clause | `.whereRaw('LOWER(name) = ?', ['john'])` | | |
| 70 | | `whereRaw(fragment)` | Raw WHERE clause (one fragment, no bindings) | ``.whereRaw(raw`LOWER(name) = ${name}`)`` | | |
| 71 | 71 | |
| 72 | 72 | ### JOIN Operations |
| 73 | 73 | |
| @@ -184,17 +184,58 @@ const modified = await db | ||
| 184 | 184 | |
| 185 | 185 | ### whereRaw |
| 186 | 186 | |
| 187 | For complex conditions: | |
| 187 | For complex conditions. On `db.selectFrom(...)`, `whereRaw` takes a single SQL | |
| 188 | fragment and no bindings. Build the fragment with the `raw` tagged template, | |
| 189 | which inlines each interpolated value as an escaped literal: | |
| 188 | 190 | |
| 189 | 191 | ```typescript |
| 192 | import { raw } from 'bun-query-builder' | |
| 193 | ||
| 194 | const email = 'john@example.com' | |
| 190 | 195 | const result = await db |
| 191 | 196 | .selectFrom('users') |
| 192 | .whereRaw('LOWER(email) = ?', ['john@example.com']) | |
| 197 | .whereRaw(raw`LOWER(email) = ${email}`) | |
| 193 | 198 | .get() |
| 194 | 199 | |
| 195 | const recent = await db | |
| 200 | const live = await db | |
| 196 | 201 | .selectFrom('orders') |
| 197 | .whereRaw('created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)') | |
| 202 | .whereRaw(raw('deleted_at IS NULL')) | |
| 203 | .get() | |
| 204 | ``` | |
| 205 | ||
| 206 | Passing values after the fragment throws, rather than sending an unbound `?`. | |
| 207 | So does `raw('...', value)`: the plain-string form takes no values. Values are | |
| 208 | inlined, not bound, so prefer `where()` for request input. | |
| 209 | ||
| 210 | Model queries do bind `?` placeholders. `Model.query().whereRaw()` and | |
| 211 | `orWhereRaw()` take the bindings either as separate arguments or as one array, | |
| 212 | and the two spellings build the same query: | |
| 213 | ||
| 214 | ```typescript | |
| 215 | const active = await User.query() | |
| 216 | .whereRaw('LOWER(email) = ?', 'john@example.com') | |
| 217 | .where('active', true) | |
| 218 | .get() | |
| 219 | ||
| 220 | const same = await User.query() | |
| 221 | .whereRaw('LOWER(email) = ?', ['john@example.com']) | |
| 222 | .where('active', true) | |
| 223 | .get() | |
| 224 | ``` | |
| 225 | ||
| 226 | A lone array is always the bindings list, and each of its values is bound just | |
| 227 | as if it had been passed separately. Two consequences on Postgres: | |
| 228 | ||
| 229 | - Bun's driver sends an array bound to a `json`/`jsonb` parameter as JSON, so | |
| 230 | wrap an array you mean as one value. Unwrapped, | |
| 231 | `whereRaw('tags @> ?', ['bun'])` binds the string `'bun'`, not the array. | |
| 232 | - A number or boolean compared with text needs a string or a cast, e.g. | |
| 233 | `whereRaw("meta->>'id' = ?", [String(id)])`. | |
| 234 | ||
| 235 | ```typescript | |
| 236 | // tags @> '["bun"]' | |
| 237 | const tagged = await Post.query() | |
| 238 | .whereRaw('tags @> ?', [['bun']]) | |
| 198 | 239 | .get() |
| 199 | 240 | ``` |
| 200 | 241 | |
| @@ -69,9 +69,9 @@ | ||
| 69 | 69 | "release:patch": "bun x --bun bumpx patch --recursive --commit --tag --push --yes", |
| 70 | 70 | "release:minor": "bun x --bun bumpx minor --recursive --commit --tag --push --yes", |
| 71 | 71 | "release:major": "bun x --bun bumpx major --recursive --commit --tag --push --yes", |
| 72 | "dev:docs": "bun --bun vitepress dev docs", | |
| 73 | "build:docs": "bun --bun vitepress build docs", | |
| 74 | "preview:docs": "bun --bun vitepress preview docs", | |
| 72 | "dev:docs": "bun --bun bunpress dev docs", | |
| 73 | "build:docs": "bun --bun bunpress build docs", | |
| 74 | "preview:docs": "bun --bun bunpress preview docs", | |
| 75 | 75 | "typecheck": "bun --bun tsc" |
| 76 | 76 | }, |
| 77 | 77 | "dependencies": { |
| @@ -234,6 +234,14 @@ async function _ormUsage() { | ||
| 234 | 234 | Member.whereBetween('age', [18, 99]) |
| 235 | 235 | Member.whereNotBetween('karma', [0, 10]) |
| 236 | 236 | |
| 237 | // raw where: bindings as separate arguments or as one array (#1146) | |
| 238 | Member.query().whereRaw('LOWER(name) = ?', 'ada') | |
| 239 | Member.query().whereRaw('LOWER(name) = ? AND age > ?', 'ada', 21) | |
| 240 | Member.query().whereRaw('LOWER(name) = ? AND age > ?', ['ada', 21]) | |
| 241 | Member.query().whereRaw('LOWER(name) = ?', ['ada'] as const) | |
| 242 | Member.query().orWhereRaw('age > ?', [21]) | |
| 243 | Member.query().whereRaw('age > 21') | |
| 244 | ||
| 237 | 245 | // @ts-expect-error — unknown column |
| 238 | 246 | Member.where('handle', 'x') |
| 239 | 247 | // @ts-expect-error — value must match the column type (age: number) |
| @@ -1068,6 +1076,12 @@ async function _rawAndReturning( | ||
| 1068 | 1076 | |
| 1069 | 1077 | // @ts-expect-error — a bare string is rejected by the SqlFragment type |
| 1070 | 1078 | users.whereRaw('age > 18') |
| 1079 | // @ts-expect-error — the select builder's *Raw methods take no bindings (#1146) | |
| 1080 | users.whereRaw(raw('age > ?'), [18]) | |
| 1081 | // @ts-expect-error — raw(string) takes no values; use the tagged template (#1146) | |
| 1082 | users.whereRaw(raw('age > ?', 18)) | |
| 1083 | // the string overload still maps point-free | |
| 1084 | users.orderByRaw(['created_at desc'].map(raw)[0]) | |
| 1071 | 1085 | |
| 1072 | 1086 | // returning() is a SelectQueryBuilder: row-fetching methods exist and are typed |
| 1073 | 1087 | const insRow = await db.insertInto('users').values({ name: 'A' }).returning('id', 'email').first() |
| @@ -97,6 +97,32 @@ function renderBoundSqlExpression(expression: BoundSqlExpression, startIndex: nu | ||
| 97 | 97 | return { text, parameters } |
| 98 | 98 | } |
| 99 | 99 | |
| 100 | /** | |
| 101 | * Whether a call that takes one fragment was handed values it would drop. | |
| 102 | * | |
| 103 | * `undefined` and an empty array drop nothing, so a wrapper that forwards | |
| 104 | * optional bindings keeps working. So does a point-free collection callback — | |
| 105 | * `fragments.forEach(q.whereRaw)`, `columns.map(raw)`, `set.forEach(q.orderByRaw)` — | |
| 106 | * because its trailing arguments are the collection's `(index, array)`, | |
| 107 | * `(value, set)` or `(key, map)`, which point back at `first`; a real bindings | |
| 108 | * call cannot match that. A bare `(index)`, as `Array.from(xs, fn)` and iterator | |
| 109 | * helpers pass, can: it is indistinguishable from `whereRaw(frag, 0)`, so only | |
| 110 | * `raw()` exempts it, and only for a string with no `?`. | |
| 111 | * See stacksjs/bun-query-builder#1146. | |
| 112 | */ | |
| 113 | function dropsTrailingArguments(first: unknown, extra: readonly unknown[]): boolean { | |
| 114 | if (extra.length === 2) { | |
| 115 | const [key, collection] = extra | |
| 116 | if (Array.isArray(collection) && typeof key === 'number' && collection[key] === first) | |
| 117 | return false | |
| 118 | if (collection instanceof Set && key === first && collection.has(first)) | |
| 119 | return false | |
| 120 | if (collection instanceof Map && collection.get(key) === first) | |
| 121 | return false | |
| 122 | } | |
| 123 | return extra.some(arg => arg !== undefined && !(Array.isArray(arg) && arg.length === 0)) | |
| 124 | } | |
| 125 | ||
| 100 | 126 | /** |
| 101 | 127 | * # `raw` |
| 102 | 128 | * |
| @@ -123,9 +149,25 @@ function renderBoundSqlExpression(expression: BoundSqlExpression, startIndex: nu | ||
| 123 | 149 | * db.selectFrom('orders').whereRaw(raw`status = ${userStatus}`) // value escaped |
| 124 | 150 | * ``` |
| 125 | 151 | */ |
| 152 | export function raw(strings: TemplateStringsArray, ...values: unknown[]): RawExpression | |
| 153 | // eslint-disable-next-line pickier/no-unused-vars | |
| 154 | export function raw(sqlOrStrings: TemplateStringsArray | string): RawExpression | |
| 155 | // eslint-disable-next-line pickier/no-unused-vars | |
| 156 | export function raw(sql: string): RawExpression | |
| 126 | 157 | export function raw(strings: TemplateStringsArray | string, ...values: unknown[]): RawExpression { |
| 127 | if (typeof strings === 'string') | |
| 158 | if (typeof strings === 'string') { | |
| 159 | // `raw('x = ?', value)` type-checked and returned `{ raw: 'x = ?' }`, | |
| 160 | // dropping the value, which is the #1146 failure one call earlier. | |
| 161 | // `Array.from(columns, raw)` passes a bare index, harmless without a `?`. | |
| 162 | const isMapIndex = values.length === 1 && Number.isInteger(values[0]) && (values[0] as number) >= 0 && !strings.includes('?') | |
| 163 | if (!isMapIndex && dropsTrailingArguments(strings, values)) { | |
| 164 | throw new TypeError( | |
| 165 | '[query-builder] raw(string) takes no values, so the argument(s) after the string would be dropped. ' | |
| 166 | + 'Use raw as a tagged template instead, which interpolates each value as an escaped literal.', | |
| 167 | ) | |
| 168 | } | |
| 128 | 169 | return { raw: strings } |
| 170 | } | |
| 129 | 171 | let out = strings[0] |
| 130 | 172 | for (let i = 0; i < values.length; i++) |
| 131 | 173 | out += formatSubqueryValue(values[i]) + strings[i + 1] |
| @@ -410,8 +452,30 @@ function validateQualifiedIdentifier(value: unknown, context: string): void { | ||
| 410 | 452 | * so following the documented `sql\`...\`` path silently emitted |
| 411 | 453 | * "[object Promise]" into the SQL and failed at execution. Use the exported |
| 412 | 454 | * `raw` helper instead (it produces a `{ raw }` fragment). |
| 455 | * | |
| 456 | * `extra` is whatever the caller passed after the fragment. These methods take | |
| 457 | * no bindings, so the Knex/Laravel `whereRaw('LOWER(email) = ?', [email])` that | |
| 458 | * docs/guide/where.md used to show had its array silently dropped: the `?` went | |
| 459 | * out unbound, SQLite matched nothing, and Postgres raised a syntax error at | |
| 460 | * execution. That now throws here instead. See stacksjs/bun-query-builder#1146. | |
| 413 | 461 | */ |
| 414 | function renderRawFragment(fragment: unknown, context: string): string { | |
| 462 | function renderRawFragment(fragment: unknown, context: string, extra: readonly unknown[] = []): string { | |
| 463 | if (dropsTrailingArguments(fragment, extra)) { | |
| 464 | // The advice differs by clause: where()/orWhere() and the model builder's | |
| 465 | // bound whereRaw/orWhereRaw can stand in for a WHERE fragment. Nothing typed | |
| 466 | // stands in for the others — having()'s type only takes selected columns, | |
| 467 | // not the aggregates a HAVING fragment usually holds. | |
| 468 | const alternative = context.startsWith('whereRaw(') | |
| 469 | ? ', or use where(). Model.query().whereRaw(sql, ...bindings) is the builder that binds `?` values.' | |
| 470 | : context.startsWith('orWhereRaw(') | |
| 471 | ? ', or use orWhere(). Model.query().orWhereRaw(sql, ...bindings) is the builder that binds `?` values.' | |
| 472 | : '.' | |
| 473 | throw new TypeError( | |
| 474 | `[query-builder] ${context}: this method takes a single fragment and no bindings, so the ` | |
| 475 | + `argument(s) after it would be dropped. Interpolate values with the exported \`raw\` tagged ` | |
| 476 | + `template, which inlines them as escaped literals${alternative}`, | |
| 477 | ) | |
| 478 | } | |
| 415 | 479 | if (typeof fragment === 'string') { |
| 416 | 480 | warnOnceBareSqlFragment(context) |
| 417 | 481 | return fragment |
| @@ -4156,8 +4220,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 4156 | 4220 | built = null |
| 4157 | 4221 | return this as any |
| 4158 | 4222 | }, |
| 4159 | selectRaw(fragment: any) { | |
| 4160 | const frag = renderRawFragment(fragment, 'selectRaw(fragment)') | |
| 4223 | selectRaw(fragment: any, ...extra: unknown[]) { | |
| 4224 | const frag = renderRawFragment(fragment, 'selectRaw(fragment)', extra) | |
| 4161 | 4225 | // Insert raw fragment into SELECT list before FROM |
| 4162 | 4226 | const fromIdx = text.indexOf(' FROM ') |
| 4163 | 4227 | if (fromIdx !== -1) { |
| @@ -5476,12 +5540,12 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 5476 | 5540 | // A raw fragment is ONE term and is deliberately not auto-parenthesised — |
| 5477 | 5541 | // wrapping would churn emitted SQL for no correctness gain. A fragment |
| 5478 | 5542 | // containing a top-level OR must bracket itself; see docs/guide/where.md. |
| 5479 | whereRaw(fragment: any) { | |
| 5480 | pushWhere('AND', renderRawFragment(fragment, 'whereRaw(fragment)')) | |
| 5543 | whereRaw(fragment: any, ...extra: unknown[]) { | |
| 5544 | pushWhere('AND', renderRawFragment(fragment, 'whereRaw(fragment)', extra)) | |
| 5481 | 5545 | return this as any |
| 5482 | 5546 | }, |
| 5483 | orWhereRaw(fragment: any) { | |
| 5484 | pushWhere('OR', renderRawFragment(fragment, 'orWhereRaw(fragment)')) | |
| 5547 | orWhereRaw(fragment: any, ...extra: unknown[]) { | |
| 5548 | pushWhere('OR', renderRawFragment(fragment, 'orWhereRaw(fragment)', extra)) | |
| 5485 | 5549 | return this as any |
| 5486 | 5550 | }, |
| 5487 | 5551 | whereColumn(left: string, op: WhereOperator, right: string) { |
| @@ -5775,8 +5839,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 5775 | 5839 | } |
| 5776 | 5840 | return this as any |
| 5777 | 5841 | }, |
| 5778 | groupByRaw(fragment: any) { | |
| 5779 | const frag = renderRawFragment(fragment, 'groupByRaw(fragment)') | |
| 5842 | groupByRaw(fragment: any, ...extra: unknown[]) { | |
| 5843 | const frag = renderRawFragment(fragment, 'groupByRaw(fragment)', extra) | |
| 5780 | 5844 | text = SQL_PATTERNS.GROUP_BY.test(text) |
| 5781 | 5845 | ? `${text}, ${frag}` |
| 5782 | 5846 | : `${text} GROUP BY ${frag}` |
| @@ -5822,16 +5886,16 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 5822 | 5886 | } |
| 5823 | 5887 | return this as any |
| 5824 | 5888 | }, |
| 5825 | havingRaw(fragment: any) { | |
| 5826 | const frag = renderRawFragment(fragment, 'havingRaw(fragment)') | |
| 5889 | havingRaw(fragment: any, ...extra: unknown[]) { | |
| 5890 | const frag = renderRawFragment(fragment, 'havingRaw(fragment)', extra) | |
| 5827 | 5891 | const kw = hasHaving ? 'AND' : 'HAVING' |
| 5828 | 5892 | text += ` ${kw} ${frag}` |
| 5829 | 5893 | hasHaving = true |
| 5830 | 5894 | built = null |
| 5831 | 5895 | return this as any |
| 5832 | 5896 | }, |
| 5833 | orderByRaw(fragment: any) { | |
| 5834 | const frag = renderRawFragment(fragment, 'orderByRaw(fragment)') | |
| 5897 | orderByRaw(fragment: any, ...extra: unknown[]) { | |
| 5898 | const frag = renderRawFragment(fragment, 'orderByRaw(fragment)', extra) | |
| 5835 | 5899 | text = SQL_PATTERNS.ORDER_BY.test(text) |
| 5836 | 5900 | ? `${text}, ${frag}` |
| 5837 | 5901 | : `${text} ORDER BY ${frag}` |
| @@ -6748,8 +6812,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 6748 | 6812 | const cols = columns.map(String).join(', ') |
| 6749 | 6813 | return makeSub(baseText.replace(/^SELECT\s+/i, `SELECT DISTINCT ON (${cols}) `), terms, params, tail) as any |
| 6750 | 6814 | }, |
| 6751 | selectRaw(fragment: any) { | |
| 6752 | const frag = renderRawFragment(fragment, 'selectFromSub.selectRaw(fragment)') | |
| 6815 | selectRaw(fragment: any, ...extra: unknown[]) { | |
| 6816 | const frag = renderRawFragment(fragment, 'selectFromSub.selectRaw(fragment)', extra) | |
| 6753 | 6817 | const fromIdx = baseText.indexOf(' FROM ') |
| 6754 | 6818 | const newBase = fromIdx !== -1 |
| 6755 | 6819 | ? `${baseText.slice(0, fromIdx)}, ${frag}${baseText.slice(fromIdx)}` |
| @@ -98,6 +98,49 @@ function assertValidOrderByColumn(name: unknown, context: string): asserts name | ||
| 98 | 98 | throw new TypeError(`[bun-query-builder] ${context}: invalid ORDER BY column '${name}' — expected 'column' or 'table.column' of [A-Za-z0-9_] — refusing to interpolate into SQL`) |
| 99 | 99 | } |
| 100 | 100 | |
| 101 | /** | |
| 102 | * Resolve the bindings handed to `ModelQueryBuilder.whereRaw`/`orWhereRaw`. | |
| 103 | * | |
| 104 | * Both spellings are accepted: separate arguments, `whereRaw(sql, a, b)`, and | |
| 105 | * one array, `whereRaw(sql, [a, b])` — the Laravel/Knex form the docs taught. | |
| 106 | * The array form used to be stored as a single binding, `[[a, b]]`. bun:sqlite | |
| 107 | * took that as the whole bindings list only when it was the only value of a | |
| 108 | * read, threw once any other clause bound a value, and threw on every write | |
| 109 | * because `run()` does not spread. Bun.sql comma-joined it for a text parameter, | |
| 110 | * so one value passed and two failed with `insufficient data left in message`. | |
| 111 | * See stacksjs/bun-query-builder#1146. | |
| 112 | * | |
| 113 | * The array is copied, so mutating it after the call does not change the query. | |
| 114 | * | |
| 115 | * On Postgres the lone array used to reach Bun.sql whole, so the parameter's | |
| 116 | * type decided how it was sent, and some calls depended on that. Each element | |
| 117 | * is now bound exactly as if it had been passed separately: | |
| 118 | * | |
| 119 | * - an array meant as ONE json/jsonb value is wrapped, | |
| 120 | * `whereRaw('tags @> ?', [['bun']])`; unwrapped, `['bun']` now binds the | |
| 121 | * string `'bun'` where it used to bind the JSON array `["bun"]`. Arrays inside | |
| 122 | * the bindings are passed through untouched for this reason; | |
| 123 | * - a number, bigint or boolean compared with text needs a cast or a string, | |
| 124 | * `whereRaw("meta->>'id' = ?", [String(id)])`, where the whole array used to | |
| 125 | * arrive as text; | |
| 126 | * - `[null]` and `[undefined]` bind NULL, where they used to bind `''`. | |
| 127 | * | |
| 128 | * A lone `undefined` with no `?` in the fragment is no bindings: it is what a | |
| 129 | * wrapper such as `(sql, bindings?) => q.whereRaw(sql, bindings)` forwards, and | |
| 130 | * binding it would add a value no placeholder takes. With a `?` it stays a NULL | |
| 131 | * binding, as before. | |
| 132 | */ | |
| 133 | function resolveRawBindings(fragment: string, params: unknown[]): unknown[] { | |
| 134 | if (params.length !== 1) | |
| 135 | return params | |
| 136 | const only = params[0] | |
| 137 | if (Array.isArray(only)) | |
| 138 | return [...only] | |
| 139 | if (only === undefined && !fragment.includes('?')) | |
| 140 | return [] | |
| 141 | return params | |
| 142 | } | |
| 143 | ||
| 101 | 144 | // Lazy reference to model registry to avoid circular dependency |
| 102 | 145 | // eslint-disable-next-line pickier/no-unused-vars |
| 103 | 146 | let _getModel: ((name: string) => any) | null = null |
| @@ -2582,24 +2625,42 @@ class ModelQueryBuilder< | ||
| 2582 | 2625 | |
| 2583 | 2626 | /** |
| 2584 | 2627 | * Append a raw SQL fragment as a WHERE clause, with optional positional |
| 2585 | * parameters. Use this when you need a nested OR-group or any SQL the | |
| 2628 | * `?` bindings. Use this when you need a nested OR-group or any SQL the | |
| 2586 | 2629 | * builder doesn't expose directly. |
| 2587 | 2630 | * |
| 2631 | * Bindings may be passed as separate arguments or as one array; the two | |
| 2632 | * calls below are the same query. A lone array is always the bindings list, | |
| 2633 | * so on Postgres an array meant as one json/jsonb value is wrapped: | |
| 2634 | * `whereRaw('tags @> ?', [['bun']])`. | |
| 2635 | * | |
| 2588 | 2636 | * @example |
| 2589 | 2637 | * ```ts |
| 2590 | 2638 | * Car.query() |
| 2591 | 2639 | * .where('status', 'active') |
| 2592 | 2640 | * .whereRaw('(LOWER(make) LIKE ? OR LOWER(model) LIKE ?)', '%tesla%', '%tesla%') |
| 2593 | 2641 | * .get() |
| 2642 | * | |
| 2643 | * Car.query() | |
| 2644 | * .where('status', 'active') | |
| 2645 | * .whereRaw('(LOWER(make) LIKE ? OR LOWER(model) LIKE ?)', ['%tesla%', '%tesla%']) | |
| 2646 | * .get() | |
| 2594 | 2647 | * ``` |
| 2595 | 2648 | */ |
| 2649 | whereRaw(fragment: string, bindings: readonly unknown[]): ModelQueryBuilder<TDef, TSelected> | |
| 2650 | whereRaw(fragment: string, ...bindings: unknown[]): ModelQueryBuilder<TDef, TSelected> | |
| 2596 | 2651 | whereRaw(fragment: string, ...params: unknown[]): ModelQueryBuilder<TDef, TSelected> { |
| 2597 | this._wheres.push({ raw: fragment, rawParams: params, boolean: 'and' }) | |
| 2652 | this._wheres.push({ raw: fragment, rawParams: resolveRawBindings(fragment, params), boolean: 'and' }) | |
| 2598 | 2653 | return this |
| 2599 | 2654 | } |
| 2600 | 2655 | |
| 2656 | /** | |
| 2657 | * OR-connected counterpart of {@link whereRaw}, taking bindings the same two | |
| 2658 | * ways. | |
| 2659 | */ | |
| 2660 | orWhereRaw(fragment: string, bindings: readonly unknown[]): ModelQueryBuilder<TDef, TSelected> | |
| 2661 | orWhereRaw(fragment: string, ...bindings: unknown[]): ModelQueryBuilder<TDef, TSelected> | |
| 2601 | 2662 | orWhereRaw(fragment: string, ...params: unknown[]): ModelQueryBuilder<TDef, TSelected> { |
| 2602 | this._wheres.push({ raw: fragment, rawParams: params, boolean: 'or' }) | |
| 2663 | this._wheres.push({ raw: fragment, rawParams: resolveRawBindings(fragment, params), boolean: 'or' }) | |
| 2603 | 2664 | return this |
| 2604 | 2665 | } |
| 2605 | 2666 | |
| @@ -0,0 +1,167 @@ | ||
| 1 | /** | |
| 2 | * The select builder's `*Raw` methods take one fragment and no bindings — | |
| 3 | * stacksjs/bun-query-builder#1146. | |
| 4 | * | |
| 5 | * docs/guide/where.md and docs/api/reference.md showed | |
| 6 | * `db.selectFrom('users').whereRaw('LOWER(email) = ?', ['john@example.com'])`. | |
| 7 | * The TS signature rejects the second argument, but the runtime accepted it and | |
| 8 | * dropped it, so a JS or `as any` caller got `WHERE LOWER(email) = ?` with | |
| 9 | * nothing bound: | |
| 10 | * | |
| 11 | * - SQLite bound the `?` as NULL and returned `[]`, with no error; | |
| 12 | * - with another `where()` in front it threw `expected 2 values, received 1`; | |
| 13 | * - Postgres sent the bare `?` and raised `syntax error at end of input`. | |
| 14 | * | |
| 15 | * Now the call itself throws. `undefined` and an empty array drop nothing, so | |
| 16 | * those still pass, which keeps a wrapper that forwards optional bindings working. | |
| 17 | */ | |
| 18 | ||
| 19 | import { describe, expect, it } from 'bun:test' | |
| 20 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder, raw } from '../src' | |
| 21 | ||
| 22 | function qb(): any { | |
| 23 | const models = { | |
| 24 | users: { | |
| 25 | columns: { | |
| 26 | id: { type: 'integer', isPrimaryKey: true }, | |
| 27 | name: { type: 'text' }, | |
| 28 | team_id: { type: 'integer' }, | |
| 29 | }, | |
| 30 | }, | |
| 31 | } as any | |
| 32 | return createQueryBuilder<any>({ | |
| 33 | schema: buildDatabaseSchema(models), | |
| 34 | meta: buildSchemaMeta(models), | |
| 35 | }) | |
| 36 | } | |
| 37 | ||
| 38 | const REFUSED = /takes a single fragment and no bindings/ | |
| 39 | const text = (q: any): string => String(q.toSQL()) | |
| 40 | ||
| 41 | describe('select builder *Raw methods refuse bindings (#1146)', () => { | |
| 42 | it('whereRaw with the documented array form', () => { | |
| 43 | // The exact call the docs showed. | |
| 44 | expect(() => qb().selectFrom('users').whereRaw('LOWER(name) = ?', ['john'])).toThrow(REFUSED) | |
| 45 | expect(() => qb().selectFrom('users').whereRaw(raw('LOWER(name) = ?'), ['john'])).toThrow(TypeError) | |
| 46 | }) | |
| 47 | ||
| 48 | it('whereRaw with separate arguments', () => { | |
| 49 | expect(() => qb().selectFrom('users').whereRaw(raw('LOWER(name) = ?'), 'john')).toThrow(REFUSED) | |
| 50 | }) | |
| 51 | ||
| 52 | it('every other single-fragment *Raw method', () => { | |
| 53 | const users = () => qb().selectFrom('users') | |
| 54 | expect(() => users().orWhereRaw(raw('id = ?'), [1])).toThrow(/orWhereRaw\(fragment\)/) | |
| 55 | expect(() => users().selectRaw(raw('? as one'), [1])).toThrow(/selectRaw\(fragment\)/) | |
| 56 | expect(() => users().groupByRaw(raw('team_id + ?'), [1])).toThrow(/groupByRaw\(fragment\)/) | |
| 57 | expect(() => users().havingRaw(raw('count(*) > ?'), [1])).toThrow(/havingRaw\(fragment\)/) | |
| 58 | expect(() => users().orderByRaw(raw('id + ?'), [1])).toThrow(/orderByRaw\(fragment\)/) | |
| 59 | ||
| 60 | const sub = qb().selectFrom('users') | |
| 61 | expect(() => qb().selectFromSub(sub, 'u').selectRaw(raw('? as one'), 1)).toThrow(/selectFromSub\.selectRaw\(fragment\)/) | |
| 62 | }) | |
| 63 | ||
| 64 | it('points each clause at the alternatives that fit it', () => { | |
| 65 | const messageOf = (call: () => unknown): string => { | |
| 66 | try { | |
| 67 | call() | |
| 68 | } | |
| 69 | catch (e) { | |
| 70 | return (e as Error).message | |
| 71 | } | |
| 72 | return '' | |
| 73 | } | |
| 74 | const where = messageOf(() => qb().selectFrom('users').whereRaw(raw('id = ?'), [1])) | |
| 75 | expect(where).toContain('`raw`') | |
| 76 | expect(where).toContain('where()') | |
| 77 | expect(where).toContain('Model.query().whereRaw') | |
| 78 | ||
| 79 | // An OR fragment must not be steered to the AND-joining where(). | |
| 80 | const orWhere = messageOf(() => qb().selectFrom('users').orWhereRaw(raw('id = ?'), [1])) | |
| 81 | expect(orWhere).toContain('orWhere()') | |
| 82 | expect(orWhere).toContain('Model.query().orWhereRaw') | |
| 83 | expect(orWhere).not.toContain(' where()') | |
| 84 | ||
| 85 | // Nothing typed stands in for HAVING or ORDER BY fragments — having()'s type | |
| 86 | // takes selected columns, not aggregates — so only `raw` is offered. | |
| 87 | for (const call of [ | |
| 88 | () => qb().selectFrom('users').havingRaw(raw('count(*) > ?'), [1]), | |
| 89 | () => qb().selectFrom('users').orderByRaw(raw('id + ?'), [1]), | |
| 90 | ]) { | |
| 91 | const message = messageOf(call) | |
| 92 | expect(message).toContain('`raw`') | |
| 93 | expect(message).not.toContain('where()') | |
| 94 | expect(message).not.toContain('having()') | |
| 95 | expect(message).not.toContain('Model.query()') | |
| 96 | } | |
| 97 | }) | |
| 98 | ||
| 99 | it('accepts trailing arguments that drop nothing', () => { | |
| 100 | expect(text(qb().selectFrom('users').whereRaw(raw('id = 1'), undefined))).toContain('WHERE id = 1') | |
| 101 | expect(text(qb().selectFrom('users').whereRaw(raw('id = 1'), []))).toContain('WHERE id = 1') | |
| 102 | expect(text(qb().selectFrom('users').orderByRaw(raw('id desc'), undefined, []))).toContain('ORDER BY id desc') | |
| 103 | }) | |
| 104 | ||
| 105 | it('still refuses a value that follows an allowed undefined or []', () => { | |
| 106 | expect(() => qb().selectFrom('users').whereRaw(raw('id = ?'), undefined, [1])).toThrow(REFUSED) | |
| 107 | expect(() => qb().selectFrom('users').orderByRaw(raw('id'), [], 'desc')).toThrow(REFUSED) | |
| 108 | expect(() => qb().selectFrom('users').whereRaw(raw('id = ?'), 0)).toThrow(REFUSED) | |
| 109 | expect(() => qb().selectFrom('users').whereRaw(raw('id = ?'), null)).toThrow(REFUSED) | |
| 110 | }) | |
| 111 | ||
| 112 | it('does not mistake values that merely resemble an iteration callback', () => { | |
| 113 | const frag = raw('id = ?') | |
| 114 | expect(() => qb().selectFrom('users').whereRaw(frag, 0, ['x'])).toThrow(REFUSED) | |
| 115 | expect(() => qb().selectFrom('users').whereRaw(frag, 'x', new Set())).toThrow(REFUSED) | |
| 116 | expect(() => qb().selectFrom('users').orderByRaw(raw('id'), 'desc', new Map([['desc', 1]]))).toThrow(REFUSED) | |
| 117 | expect(() => qb().selectFrom('users').whereRaw(frag, 'x', 0, [frag])).toThrow(REFUSED) | |
| 118 | // A bare index is refused on the builder: it cannot be told from a binding. | |
| 119 | expect(() => Array.from([frag], qb().selectFrom('users').whereRaw)).toThrow(REFUSED) | |
| 120 | }) | |
| 121 | ||
| 122 | it('keeps point-free iteration callbacks working', () => { | |
| 123 | // The chain methods close over builder state, so forEach can pass one | |
| 124 | // unbound — and the iteration's (index, array) are not bindings. | |
| 125 | const q = qb().selectFrom('users') | |
| 126 | ;[raw('id > 1'), raw('team_id = 2')].forEach(q.whereRaw) | |
| 127 | expect(text(q)).toContain('WHERE id > 1 AND team_id = 2') | |
| 128 | ||
| 129 | const ordered = qb().selectFrom('users') | |
| 130 | new Set([raw('id desc')]).forEach(ordered.orderByRaw) | |
| 131 | expect(text(ordered)).toContain('ORDER BY id desc') | |
| 132 | ||
| 133 | const grouped = qb().selectFrom('users') | |
| 134 | new Map([['k', raw('team_id')]]).forEach(grouped.groupByRaw) | |
| 135 | expect(text(grouped)).toContain('GROUP BY team_id') | |
| 136 | }) | |
| 137 | ||
| 138 | it('a single fragment is unchanged', () => { | |
| 139 | expect(text(qb().selectFrom('users').whereRaw(raw`name = ${'Ada'}`))).toContain(`WHERE name = 'Ada'`) | |
| 140 | }) | |
| 141 | }) | |
| 142 | ||
| 143 | describe('raw(string) refuses values (#1146)', () => { | |
| 144 | // The quickest edit away from `whereRaw('x = ?', [v])` is | |
| 145 | // `whereRaw(raw('x = ?', v))`. That type-checked, returned `{ raw: 'x = ?' }` | |
| 146 | // and dropped the value, so the `?` still went out unbound. | |
| 147 | it('throws when a string is given values', () => { | |
| 148 | expect(() => (raw as any)('LOWER(name) = ?', 'john')).toThrow(/raw\(string\) takes no values/) | |
| 149 | expect(() => (raw as any)('LOWER(name) = ?', ['john'])).toThrow(TypeError) | |
| 150 | expect(() => (raw as any)('LOWER(name) = ?', null)).toThrow(TypeError) | |
| 151 | // An integer is a binding when the string has a placeholder for it. | |
| 152 | expect(() => (raw as any)('id = ?', 0)).toThrow(TypeError) | |
| 153 | }) | |
| 154 | ||
| 155 | it('the string and tagged forms are unchanged', () => { | |
| 156 | expect(raw('id = 1')).toEqual({ raw: 'id = 1' }) | |
| 157 | expect((raw as any)('id = 1', undefined)).toEqual({ raw: 'id = 1' }) | |
| 158 | expect(raw`name = ${"O'Brien"}`).toEqual({ raw: "name = 'O''Brien'" }) | |
| 159 | }) | |
| 160 | ||
| 161 | it('maps over strings point-free', () => { | |
| 162 | const want = [{ raw: 'id desc' }, { raw: 'name asc' }] | |
| 163 | expect(['id desc', 'name asc'].map(raw)).toEqual(want) | |
| 164 | // Array.from's mapper gets (value, index), with no collection to match. | |
| 165 | expect(Array.from(new Set(['id desc', 'name asc']), raw)).toEqual(want) | |
| 166 | }) | |
| 167 | }) | |
| @@ -0,0 +1,134 @@ | ||
| 1 | /** | |
| 2 | * `Model.query().whereRaw(sql, [a, b])` against a live Postgres — | |
| 3 | * stacksjs/bun-query-builder#1146. | |
| 4 | * | |
| 5 | * Postgres failed the array form differently from SQLite. Bun.sql's `unsafe()` | |
| 6 | * sends an array bound to a text parameter as its elements joined with commas, so a one-value | |
| 7 | * array reached the server as that value and looked fine, reads and writes | |
| 8 | * alike. Two values did not: `whereRaw('lower(name) = ? and country = ?', | |
| 9 | * ['ada', 'UK'])` put one comma-joined parameter against two placeholders and | |
| 10 | * failed with `insufficient data left in message` (08P01). A single `?` given | |
| 11 | * two values, `['ada', 'bob']`, bound the string `'ada,bob'` and returned no | |
| 12 | * rows without an error. | |
| 13 | * | |
| 14 | * Bun.sql encodes by the parameter's type, though: an array bound to a jsonb | |
| 15 | * parameter goes over as JSON. So `whereRaw('tags @> ?', ['bun'])` used to | |
| 16 | * compare against the array `["bun"]`, and now binds the string `"bun"` like | |
| 17 | * any other bindings list. The jsonb checks below pin both that change and the | |
| 18 | * spellings that still bind an array as one value. | |
| 19 | * | |
| 20 | * The SQLite suite (orm.where-raw-bindings.test.ts) asserts the bindings the | |
| 21 | * builder emits; this checks that the network driver executes them. | |
| 22 | * | |
| 23 | * Runs in a subprocess: an earlier configureOrm() in the same process pins the | |
| 24 | * model layer to sqlite and would mask the network-driver path. | |
| 25 | */ | |
| 26 | ||
| 27 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 28 | import { tmpdir } from 'node:os' | |
| 29 | import { join, resolve } from 'node:path' | |
| 30 | import process from 'node:process' | |
| 31 | import { describe, expect, it } from 'bun:test' | |
| 32 | import { PG_URL, probePostgres } from './pg' | |
| 33 | ||
| 34 | const pgAvailable = await probePostgres() | |
| 35 | ||
| 36 | describe.skipIf(!pgAvailable)('whereRaw array bindings against live Postgres (#1146)', () => { | |
| 37 | it('binds every value of the array form, on reads and writes', () => { | |
| 38 | const srcEntry = resolve(import.meta.dir, '../src/index.ts') | |
| 39 | const dir = mkdtempSync(join(tmpdir(), 'qb-wrb1146-')) | |
| 40 | const scriptPath = join(dir, 'probe.ts') | |
| 41 | ||
| 42 | writeFileSync(scriptPath, ` | |
| 43 | import { SQL } from 'bun' | |
| 44 | import { setConfig, resetConnection, defineModel, clearModelRegistry } from ${JSON.stringify(srcEntry)} | |
| 45 | ||
| 46 | const URL = ${JSON.stringify(PG_URL)} | |
| 47 | const failures = [] | |
| 48 | const check = (label, got, want) => { | |
| 49 | const g = JSON.stringify(got), w = JSON.stringify(want) | |
| 50 | if (g !== w) failures.push(label + ': got ' + g + ', want ' + w) | |
| 51 | } | |
| 52 | const attempt = async (label, fn) => { | |
| 53 | try { return await fn() } | |
| 54 | catch (e) { failures.push(label + ': threw ' + (e && e.message)); return undefined } | |
| 55 | } | |
| 56 | ||
| 57 | const raw = new SQL(URL) | |
| 58 | const seed = async () => { | |
| 59 | await raw.unsafe('DROP TABLE IF EXISTS _qb_wrb1146') | |
| 60 | await raw.unsafe('CREATE TABLE _qb_wrb1146 (id int primary key, name text not null, country text not null, score int not null default 0, tags jsonb not null)') | |
| 61 | await raw.unsafe(\`INSERT INTO _qb_wrb1146 (id, name, country, tags) VALUES | |
| 62 | (1, 'Ada', 'US', '["bun","sql"]'), (2, 'ada', 'UK', '["node"]'), (3, 'Grace', 'US', '"bun"')\`) | |
| 63 | } | |
| 64 | const idsLeft = async () => (await raw.unsafe('SELECT id FROM _qb_wrb1146 ORDER BY id')).map(r => Number(r.id)) | |
| 65 | ||
| 66 | setConfig({ dialect: 'postgres', database: { url: URL } }) | |
| 67 | resetConnection() | |
| 68 | clearModelRegistry() | |
| 69 | const User = defineModel({ name: 'Wrbpguser', table: '_qb_wrb1146', primaryKey: 'id', | |
| 70 | attributes: { name: { type: 'string', fillable: true }, country: { type: 'string', fillable: true }, | |
| 71 | score: { type: 'number', fillable: true }, tags: { type: 'json', fillable: true } } }) | |
| 72 | ||
| 73 | const ids = rows => (rows ?? []).map(r => Number(r.id ?? r.get('id'))).sort((a, b) => a - b) | |
| 74 | ||
| 75 | await seed() | |
| 76 | // Two values from one array. Threw 08P01 while #1146 was live. | |
| 77 | check('two-value array read', ids(await attempt('two-value array read', () => | |
| 78 | User.query().whereRaw('lower(name) = ? and country = ?', ['ada', 'UK']).get())), [2]) | |
| 79 | check('two-value array + where', ids(await attempt('two-value array + where', () => | |
| 80 | User.query().where('id', 2).whereRaw('lower(name) = ? and country = ?', ['ada', 'UK']).get())), [2]) | |
| 81 | check('array + where, count', await attempt('array + where, count', () => | |
| 82 | User.query().whereRaw('lower(name) = ?', ['ada']).where('country', 'US').count()), 1) | |
| 83 | check('orWhereRaw array', ids(await attempt('orWhereRaw array', () => | |
| 84 | User.query().where('country', 'UK').orWhereRaw('lower(name) = ? or id = ?', ['grace', 1]).get())), [1, 2, 3]) | |
| 85 | check('empty array + where', ids(await attempt('empty array + where', () => | |
| 86 | User.query().whereRaw('1 = 1', []).where('country', 'US').get())), [1, 3]) | |
| 87 | ||
| 88 | // jsonb. Bun.sql sends an array bound to a jsonb parameter as JSON, so an | |
| 89 | // array meant as one value is wrapped, or passed among separate arguments. | |
| 90 | check('jsonb wrapped array', ids(await attempt('jsonb wrapped array', () => | |
| 91 | User.query().whereRaw('tags @> ?', [['bun']]).get())), [1]) | |
| 92 | check('jsonb array among separate arguments', ids(await attempt('jsonb separate', () => | |
| 93 | User.query().whereRaw('country = ? and tags @> ?', 'US', ['sql']).get())), [1]) | |
| 94 | check('jsonb array inside a bindings list', ids(await attempt('jsonb list', () => | |
| 95 | User.query().whereRaw('country = ? and tags @> ?', ['US', ['bun']]).get())), [1]) | |
| 96 | check('jsonb array first among separate arguments', ids(await attempt('jsonb first', () => | |
| 97 | User.query().whereRaw('tags @> ? and country = ?', ['bun'], 'US').get())), [1]) | |
| 98 | // The documented behaviour change: a bare array is the bindings list, so this | |
| 99 | // binds the JSON string "bun" (contained by row 1's array and equal to row 3), | |
| 100 | // where it used to bind the array ["bun"] and match row 1 only. | |
| 101 | check('jsonb bare array is the bindings list', ids(await attempt('jsonb bare', () => | |
| 102 | User.query().whereRaw('tags @> ?', ['bun']).get())), [1, 3]) | |
| 103 | ||
| 104 | // Writes: the placeholders are renumbered after update()'s SET values. | |
| 105 | check('update affected', await attempt('update', () => | |
| 106 | User.query().whereRaw('lower(name) = ? and country = ?', ['ada', 'UK']).update({ country: 'CA' })), 1) | |
| 107 | check('update landed', (await raw.unsafe('SELECT country FROM _qb_wrb1146 WHERE id = 2'))[0].country, 'CA') | |
| 108 | await attempt('increment', () => User.query().whereRaw('id = ? or id = ?', [1, 3]).increment('score', 4)) | |
| 109 | check('increment landed', (await raw.unsafe('SELECT score FROM _qb_wrb1146 ORDER BY id')).map(r => Number(r.score)), [4, 0, 4]) | |
| 110 | check('delete affected', await attempt('delete', () => | |
| 111 | User.query().where('country', 'US').whereRaw('lower(name) = ? or lower(name) = ?', ['grace', 'nobody']).delete()), 1) | |
| 112 | check('delete landed', await idsLeft(), [1, 2]) | |
| 113 | ||
| 114 | await raw.unsafe('DROP TABLE IF EXISTS _qb_wrb1146') | |
| 115 | await raw.end() | |
| 116 | ||
| 117 | if (failures.length) { | |
| 118 | console.error('FAILURES:\\n' + failures.join('\\n')) | |
| 119 | process.exit(1) | |
| 120 | } | |
| 121 | console.log('OK') | |
| 122 | process.exit(0) | |
| 123 | `) | |
| 124 | ||
| 125 | const proc = Bun.spawnSync({ cmd: ['bun', scriptPath], stdout: 'pipe', stderr: 'pipe', cwd: process.cwd(), env: { ...process.env } }) | |
| 126 | const dec = new TextDecoder() | |
| 127 | const out = dec.decode(proc.stdout).trim() | |
| 128 | const err = dec.decode(proc.stderr).trim() | |
| 129 | rmSync(dir, { recursive: true, force: true }) | |
| 130 | ||
| 131 | expect(proc.exitCode, `whereRaw array bindings pg probe failed.\nstdout: ${out}\nstderr: ${err}`).toBe(0) | |
| 132 | expect(out).toContain('OK') | |
| 133 | }, 30_000) | |
| 134 | }) | |
| @@ -0,0 +1,229 @@ | ||
| 1 | /** | |
| 2 | * `Model.query().whereRaw(sql, [value])` — stacksjs/bun-query-builder#1146. | |
| 3 | * | |
| 4 | * `whereRaw`/`orWhereRaw` were declared `(fragment, ...params)` and stored the | |
| 5 | * rest array as-is, so the Knex/Laravel array form the docs taught stored | |
| 6 | * `rawParams = [[value]]`. `buildWhereClauses` spreads one level, which left the | |
| 7 | * array as ONE binding, and bun:sqlite read that three ways: | |
| 8 | * | |
| 9 | * - a read whose only binding it was: `.all([value])` takes a lone array as | |
| 10 | * the whole bindings list, so it worked by accident; | |
| 11 | * - a read with any other binding: `expected 2 values, received 1` when the | |
| 12 | * array came first, `Binding expected string, …` when it did not; | |
| 13 | * - every write: `SqliteExecutor.run` passes params unspread, so `delete()`, | |
| 14 | * `update()` and `increment()` threw even with a single binding. | |
| 15 | * | |
| 16 | * It passed every check that used no second filter and returned 500 in | |
| 17 | * production once a country filter added one. The empty array was worse than a | |
| 18 | * throw: `whereRaw('1 = 1', []).where('country', 'US')` bound nothing to | |
| 19 | * `country = ?`, which SQLite reads as NULL, and returned no rows. | |
| 20 | * | |
| 21 | * Most assertions execute, because `toSql()` printed `[["ada"]]` throughout and | |
| 22 | * nobody read it as wrong. The SQL text was always right; only the bindings | |
| 23 | * were not. | |
| 24 | */ | |
| 25 | ||
| 26 | import { Database } from 'bun:sqlite' | |
| 27 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 28 | import { clearModelRegistry, configureOrm, createModel } from '../src' | |
| 29 | ||
| 30 | let db: Database | |
| 31 | let User: any | |
| 32 | ||
| 33 | const LOWER_NAME = 'LOWER(name) = ?' | |
| 34 | ||
| 35 | const ids = (rows: any[]): number[] => rows.map(r => Number(r.id ?? r.get?.('id'))).sort((a, b) => a - b) | |
| 36 | const remaining = (): number[] => | |
| 37 | (db.query('SELECT id FROM wrb_users ORDER BY id').all() as { id: number }[]).map(r => r.id) | |
| 38 | const scoreOf = (id: number): number => | |
| 39 | (db.query('SELECT score FROM wrb_users WHERE id = ?').get(id) as { score: number }).score | |
| 40 | ||
| 41 | beforeEach(() => { | |
| 42 | clearModelRegistry() | |
| 43 | db = new Database(':memory:', { create: true }) | |
| 44 | db.run(`CREATE TABLE wrb_users ( | |
| 45 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 46 | name TEXT NOT NULL, | |
| 47 | country TEXT NOT NULL, | |
| 48 | score INTEGER NOT NULL DEFAULT 0 | |
| 49 | )`) | |
| 50 | db.run(`INSERT INTO wrb_users (id, name, country) VALUES (1, 'Ada', 'US'), (2, 'ada', 'UK'), (3, 'Grace', 'US')`) | |
| 51 | configureOrm({ database: db }) | |
| 52 | User = createModel({ | |
| 53 | name: 'WrbUser', | |
| 54 | table: 'wrb_users', | |
| 55 | primaryKey: 'id', | |
| 56 | attributes: { | |
| 57 | name: { type: 'string', fillable: true }, | |
| 58 | country: { type: 'string', fillable: true }, | |
| 59 | score: { type: 'number', fillable: true }, | |
| 60 | }, | |
| 61 | } as any) | |
| 62 | }) | |
| 63 | ||
| 64 | afterEach(() => { | |
| 65 | clearModelRegistry() | |
| 66 | db.close() | |
| 67 | }) | |
| 68 | ||
| 69 | describe('whereRaw bindings passed as one array (#1146)', () => { | |
| 70 | it('builds the same flat bindings as separate arguments', () => { | |
| 71 | const asArray = User.query().whereRaw(LOWER_NAME, ['ada']).where('country', 'US').toSql() | |
| 72 | const asArgs = User.query().whereRaw(LOWER_NAME, 'ada').where('country', 'US').toSql() | |
| 73 | ||
| 74 | // Was `[["ada"],"US"]`. | |
| 75 | expect(asArray.params).toEqual(['ada', 'US']) | |
| 76 | expect(asArray).toEqual(asArgs) | |
| 77 | ||
| 78 | const multi = User.query().whereRaw('LOWER(name) = ? AND country = ?', ['ada', 'UK']).toSql() | |
| 79 | expect(multi.params).toEqual(['ada', 'UK']) | |
| 80 | }) | |
| 81 | ||
| 82 | it('reads with no other binding (the case that worked by accident)', async () => { | |
| 83 | expect(ids(await User.query().whereRaw(LOWER_NAME, ['ada']).get())).toEqual([1, 2]) | |
| 84 | }) | |
| 85 | ||
| 86 | it('reads when another clause binds after it', async () => { | |
| 87 | // Threw `SQLite query expected 2 values, received 1`. | |
| 88 | expect(ids(await User.query().whereRaw(LOWER_NAME, ['ada']).where('country', 'US').get())).toEqual([1]) | |
| 89 | }) | |
| 90 | ||
| 91 | it('reads when another clause binds before it', async () => { | |
| 92 | // Threw `TypeError: Binding expected string, TypedArray, …`. | |
| 93 | expect(ids(await User.query().where('country', 'US').whereRaw(LOWER_NAME, ['ada']).get())).toEqual([1]) | |
| 94 | }) | |
| 95 | ||
| 96 | it('works on the aggregate and paging terminals', async () => { | |
| 97 | const q = () => User.query().where('country', 'US').whereRaw(LOWER_NAME, ['ada']) | |
| 98 | expect(await q().count()).toBe(1) | |
| 99 | expect(await q().exists()).toBe(true) | |
| 100 | expect(await q().pluck('name')).toEqual(['Ada']) | |
| 101 | const page = await q().paginate(1, 10) | |
| 102 | expect(page.data.length).toBe(1) | |
| 103 | }) | |
| 104 | ||
| 105 | it('orWhereRaw takes the array form too', async () => { | |
| 106 | const rows = await User.query().where('country', 'UK').orWhereRaw(LOWER_NAME, ['grace']).get() | |
| 107 | expect(ids(rows)).toEqual([2, 3]) | |
| 108 | }) | |
| 109 | ||
| 110 | it('carries through whereGroup into the outer query', async () => { | |
| 111 | const rows = await User.query() | |
| 112 | .whereGroup((b: any) => b.whereRaw(LOWER_NAME, ['ada'])) | |
| 113 | .where('country', 'US') | |
| 114 | .get() | |
| 115 | expect(ids(rows)).toEqual([1]) | |
| 116 | }) | |
| 117 | ||
| 118 | it('binds several values from one array', async () => { | |
| 119 | const rows = await User.query().whereRaw('LOWER(name) = ? AND country = ?', ['ada', 'UK']).where('id', 2).get() | |
| 120 | // Threw `expected 3 values, received 2`. | |
| 121 | expect(ids(rows)).toEqual([2]) | |
| 122 | }) | |
| 123 | }) | |
| 124 | ||
| 125 | describe('whereRaw array bindings on writes (#1146)', () => { | |
| 126 | it('delete() with the array as the only binding', async () => { | |
| 127 | // Threw `Binding expected …` and deleted nothing. | |
| 128 | expect(await User.query().whereRaw(LOWER_NAME, ['grace']).delete()).toBe(1) | |
| 129 | expect(remaining()).toEqual([1, 2]) | |
| 130 | }) | |
| 131 | ||
| 132 | it('delete() with another binding', async () => { | |
| 133 | expect(await User.query().where('country', 'UK').whereRaw(LOWER_NAME, ['ada']).delete()).toBe(1) | |
| 134 | expect(remaining()).toEqual([1, 3]) | |
| 135 | }) | |
| 136 | ||
| 137 | it('update() puts its SET values ahead of the raw bindings', async () => { | |
| 138 | expect(await User.query().whereRaw(LOWER_NAME, ['ada']).where('country', 'UK').update({ country: 'CA' })).toBe(1) | |
| 139 | const row = db.query('SELECT country FROM wrb_users WHERE id = 2').get() as { country: string } | |
| 140 | expect(row.country).toBe('CA') | |
| 141 | }) | |
| 142 | ||
| 143 | it('increment() and decrement()', async () => { | |
| 144 | await User.query().whereRaw(LOWER_NAME, ['grace']).increment('score', 5) | |
| 145 | await User.query().where('country', 'US').whereRaw(LOWER_NAME, ['ada']).decrement('score', 2) | |
| 146 | expect(scoreOf(3)).toBe(5) | |
| 147 | expect(scoreOf(1)).toBe(-2) | |
| 148 | expect(scoreOf(2)).toBe(0) | |
| 149 | }) | |
| 150 | }) | |
| 151 | ||
| 152 | describe('whereRaw with an empty bindings array (#1146)', () => { | |
| 153 | it('reads return the rows the other clauses select', async () => { | |
| 154 | // Returned [] silently: `country = ?` was bound to nothing, i.e. NULL. | |
| 155 | expect(ids(await User.query().whereRaw('1 = 1', []).where('country', 'US').get())).toEqual([1, 3]) | |
| 156 | expect(await User.query().whereRaw('1 = 1', []).where('country', 'US').count()).toBe(2) | |
| 157 | }) | |
| 158 | ||
| 159 | it('writes run', async () => { | |
| 160 | // Threw `expected 0 values, received 1`. | |
| 161 | expect(await User.query().whereRaw('id = 3', []).delete()).toBe(1) | |
| 162 | expect(remaining()).toEqual([1, 2]) | |
| 163 | }) | |
| 164 | }) | |
| 165 | ||
| 166 | describe('whereRaw array values inside the bindings (#1146)', () => { | |
| 167 | // Postgres binds an array to a json/jsonb parameter as JSON, so an array | |
| 168 | // meant as ONE value has to survive: wrapped in the bindings array, or passed | |
| 169 | // among separate arguments. orm.where-raw-bindings.pg.test.ts executes these. | |
| 170 | it('a wrapped array stays one binding', () => { | |
| 171 | expect(User.query().whereRaw('tags @> ?', [['bun']]).toSql().params).toEqual([['bun']]) | |
| 172 | expect(User.query().whereRaw('country = ? AND tags @> ?', ['US', ['bun']]).toSql().params).toEqual(['US', ['bun']]) | |
| 173 | }) | |
| 174 | ||
| 175 | it('an array among separate arguments is unchanged', () => { | |
| 176 | expect(User.query().whereRaw('country = ? AND tags @> ?', 'US', ['bun']).toSql().params).toEqual(['US', ['bun']]) | |
| 177 | // Only a LONE array is the bindings list, not one that merely comes first. | |
| 178 | expect(User.query().whereRaw('tags @> ? AND country = ?', ['bun'], 'US').toSql().params).toEqual([['bun'], 'US']) | |
| 179 | }) | |
| 180 | ||
| 181 | it('does not treat a typed array as a bindings list', async () => { | |
| 182 | const blob = new Uint8Array([65, 100, 97]) // 'Ada' | |
| 183 | expect(ids(await User.query().whereRaw('CAST(name AS BLOB) = ?', blob).get())).toEqual([1]) | |
| 184 | expect(ids(await User.query().whereRaw('CAST(name AS BLOB) = ?', [blob]).get())).toEqual([1]) | |
| 185 | }) | |
| 186 | }) | |
| 187 | ||
| 188 | describe('whereRaw bindings are captured at the call (#1146)', () => { | |
| 189 | it('mutating the array afterwards does not change the query', async () => { | |
| 190 | const bindings = ['grace'] | |
| 191 | const q = User.query().whereRaw(LOWER_NAME, bindings) | |
| 192 | bindings[0] = 'ada' | |
| 193 | bindings.push('extra') | |
| 194 | expect(q.toSql().params).toEqual(['grace']) | |
| 195 | expect(ids(await q.get())).toEqual([3]) | |
| 196 | }) | |
| 197 | ||
| 198 | it('binds the values of a frozen array', async () => { | |
| 199 | // Before the fix this array reached bun:sqlite whole, as `.all(frozen)`, | |
| 200 | // which segfaults Bun 1.3.14. Its values are now spread into the bindings. | |
| 201 | const rows = await User.query().whereRaw(LOWER_NAME, Object.freeze(['ada'])).get() | |
| 202 | expect(ids(rows)).toEqual([1, 2]) | |
| 203 | }) | |
| 204 | ||
| 205 | it('a fragment with no bindings is unchanged', async () => { | |
| 206 | expect(User.query().whereRaw('id = 3').toSql().params).toEqual([]) | |
| 207 | expect(await User.query().whereRaw('id = 3').delete()).toBe(1) | |
| 208 | }) | |
| 209 | }) | |
| 210 | ||
| 211 | describe('whereRaw forwarded an undefined bindings argument (#1146)', () => { | |
| 212 | // What `(sql, bindings?) => q.whereRaw(sql, bindings)` passes when called | |
| 213 | // without bindings. It was bound as a value, which failed exactly like the | |
| 214 | // array form: fine alone, a mismatch beside another binding, a throw on writes. | |
| 215 | const forward = (sql: string, bindings?: unknown[]) => User.query().whereRaw(sql, bindings) | |
| 216 | ||
| 217 | it('is no bindings when the fragment has no placeholder', async () => { | |
| 218 | expect(forward('1 = 1').where('country', 'US').toSql().params).toEqual(['US']) | |
| 219 | expect(ids(await forward('1 = 1').where('country', 'US').get())).toEqual([1, 3]) | |
| 220 | expect(await forward('id = 3').delete()).toBe(1) | |
| 221 | expect(User.query().where('id', 1).orWhereRaw('id = 2', undefined).toSql().params).toEqual([1]) | |
| 222 | }) | |
| 223 | ||
| 224 | it('stays a NULL binding when the fragment has one', async () => { | |
| 225 | const q = User.query().whereRaw('? IS NULL', undefined).where('country', 'UK') | |
| 226 | expect(q.toSql().params).toEqual([undefined, 'UK']) | |
| 227 | expect(ids(await q.get())).toEqual([2]) | |
| 228 | }) | |
| 229 | }) | |