ReviewOS

also looking at this

stacks/bun-query-builder

fix(orm)!: accept whereRaw bindings as one array

#1148
Merged glennmichael123 wants to merge fix/1146-where-raw-array-bindings into main
10 files +742 -27
docs/api/reference.mdmodified+1-1
Changes to docs/api/reference.md
@@ -67,7 +67,7 @@ const meta = buildSchemaMeta(models)
6767| `whereNull(col)` | WHERE IS NULL | `.whereNull('deleted_at')` |
6868| `whereNotNull(col)` | WHERE IS NOT NULL | `.whereNotNull('email')` |
6969| `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}`)`` |
7171
7272### JOIN Operations
7373
docs/guide/where.mdmodified+45-4
Changes to docs/guide/where.md
@@ -184,17 +184,58 @@ const modified = await db
184184
185185### whereRaw
186186
187For complex conditions:
187For complex conditions. On `db.selectFrom(...)`, `whereRaw` takes a single SQL
188fragment and no bindings. Build the fragment with the `raw` tagged template,
189which inlines each interpolated value as an escaped literal:
188190
189191```typescript
192import { raw } from 'bun-query-builder'
193
194const email = 'john@example.com'
190195const result = await db
191196 .selectFrom('users')
192 .whereRaw('LOWER(email) = ?', ['john@example.com'])
197 .whereRaw(raw`LOWER(email) = ${email}`)
193198 .get()
194199
195const recent = await db
200const live = await db
196201 .selectFrom('orders')
197 .whereRaw('created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)')
202 .whereRaw(raw('deleted_at IS NULL'))
203 .get()
204```
205
206Passing values after the fragment throws, rather than sending an unbound `?`.
207So does `raw('...', value)`: the plain-string form takes no values. Values are
208inlined, not bound, so prefer `where()` for request input.
209
210Model queries do bind `?` placeholders. `Model.query().whereRaw()` and
211`orWhereRaw()` take the bindings either as separate arguments or as one array,
212and the two spellings build the same query:
213
214```typescript
215const active = await User.query()
216 .whereRaw('LOWER(email) = ?', 'john@example.com')
217 .where('active', true)
218 .get()
219
220const same = await User.query()
221 .whereRaw('LOWER(email) = ?', ['john@example.com'])
222 .where('active', true)
223 .get()
224```
225
226A lone array is always the bindings list, and each of its values is bound just
227as 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"]'
237const tagged = await Post.query()
238 .whereRaw('tags @> ?', [['bun']])
198239 .get()
199240```
200241
netlify.tomladded+5-0
Changes to netlify.toml
@@ -0,0 +1,5 @@
1# The docs moved from VitePress to BunPress, but the build still ran
2# `vitepress`, which is not installed, so every deploy preview failed.
3[build]
4 command = "bun run build:docs"
5 publish = "dist/.bunpress"
package.jsonmodifiedrenamed throughout+3-3
Changes to package.json
@@ -69,9 +69,9 @@
6969 "release:patch": "bun x --bun bumpx patch --recursive --commit --tag --push --yes",
7070 "release:minor": "bun x --bun bumpx minor --recursive --commit --tag --push --yes",
7171 "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",
7575 "typecheck": "bun --bun tsc"
7676 },
7777 "dependencies": {
packages/bun-query-builder/src/__tests__/type-usage-compile.tsmodified+14-0
Changes to packages/bun-query-builder/src/__tests__/type-usage-compile.ts
@@ -234,6 +234,14 @@ async function _ormUsage() {
234234 Member.whereBetween('age', [18, 99])
235235 Member.whereNotBetween('karma', [0, 10])
236236
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
237245 // @ts-expect-error — unknown column
238246 Member.where('handle', 'x')
239247 // @ts-expect-error — value must match the column type (age: number)
@@ -1068,6 +1076,12 @@ async function _rawAndReturning(
10681076
10691077 // @ts-expect-error — a bare string is rejected by the SqlFragment type
10701078 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])
10711085
10721086 // returning() is a SelectQueryBuilder: row-fetching methods exist and are typed
10731087 const insRow = await db.insertInto('users').values({ name: 'A' }).returning('id', 'email').first()
packages/bun-query-builder/src/client.tsmodified+80-16
Changes to packages/bun-query-builder/src/client.ts
@@ -97,6 +97,32 @@ function renderBoundSqlExpression(expression: BoundSqlExpression, startIndex: nu
9797 return { text, parameters }
9898}
9999
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 */
113function 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
100126/**
101127 * # `raw`
102128 *
@@ -123,9 +149,25 @@ function renderBoundSqlExpression(expression: BoundSqlExpression, startIndex: nu
123149 * db.selectFrom('orders').whereRaw(raw`status = ${userStatus}`) // value escaped
124150 * ```
125151 */
152export function raw(strings: TemplateStringsArray, ...values: unknown[]): RawExpression
153// eslint-disable-next-line pickier/no-unused-vars
154export function raw(sqlOrStrings: TemplateStringsArray | string): RawExpression
155// eslint-disable-next-line pickier/no-unused-vars
156export function raw(sql: string): RawExpression
126157export 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 }
128169 return { raw: strings }
170 }
129171 let out = strings[0]
130172 for (let i = 0; i < values.length; i++)
131173 out += formatSubqueryValue(values[i]) + strings[i + 1]
@@ -410,8 +452,30 @@ function validateQualifiedIdentifier(value: unknown, context: string): void {
410452 * so following the documented `sql\`...\`` path silently emitted
411453 * "[object Promise]" into the SQL and failed at execution. Use the exported
412454 * `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.
413461 */
414function renderRawFragment(fragment: unknown, context: string): string {
462function 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 }
415479 if (typeof fragment === 'string') {
416480 warnOnceBareSqlFragment(context)
417481 return fragment
@@ -4156,8 +4220,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
41564220 built = null
41574221 return this as any
41584222 },
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)
41614225 // Insert raw fragment into SELECT list before FROM
41624226 const fromIdx = text.indexOf(' FROM ')
41634227 if (fromIdx !== -1) {
@@ -5476,12 +5540,12 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
54765540 // A raw fragment is ONE term and is deliberately not auto-parenthesised —
54775541 // wrapping would churn emitted SQL for no correctness gain. A fragment
54785542 // 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))
54815545 return this as any
54825546 },
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))
54855549 return this as any
54865550 },
54875551 whereColumn(left: string, op: WhereOperator, right: string) {
@@ -5775,8 +5839,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
57755839 }
57765840 return this as any
57775841 },
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)
57805844 text = SQL_PATTERNS.GROUP_BY.test(text)
57815845 ? `${text}, ${frag}`
57825846 : `${text} GROUP BY ${frag}`
@@ -5822,16 +5886,16 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
58225886 }
58235887 return this as any
58245888 },
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)
58275891 const kw = hasHaving ? 'AND' : 'HAVING'
58285892 text += ` ${kw} ${frag}`
58295893 hasHaving = true
58305894 built = null
58315895 return this as any
58325896 },
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)
58355899 text = SQL_PATTERNS.ORDER_BY.test(text)
58365900 ? `${text}, ${frag}`
58375901 : `${text} ORDER BY ${frag}`
@@ -6748,8 +6812,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
67486812 const cols = columns.map(String).join(', ')
67496813 return makeSub(baseText.replace(/^SELECT\s+/i, `SELECT DISTINCT ON (${cols}) `), terms, params, tail) as any
67506814 },
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)
67536817 const fromIdx = baseText.indexOf(' FROM ')
67546818 const newBase = fromIdx !== -1
67556819 ? `${baseText.slice(0, fromIdx)}, ${frag}${baseText.slice(fromIdx)}`
packages/bun-query-builder/src/orm.tsmodified+64-3
Changes to packages/bun-query-builder/src/orm.ts
@@ -98,6 +98,49 @@ function assertValidOrderByColumn(name: unknown, context: string): asserts name
9898 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`)
9999}
100100
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 */
133function 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
101144// Lazy reference to model registry to avoid circular dependency
102145// eslint-disable-next-line pickier/no-unused-vars
103146let _getModel: ((name: string) => any) | null = null
@@ -2582,24 +2625,42 @@ class ModelQueryBuilder<
25822625
25832626 /**
25842627 * 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
25862629 * builder doesn't expose directly.
25872630 *
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 *
25882636 * @example
25892637 * ```ts
25902638 * Car.query()
25912639 * .where('status', 'active')
25922640 * .whereRaw('(LOWER(make) LIKE ? OR LOWER(model) LIKE ?)', '%tesla%', '%tesla%')
25932641 * .get()
2642 *
2643 * Car.query()
2644 * .where('status', 'active')
2645 * .whereRaw('(LOWER(make) LIKE ? OR LOWER(model) LIKE ?)', ['%tesla%', '%tesla%'])
2646 * .get()
25942647 * ```
25952648 */
2649 whereRaw(fragment: string, bindings: readonly unknown[]): ModelQueryBuilder<TDef, TSelected>
2650 whereRaw(fragment: string, ...bindings: unknown[]): ModelQueryBuilder<TDef, TSelected>
25962651 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' })
25982653 return this
25992654 }
26002655
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>
26012662 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' })
26032664 return this
26042665 }
26052666
packages/bun-query-builder/test/client.raw-bindings-refused.test.tsadded+167-0
Changes to packages/bun-query-builder/test/client.raw-bindings-refused.test.ts
@@ -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
19import { describe, expect, it } from 'bun:test'
20import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder, raw } from '../src'
21
22function 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
38const REFUSED = /takes a single fragment and no bindings/
39const text = (q: any): string => String(q.toSQL())
40
41describe('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
143describe('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})
packages/bun-query-builder/test/orm.where-raw-bindings.pg.test.tsadded+134-0
Changes to packages/bun-query-builder/test/orm.where-raw-bindings.pg.test.ts
@@ -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
27import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
28import { tmpdir } from 'node:os'
29import { join, resolve } from 'node:path'
30import process from 'node:process'
31import { describe, expect, it } from 'bun:test'
32import { PG_URL, probePostgres } from './pg'
33
34const pgAvailable = await probePostgres()
35
36describe.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, `
43import { SQL } from 'bun'
44import { setConfig, resetConnection, defineModel, clearModelRegistry } from ${JSON.stringify(srcEntry)}
45
46const URL = ${JSON.stringify(PG_URL)}
47const failures = []
48const 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}
52const attempt = async (label, fn) => {
53 try { return await fn() }
54 catch (e) { failures.push(label + ': threw ' + (e && e.message)); return undefined }
55}
56
57const raw = new SQL(URL)
58const 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}
64const idsLeft = async () => (await raw.unsafe('SELECT id FROM _qb_wrb1146 ORDER BY id')).map(r => Number(r.id))
65
66setConfig({ dialect: 'postgres', database: { url: URL } })
67resetConnection()
68clearModelRegistry()
69const 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
73const ids = rows => (rows ?? []).map(r => Number(r.id ?? r.get('id'))).sort((a, b) => a - b)
74
75await seed()
76// Two values from one array. Threw 08P01 while #1146 was live.
77check('two-value array read', ids(await attempt('two-value array read', () =>
78 User.query().whereRaw('lower(name) = ? and country = ?', ['ada', 'UK']).get())), [2])
79check('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])
81check('array + where, count', await attempt('array + where, count', () =>
82 User.query().whereRaw('lower(name) = ?', ['ada']).where('country', 'US').count()), 1)
83check('orWhereRaw array', ids(await attempt('orWhereRaw array', () =>
84 User.query().where('country', 'UK').orWhereRaw('lower(name) = ? or id = ?', ['grace', 1]).get())), [1, 2, 3])
85check('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.
90check('jsonb wrapped array', ids(await attempt('jsonb wrapped array', () =>
91 User.query().whereRaw('tags @> ?', [['bun']]).get())), [1])
92check('jsonb array among separate arguments', ids(await attempt('jsonb separate', () =>
93 User.query().whereRaw('country = ? and tags @> ?', 'US', ['sql']).get())), [1])
94check('jsonb array inside a bindings list', ids(await attempt('jsonb list', () =>
95 User.query().whereRaw('country = ? and tags @> ?', ['US', ['bun']]).get())), [1])
96check('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.
101check('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.
105check('update affected', await attempt('update', () =>
106 User.query().whereRaw('lower(name) = ? and country = ?', ['ada', 'UK']).update({ country: 'CA' })), 1)
107check('update landed', (await raw.unsafe('SELECT country FROM _qb_wrb1146 WHERE id = 2'))[0].country, 'CA')
108await attempt('increment', () => User.query().whereRaw('id = ? or id = ?', [1, 3]).increment('score', 4))
109check('increment landed', (await raw.unsafe('SELECT score FROM _qb_wrb1146 ORDER BY id')).map(r => Number(r.score)), [4, 0, 4])
110check('delete affected', await attempt('delete', () =>
111 User.query().where('country', 'US').whereRaw('lower(name) = ? or lower(name) = ?', ['grace', 'nobody']).delete()), 1)
112check('delete landed', await idsLeft(), [1, 2])
113
114await raw.unsafe('DROP TABLE IF EXISTS _qb_wrb1146')
115await raw.end()
116
117if (failures.length) {
118 console.error('FAILURES:\\n' + failures.join('\\n'))
119 process.exit(1)
120}
121console.log('OK')
122process.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})
packages/bun-query-builder/test/orm.where-raw-bindings.test.tsadded+229-0
Changes to packages/bun-query-builder/test/orm.where-raw-bindings.test.ts
@@ -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
26import { Database } from 'bun:sqlite'
27import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
28import { clearModelRegistry, configureOrm, createModel } from '../src'
29
30let db: Database
31let User: any
32
33const LOWER_NAME = 'LOWER(name) = ?'
34
35const ids = (rows: any[]): number[] => rows.map(r => Number(r.id ?? r.get?.('id'))).sort((a, b) => a - b)
36const remaining = (): number[] =>
37 (db.query('SELECT id FROM wrb_users ORDER BY id').all() as { id: number }[]).map(r => r.id)
38const scoreOf = (id: number): number =>
39 (db.query('SELECT score FROM wrb_users WHERE id = ?').get(id) as { score: number }).score
40
41beforeEach(() => {
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
64afterEach(() => {
65 clearModelRegistry()
66 db.close()
67})
68
69describe('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
125describe('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
152describe('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
166describe('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
188describe('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
211describe('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})