also looking at this
refactor(types): stop publishing `any` across the query builder's surface
#1117
13 files
+359
-110
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { DatabaseConfig, PoolConfig, SupportedDialect } from './types' |
| 2 | import type { SQLQueryBindings } from 'bun:sqlite' | |
| 2 | 3 | import { SQL } from 'bun' |
| 3 | 4 | |
| 4 | 5 | /** |
| @@ -6,6 +7,42 @@ import { SQL } from 'bun' | ||
| 6 | 7 | * Both Bun's native `SQL` query and our `createSQLiteSQL` wrapper satisfy this. |
| 7 | 8 | * See stacksjs/bun-query-builder#1044. |
| 8 | 9 | */ |
| 10 | /** | |
| 11 | * What a value interpolated into a tagged template can turn out to be. | |
| 12 | * | |
| 13 | * Three things arrive here: a plain bind param, a nested query (carries `sql` | |
| 14 | * plus a `values` ARRAY), and a raw-SQL marker (carries `__raw`/`raw` and no | |
| 15 | * `values`). Naming the shape is what lets the branches below read properties | |
| 16 | * without an `any` - and the order of those branches is load-bearing, see | |
| 17 | * stacksjs/bun-query-builder#1084. | |
| 18 | */ | |
| 19 | export interface EmbeddedValue { | |
| 20 | sql?: unknown | |
| 21 | values?: unknown | |
| 22 | __raw?: unknown | |
| 23 | raw?: unknown | |
| 24 | value?: unknown | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * The one place user values become driver bindings. | |
| 29 | * | |
| 30 | * Everything a caller interpolates arrives as `unknown` - that is honest, we | |
| 31 | * do not know what it is. bun:sqlite accepts a narrower set. The conversion is | |
| 32 | * unchecked because the driver itself is the validator: it throws a clear | |
| 33 | * "can't bind" for anything it cannot take, and a check here would only | |
| 34 | * duplicate that rule and drift from it. Kept as one named function so the | |
| 35 | * boundary is visible, rather than an `any` at each of the six call sites. | |
| 36 | */ | |
| 37 | export function toBindings(params: readonly unknown[]): SQLQueryBindings[] { | |
| 38 | return params as SQLQueryBindings[] | |
| 39 | } | |
| 40 | ||
| 41 | /** A template value seen as a possible fragment; null when it is a plain bind param. */ | |
| 42 | export function asEmbeddedValue(value: unknown): EmbeddedValue | null { | |
| 43 | return value !== null && typeof value === 'object' ? value as EmbeddedValue : null | |
| 44 | } | |
| 45 | ||
| 9 | 46 | export interface DriverQuery { |
| 10 | 47 | execute: () => Promise<any> |
| 11 | 48 | values?: () => any |
renamed throughout · 14 hidden lines
| @@ -30,11 +67,11 @@ export type AwaitableDriverQuery = DriverQuery & PromiseLike<any> renamed throughout | ||
| 30 | 67 | */ |
| 31 | 68 | export interface DriverConnection { |
| 32 | 69 | /** Tagged-template form: `` sql`SELECT 1` ``. */ |
| 33 | (strings: TemplateStringsArray, ...values: any[]): DriverQuery | |
| 70 | (strings: TemplateStringsArray, ...values: unknown[]): DriverQuery | |
| 34 | 71 | /** Function form for raw identifiers / value helpers: `sql('col')`. */ |
| 35 | 72 | (value: any): any |
| 36 | unsafe: (sql: string, values?: any[]) => AwaitableDriverQuery | |
| 37 | query?: (sql: string, params?: any[]) => any | |
| 73 | unsafe: (sql: string, values?: unknown[]) => AwaitableDriverQuery | |
| 74 | query?: (sql: string, params?: unknown[]) => any | |
| 38 | 75 | close?: () => Promise<void> | void |
| 39 | 76 | _prepareStatement?: (sql: string) => any |
| 40 | 77 | [key: string]: any |
| @@ -74,11 +111,11 @@ import { applySqliteBootstrapPragmas } from './sqlite-pragmas' | ||
| 74 | 111 | * numbered placeholders is returned untouched, so `?` queries and Bun's own |
| 75 | 112 | * tagged-template output are unaffected. |
| 76 | 113 | */ |
| 77 | export function bindNumberedPlaceholders(sql: string, params: any[]): { sql: string, params: any[] } { | |
| 114 | export function bindNumberedPlaceholders<T>(sql: string, params: T[]): { sql: string, params: T[] } { | |
| 78 | 115 | if (params.length === 0 || !/\$\d/.test(sql)) |
| 79 | 116 | return { sql, params } |
| 80 | 117 | |
| 81 | const out: any[] = [] | |
| 118 | const out: T[] = [] | |
| 82 | 119 | let rewritten = '' |
| 83 | 120 | let highest = 0 |
| 84 | 121 | |
| @@ -185,7 +222,7 @@ class SQLiteWrapper { | ||
| 185 | 222 | * Execute a query with parameters and return results. |
| 186 | 223 | * This mimics the SQL tagged template literal behavior. |
| 187 | 224 | */ |
| 188 | query(sql: string, params: any[] = []): any[] { | |
| 225 | query(sql: string, params: SQLQueryBindings[] = []): any[] { | |
| 189 | 226 | const bound = bindNumberedPlaceholders(sql, params) |
| 190 | 227 | const stmt = this.db.prepare(bound.sql) |
| 191 | 228 | return stmt.all(...bound.params) |
| @@ -194,7 +231,7 @@ class SQLiteWrapper { | ||
| 194 | 231 | /** |
| 195 | 232 | * Execute a query that doesn't return results (INSERT, UPDATE, DELETE). |
| 196 | 233 | */ |
| 197 | run(sql: string, params: any[] = []): any { | |
| 234 | run(sql: string, params: SQLQueryBindings[] = []): any { | |
| 198 | 235 | const bound = bindNumberedPlaceholders(sql, params) |
| 199 | 236 | const stmt = this.db.prepare(bound.sql) |
| 200 | 237 | return stmt.run(...bound.params) |
renamed throughout · 12 hidden lines
| @@ -364,10 +401,10 @@ function createSQLiteSQL(filename: string): SQL { renamed throughout | ||
| 364 | 401 | /** |
| 365 | 402 | * Process a tagged template literal and return a query object |
| 366 | 403 | */ |
| 367 | function processTaggedTemplate(strings: TemplateStringsArray, ...values: any[]): any { | |
| 404 | function processTaggedTemplate(strings: TemplateStringsArray, ...values: unknown[]): any { | |
| 368 | 405 | // Build the SQL string with placeholders |
| 369 | 406 | let sql = strings[0] |
| 370 | const params: any[] = [] | |
| 407 | const params: unknown[] = [] | |
| 371 | 408 | |
| 372 | 409 | for (let i = 0; i < values.length; i++) { |
| 373 | 410 | const value = values[i] |
| @@ -396,13 +433,16 @@ function createSQLiteSQL(filename: string): SQL { | ||
| 396 | 433 | // The discriminator is `values` being an ARRAY: raw markers carry |
| 397 | 434 | // `__raw` and no `values`, and Bun's native query objects expose `values` |
| 398 | 435 | // as a method, so neither is captured here. |
| 399 | if (value && typeof value === 'object' && typeof value.sql === 'string' && Array.isArray(value.values)) { | |
| 400 | sql += value.sql + (strings[i + 1] || '') | |
| 401 | params.push(...value.values) | |
| 436 | const embedded = asEmbeddedValue(value) | |
| 437 | ||
| 438 | if (embedded && typeof embedded.sql === 'string' && Array.isArray(embedded.values)) { | |
| 439 | sql += embedded.sql + (strings[i + 1] || '') | |
| 440 | params.push(...embedded.values) | |
| 402 | 441 | } |
| 403 | 442 | // Handle raw SQL markers (from sql('identifier') or sql.raw()) |
| 404 | else if (value && typeof value === 'object' && (value.__raw || value.raw)) { | |
| 405 | const rawValue = value.__raw ? value.value : (typeof value.raw === 'string' ? value.raw : value.raw()) | |
| 443 | else if (embedded && (embedded.__raw || embedded.raw)) { | |
| 444 | const raw = embedded.raw | |
| 445 | const rawValue = embedded.__raw ? embedded.value : (typeof raw === 'string' ? raw : (raw as () => string)()) | |
| 406 | 446 | sql += rawValue + (strings[i + 1] || '') |
| 407 | 447 | } |
| 408 | 448 | // Handle arrays - expand to placeholders |
| @@ -432,11 +472,11 @@ function createSQLiteSQL(filename: string): SQL { | ||
| 432 | 472 | // caller silently loses its data. |
| 433 | 473 | const trimmed = sql.trim().toUpperCase() |
| 434 | 474 | if (trimmed.startsWith('SELECT') || trimmed.startsWith('PRAGMA') || /\bRETURNING\b/.test(trimmed)) { |
| 435 | const result = wrapper.query(sql, params) | |
| 475 | const result = wrapper.query(sql, toBindings(params)) | |
| 436 | 476 | return Promise.resolve(result) |
| 437 | 477 | } |
| 438 | 478 | else { |
| 439 | const result = wrapper.run(sql, params) | |
| 479 | const result = wrapper.run(sql, toBindings(params)) | |
| 440 | 480 | return Promise.resolve(result) |
| 441 | 481 | } |
| 442 | 482 | } |
| @@ -455,7 +495,7 @@ function createSQLiteSQL(filename: string): SQL { | ||
| 455 | 495 | * - Tagged template literals: sql`SELECT * FROM users` |
| 456 | 496 | * - Function calls for raw identifiers: sql('column_name') |
| 457 | 497 | */ |
| 458 | function sqlFunction(stringsOrValue: TemplateStringsArray | string, ...values: any[]): any { | |
| 498 | function sqlFunction(stringsOrValue: TemplateStringsArray | string, ...values: unknown[]): any { | |
| 459 | 499 | // If called with a template literal (array of strings) |
| 460 | 500 | if (Array.isArray(stringsOrValue) && 'raw' in stringsOrValue) { |
| 461 | 501 | return processTaggedTemplate(stringsOrValue as TemplateStringsArray, ...values) |
| @@ -475,7 +515,7 @@ function createSQLiteSQL(filename: string): SQL { | ||
| 475 | 515 | sqlFunction.raw = (str: string) => createRawMarker(str) |
| 476 | 516 | |
| 477 | 517 | // Add .unsafe() method for raw SQL with parameters (like Bun's SQL.unsafe) |
| 478 | sqlFunction.unsafe = (sql: string, params: any[] = []) => { | |
| 518 | sqlFunction.unsafe = (sql: string, params: unknown[] = []) => { | |
| 479 | 519 | const execute = (): Promise<any> => { |
| 480 | 520 | try { |
| 481 | 521 | // A RETURNING clause turns an INSERT/UPDATE/DELETE into a row producer; |
| @@ -483,11 +523,11 @@ function createSQLiteSQL(filename: string): SQL { | ||
| 483 | 523 | // `.run()`'s { changes, lastInsertRowid }. See the tagged-template path. |
| 484 | 524 | const trimmed = sql.trim().toUpperCase() |
| 485 | 525 | if (trimmed.startsWith('SELECT') || trimmed.startsWith('PRAGMA') || /\bRETURNING\b/.test(trimmed)) { |
| 486 | const result = wrapper.query(sql, params) | |
| 526 | const result = wrapper.query(sql, toBindings(params)) | |
| 487 | 527 | return Promise.resolve(result) |
| 488 | 528 | } |
| 489 | 529 | else { |
| 490 | const result = wrapper.run(sql, params) | |
| 530 | const result = wrapper.run(sql, toBindings(params)) | |
| 491 | 531 | return Promise.resolve(result) |
| 492 | 532 | } |
| 493 | 533 | } |
| @@ -522,9 +562,9 @@ function createSQLiteSQL(filename: string): SQL { | ||
| 522 | 562 | } |
| 523 | 563 | |
| 524 | 564 | // Add .query() method for direct SQL execution |
| 525 | sqlFunction.query = (sql: string, params?: any[]) => { | |
| 565 | sqlFunction.query = (sql: string, params?: unknown[]) => { | |
| 526 | 566 | try { |
| 527 | const result = wrapper.query(sql, params || []) | |
| 567 | const result = wrapper.query(sql, toBindings(params || [])) | |
| 528 | 568 | return Promise.resolve(result) |
| 529 | 569 | } |
| 530 | 570 | catch (error) { |
| @@ -533,7 +573,7 @@ function createSQLiteSQL(filename: string): SQL { | ||
| 533 | 573 | } |
| 534 | 574 | |
| 535 | 575 | // Add .file() method for executing SQL from a file |
| 536 | sqlFunction.file = async (filePath: string, _params?: any[]) => { | |
| 576 | sqlFunction.file = async (filePath: string, _params?: unknown[]) => { | |
| 537 | 577 | try { |
| 538 | 578 | const { readFileSync } = await import('node:fs') |
| 539 | 579 | const sqlContent = readFileSync(filePath, 'utf-8') |