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
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,