also looking at this
fix(client): honour the hooks a builder was constructed with
#1074
2 files
+176
-19
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | import type { SchemaMeta } from './meta' |
| 5 | 5 | import type { ResolvedPivot } from './pivot' |
| 6 | 6 | import type { DatabaseSchema } from './schema' |
| 7 | import type { QueryBuilderOptions } from './types' | |
| 7 | import type { QueryBuilderOptions, QueryHooks } from './types' | |
| 8 | 8 | import { config, getPlaceholder, getPlaceholders, isMysqlLike, setConfig } from './config' |
| 9 | 9 | import type { DriverConnection } from './db' |
| 10 | 10 | import { bunSql, getOrCreateBunSql, resetConnection } from './db' |
| @@ -2532,6 +2532,21 @@ interface InternalState { | ||
| 2532 | 2532 | meta?: SchemaMeta |
| 2533 | 2533 | schema?: any |
| 2534 | 2534 | txDefaults?: TransactionOptions |
| 2535 | /** | |
| 2536 | * Lifecycle hooks for this builder specifically. | |
| 2537 | * | |
| 2538 | * `createQueryBuilder({ schema, meta, hooks })` is the form the docs have | |
| 2539 | * shown for a long time, and it did not exist: `hooks` was not a member of | |
| 2540 | * this interface, so the call was a `TS2353` excess-property error. The | |
| 2541 | * obvious workaround made it worse rather than better — `as any` compiled, | |
| 2542 | * and then the hooks never fired, because every hook site read the | |
| 2543 | * process-wide `config.hooks` and nothing ever looked at builder state. A | |
| 2544 | * builder that appeared configured silently ignored every hook it was given. | |
| 2545 | * | |
| 2546 | * These merge OVER `config.hooks` per key (see `activeHooks`), so a global | |
| 2547 | * logger or tracer keeps firing for builders that only override one hook. | |
| 2548 | */ | |
| 2549 | hooks?: QueryHooks | |
| 2535 | 2550 | /** |
| 2536 | 2551 | * Set on the builder handed to a `transaction()` callback. A nested |
| 2537 | 2552 | * `transaction()` must open a SAVEPOINT (`tx.savepoint(...)`) rather than a |
| @@ -2766,6 +2781,25 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 2766 | 2781 | const meta = state?.meta |
| 2767 | 2782 | const schema = state?.schema |
| 2768 | 2783 | |
| 2784 | /** | |
| 2785 | * The hooks this builder should fire. | |
| 2786 | * | |
| 2787 | * Read through this rather than reaching for `config.hooks` directly, for the | |
| 2788 | * same reason statements go through `_sql`: a builder can be handed its own, | |
| 2789 | * and ignoring them makes a configured builder silently do nothing. | |
| 2790 | * | |
| 2791 | * Resolved per call, not captured once, because `setConfig({ hooks })` may | |
| 2792 | * land after the builder is constructed — the usual | |
| 2793 | * `export const db = createQueryBuilder(...)` at module scope followed by | |
| 2794 | * configuration at boot. Merged per key so a global logger still fires for a | |
| 2795 | * builder that overrides only `beforeCreate`. | |
| 2796 | */ | |
| 2797 | function activeHooks(): QueryHooks | undefined { | |
| 2798 | if (!state?.hooks) | |
| 2799 | return config.hooks | |
| 2800 | return { ...config.hooks, ...state.hooks } | |
| 2801 | } | |
| 2802 | ||
| 2769 | 2803 | // `whereCreatedAt` → `created_at` resolution for the dynamic-where Proxy, |
| 2770 | 2804 | // cached per (table, prop): the schema is fixed for this builder, and the |
| 2771 | 2805 | // resolution (regex strip + snake-case + column scan) otherwise re-runs on |
| @@ -2894,7 +2928,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 2894 | 2928 | } |
| 2895 | 2929 | |
| 2896 | 2930 | function runWithHooks<T = any>(q: any, kind: 'select' | 'insert' | 'update' | 'delete' | 'raw', opts?: { signal?: AbortSignal, timeoutMs?: number }): Promise<T> { |
| 2897 | const hooks = config.hooks | |
| 2931 | const hooks = activeHooks() | |
| 2898 | 2932 | const slowMs = hooks?.slowQueryThresholdMs |
| 2899 | 2933 | const slowEnabled = slowMs != null && slowMs >= 0 |
| 2900 | 2934 | const hasSlowQuery = Boolean(hooks?.onSlowQuery || slowEnabled) |
| @@ -5605,7 +5639,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5605 | 5639 | return text |
| 5606 | 5640 | }, |
| 5607 | 5641 | async get() { |
| 5608 | const hooks = config.hooks | |
| 5642 | const hooks = activeHooks() | |
| 5609 | 5643 | const hasQueryHooks = hooks && (hooks.onQueryStart || hooks.onQueryEnd || hooks.onQueryError || hooks.startSpan || hasSlowQueryHook(hooks)) |
| 5610 | 5644 | |
| 5611 | 5645 | // Ultra-fast path: skip unsafe() entirely, use _prepareStatement for direct stmt access |
| @@ -5683,7 +5717,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5683 | 5717 | }, |
| 5684 | 5718 | async first() { |
| 5685 | 5719 | // Ultra-fast path: skip overhead, prepare statement directly from text |
| 5686 | const fHooks = config.hooks | |
| 5720 | const fHooks = activeHooks() | |
| 5687 | 5721 | const fHasQueryHooks = fHooks && (fHooks.onQueryStart || fHooks.onQueryEnd || fHooks.onQueryError || fHooks.startSpan || hasSlowQueryHook(fHooks)) |
| 5688 | 5722 | if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !fHasQueryHooks) { |
| 5689 | 5723 | const prepareFn = _sql._prepareStatement |
| @@ -5778,7 +5812,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5778 | 5812 | } |
| 5779 | 5813 | |
| 5780 | 5814 | // Ultra-fast path |
| 5781 | const cHooks = config.hooks | |
| 5815 | const cHooks = activeHooks() | |
| 5782 | 5816 | const cHasHooks = cHooks && (cHooks.onQueryStart || cHooks.onQueryEnd || cHooks.onQueryError || cHooks.startSpan || hasSlowQueryHook(cHooks)) |
| 5783 | 5817 | if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !cHasHooks) { |
| 5784 | 5818 | const prepareFn = _sql._prepareStatement |
| @@ -5804,7 +5838,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5804 | 5838 | : `SELECT AVG(${column}) as a FROM ${table}` |
| 5805 | 5839 | |
| 5806 | 5840 | // Ultra-fast path |
| 5807 | const aHooks = config.hooks | |
| 5841 | const aHooks = activeHooks() | |
| 5808 | 5842 | const aHasHooks = aHooks && (aHooks.onQueryStart || aHooks.onQueryEnd || aHooks.onQueryError || aHooks.startSpan || hasSlowQueryHook(aHooks)) |
| 5809 | 5843 | if (!config.softDeletes?.enabled && !useCache && !timeoutMs && !abortSignal && !aHasHooks) { |
| 5810 | 5844 | const prepareFn = _sql._prepareStatement |
| @@ -6496,7 +6530,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6496 | 6530 | }, |
| 6497 | 6531 | execute() { |
| 6498 | 6532 | // Ultra-fast path: use _prepareStatement to skip unsafe() and runWithHooks overhead |
| 6499 | const hooks = config.hooks | |
| 6533 | const hooks = activeHooks() | |
| 6500 | 6534 | const hasHooks = hooks && (hooks.onQueryStart || hooks.onQueryEnd || hooks.onQueryError || hooks.startSpan || hooks.beforeCreate || hooks.afterCreate || hasSlowQueryHook(hooks)) |
| 6501 | 6535 | if (!hasHooks) { |
| 6502 | 6536 | const prepareFn = _sql._prepareStatement |
| @@ -6914,7 +6948,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6914 | 6948 | }, |
| 6915 | 6949 | async execute() { |
| 6916 | 6950 | try { |
| 6917 | await config.hooks?.beforeDelete?.({ table: String(table), where: whereCondition }) | |
| 6951 | await activeHooks()?.beforeDelete?.({ table: String(table), where: whereCondition }) | |
| 6918 | 6952 | } |
| 6919 | 6953 | catch (err) { |
| 6920 | 6954 | throw err |
| @@ -6923,7 +6957,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6923 | 6957 | const result = mutationCount(await runWithHooks(ensureDelBuilt(), 'delete')) |
| 6924 | 6958 | |
| 6925 | 6959 | try { |
| 6926 | await config.hooks?.afterDelete?.({ table: String(table), where: whereCondition, result }) | |
| 6960 | await activeHooks()?.afterDelete?.({ table: String(table), where: whereCondition, result }) | |
| 6927 | 6961 | } |
| 6928 | 6962 | catch {} |
| 6929 | 6963 | |
| @@ -7049,7 +7083,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7049 | 7083 | }, |
| 7050 | 7084 | async reserve() { |
| 7051 | 7085 | const reserved = await (bunSql as any).reserve() |
| 7052 | const qb = createQueryBuilder<DB>({ sql: reserved, meta, schema }) as any | |
| 7086 | const qb = createQueryBuilder<DB>({ sql: reserved, meta, schema, hooks: state?.hooks }) as any | |
| 7053 | 7087 | qb.release = () => reserved.release() |
| 7054 | 7088 | return qb |
| 7055 | 7089 | }, |
| @@ -7117,7 +7151,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7117 | 7151 | opts.logger?.({ type: 'start', attempt }) |
| 7118 | 7152 | const start = Date.now() |
| 7119 | 7153 | return await (txConn as any)[txMethod](async (tx: any) => { |
| 7120 | const qb = createQueryBuilder<DB>({ sql: tx, meta, schema, inTransaction: true }) | |
| 7154 | const qb = createQueryBuilder<DB>({ sql: tx, meta, schema, hooks: state?.hooks, inTransaction: true }) | |
| 7121 | 7155 | |
| 7122 | 7156 | // Transaction isolation + read-only mode — dialect-specific |
| 7123 | 7157 | // SQL, with a clear "not supported" path for SQLite. The |
| @@ -7226,14 +7260,14 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7226 | 7260 | return await s.savepoint(async (sp: any) => { |
| 7227 | 7261 | // Carry the flag: a savepoint body is still inside a transaction, so a |
| 7228 | 7262 | // nested savepoint()/transaction() from here must nest, not begin. |
| 7229 | const qb = createQueryBuilder<DB>({ sql: sp, meta, schema, inTransaction: true }) | |
| 7263 | const qb = createQueryBuilder<DB>({ sql: sp, meta, schema, hooks: state?.hooks, inTransaction: true }) | |
| 7230 | 7264 | return await fn(qb) |
| 7231 | 7265 | }) |
| 7232 | 7266 | }, |
| 7233 | 7267 | async beginDistributed(name, fn) { |
| 7234 | 7268 | const txConn: any = state?.sql ?? bunSql |
| 7235 | 7269 | const res = await (txConn as any).beginDistributed(name, async (tx: any) => { |
| 7236 | const qb = createQueryBuilder<DB>({ sql: tx, meta, schema }) | |
| 7270 | const qb = createQueryBuilder<DB>({ sql: tx, meta, schema, hooks: state?.hooks }) | |
| 7237 | 7271 | return await fn(qb) |
| 7238 | 7272 | }) |
| 7239 | 7273 | return res as any |
| @@ -7382,13 +7416,13 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7382 | 7416 | async rawQuery(query: string) { |
| 7383 | 7417 | const start = Date.now() |
| 7384 | 7418 | try { |
| 7385 | config.hooks?.onQueryStart?.({ sql: query, kind: 'raw' }) | |
| 7419 | activeHooks()?.onQueryStart?.({ sql: query, kind: 'raw' }) | |
| 7386 | 7420 | const res = await (_sql as any).unsafe(query) |
| 7387 | config.hooks?.onQueryEnd?.({ sql: query, durationMs: Date.now() - start, kind: 'raw' }) | |
| 7421 | activeHooks()?.onQueryEnd?.({ sql: query, durationMs: Date.now() - start, kind: 'raw' }) | |
| 7388 | 7422 | return res |
| 7389 | 7423 | } |
| 7390 | 7424 | catch (err) { |
| 7391 | config.hooks?.onQueryError?.({ sql: query, error: err, durationMs: Date.now() - start, kind: 'raw' }) | |
| 7425 | activeHooks()?.onQueryError?.({ sql: query, error: err, durationMs: Date.now() - start, kind: 'raw' }) | |
| 7392 | 7426 | throw err |
| 7393 | 7427 | } |
| 7394 | 7428 | }, |
| @@ -7397,7 +7431,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7397 | 7431 | |
| 7398 | 7432 | // beforeCreate hook |
| 7399 | 7433 | try { |
| 7400 | await config.hooks?.beforeCreate?.({ table: String(table), data: values }) | |
| 7434 | await activeHooks()?.beforeCreate?.({ table: String(table), data: values }) | |
| 7401 | 7435 | } |
| 7402 | 7436 | catch (err) { |
| 7403 | 7437 | throw err |
| @@ -7424,7 +7458,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7424 | 7458 | |
| 7425 | 7459 | // afterCreate hook |
| 7426 | 7460 | try { |
| 7427 | await config.hooks?.afterCreate?.({ table: String(table), data: values, result: row }) | |
| 7461 | await activeHooks()?.afterCreate?.({ table: String(table), data: values, result: row }) | |
| 7428 | 7462 | } |
| 7429 | 7463 | catch {} |
| 7430 | 7464 | |
| @@ -7448,7 +7482,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7448 | 7482 | |
| 7449 | 7483 | // afterCreate hook |
| 7450 | 7484 | try { |
| 7451 | await config.hooks?.afterCreate?.({ table: String(table), data: values, result: row }) | |
| 7485 | await activeHooks()?.afterCreate?.({ table: String(table), data: values, result: row }) | |
| 7452 | 7486 | } |
| 7453 | 7487 | catch {} |
| 7454 | 7488 | |
| @@ -0,0 +1,123 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 2 | import { buildDatabaseSchema, config, createQueryBuilder, setConfig } from '../src' | |
| 3 | import { resetConnection } from '../src/db' | |
| 4 | ||
| 5 | /** | |
| 6 | * `createQueryBuilder({ hooks })` — per-builder lifecycle hooks. | |
| 7 | * | |
| 8 | * The form has been in the docs and the README for a long time and did not | |
| 9 | * exist: `hooks` was not a member of the builder's internal state, so the call | |
| 10 | * was a `TS2353` excess-property error. What made it worth a test rather than a | |
| 11 | * type tweak is the workaround. `as any` compiled, and then the hooks never | |
| 12 | * fired — every hook site read the process-wide `config.hooks` and nothing | |
| 13 | * looked at builder state — so a builder that appeared configured silently | |
| 14 | * ignored every hook it had been handed. A silent no-op is worse than the | |
| 15 | * compile error in front of it. | |
| 16 | */ | |
| 17 | ||
| 18 | const schema = buildDatabaseSchema({ | |
| 19 | User: { | |
| 20 | name: 'User', | |
| 21 | table: 'users', | |
| 22 | primaryKey: 'id', | |
| 23 | attributes: { id: { validation: { rule: {} } }, name: { validation: { rule: {} } } }, | |
| 24 | }, | |
| 25 | } as any) | |
| 26 | ||
| 27 | let saved: { dialect: any, database: any, hooks: any } | |
| 28 | ||
| 29 | beforeEach(async () => { | |
| 30 | saved = { dialect: config.dialect, database: { ...config.database }, hooks: config.hooks } | |
| 31 | setConfig({ dialect: 'sqlite', database: { database: ':memory:' }, hooks: {} }) | |
| 32 | resetConnection() | |
| 33 | await createQueryBuilder<typeof schema>({ schema }) | |
| 34 | .unsafe('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)') | |
| 35 | }) | |
| 36 | ||
| 37 | afterEach(() => { | |
| 38 | setConfig({ dialect: saved.dialect, database: saved.database, hooks: saved.hooks } as any) | |
| 39 | resetConnection() | |
| 40 | }) | |
| 41 | ||
| 42 | describe('createQueryBuilder({ hooks })', () => { | |
| 43 | it('fires the hooks it was given', async () => { | |
| 44 | const seen: string[] = [] | |
| 45 | // No cast: this is the compile error the issue was about. | |
| 46 | const db = createQueryBuilder<typeof schema>({ | |
| 47 | schema, | |
| 48 | hooks: { onQueryStart: e => void seen.push(String(e.kind)) }, | |
| 49 | }) | |
| 50 | ||
| 51 | await db.selectFrom('users').execute() | |
| 52 | ||
| 53 | expect(seen).toEqual(['select']) | |
| 54 | }) | |
| 55 | ||
| 56 | it('leaves a builder without hooks on the global ones', async () => { | |
| 57 | const seen: string[] = [] | |
| 58 | setConfig({ hooks: { onQueryStart: () => void seen.push('global') } }) | |
| 59 | ||
| 60 | await createQueryBuilder<typeof schema>({ schema }).selectFrom('users').execute() | |
| 61 | ||
| 62 | expect(seen).toEqual(['global']) | |
| 63 | }) | |
| 64 | ||
| 65 | it('merges per key, so a global hook still fires when a builder overrides a different one', async () => { | |
| 66 | // The reason this merges rather than replaces: an app-wide logger or tracer | |
| 67 | // should not go quiet because one builder wanted a `beforeCreate`. | |
| 68 | const globals: string[] = [] | |
| 69 | const locals: string[] = [] | |
| 70 | setConfig({ hooks: { onQueryStart: () => void globals.push('global') } }) | |
| 71 | ||
| 72 | const db = createQueryBuilder<typeof schema>({ | |
| 73 | schema, | |
| 74 | hooks: { onQueryEnd: () => void locals.push('local') }, | |
| 75 | }) | |
| 76 | await db.selectFrom('users').execute() | |
| 77 | ||
| 78 | expect(globals).toEqual(['global']) | |
| 79 | expect(locals).toEqual(['local']) | |
| 80 | }) | |
| 81 | ||
| 82 | it('lets a builder hook override the global one of the same name', async () => { | |
| 83 | const order: string[] = [] | |
| 84 | setConfig({ hooks: { onQueryStart: () => void order.push('global') } }) | |
| 85 | ||
| 86 | const db = createQueryBuilder<typeof schema>({ | |
| 87 | schema, | |
| 88 | hooks: { onQueryStart: () => void order.push('builder') }, | |
| 89 | }) | |
| 90 | await db.selectFrom('users').execute() | |
| 91 | ||
| 92 | expect(order).toEqual(['builder']) | |
| 93 | }) | |
| 94 | ||
| 95 | it('passes its hooks down to the transaction callback builder', async () => { | |
| 96 | // The tx callback gets a NEW builder around the transaction's connection; | |
| 97 | // without threading, it silently lost the parent's hooks. | |
| 98 | const seen: string[] = [] | |
| 99 | const db = createQueryBuilder<typeof schema>({ | |
| 100 | schema, | |
| 101 | hooks: { onQueryStart: () => void seen.push('tx') }, | |
| 102 | }) | |
| 103 | ||
| 104 | await db.transaction(async (tx) => { | |
| 105 | await tx.selectFrom('users').execute() | |
| 106 | }) | |
| 107 | ||
| 108 | expect(seen.length).toBeGreaterThan(0) | |
| 109 | }) | |
| 110 | ||
| 111 | it('sees hooks configured after the builder was constructed', async () => { | |
| 112 | // `export const db = createQueryBuilder(...)` at module scope, then | |
| 113 | // `setConfig({ hooks })` at boot, is the ordinary shape of an app. Capturing | |
| 114 | // hooks once at construction would silently miss all of them. | |
| 115 | const seen: string[] = [] | |
| 116 | const db = createQueryBuilder<typeof schema>({ schema }) | |
| 117 | ||
| 118 | setConfig({ hooks: { onQueryStart: () => void seen.push('late') } }) | |
| 119 | await db.selectFrom('users').execute() | |
| 120 | ||
| 121 | expect(seen).toEqual(['late']) | |
| 122 | }) | |
| 123 | }) | |