also looking at this
fix(client)!: bind db.raw and db.sql fragment values or refuse them
#1152
16 files
+455
-118
| @@ -16,47 +16,54 @@ import { createQueryBuilder } from 'bun-query-builder' | ||
| 16 | 16 | |
| 17 | 17 | const db = createQueryBuilder<typeof schema>({ schema, meta }) |
| 18 | 18 | |
| 19 | // Raw query with parameters | |
| 20 | const users = await db.raw( | |
| 19 | // Raw query with parameters (SQLite and MySQL use `?` placeholders) | |
| 20 | const users = await db.unsafe( | |
| 21 | 21 | 'SELECT * FROM users WHERE active = ? AND age > ?', |
| 22 | 22 | [true, 18] |
| 23 | 23 | ) |
| 24 | 24 | |
| 25 | // Using named parameters | |
| 26 | const 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 | |
| 26 | const posts = await db.unsafe( | |
| 27 | 'SELECT * FROM posts WHERE user_id = $1 AND published = $2', | |
| 28 | [1, true] | |
| 29 | 29 | ) |
| 30 | 30 | ``` |
| 31 | 31 | |
| 32 | `db.unsafe(sql, params)` runs the statement and resolves to its rows. `db.raw(sql, bindings)` | |
| 33 | does 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 | ||
| 32 | 36 | ## Raw Expressions in Queries |
| 33 | 37 | |
| 34 | 38 | Use raw expressions within the query builder: |
| 35 | 39 | |
| 36 | 40 | ```typescript |
| 41 | import { raw } from 'bun-query-builder' | |
| 42 | ||
| 37 | 43 | // Raw in select |
| 38 | 44 | const results = await db |
| 39 | 45 | .selectFrom('users') |
| 40 | .selectRaw('COUNT(*) AS total, AVG(age) AS avg_age') | |
| 46 | .selectRaw(raw`COUNT(*) AS total, AVG(age) AS avg_age`) | |
| 41 | 47 | .get() |
| 42 | 48 | |
| 43 | // Raw in where | |
| 49 | // Raw in where (the interpolated Date is inlined as an escaped literal) | |
| 50 | const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) | |
| 44 | 51 | const recentUsers = await db |
| 45 | 52 | .selectFrom('users') |
| 46 | .whereRaw('DATE(created_at) > DATE_SUB(NOW(), INTERVAL 30 DAY)') | |
| 53 | .whereRaw(raw`created_at > ${since}`) | |
| 47 | 54 | .get() |
| 48 | 55 | |
| 49 | 56 | // Raw in order by |
| 50 | 57 | const sorted = await db |
| 51 | 58 | .selectFrom('products') |
| 52 | .orderByRaw('price * quantity DESC') | |
| 59 | .orderByRaw(raw`price * quantity DESC`) | |
| 53 | 60 | .get() |
| 54 | 61 | |
| 55 | 62 | // Raw in group by |
| 56 | 63 | const grouped = await db |
| 57 | 64 | .selectFrom('orders') |
| 58 | 65 | .select(['SUM(amount) AS total']) |
| 59 | .groupByRaw("strftime('%Y-%m', created_at)") | |
| 66 | .groupByRaw(raw`strftime('%Y-%m', created_at)`) | |
| 60 | 67 | .get() |
| 61 | 68 | |
| 62 | 69 | // Raw in having |
| @@ -64,7 +71,7 @@ const filtered = await db | ||
| 64 | 71 | .selectFrom('orders') |
| 65 | 72 | .select(['user_id', 'SUM(amount) AS total']) |
| 66 | 73 | .groupBy('user_id') |
| 67 | .havingRaw('SUM(amount) > 1000') | |
| 74 | .havingRaw(raw`SUM(amount) > 1000`) | |
| 68 | 75 | .get() |
| 69 | 76 | |
| 70 | 77 | ``` |
| @@ -123,13 +130,48 @@ await db.selectFrom('users').orderByRaw(raw`created_at desc`).get() | ||
| 123 | 130 | await db.selectFrom('orders').whereRaw(raw`status = ${userStatus}`).get() |
| 124 | 131 | ``` |
| 125 | 132 | |
| 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`. | |
| 133 | The `*Raw` methods take exactly one fragment and no bindings. Passing extra | |
| 134 | arguments, or a fragment that carries bound values (`db.raw('x = ?', [v])`), | |
| 135 | throws instead of silently dropping the values. Bare strings still work at | |
| 136 | runtime 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 | ||
| 148 | To bind values inside a SQL expression, build a fragment with | |
| 149 | `db.raw(sql, bindings)`. Each `?` binds the matching array element, on every | |
| 150 | dialect (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() | |
| 155 | await 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 | |
| 162 | const matches = await db | |
| 163 | .selectFrom('users') | |
| 164 | .where(db.raw('LOWER(email) = ?', [email.toLowerCase()])) | |
| 165 | .get() | |
| 166 | ||
| 167 | await 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 ...', [..])` | |
| 174 | rejects with a `TypeError`. Use `db.unsafe(sql, params)` to run SQL. | |
| 133 | 175 | |
| 134 | 176 | ## Raw with Bun Tagged Templates |
| 135 | 177 | |
| @@ -176,7 +218,7 @@ interface UserStats { | ||
| 176 | 218 | avg_age: number |
| 177 | 219 | } |
| 178 | 220 | |
| 179 | const stats = await db.raw<UserStats[]>(` | |
| 221 | const stats = await db.unsafe<UserStats>(` | |
| 180 | 222 | SELECT |
| 181 | 223 | country, |
| 182 | 224 | COUNT(*) AS count, |
| @@ -220,7 +262,7 @@ Execute raw queries within transactions: | ||
| 220 | 262 | |
| 221 | 263 | await db.transaction(async (trx) => { |
| 222 | 264 | // Raw insert |
| 223 | await trx.raw( | |
| 265 | await trx.unsafe( | |
| 224 | 266 | 'INSERT INTO audit_log (action, user_id) VALUES (?, ?)', |
| 225 | 267 | ['login', userId] |
| 226 | 268 | ) |
| @@ -229,7 +271,7 @@ await db.transaction(async (trx) => { | ||
| 229 | 271 | await trx.updateTable('users').set({ last_login: new Date() }).where({ id: userId }) |
| 230 | 272 | |
| 231 | 273 | // Raw update |
| 232 | await trx.raw( | |
| 274 | await trx.unsafe( | |
| 233 | 275 | 'UPDATE statistics SET login_count = login_count + 1 WHERE user_id = ?', |
| 234 | 276 | [userId] |
| 235 | 277 | ) |
| @@ -256,7 +298,7 @@ console.log(explain) | ||
| 256 | 298 | |
| 257 | 299 | ```typescript |
| 258 | 300 | |
| 259 | import { createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from 'bun-query-builder' | |
| 301 | import { createQueryBuilder, buildDatabaseSchema, buildSchemaMeta, raw } from 'bun-query-builder' | |
| 260 | 302 | |
| 261 | 303 | const models = { |
| 262 | 304 | User: { |
| @@ -288,7 +330,7 @@ async function getComplexAnalytics() { | ||
| 288 | 330 | total_active: number |
| 289 | 331 | } |
| 290 | 332 | |
| 291 | const stats = await db.raw<MonthlyStats[]>(` | |
| 333 | const stats = await db.unsafe<MonthlyStats>(` | |
| 292 | 334 | WITH monthly_users AS ( |
| 293 | 335 | SELECT |
| 294 | 336 | strftime('%Y-%m', created_at) AS month, |
| @@ -310,15 +352,15 @@ async function getComplexAnalytics() { | ||
| 310 | 352 | // Combine with query builder |
| 311 | 353 | const basicStats = await db |
| 312 | 354 | .selectFrom('users') |
| 313 | .selectRaw(` | |
| 355 | .selectRaw(raw` | |
| 314 | 356 | COUNT(*) AS total, |
| 315 | 357 | COUNT(CASE WHEN active = 1 THEN 1 END) AS active, |
| 316 | 358 | AVG(age) AS avg_age |
| 317 | 359 | `) |
| 318 | 360 | .first() |
| 319 | 361 | |
| 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( | |
| 322 | 364 | ` |
| 323 | 365 | SELECT |
| 324 | 366 | country, |