ReviewOS

also looking at this

stacks/bun-query-builder

fix(client)!: bind db.raw and db.sql fragment values or refuse them

#1152
Merged glennmichael123 wants to merge fix/db-sql-raw-bindings into main
16 files +455 -118

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/advanced/api.mdmodified+5-4
Changes to docs/advanced/api.md
@@ -27,7 +27,7 @@ import {
2727} from 'bun-query-builder'
2828
2929// Example: Chris's team setup
30import { buildDatabaseSchema, buildSchemaMeta, config, createQueryBuilder } from 'bun-query-builder'
30import { buildDatabaseSchema, buildSchemaMeta, config, createQueryBuilder, raw } from 'bun-query-builder'
3131import { userModels } from './models'
3232
3333// Configure for PostgreSQL production environment
@@ -87,9 +87,10 @@ setConfig({ transactionDefaults: { retries: 5 } })
8787- updateTable(table)
8888- deleteFrom(table)
8989- sql: passthrough to Bun’s `sql`
90- raw(strings, ...values)
90- raw(sql, bindings?): bound `{ sql, parameters }` fragment for `set()` / `where()` (not a query; awaiting it rejects)
91- ``raw`...` `` (tagged template): passthrough to Bun’s `sql`
9192- simple(strings, ...values)
92- unsafe(query, params?)
93- unsafe(query, params?): run SQL and return rows
9394- file(path, params?)
9495- reserve(): Promise<QueryBuilder & { release() }>
9596- close(opts?)
@@ -234,7 +235,7 @@ setConfig({ transactionDefaults: { retries: 5 } })
234235const userAnalytics = await db
235236 .selectFrom('users')
236237 .select('users', 'id', 'name', 'email', 'created_at')
237 .selectRaw(db.sql`COUNT(posts.id) as post_count`)
238 .selectRaw(raw`COUNT(posts.id) as post_count`)
238239 .with('Profile', 'Team')
239240 .leftJoin('posts', 'posts.author_id', '=', 'users.id')
240241 .where({ 'users.active': true })
docs/advanced/dialects.mdmodified+3-1
Changes to docs/advanced/dialects.md
@@ -350,13 +350,15 @@ function optimizeForSQLite() {
350350- **Migration Strategy**: Plan for potential dialect changes
351351
352352```ts
353import { raw } from 'bun-query-builder'
354
353355// Feature detection pattern
354356function getRandomOrderQuery() {
355357 const randomFunction = config.sql.randomFunction || 'RANDOM()'
356358
357359 return db
358360 .selectFrom('quotes')
359 .orderByRaw(db.sql`${randomFunction}`)
361 .orderByRaw(raw(randomFunction))
360362 .limit(1)
361363}
362364
docs/advanced/distributed.mdmodified+1-1
Changes to docs/advanced/distributed.md
@@ -107,7 +107,7 @@ async function processOrder(orderData: {
107107 // Reserve inventory for each item
108108 for (const item of orderData.items) {
109109 await tx.updateTable('products')
110 .set({ reserved_stock: db.sql`reserved_stock + ${item.quantity}` })
110 .set({ reserved_stock: db.raw('reserved_stock + ?', [item.quantity]) })
111111 .where({ id: item.productId })
112112 .execute()
113113
docs/aggregations.mdmodified+8-7
Changes to docs/aggregations.md
@@ -159,6 +159,7 @@ const highValueCustomers = await db
159159Use raw SQL for complex aggregations:
160160
161161```typescript
162import { raw } from 'bun-query-builder'
162163
163164// Custom aggregation
164165const results = await db
@@ -173,12 +174,12 @@ const results = await db
173174// Group by with raw expressions
174175const weeklyStats = await db
175176 .selectFrom('orders')
176 .selectRaw(`
177 .selectRaw(raw`
177178 strftime('%Y-%W', created_at) AS week,
178 COUNT(_) AS orders,
179 COUNT(*) AS orders,
179180 SUM(amount) AS revenue
180181 `)
181 .groupByRaw("strftime('%Y-%W', created_at)")
182 .groupByRaw(raw`strftime('%Y-%W', created_at)`)
182183 .get()
183184
184185```
@@ -237,7 +238,7 @@ const authorStats = await db
237238
238239```typescript
239240
240import { createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from 'bun-query-builder'
241import { createQueryBuilder, buildDatabaseSchema, buildSchemaMeta, raw } from 'bun-query-builder'
241242
242243// Setup
243244const models = {
@@ -315,12 +316,12 @@ async function getAnalytics() {
315316 // Monthly revenue trend
316317 const monthlyRevenue = await db
317318 .selectFrom('orders')
318 .selectRaw(`
319 .selectRaw(raw`
319320 strftime('%Y-%m', created_at) AS month,
320 COUNT(_) AS orders,
321 COUNT(*) AS orders,
321322 SUM(amount) AS revenue
322323 `)
323 .groupByRaw("strftime('%Y-%m', created_at)")
324 .groupByRaw(raw`strftime('%Y-%m', created_at)`)
324325 .orderBy('month')
325326 .get()
326327
docs/api/reference.mdmodified+1-1
Changes to docs/api/reference.md
@@ -49,7 +49,7 @@ const meta = buildSchemaMeta(models)
4949|--------|-------------|---------|
5050| `selectFrom(table)` | Start a SELECT query | `db.selectFrom('users')` |
5151| `select(columns)` | Select specific columns | `.select(['id', 'name'])` |
52| `selectRaw(sql)` | Select with raw SQL | `.selectRaw('COUNT(*) as count')` |
52| `selectRaw(fragment)` | Select with raw SQL (one fragment, no bindings) | ``.selectRaw(raw`COUNT(*) as count`)`` |
5353| `distinct()` | Add DISTINCT modifier | `.distinct()` |
5454| `distinctOn(columns)` | DISTINCT ON (PostgreSQL) | `.distinctOn('email')` |
5555
docs/features/builder.mdmodified+19-17
Changes to docs/features/builder.md
@@ -48,6 +48,8 @@ const recentOrders = await db
4848## Selecting Data
4949
5050```ts
51import { raw } from 'bun-query-builder'
52
5153// Select all columns from projects
5254const allProjects = await db
5355 .selectFrom('projects')
@@ -64,7 +66,7 @@ const userInfo = await db
6466const userStats = await db
6567 .selectFrom('users')
6668 .select('users', 'id', 'name')
67 .selectRaw(db.sql`COUNT(posts.id) as post_count`)
69 .selectRaw(raw`COUNT(posts.id) as post_count`)
6870 .leftJoin('posts', 'posts.user_id', '=', 'users.id')
6971 .groupBy('users.id', 'users.name')
7072 .execute()
@@ -74,7 +76,7 @@ const userProfiles = await db
7476 .selectFrom('users')
7577 .innerJoin('profiles', 'profiles.user_id', '=', 'users.id')
7678 .select('users', 'id', 'name')
77 .selectRaw(db.sql`profiles.bio, profiles.avatar_url`)
79 .selectRaw(raw`profiles.bio, profiles.avatar_url`)
7880 .where({ 'users.active': true })
7981 .execute()
8082```
@@ -82,7 +84,7 @@ const userProfiles = await db
8284### Aliases and Expressions
8385
8486- Use `"column as alias"` syntax in `select(table, ...)` for simple aliases
85- Use `selectRaw(db.sql\`...\`)` for complex expressions, functions, and computed columns
87- Use ``selectRaw(raw`...`)`` (the `raw` helper exported by `bun-query-builder`) for complex expressions, functions, and computed columns
8688- Combine `select()` and `selectRaw()` calls to build comprehensive column lists
8789- Always prefix column names with table names when joining to avoid ambiguity
8890
@@ -375,9 +377,9 @@ Use raw sparingly for complex cases not covered by helpers.
375377```ts
376378await db
377379 .selectFrom('users')
378 .whereRaw(db.sql`coalesce(age, 0) > 0`)
379 .groupByRaw(db.sql`country`)
380 .havingRaw(db.sql`count(_) > 10`)
380 .whereRaw(raw`coalesce(age, 0) > 0`)
381 .groupByRaw(raw`country`)
382 .havingRaw(raw`count(*) > 10`)
381383 .execute()
382384```
383385
@@ -684,7 +686,7 @@ await db
684686// Update with complex conditions
685687await db
686688 .updateTable('users')
687 .set({ last_login: new Date(), login_count: db.sql`login_count + 1` })
689 .set({ last_login: new Date(), login_count: db.raw('login_count + 1') })
688690 .where({ email: 'chris@example.com' })
689691 .andWhere(['active', '=', true])
690692 .execute()
@@ -869,7 +871,7 @@ const users = await db
869871// Avoid: Excessive raw SQL
870872const users = await db
871873 .selectFrom('users')
872 .whereRaw(db.sql`active = true ${includeAdmin ? 'OR role = "admin"' : ''}`)
874 .whereRaw(raw(`active = true${includeAdmin ? " OR role = 'admin'" : ''}`))
873875 .execute()
874876```
875877
@@ -917,7 +919,7 @@ const user = await db
917919// Dangerous: String interpolation
918920const user = await db
919921 .selectFrom('users')
920 .whereRaw(db.sql`email = '${userInput.email}'`) // DON'T DO THIS
922 .whereRaw(raw(`email = '${userInput.email}'`)) // DON'T DO THIS: the string is built before raw() sees it
921923```
922924
923925### Error Handling and Monitoring
@@ -1071,14 +1073,14 @@ const searchQuery = 'TypeScript' renamed throughout
10711073const articleResults = db
10721074 .selectFrom('articles')
10731075 .select('articles', 'id', 'title', 'created_at')
1074 .selectRaw(db.sql`'article' as content_type`)
1076 .selectRaw(raw`'article' as content_type`)
10751077 .where(['title', 'like', `%${searchQuery}%`])
10761078 .orWhere(['content', 'like', `%${searchQuery}%`])
10771079
10781080const projectResults = db
10791081 .selectFrom('projects')
10801082 .select('projects', 'id', 'name as title', 'created_at')
1081 .selectRaw(db.sql`'project' as content_type`)
1083 .selectRaw(raw`'project' as content_type`)
10821084 .where(['name', 'like', `%${searchQuery}%`])
10831085 .orWhere(['description', 'like', `%${searchQuery}%`])
10841086
@@ -1099,7 +1101,7 @@ const userStats = await db
10991101 .withCount('posts', 'total_posts')
11001102 .withCount('posts', 'published_posts', ['published', '=', true])
11011103 .withCount('comments', 'total_comments')
1102 .selectRaw(db.sql`
1104 .selectRaw(raw`
11031105 CASE
11041106 WHEN COUNT(posts.id) > 10 THEN 'high'
11051107 WHEN COUNT(posts.id) > 5 THEN 'medium'
@@ -1114,13 +1116,13 @@ const userStats = await db
11141116// Monthly user registration trends
11151117const registrationTrends = await db
11161118 .selectFrom('users')
1117 .selectRaw(db.sql`
1119 .selectRaw(raw`
11181120 DATE_TRUNC('month', created_at) as month,
1119 COUNT(_) as new_users,
1121 COUNT(*) as new_users,
11201122 COUNT(CASE WHEN role = 'admin' THEN 1 END) as new_admins
11211123 `)
11221124 .where(['created_at', '>=', new Date('2024-01-01')])
1123 .groupBy(db.sql`DATE_TRUNC('month', created_at)`)
1125 .groupByRaw(raw`DATE_TRUNC('month', created_at)`)
11241126 .orderBy('month', 'desc')
11251127 .execute()
11261128```
@@ -1144,7 +1146,7 @@ const chrisTeamWithStats = await db
11441146const projectsWithCollaborators = await db
11451147 .selectFrom('projects')
11461148 .select('projects', 'id', 'name', 'description')
1147 .selectRaw(db.sql`
1149 .selectRaw(raw`
11481150 STRING_AGG(users.name, ', ') as collaborators,
11491151 COUNT(DISTINCT project_users.user_id) as collaborator_count
11501152 `)
@@ -1226,7 +1228,7 @@ Use `whereColumn(left, op, right)`.
12261228
12271229### How do I add arbitrary fragments
12281230
1229Use `selectRaw`, `whereRaw`, `groupByRaw`, or `havingRaw` and pass a Bun `sql` fragment.
1231Use `selectRaw`, `whereRaw`, `orderByRaw`, `groupByRaw`, or `havingRaw` and pass one fragment built with the exported `raw` helper: ``raw`count(*) as c` `` or `raw('age > 18')`. These methods take no bindings, and values interpolated into ``raw`...` `` are inlined as escaped literals. To bind values, pass `db.raw('name = ?', [value])` or `{ sql, parameters }` to `where()` or `set()`. A Bun ``db.sql`...` `` query is not a fragment: it throws on Postgres and MySQL.
12301232
12311233---
12321234
docs/features/relations.mdmodified+15-13
Changes to docs/features/relations.md
@@ -462,11 +462,13 @@ the model layer's eager loading.
462462Example:
463463
464464```ts
465import { raw } from 'bun-query-builder'
466
465467await db
466468 .selectFrom('users')
467469 .with('Project')
468470 .select('users', 'id', 'email')
469 .selectRaw(db.sql`projects.name as projects_name`)
471 .selectRaw(raw`projects.name as projects_name`)
470472 .execute()
471473```
472474
@@ -508,8 +510,8 @@ await db renamed throughout
508510 .selectFrom('users')
509511 .with('Project')
510512 .select('users', 'id', 'email')
511 .selectRaw(db.sql`projects.id as projects_id`)
512 .selectRaw(db.sql`projects.name as projects_name`)
513 .selectRaw(raw`projects.id as projects_id`)
514 .selectRaw(raw`projects.name as projects_name`)
513515 .execute()
514516```
515517
@@ -520,7 +522,7 @@ await db
520522 .selectFrom('users')
521523 .with('Project')
522524 .groupBy('users.id')
523 .selectRaw(db.sql`COUNT(projects.id) as projects_count`)
525 .selectRaw(raw`COUNT(projects.id) as projects_count`)
524526 .execute()
525527```
526528
@@ -568,7 +570,7 @@ const teamData = await db
568570 .selectFrom('users')
569571 .with('Project')
570572 .select('users', 'id', 'name', 'role')
571 .selectRaw(db.sql`projects.id as project_id, projects.name as project_name`)
573 .selectRaw(raw`projects.id as project_id, projects.name as project_name`)
572574 .where({ 'users.team': 'Engineering' })
573575 .execute()
574576```
@@ -671,7 +673,7 @@ await db
671673 .with('Project')
672674 .whereHas('Project', ['status', '=', 'active'])
673675 .groupBy('users.id')
674 .havingRaw(db.sql`COUNT(CASE WHEN projects.status = 'inactive' THEN 1 END) = 0`)
676 .havingRaw(raw`COUNT(CASE WHEN projects.status = 'inactive' THEN 1 END) = 0`)
675677 .execute()
676678```
677679
@@ -682,7 +684,7 @@ await db
682684 .selectFrom('users')
683685 .with('Project')
684686 .groupBy('users.id')
685 .selectRaw(db.sql`COUNT(projects.id) as projects_count`)
687 .selectRaw(raw`COUNT(projects.id) as projects_count`)
686688 .orderByDesc('projects_count' as any)
687689 .limit(10)
688690 .execute()
@@ -696,7 +698,7 @@ await db
696698 .selectFrom('users')
697699 .with('Project')
698700 .groupBy('users.id')
699 .havingRaw(db.sql`COUNT(projects.id) >= ${db.sql(String(N))}`)
701 .havingRaw(raw`COUNT(projects.id) >= ${N}`)
700702 .execute()
701703```
702704
@@ -740,7 +742,7 @@ await db
740742 .selectFrom('users')
741743 .join('projects', 'projects.ownerId' as any, '=', 'users.uid' as any)
742744 .select('users', 'uid as id', 'email')
743 .selectRaw(db.sql`projects.title as projects_title`)
745 .selectRaw(raw`projects.title as projects_title`)
744746 .execute()
745747```
746748
@@ -782,8 +784,8 @@ await db renamed throughout
782784 .selectFrom('users')
783785 .with('Project')
784786 .select('users', 'id', 'email')
785 .selectRaw(db.sql`projects.id as projects_id`)
786 .selectRaw(db.sql`projects.status as projects_status`)
787 .selectRaw(raw`projects.id as projects_id`)
788 .selectRaw(raw`projects.status as projects_status`)
787789 .execute()
788790```
789791
@@ -794,8 +796,8 @@ await db renamed throughout
794796 .selectFrom('users')
795797 .with('Project')
796798 .groupBy('users.id')
797 .selectRaw(db.sql`COUNT(projects.id) as projects_count`)
798 .selectRaw(db.sql`SUM(CASE WHEN projects.status = 'active' THEN 1 ELSE 0 END) as active_count`)
799 .selectRaw(raw`COUNT(projects.id) as projects_count`)
800 .selectRaw(raw`SUM(CASE WHEN projects.status = 'active' THEN 1 ELSE 0 END) as active_count`)
799801 .execute()
800802```
801803
docs/features/transactions.mdmodified+7-5
Changes to docs/features/transactions.md
@@ -251,12 +251,12 @@ async function transferFunds(fromAccount: number, toAccount: number, amount: num
251251 }
252252
253253 await tx.updateTable('accounts')
254 .set({ balance: db.sql`balance - ${amount}` })
254 .set({ balance: db.raw('balance - ?', [amount]) })
255255 .where({ id: fromAccount })
256256 .execute()
257257
258258 await tx.updateTable('accounts')
259 .set({ balance: db.sql`balance + ${amount}` })
259 .set({ balance: db.raw('balance + ?', [amount]) })
260260 .where({ id: toAccount })
261261 .execute()
262262 }, { isolation: 'serializable', retries: 3 })
@@ -288,7 +288,7 @@ async function highContentionOperation(data: any) {
288288 return await db.transaction(async (tx) => {
289289 // Operations that might conflict with other transactions
290290 await tx.updateTable('counters')
291 .set({ value: db.sql`value + 1` })
291 .set({ value: db.raw('value + 1') })
292292 .where({ name: 'page_views' })
293293 .execute()
294294 }, {
@@ -318,7 +318,7 @@ async function processOrder(orderData: any) {
318318
319319 // Update inventory
320320 await tx.updateTable('products')
321 .set({ stock_quantity: db.sql`stock_quantity - ${orderData.quantity}` })
321 .set({ stock_quantity: db.raw('stock_quantity - ?', [orderData.quantity]) })
322322 .where({ id: orderData.product_id })
323323 .execute()
324324
@@ -448,11 +448,13 @@ await db.transaction(async (tx) => {
448448### Serializable reads with retries
449449
450450```ts
451import { raw } from 'bun-query-builder'
452
451453await db.transaction(async (tx) => {
452454 const totals = await tx
453455 .selectFrom('orders')
454456 .groupBy('customer_id')
455 .selectRaw(db.sql`SUM(total) as total`)
457 .selectRaw(raw`SUM(total) as total`)
456458 .execute()
457459 // ...work with totals
458460}, { isolation: 'serializable', retries: 2 })
docs/guide/insert.mdmodified+1-1
Changes to docs/guide/insert.md
@@ -93,7 +93,7 @@ const user = await db
9393 .onConflict('email')
9494 .doUpdate({
9595 name: 'John Doe',
96 login_count: sql`login_count + 1`
96 login_count: db.raw('login_count + 1')
9797 })
9898 .execute()
9999```
docs/guide/update.mdmodified+3-3
Changes to docs/guide/update.md
@@ -151,8 +151,8 @@ await db.transaction(async (trx) => {
151151await db
152152 .updateTable('users')
153153 .set({
154 login_count: sql`login_count + 1`,
155 last_login: sql`NOW()`
154 login_count: db.raw('login_count + 1'),
155 last_login: db.raw('CURRENT_TIMESTAMP')
156156 })
157157 .where({ id: userId })
158158 .execute()
@@ -165,7 +165,7 @@ await db
165165await db
166166 .updateTable('users')
167167 .set({
168 preferences: sql`JSON_SET(preferences, '$.theme', 'dark')`
168 preferences: db.raw("JSON_SET(preferences, '$.theme', 'dark')")
169169 })
170170 .where({ id: userId })
171171 .execute()
docs/raw-queries.mdmodified+69-27
Changes to docs/raw-queries.md
@@ -16,47 +16,54 @@ import { createQueryBuilder } from 'bun-query-builder'
1616
1717const db = createQueryBuilder<typeof schema>({ schema, meta })
1818
19// Raw query with parameters
20const users = await db.raw(
19// Raw query with parameters (SQLite and MySQL use `?` placeholders)
20const users = await db.unsafe(
2121 'SELECT * FROM users WHERE active = ? AND age > ?',
2222 [true, 18]
2323)
2424
25// Using named parameters
26const posts = await db.raw(
27 'SELECT * FROM posts WHERE user_id = $userId AND published = $published',
28 { userId: 1, published: true }
25// Postgres uses numbered placeholders
26const posts = await db.unsafe(
27 'SELECT * FROM posts WHERE user_id = $1 AND published = $2',
28 [1, true]
2929)
3030```
3131
32`db.unsafe(sql, params)` runs the statement and resolves to its rows. `db.raw(sql, bindings)`
33does not run anything: it builds a fragment for `set()` and `where()` (see
34[Bound Fragments](#bound-fragments-in-set-and-where)), and awaiting it rejects.
35
3236## Raw Expressions in Queries
3337
3438Use raw expressions within the query builder:
3539
3640```typescript
41import { raw } from 'bun-query-builder'
42
3743// Raw in select
3844const results = await db
3945 .selectFrom('users')
40 .selectRaw('COUNT(*) AS total, AVG(age) AS avg_age')
46 .selectRaw(raw`COUNT(*) AS total, AVG(age) AS avg_age`)
4147 .get()
4248
43// Raw in where
49// Raw in where (the interpolated Date is inlined as an escaped literal)
50const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
4451const recentUsers = await db
4552 .selectFrom('users')
46 .whereRaw('DATE(created_at) > DATE_SUB(NOW(), INTERVAL 30 DAY)')
53 .whereRaw(raw`created_at > ${since}`)
4754 .get()
4855
4956// Raw in order by
5057const sorted = await db
5158 .selectFrom('products')
52 .orderByRaw('price * quantity DESC')
59 .orderByRaw(raw`price * quantity DESC`)
5360 .get()
5461
5562// Raw in group by
5663const grouped = await db
5764 .selectFrom('orders')
5865 .select(['SUM(amount) AS total'])
59 .groupByRaw("strftime('%Y-%m', created_at)")
66 .groupByRaw(raw`strftime('%Y-%m', created_at)`)
6067 .get()
6168
6269// Raw in having
@@ -64,7 +71,7 @@ const filtered = await db
6471 .selectFrom('orders')
6572 .select(['user_id', 'SUM(amount) AS total'])
6673 .groupBy('user_id')
67 .havingRaw('SUM(amount) > 1000')
74 .havingRaw(raw`SUM(amount) > 1000`)
6875 .get()
6976
7077```
@@ -123,13 +130,48 @@ await db.selectFrom('users').orderByRaw(raw`created_at desc`).get()
123130await db.selectFrom('orders').whereRaw(raw`status = ${userStatus}`).get()
124131```
125132
126> **Do not pass a Bun `sql\`...\`` query to the `*Raw` methods.** A Bun query
127> object cannot be converted back to SQL text (it stringifies to
128> `"[object Promise]"`), so it would corrupt the generated SQL. The builder
129> now throws a clear error if you do. `raw` returns a `{ raw }` fragment that
130> renders correctly and satisfies the `SqlFragment` type (so it still passes
131> the bare-string injection guard). For user input that must be
132> parameterised, prefer the typed `where(...)` methods over `raw`.
133The `*Raw` methods take exactly one fragment and no bindings. Passing extra
134arguments, or a fragment that carries bound values (`db.raw('x = ?', [v])`),
135throws instead of silently dropping the values. Bare strings still work at
136runtime but log a deprecation warning and are a TypeScript error.
137
138> **Do not pass a Bun ``sql`...` `` query to builder methods.** A Bun query
139> object cannot be converted back to SQL text, so on Postgres and MySQL the
140> builder throws a clear error if you pass one to a `*Raw` method, `where()`,
141> or `set()`. `raw` returns a `{ raw }` fragment that renders correctly and
142> satisfies the `SqlFragment` type. Values interpolated into ``raw`...` `` are
143> inlined as escaped literals, not bound; for user input that must be
144> parameterised, use `where(...)` or a bound fragment (below).
145
146## Bound Fragments in `set()` and `where()`
147
148To bind values inside a SQL expression, build a fragment with
149`db.raw(sql, bindings)`. Each `?` binds the matching array element, on every
150dialect (the builder renumbers them to `$n` on Postgres). A plain
151`{ sql, parameters }` object works the same way.
152
153```typescript
154// Expression in set()
155await db
156 .updateTable('products')
157 .set({ stock: db.raw('stock - ?', [quantity]) })
158 .where({ id: productId })
159 .execute()
160
161// Condition in where() / orWhere(), on select, update and delete builders
162const matches = await db
163 .selectFrom('users')
164 .where(db.raw('LOWER(email) = ?', [email.toLowerCase()]))
165 .get()
166
167await db
168 .deleteFrom('sessions')
169 .where({ sql: 'expires_at < ?', parameters: [new Date().toISOString()] })
170 .execute()
171```
172
173`db.raw(sql, bindings)` is not a query: `await db.raw('SELECT ...', [..])`
174rejects with a `TypeError`. Use `db.unsafe(sql, params)` to run SQL.
133175
134176## Raw with Bun Tagged Templates
135177
@@ -176,7 +218,7 @@ interface UserStats {
176218 avg_age: number
177219}
178220
179const stats = await db.raw<UserStats[]>(`
221const stats = await db.unsafe<UserStats>(`
180222 SELECT
181223 country,
182224 COUNT(*) AS count,
@@ -220,7 +262,7 @@ Execute raw queries within transactions:
220262
221263await db.transaction(async (trx) => {
222264 // Raw insert
223 await trx.raw(
265 await trx.unsafe(
224266 'INSERT INTO audit_log (action, user_id) VALUES (?, ?)',
225267 ['login', userId]
226268 )
@@ -229,7 +271,7 @@ await db.transaction(async (trx) => {
229271 await trx.updateTable('users').set({ last_login: new Date() }).where({ id: userId })
230272
231273 // Raw update
232 await trx.raw(
274 await trx.unsafe(
233275 'UPDATE statistics SET login_count = login_count + 1 WHERE user_id = ?',
234276 [userId]
235277 )
@@ -256,7 +298,7 @@ console.log(explain)
256298
257299```typescript
258300
259import { createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from 'bun-query-builder'
301import { createQueryBuilder, buildDatabaseSchema, buildSchemaMeta, raw } from 'bun-query-builder'
260302
261303const models = {
262304 User: {
@@ -288,7 +330,7 @@ async function getComplexAnalytics() {
288330 total_active: number
289331 }
290332
291 const stats = await db.raw<MonthlyStats[]>(`
333 const stats = await db.unsafe<MonthlyStats>(`
292334 WITH monthly_users AS (
293335 SELECT
294336 strftime('%Y-%m', created_at) AS month,
@@ -310,15 +352,15 @@ async function getComplexAnalytics() {
310352 // Combine with query builder
311353 const basicStats = await db
312354 .selectFrom('users')
313 .selectRaw(`
355 .selectRaw(raw`
314356 COUNT(*) AS total,
315357 COUNT(CASE WHEN active = 1 THEN 1 END) AS active,
316358 AVG(age) AS avg_age
317359 `)
318360 .first()
319361
320 // Parameterized complex query
321 const countryStats = await db.raw(
362 // Parameterized complex query (`?` placeholders; use $1, $2, ... on Postgres)
363 const countryStats = await db.unsafe(
322364 `
323365 SELECT
324366 country,
docs/select.mdmodified+5-2
Changes to docs/select.md
@@ -200,11 +200,14 @@ const neverUpdated = await db
200200## Raw Where Clauses
201201
202202```typescript
203import { raw } from 'bun-query-builder'
203204
204// Raw SQL in where
205// Raw SQL in where: one fragment, no bindings. The interpolated Date is
206// inlined as an escaped literal; use where() to bind request input.
207const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
205208const activeRecent = await db
206209 .selectFrom('users')
207 .whereRaw('DATE(created_at) > DATE_SUB(NOW(), INTERVAL 30 DAY)')
210 .whereRaw(raw`created_at > ${since}`)
208211 .get()
209212
210213```
docs/usage.mdmodified+3-3
Changes to docs/usage.md
@@ -7,7 +7,7 @@ There are two ways to use bun-query-builder: as a library and via the CLI. This
77### Quick Start
88
99```ts
10import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from 'bun-query-builder'
10import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder, raw } from 'bun-query-builder'
1111
1212// Define your models with relationships
1313const models = {
@@ -86,7 +86,7 @@ const featuredProducts = await db
8686// Buddy's analytics: User engagement metrics
8787const userStats = await db
8888 .selectFrom('users')
89 .selectRaw(db.sql`
89 .selectRaw(raw`
9090 COUNT(posts.id) as post_count,
9191 AVG(posts.engagement_score) as avg_engagement
9292 `)
@@ -233,7 +233,7 @@ async function processOrderTransaction(orderData: any) {
233233
234234 await tx
235235 .updateTable('products')
236 .set({ stock_quantity: db.sql`stock_quantity - ${item.quantity}` })
236 .set({ stock_quantity: db.raw('stock_quantity - ?', [item.quantity]) })
237237 .where({ id: item.product_id })
238238 .execute()
239239 }
packages/bun-query-builder/src/__tests__/type-usage-compile.tsmodified+13-0
Changes to packages/bun-query-builder/src/__tests__/type-usage-compile.ts
@@ -554,6 +554,10 @@ async function _clientUsage(
554554 rows[0].seats
555555 }
556556
557 // a bound fragment is accepted by the typed builder's where family too (#1146 follow-up)
558 const typedRaw = null as unknown as import('../client').BoundSqlFragment
559 users.where(typedRaw).orWhere(typedRaw)
560
557561 const email: string = await users.value('email')
558562 const count: number = await users.value('login_count')
559563 const names: string[] = await users.pluck('name')
@@ -1075,6 +1079,15 @@ async function _rawAndReturning(
10751079 // raw tagged-template with an escaped value
10761080 users.whereRaw(raw`name = ${'Ada'}`)
10771081
1082 // db.raw(sql, bindings) builds a bound fragment that where() and set() accept
1083 const dbRaw = null as unknown as import('../client').QueryBuilder<QDB>['raw']
1084 const bound: import('../client').BoundSqlFragment = dbRaw('login_count + ?', [1])
1085 users.where(bound)
1086 users.orWhere(dbRaw('login_count > ?', [3]))
1087 db.updateTable('users').set({ login_count: bound }).where(dbRaw('id = ?', [1]))
1088 db.deleteFrom('users').where(dbRaw('id = ?', [1]))
1089 dbRaw`SELECT 1`
1090
10781091 // @ts-expect-error — a bare string is rejected by the SqlFragment type
10791092 users.whereRaw('age > 18')
10801093 // @ts-expect-error — the select builder's *Raw methods take no bindings (#1146)
packages/bun-query-builder/src/client.tsmodified+151-33
Changes to packages/bun-query-builder/src/client.ts
@@ -30,6 +30,17 @@ function isRawExpression(expr: unknown): expr is RawExpression {
3030interface BoundSqlExpression {
3131 sql: string
3232 parameters?: readonly unknown[]
33 /** What the SQLite driver's `sql` tagged template carries its bindings in. */
34 values?: unknown
35}
36
37/**
38 * A fragment with bound values, as `db.raw('stock - ?', [n])` builds it.
39 * `set()` and `where()` render it at its position and bind its values.
40 */
41export interface BoundSqlFragment {
42 readonly sql: string
43 readonly parameters: readonly unknown[]
3344}
3445
3546/**
@@ -69,6 +80,66 @@ function isBoundSqlExpression(expr: unknown): expr is BoundSqlExpression {
6980 && (!('parameters' in expr) || Array.isArray((expr as BoundSqlExpression).parameters))
7081}
7182
83/**
84 * `db.raw(sql, bindings)`: a `{ sql, parameters }` fragment.
85 *
86 * `db.raw` was a tagged-template passthrough only. Called the way the docs
87 * called it, `db.raw('stock - ?', [quantity])`, it returned the SQLite driver's
88 * identifier marker with the values dropped — `set()` then changed no rows — and
89 * threw `Query not called as a tagged template literal` on Postgres.
90 *
91 * Awaiting the fragment rejects, because `await db.raw(sql, [..])` reads like a
92 * query and would otherwise resolve to this object; `db.unsafe` runs SQL.
93 */
94function boundFragment(sql: string, args: readonly unknown[]): BoundSqlFragment {
95 const [bindings, ...rest] = args
96 if (rest.length > 0 || (bindings !== undefined && !Array.isArray(bindings))) {
97 throw new TypeError(
98 '[query-builder] db.raw(sql, bindings): pass the bindings as one array, db.raw(\'a = ? AND b = ?\', [a, b]).',
99 )
100 }
101 const fragment = { sql, parameters: [...(bindings ?? [])] }
102 Object.defineProperty(fragment, 'then', {
103 enumerable: false,
104 value: (_onFulfilled: unknown, onRejected?: (reason: unknown) => unknown) => {
105 const error = new TypeError(
106 '[query-builder] db.raw(sql, bindings) builds a fragment for set() or where(), not a query. '
107 + 'Run SQL with db.unsafe(sql, params).',
108 )
109 if (typeof onRejected === 'function')
110 return onRejected(error)
111 throw error
112 },
113 })
114 return fragment
115}
116
117/**
118 * A query object from Bun's network driver (`db.sql` on Postgres and MySQL).
119 * It is a Promise subclass that exposes no SQL text, so it cannot be spliced
120 * into a statement; see {@link refuseNativeQuery}.
121 */
122function isNativeDriverQuery(value: unknown): boolean {
123 return value instanceof Promise
124 && typeof (value as { execute?: unknown }).execute === 'function'
125 && typeof (value as { cancel?: unknown }).cancel === 'function'
126}
127
128/**
129 * Throw for a Bun network-driver query used as a fragment.
130 *
131 * `where(db.sql\`name = ${v}\`)` on Postgres read the query object as an empty
132 * column map, added no condition, and returned every row. In `set()` the
133 * driver rejected it with `Unknown object is not a valid PostgreSQL type`.
134 */
135function refuseNativeQuery(context: string): never {
136 throw new TypeError(
137 `[query-builder] ${context}: a Bun \`sql\`...\`\` query cannot be used as a fragment, because its SQL text `
138 + `is not readable. Bind values with db.raw('name = ?', [value]) or { sql, parameters }, or use the `
139 + `exported \`raw\` tagged template.`,
140 )
141}
142
72143/**
73144 * Render a parameterized SQL expression at its current position in a query.
74145 *
@@ -77,7 +148,9 @@ function isBoundSqlExpression(expr: unknown): expr is BoundSqlExpression {
77148 * parameters and start after the query parameters already collected.
78149 */
79150function renderBoundSqlExpression(expression: BoundSqlExpression, startIndex: number): { text: string, parameters: readonly unknown[] } {
80 const parameters = expression.parameters ?? []
151 // The SQLite driver's `db.sql` tagged template keeps its bindings in `values`.
152 // Reading only `parameters` sent `n = n + ?` with nothing bound.
153 const parameters = expression.parameters ?? (Array.isArray(expression.values) ? expression.values : [])
81154 if (config.dialect !== 'postgres' || parameters.length === 0)
82155 return { text: expression.sql, parameters }
83156
@@ -459,7 +532,12 @@ function validateQualifiedIdentifier(value: unknown, context: string): void {
459532 * execution. That now throws here instead. See stacksjs/bun-query-builder#1146.
460533 */
461534function renderRawFragment(fragment: unknown, context: string, extra: readonly unknown[] = []): string {
462 if (dropsTrailingArguments(fragment, extra)) {
535 const carried = typeof fragment === 'object' && fragment !== null
536 ? ((fragment as BoundSqlExpression).parameters ?? (fragment as BoundSqlExpression).values)
537 : undefined
538 // A `db.sql\`name = ${v}\`` or `db.raw('name = ?', [v])` fragment carries its
539 // values with it, and rendering only its text dropped them just the same.
540 if (dropsTrailingArguments(fragment, extra) || (Array.isArray(carried) && carried.length > 0)) {
463541 // The advice differs by clause: where()/orWhere() and the model builder's
464542 // bound whereRaw/orWhereRaw can stand in for a WHERE fragment. Nothing typed
465543 // stands in for the others — having()'s type only takes selected columns,
@@ -679,19 +757,21 @@ export interface WhereRaw {
679757}
680758
681759/**
682 * Brand for SQL fragments produced by Bun's `sql\`...\`` tagged-template
683 * (or any equivalent helper). Typed as `object` so the *Raw methods
684 * (`whereRaw`, `selectRaw`, `groupByRaw`, `havingRaw`, `orderByRaw`)
685 * refuse to compile when passed a bare string — concatenated user
686 * input (`whereRaw(\`status = '${req.body.s}'\`)`) was the canonical
687 * SQL-injection vector flagged by the audit as Q-3.
760 * Brand for SQL fragments, such as the exported `raw` helper builds. Typed as
761 * `object` so the *Raw methods (`whereRaw`, `selectRaw`, `groupByRaw`,
762 * `havingRaw`, `orderByRaw`) refuse to compile when passed a bare string —
763 * concatenated user input (`whereRaw(\`status = '${req.body.s}'\`)`) was the
764 * canonical SQL-injection vector flagged by the audit as Q-3.
688765 *
689 * Callers who legitimately need raw SQL use `sql\`...\`` which
690 * separates the SQL fragment from parameter values:
766 * Callers who need raw SQL use `raw`, which escapes interpolated values, or
767 * bind them with `db.raw('...', [values])` in `where()` and `set()`. A Bun
768 * `sql\`...\`` query is not a fragment: on Postgres and MySQL its text cannot be
769 * read back, and the *Raw methods take no bindings.
691770 *
692771 * ```ts
693 * import { sql } from 'bun'
694 * db.selectFrom('users').whereRaw(sql\`lower(name) = lower(${input})\`)
772 * import { raw } from 'bun-query-builder'
773 * db.selectFrom('users').whereRaw(raw\`lower(name) = lower(${input})\`)
774 * db.selectFrom('users').where(db.raw('lower(name) = lower(?)', [input]))
695775 * ```
696776 *
697777 * The runtime guard in each *Raw method also rejects bare strings as
@@ -879,7 +959,7 @@ export type TypedSelectQueryBuilder<
879959 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} WHERE ${K} = ?`>) & (<K extends keyof DB[TTable]['columns'] & string, OP extends WhereOperator>(
880960 expr: [K, OP, WhereValue<DB[TTable]['columns'][K]>],
881961 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} WHERE ${K} ${Uppercase<OP>} ${OP extends 'in' | 'not in' ? '(?)' : '?'}`>) & ((
882 expr: WhereExpression<DB[TTable]['columns']> | string,
962 expr: WhereExpression<DB[TTable]['columns']> | string | BoundSqlFragment,
883963 op?: WhereOperator,
884964 value?: WhereValue<DB[TTable]['columns'][keyof DB[TTable]['columns'] & string]>,
885965 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} WHERE ${string}`>)
@@ -888,7 +968,7 @@ export type TypedSelectQueryBuilder<
888968 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} AND ${K} = ?`>) & (<K extends keyof DB[TTable]['columns'] & string, OP extends WhereOperator>(
889969 expr: [K, OP, WhereValue<DB[TTable]['columns'][K]>],
890970 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} AND ${K} ${Uppercase<OP>} ${OP extends 'in' | 'not in' ? '(?)' : '?'}`>) & ((
891 expr: WhereExpression<DB[TTable]['columns']> | string,
971 expr: WhereExpression<DB[TTable]['columns']> | string | BoundSqlFragment,
892972 op?: WhereOperator,
893973 value?: WhereValue<DB[TTable]['columns'][keyof DB[TTable]['columns'] & string]>,
894974 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} AND ${string}`>)
@@ -897,7 +977,7 @@ export type TypedSelectQueryBuilder<
897977 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} OR ${K} = ?`>) & (<K extends keyof DB[TTable]['columns'] & string, OP extends WhereOperator>(
898978 expr: [K, OP, WhereValue<DB[TTable]['columns'][K]>],
899979 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} OR ${K} ${Uppercase<OP>} ${OP extends 'in' | 'not in' ? '(?)' : '?'}`>) & ((
900 expr: WhereExpression<DB[TTable]['columns']> | string,
980 expr: WhereExpression<DB[TTable]['columns']> | string | BoundSqlFragment,
901981 op?: WhereOperator,
902982 value?: WhereValue<DB[TTable]['columns'][keyof DB[TTable]['columns'] & string]>,
903983 ) => TypedSelectQueryBuilder<DB, TTable, TSelected, TJoined, `${TSql} OR ${string}`>)
@@ -959,8 +1039,8 @@ export interface BaseSelectQueryBuilder< renamed throughout
9591039 *
9601040 * @example
9611041 * ```ts
962 * const rows = await db.selectFrom('users').selectRaw(sql`count(*) as c`).get()
963 * const sqlText = db.selectFrom('users').selectRaw(sql`now() as ts`).toSQL()
1042 * const rows = await db.selectFrom('users').selectRaw(raw`count(*) as c`).get()
1043 * const sqlText = db.selectFrom('users').selectRaw(raw`now() as ts`).toSQL()
9641044 * ```
9651045 */
9661046 selectRaw: (fragment: SqlFragment) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -977,7 +1057,7 @@ export interface BaseSelectQueryBuilder<
9771057 * ```
9781058 */
9791059 where: <K extends keyof DB[TTable]['columns'] & string>(
980 expr: WhereExpression<DB[TTable]['columns']> | K,
1060 expr: WhereExpression<DB[TTable]['columns']> | K | BoundSqlFragment,
9811061 op?: WhereOperator,
9821062 value?: WhereValue<DB[TTable]['columns'][K]>,
9831063 ) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -988,8 +1068,8 @@ export interface BaseSelectQueryBuilder<
9881068 *
9891069 * @example
9901070 * ```ts
991 * const rows = await db.selectFrom('users').whereRaw(sql`lower(name) = lower(${ 'Alice' })`).get()
992 * const sqlText = db.selectFrom('users').whereRaw(sql`custom_condition`).toSQL()
1071 * const rows = await db.selectFrom('users').whereRaw(raw`lower(name) = lower(${'Alice'})`).get()
1072 * const sqlText = db.selectFrom('users').whereRaw(raw`custom_condition`).toSQL()
9931073 * ```
9941074 */
9951075 whereRaw: (fragment: SqlFragment) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -1025,7 +1105,7 @@ export interface BaseSelectQueryBuilder<
10251105 * @example
10261106 * ```ts
10271107 * const rows = await db.selectFrom('users').whereIn('id', [1, 2, 3]).get()
1028 * const sqlText = db.selectFrom('users').whereIn('id', db.selectFrom('admins').selectRaw(sql`id`)).toSQL()
1108 * const sqlText = db.selectFrom('users').whereIn('id', db.selectFrom('admins').selectRaw(raw`id`)).toSQL()
10291109 * ```
10301110 */
10311111 whereIn: <K extends keyof DB[TTable]['columns'] & string>(column: K, values: DB[TTable]['columns'][K][] | { toSQL: () => string }) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -1286,7 +1366,7 @@ export interface BaseSelectQueryBuilder<
12861366 * ```
12871367 */
12881368 andWhere: <K extends keyof DB[TTable]['columns'] & string>(
1289 expr: WhereExpression<DB[TTable]['columns']> | K,
1369 expr: WhereExpression<DB[TTable]['columns']> | K | BoundSqlFragment,
12901370 op?: WhereOperator,
12911371 value?: WhereValue<DB[TTable]['columns'][K]>,
12921372 ) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -1302,7 +1382,7 @@ export interface BaseSelectQueryBuilder<
13021382 * ```
13031383 */
13041384 orWhere: <K extends keyof DB[TTable]['columns'] & string>(
1305 expr: WhereExpression<DB[TTable]['columns']> | K,
1385 expr: WhereExpression<DB[TTable]['columns']> | K | BoundSqlFragment,
13061386 op?: WhereOperator,
13071387 value?: WhereValue<DB[TTable]['columns'][K]>,
13081388 ) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -1466,8 +1546,8 @@ export interface BaseSelectQueryBuilder< renamed throughout
14661546 *
14671547 * @example
14681548 * ```ts
1469 * const sql = db.selectFrom('users').groupByRaw(sql`date_trunc('day', created_at)`).toSQL()
1470 * const rows = await db.selectFrom('users').groupByRaw(sql`1`).get()
1549 * const sql = db.selectFrom('users').groupByRaw(raw`date_trunc('day', created_at)`).toSQL()
1550 * const rows = await db.selectFrom('users').groupByRaw(raw`1`).get()
14711551 * ```
14721552 */
14731553 groupByRaw: (fragment: SqlFragment) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -1490,8 +1570,8 @@ export interface BaseSelectQueryBuilder< renamed throughout
14901570 *
14911571 * @example
14921572 * ```ts
1493 * const sql = db.selectFrom('users').groupBy('role').havingRaw(sql`count(*) > 10`).toSQL()
1494 * const rows = await db.selectFrom('users').havingRaw(sql`count(*) > 0`).get()
1573 * const sql = db.selectFrom('users').groupBy('role').havingRaw(raw`count(*) > 10`).toSQL()
1574 * const rows = await db.selectFrom('users').havingRaw(raw`count(*) > 0`).get()
14951575 * ```
14961576 */
14971577 havingRaw: (fragment: SqlFragment) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -1527,8 +1607,8 @@ export interface BaseSelectQueryBuilder< renamed throughout
15271607 *
15281608 * @example
15291609 * ```ts
1530 * const sql = db.selectFrom('users').orderByRaw(sql`random()`).toSQL()
1531 * const rows = await db.selectFrom('users').orderByRaw(sql`1`).get()
1610 * const sql = db.selectFrom('users').orderByRaw(raw`random()`).toSQL()
1611 * const rows = await db.selectFrom('users').orderByRaw(raw`1`).get()
15321612 * ```
15331613 */
15341614 orderByRaw: (fragment: SqlFragment) => SelectQueryBuilder<DB, TTable, TSelected, TJoined>
@@ -2324,7 +2404,7 @@ export interface DeleteQueryBuilder<DB extends AnyDatabaseSchema, TTable extends
23242404 * an injected operator is unrecoverable.
23252405 */
23262406 where: <K extends keyof DB[TTable]['columns'] & string>(
2327 expr: WhereExpression<DB[TTable]['columns']> | K,
2407 expr: WhereExpression<DB[TTable]['columns']> | K | BoundSqlFragment,
23282408 op?: WhereOperator,
23292409 value?: WhereValue<DB[TTable]['columns'][K]>,
23302410 ) => DeleteQueryBuilder<DB, TTable>
@@ -2544,14 +2624,20 @@ export interface QueryBuilder<DB extends AnyDatabaseSchema> {
25442624 /**
25452625 * # `raw`
25462626 *
2547 * Tagged template passthrough to Bun.sql.
2627 * Called with a string, builds a bound fragment for `set()` and `where()`:
2628 * each `?` binds the matching value, on every dialect. It is not a query —
2629 * run one with `db.unsafe(sql, params)`.
2630 *
2631 * As a tagged template it passes through to the driver's `sql` tag.
25482632 *
25492633 * @example
25502634 * ```ts
2635 * await db.updateTable('products').set({ stock: db.raw('stock - ?', [quantity]) }).where('id', '=', id).execute()
25512636 * const q = db.raw`SELECT ${1} as one`
25522637 * ```
25532638 */
2554 raw: (strings: TemplateStringsArray, ...values: unknown[]) => SqlFragment
2639 // eslint-disable-next-line pickier/no-unused-vars
2640 raw: ((sql: string, bindings?: readonly unknown[]) => BoundSqlFragment) & ((strings: TemplateStringsArray, ...values: unknown[]) => SqlFragment)
25552641 /**
25562642 * # `simple`
25572643 *
@@ -3793,6 +3879,16 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
37933879 return self
37943880 }
37953881
3882 // A bound fragment, handled before the object branch as in where().
3883 if (isNativeDriverQuery(expr))
3884 refuseNativeQuery(`${label}(expr)`)
3885 if (isBoundSqlExpression(expr)) {
3886 const rendered = renderBoundSqlExpression(expr, whereParams.length + 1)
3887 whereParams.push(...rendered.parameters)
3888 pushWhere(conn, rendered.text)
3889 return self
3890 }
3891
37963892 // Object format: { name: 'Alice', age: 25 }
37973893 if (expr && typeof expr === 'object' && !('raw' in expr)) {
37983894 const conditions: string[] = []
@@ -5195,6 +5291,19 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
51955291 return this
51965292 }
51975293
5294 // A bound fragment — db.raw('name = ?', [v]), { sql, parameters }, or the
5295 // SQLite driver's db.sql`...` — is an object too. Read as a column map it
5296 // became `sql = ?` on SQLite, and a Postgres query object has no keys, so
5297 // it added no condition and every row came back.
5298 if (isNativeDriverQuery(expr))
5299 refuseNativeQuery('where(expr)')
5300 if (isBoundSqlExpression(expr)) {
5301 const rendered = renderBoundSqlExpression(expr, whereParams.length + 1)
5302 whereParams.push(...rendered.parameters)
5303 pushWhere('AND', rendered.text)
5304 return this
5305 }
5306
51985307 // Handle object format: { name: 'Alice', age: 25 }
51995308 if (expr && typeof expr === 'object' && !isRawExpression(expr)) {
52005309 const whereObject = expr as Record<string, unknown>
@@ -7534,6 +7643,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
75347643 for (let i = 0; i < len; i++) {
75357644 const key = keys[i]
75367645 const value = (shaped as any)[key]
7646 if (isNativeDriverQuery(value))
7647 refuseNativeQuery(`set(${key})`)
75377648 if (isRawExpression(value)) {
75387649 setClauses[i] = `${quoteId(key)} = ${value.raw}`
75397650 }
@@ -7619,6 +7730,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
76197730 // as success. Handled before the object branch because a bound
76207731 // expression is an object and would otherwise be read as the column
76217732 // map `{ sql: ... }`. See #1101.
7733 if (isNativeDriverQuery(expr))
7734 refuseNativeQuery('updateTable.where(expr)')
76227735 if (isRawExpression(expr)) {
76237736 appendPredicate(expr.raw)
76247737 built = prepareQuery(sqlText, params)
@@ -7917,6 +8030,8 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
79178030 // fragment was interpolated as a bound value, so Postgres rejected
79188031 // `where(raw('id = 1'))` with "invalid input syntax for type
79198032 // boolean". The signature accepts SqlFragment, so it has to work.
8033 if (isNativeDriverQuery(expr))
8034 refuseNativeQuery('deleteFrom.where(expr)')
79208035 if (isRawExpression(expr)) {
79218036 appendPredicate(expr.raw)
79228037 built = null
@@ -8110,7 +8225,10 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
81108225 }
81118226 },
81128227 sql: _sql,
8113 raw(strings: TemplateStringsArray, ...values: any[]) {
8228 // One implementation behind the two call signatures declared on QueryBuilder.
8229 raw(strings: TemplateStringsArray | string, ...values: any[]): any {
8230 if (typeof strings === 'string')
8231 return boundFragment(strings, values)
81148232 return _sql(strings, ...values)
81158233 },
81168234 simple(strings: TemplateStringsArray, ...values: any[]) {
packages/bun-query-builder/test/client.bound-fragments.test.tsadded+151-0
Changes to packages/bun-query-builder/test/client.bound-fragments.test.ts
@@ -0,0 +1,151 @@
1/**
2 * `db.sql` and `db.raw` fragments carrying values.
3 *
4 * The docs used both as fragments, and neither kept its values:
5 *
6 * - `db.raw('stock - ?', [quantity])` returned the SQLite driver's identifier
7 * marker with the array dropped, so `set({ stock: ... })` changed no rows. On
8 * Postgres it threw `Query not called as a tagged template literal`.
9 * - `where(db.sql\`name = ${v}\`)` on Postgres read the query object as an empty
10 * column map, added no condition, and returned EVERY row. On SQLite it threw.
11 * - `whereRaw(db.sql\`name = ${v}\`)` on SQLite rendered `name = ?` with the value
12 * dropped and returned [] without an error; `orderByRaw(db.sql\`${col}\`)`
13 * silently ordered by a constant.
14 * - `set({ n: db.sql\`n + ${1}\` })` on SQLite bound nothing to the `?`.
15 * - `where({ sql, parameters })` was read as a column named `sql`.
16 *
17 * Each now binds its values or throws. Runs each dialect in a subprocess
18 * because the connection config is global; Postgres is skipped without a server.
19 */
20
21import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
23import { join, resolve } from 'node:path'
24import process from 'node:process'
25import { describe, expect, it } from 'bun:test'
26import { PG_URL, probePostgres } from './pg'
27
28const pgAvailable = await probePostgres()
29
30function runProbe(dialect: 'sqlite' | 'postgres'): { code: number | null, out: string, err: string } {
31 const srcEntry = resolve(import.meta.dir, '../src/index.ts')
32 const dir = mkdtempSync(join(tmpdir(), 'qb-boundfrag-'))
33 const scriptPath = join(dir, 'probe.ts')
34
35 writeFileSync(scriptPath, `
36import { SQL } from 'bun'
37import { setConfig, resetConnection, createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from ${JSON.stringify(srcEntry)}
38
39const DIALECT = ${JSON.stringify(dialect)}
40const PG = ${JSON.stringify(PG_URL)}
41const failures = []
42const check = (label, got, want) => {
43 const g = JSON.stringify(got), w = JSON.stringify(want)
44 if (g !== w) failures.push(label + ': got ' + g + ', want ' + w)
45}
46const throws = async (label, fn, pattern) => {
47 try { await fn(); failures.push(label + ': did not throw') }
48 catch (e) { if (!pattern.test(String(e && e.message))) failures.push(label + ': threw ' + (e && e.message)) }
49}
50
51if (DIALECT === 'postgres') {
52 const r = new SQL(PG)
53 await r.unsafe('DROP TABLE IF EXISTS _qb_boundfrag')
54 await r.unsafe('CREATE TABLE _qb_boundfrag (id int primary key, name text, n int)')
55 await r.end()
56 setConfig({ dialect: 'postgres', database: { url: PG } })
57}
58else {
59 setConfig({ dialect: 'sqlite', database: { database: ${JSON.stringify(join(dir, 'probe.db'))} } })
60}
61resetConnection()
62
63const models = { _qb_boundfrag: { columns: { id: { type: 'integer', isPrimaryKey: true }, name: { type: 'text' }, n: { type: 'integer' } } } }
64const db = createQueryBuilder({ schema: buildDatabaseSchema(models), meta: buildSchemaMeta(models), autoMigration: { enabled: false } })
65if (DIALECT === 'sqlite')
66 await db.unsafe('CREATE TABLE _qb_boundfrag (id integer primary key, name text, n integer)')
67
68const reset = async () => {
69 await db.unsafe('DELETE FROM _qb_boundfrag')
70 await db.unsafe("INSERT INTO _qb_boundfrag (id, name, n) VALUES (1, 'a', 10), (2, 'b', 20)")
71}
72const ns = async () => (await db.unsafe('SELECT n FROM _qb_boundfrag ORDER BY id')).map(r => Number(r.n))
73const ids = rows => rows.map(r => Number(r.id)).sort((a, b) => a - b)
74
75// db.raw(sql, bindings) in set() and where()
76await reset()
77await db.updateTable('_qb_boundfrag').set({ n: db.raw('n - ?', [3]) }).where('id', '=', 1).execute()
78check('set db.raw with binding', await ns(), [7, 20])
79await db.updateTable('_qb_boundfrag').set({ n: db.raw('n + 1') }).where('id', '=', 2).execute()
80check('set db.raw without binding', await ns(), [7, 21])
81check('where db.raw', ids(await db.selectFrom('_qb_boundfrag').where(db.raw('name = ?', ['b'])).get()), [2])
82check('orWhere db.raw', ids(await db.selectFrom('_qb_boundfrag').where('id', '=', 1).orWhere(db.raw('n > ?', [20])).get()), [1, 2])
83check('where db.raw beside where', ids(await db.selectFrom('_qb_boundfrag').where('id', '>', 0).where(db.raw('name = ?', ['a'])).get()), [1])
84check('delete where db.raw', await (async () => {
85 await db.deleteFrom('_qb_boundfrag').where(db.raw('n > ?', [20])).execute()
86 return ids(await db.selectFrom('_qb_boundfrag').get())
87})(), [1])
88
89// { sql, parameters } in the select builder's where()
90await reset()
91check('where { sql, parameters }', ids(await db.selectFrom('_qb_boundfrag').where({ sql: 'name = ?', parameters: ['a'] }).get()), [1])
92
93// db.raw is not a query
94await throws('await db.raw', () => db.raw('SELECT 1 WHERE 1 = ?', [1]), /not a query/)
95await throws('db.raw bindings not an array', async () => db.raw('a = ?', 1), /one array/)
96
97// *Raw methods refuse fragments that carry values
98await throws('whereRaw db.raw', async () => db.selectFrom('_qb_boundfrag').whereRaw(db.raw('name = ?', ['a'])), /no bindings/)
99
100if (DIALECT === 'sqlite') {
101 // The SQLite driver's db.sql fragments carry readable text and values.
102 await reset()
103 await db.updateTable('_qb_boundfrag').set({ n: db.sql\`n + \${5}\` }).where('id', '=', 1).execute()
104 check('set db.sql', await ns(), [15, 20])
105 check('where db.sql', ids(await db.selectFrom('_qb_boundfrag').where(db.sql\`name = \${'b'}\`).get()), [2])
106 await throws('whereRaw db.sql with value', async () => db.selectFrom('_qb_boundfrag').whereRaw(db.sql\`name = \${'a'}\`), /no bindings/)
107 await throws('orderByRaw db.sql with value', async () => db.selectFrom('_qb_boundfrag').orderByRaw(db.sql\`\${'n'} desc\`), /no bindings/)
108 check('selectRaw db.sql without value', (await db.selectFrom('_qb_boundfrag').selectRaw(db.sql\`count(*) as c\`).get()).length > 0, true)
109}
110else {
111 // Bun's Postgres query objects expose no SQL text.
112 await reset()
113 await throws('where db.sql', async () => db.selectFrom('_qb_boundfrag').where(db.sql\`name = \${'a'}\`), /cannot be used as a fragment/)
114 await throws('orWhere db.sql', async () => db.selectFrom('_qb_boundfrag').where('id', '=', 1).orWhere(db.sql\`name = \${'b'}\`), /cannot be used as a fragment/)
115 await throws('set db.sql', async () => db.updateTable('_qb_boundfrag').set({ n: db.sql\`n + \${1}\` }), /cannot be used as a fragment/)
116 await throws('update where db.sql', async () => db.updateTable('_qb_boundfrag').set({ n: 1 }).where(db.sql\`id = \${1}\`), /cannot be used as a fragment/)
117 await throws('delete where db.sql', async () => db.deleteFrom('_qb_boundfrag').where(db.sql\`id = \${1}\`), /cannot be used as a fragment/)
118 check('no rows changed', await ns(), [10, 20])
119 const r = new SQL(PG)
120 await r.unsafe('DROP TABLE IF EXISTS _qb_boundfrag')
121 await r.end()
122}
123
124if (failures.length) {
125 console.error('FAILURES:\\n' + failures.join('\\n'))
126 process.exit(1)
127}
128console.log('OK')
129process.exit(0)
130`)
131
132 const proc = Bun.spawnSync({ cmd: ['bun', scriptPath], stdout: 'pipe', stderr: 'pipe', cwd: process.cwd(), env: { ...process.env } })
133 const dec = new TextDecoder()
134 const result = { code: proc.exitCode, out: dec.decode(proc.stdout).trim(), err: dec.decode(proc.stderr).trim() }
135 rmSync(dir, { recursive: true, force: true })
136 return result
137}
138
139describe('db.sql and db.raw fragments carrying values', () => {
140 it('bind or refuse on SQLite', () => {
141 const { code, out, err } = runProbe('sqlite')
142 expect(code, `sqlite probe failed.\nstdout: ${out}\nstderr: ${err}`).toBe(0)
143 expect(out).toContain('OK')
144 }, 30_000)
145
146 it.skipIf(!pgAvailable)('bind or refuse on Postgres', () => {
147 const { code, out, err } = runProbe('postgres')
148 expect(code, `postgres probe failed.\nstdout: ${out}\nstderr: ${err}`).toBe(0)
149 expect(out).toContain('OK')
150 }, 30_000)
151})