turbine-orm 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/query.js CHANGED
@@ -174,16 +174,44 @@ class LRUCache {
174
174
  return this.cache.size;
175
175
  }
176
176
  }
177
+ /**
178
+ * FNV-1a 64-bit hash returning 16 lowercase hex chars.
179
+ * Single-loop string iteration. Uses BigInt for 64-bit math.
180
+ *
181
+ * @internal Exported for testing only.
182
+ */
183
+ export function fnv1a64Hex(s) {
184
+ // FNV-1a offset basis and prime for 64-bit
185
+ let hash = 0xcbf29ce484222325n;
186
+ const prime = 0x100000001b3n;
187
+ const mask = 0xffffffffffffffffn; // 64-bit mask
188
+ for (let i = 0; i < s.length; i++) {
189
+ hash ^= BigInt(s.charCodeAt(i));
190
+ hash = (hash * prime) & mask;
191
+ }
192
+ return hash.toString(16).padStart(16, '0');
193
+ }
194
+ /**
195
+ * Derive a prepared-statement name from a SQL string.
196
+ * Format: `t_<16hex>` — always 18 chars, well under NAMEDATALEN (63).
197
+ *
198
+ * @internal Exported for testing only.
199
+ */
200
+ export function sqlToPreparedName(sql) {
201
+ return `t_${fnv1a64Hex(sql)}`;
202
+ }
177
203
  export class QueryInterface {
178
204
  pool;
179
205
  table;
180
206
  schema;
181
207
  tableMeta;
182
- /** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
183
- sqlCache = new LRUCache(1000);
208
+ /** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
209
+ sqlTemplateCache = new LRUCache(1000);
184
210
  middlewares;
185
211
  defaultLimit;
186
212
  warnOnUnlimited;
213
+ preparedStatementsEnabled;
214
+ sqlCacheEnabled;
187
215
  /**
188
216
  * Tracks tables that have already triggered an unlimited-query warning so
189
217
  * the user is not spammed once per row. Per-instance state — each
@@ -193,6 +221,9 @@ export class QueryInterface {
193
221
  * cross-table sharing.
194
222
  */
195
223
  warnedTables = new Set();
224
+ /** Cache hit/miss counters for diagnostics */
225
+ cacheHits = 0;
226
+ cacheMisses = 0;
196
227
  /** Pre-computed column type lookups (avoids linear scans per query) */
197
228
  columnPgTypeMap;
198
229
  columnArrayTypeMap;
@@ -211,6 +242,8 @@ export class QueryInterface {
211
242
  // than the (small) risk of noisy logs. Callers explicitly opt out with
212
243
  // `warnOnUnlimited: false`.
213
244
  this.warnOnUnlimited = options?.warnOnUnlimited !== false;
245
+ this.preparedStatementsEnabled = options?.preparedStatements ?? true;
246
+ this.sqlCacheEnabled = options?.sqlCache !== false;
214
247
  // Pre-compute column type lookup maps (TASK-26)
215
248
  this.columnPgTypeMap = new Map();
216
249
  this.columnArrayTypeMap = new Map();
@@ -219,6 +252,43 @@ export class QueryInterface {
219
252
  this.columnArrayTypeMap.set(col.name, col.pgArrayType);
220
253
  }
221
254
  }
255
+ /**
256
+ * Return cache hit/miss statistics for this QueryInterface instance.
257
+ * Useful for monitoring and benchmarking.
258
+ */
259
+ cacheStats() {
260
+ const total = this.cacheHits + this.cacheMisses;
261
+ return {
262
+ hits: this.cacheHits,
263
+ misses: this.cacheMisses,
264
+ hitRate: total > 0 ? this.cacheHits / total : 0,
265
+ size: this.sqlTemplateCache.size,
266
+ };
267
+ }
268
+ /**
269
+ * Look up or build a SQL template in the cache.
270
+ * On miss, calls `build()` to generate the SQL, stores the entry, and returns it.
271
+ * On hit, increments counters and returns the cached entry.
272
+ *
273
+ * When `sqlCache` is disabled, always calls `build()` without caching.
274
+ */
275
+ acquireSql(cacheKey, build) {
276
+ if (!this.sqlCacheEnabled) {
277
+ const sql = build();
278
+ this.cacheMisses++;
279
+ return { sql, name: sqlToPreparedName(sql) };
280
+ }
281
+ const cached = this.sqlTemplateCache.get(cacheKey);
282
+ if (cached) {
283
+ this.cacheHits++;
284
+ return cached;
285
+ }
286
+ const sql = build();
287
+ const entry = { sql, name: sqlToPreparedName(sql) };
288
+ this.sqlTemplateCache.set(cacheKey, entry);
289
+ this.cacheMisses++;
290
+ return entry;
291
+ }
222
292
  /**
223
293
  * Reset the per-instance unlimited-query warning dedupe set.
224
294
  * Exposed for tests so a single test process can verify the warning fires
@@ -232,10 +302,16 @@ export class QueryInterface {
232
302
  * If timeout is set, races the query against a timer and rejects on expiry.
233
303
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
234
304
  */
235
- async queryWithTimeout(sql, params, timeout) {
305
+ async queryWithTimeout(sql, params, timeout, preparedName) {
306
+ // Build the query argument — use object form with `name` for prepared
307
+ // statements, or the plain (text, values) form otherwise.
308
+ const usePrepared = preparedName && this.preparedStatementsEnabled;
309
+ const exec = usePrepared
310
+ ? this.pool.query({ name: preparedName, text: sql, values: params })
311
+ : this.pool.query(sql, params);
236
312
  if (!timeout) {
237
313
  try {
238
- return await this.pool.query(sql, params);
314
+ return await exec;
239
315
  }
240
316
  catch (err) {
241
317
  throw wrapPgError(err);
@@ -246,7 +322,7 @@ export class QueryInterface {
246
322
  timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
247
323
  });
248
324
  try {
249
- return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
325
+ return await Promise.race([exec, timeoutPromise]);
250
326
  }
251
327
  catch (err) {
252
328
  throw wrapPgError(err);
@@ -287,71 +363,100 @@ export class QueryInterface {
287
363
  async findUnique(args) {
288
364
  return this.executeWithMiddleware('findUnique', args, async () => {
289
365
  const deferred = this.buildFindUnique(args);
290
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
366
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
291
367
  return deferred.transform(result);
292
368
  });
293
369
  }
294
370
  buildFindUnique(args) {
295
371
  const columnsList = this.resolveColumns(args.select, args.omit);
296
372
  const whereObj = args.where;
373
+ const colKey = columnsList ? columnsList.join(',') : '*';
374
+ const whereFingerprint = this.fingerprintWhere(whereObj);
375
+ const withFp = args.with ? this.withFingerprint(args.with) : '';
376
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
377
+ const params = [];
297
378
  // Check if all where values are simple (plain equality, no operators/null/OR)
298
379
  const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
299
380
  const isSimpleWhere = !whereObj.OR &&
381
+ !whereObj.AND &&
382
+ !whereObj.NOT &&
300
383
  whereKeys.every((k) => {
301
384
  const v = whereObj[k];
302
- return v !== null && !isWhereOperator(v);
385
+ return v !== null && !isWhereOperator(v) && !this.tableMeta.relations[k];
303
386
  });
304
- // For simple queries (no nested with, no operators), use cached SQL template
387
+ // Simple path: plain equality, no operators/null/OR
305
388
  if (!args.with && isSimpleWhere) {
306
- const colKey = columnsList ? columnsList.join(',') : '*';
307
- const ck = `fu:${whereKeys.sort().join(',')}:c=${colKey}`;
308
- let sql = this.sqlCache.get(ck);
309
- const params = whereKeys.map((k) => whereObj[k]);
310
- if (!sql) {
389
+ const entry = this.acquireSql(ck, () => {
311
390
  const qt = quoteIdent(this.table);
391
+ const tempParams = whereKeys.map((k) => whereObj[k]);
312
392
  const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
313
393
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
314
394
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
315
- sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
316
- this.sqlCache.set(ck, sql);
395
+ void tempParams; // params are positional, SQL is value-invariant
396
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
397
+ });
398
+ // Collect params (same order as build)
399
+ for (const k of whereKeys) {
400
+ params.push(whereObj[k]);
317
401
  }
318
402
  return {
319
- sql,
403
+ sql: entry.sql,
320
404
  params,
321
405
  transform: (result) => {
322
406
  const row = result.rows[0];
323
407
  return row ? this.parseRow(row, this.table) : null;
324
408
  },
325
409
  tag: `${this.table}.findUnique`,
410
+ preparedName: entry.name,
326
411
  };
327
412
  }
328
- // General path: supports operators, null, OR, nested with
329
- const { sql: whereSql, params } = this.buildWhere(args.where);
413
+ // General path (with operators, null, OR, with clause)
330
414
  if (!args.with) {
331
- const qt = quoteIdent(this.table);
332
- const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
333
- const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
415
+ const entry = this.acquireSql(ck, () => {
416
+ const freshParams = [];
417
+ const clause = this.buildWhereClause(whereObj, freshParams);
418
+ const whereSql = clause ? ` WHERE ${clause}` : '';
419
+ const qt = quoteIdent(this.table);
420
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
421
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
422
+ });
423
+ // Collect params
424
+ this.collectWhereParams(whereObj, params);
334
425
  return {
335
- sql,
426
+ sql: entry.sql,
336
427
  params,
337
428
  transform: (result) => {
338
429
  const row = result.rows[0];
339
430
  return row ? this.parseRow(row, this.table) : null;
340
431
  },
341
432
  tag: `${this.table}.findUnique`,
433
+ preparedName: entry.name,
342
434
  };
343
435
  }
344
- // Nested queries: build fresh each time (with clause affects params)
345
- const selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
346
- const sql = `SELECT ${selectClause} FROM ${quoteIdent(this.table)}${whereSql} LIMIT 1`;
436
+ // Nested queries with `with` clause.
437
+ // The param order in the original code is:
438
+ // 1. buildWhere pushes where params
439
+ // 2. buildSelectWithRelations pushes relation params to same array
440
+ // We must preserve this exact order.
441
+ const entry = this.acquireSql(ck, () => {
442
+ const freshParams = [];
443
+ const clause = this.buildWhereClause(whereObj, freshParams);
444
+ const whereSql = clause ? ` WHERE ${clause}` : '';
445
+ const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
446
+ return `SELECT ${selectClause} FROM ${quoteIdent(this.table)}${whereSql} LIMIT 1`;
447
+ });
448
+ // Collect params in exact build order: where first, then with-clause relations
449
+ this.collectWhereParams(whereObj, params);
450
+ this.collectWithParams(args.with, params);
347
451
  return {
348
- sql,
452
+ sql: entry.sql,
349
453
  params,
350
454
  transform: (result) => {
351
455
  const row = result.rows[0];
352
456
  return row ? this.parseNestedRow(row, this.table) : null;
353
457
  },
354
458
  tag: `${this.table}.findUnique`,
459
+ preparedName: entry.name,
355
460
  };
356
461
  }
357
462
  // -------------------------------------------------------------------------
@@ -361,7 +466,7 @@ export class QueryInterface {
361
466
  this.maybeWarnUnlimited(args);
362
467
  return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
363
468
  const deferred = this.buildFindMany(args);
364
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
469
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
365
470
  return deferred.transform(result);
366
471
  });
367
472
  }
@@ -390,65 +495,116 @@ export class QueryInterface {
390
495
  'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
391
496
  }
392
497
  buildFindMany(args) {
393
- const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
394
498
  const columnsList = this.resolveColumns(args?.select, args?.omit);
395
- const qt = quoteIdent(this.table);
396
- // Distinct support
397
- let distinctPrefix = '';
398
- if (args?.distinct && args.distinct.length > 0) {
399
- const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
400
- distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
499
+ const colKey = columnsList ? columnsList.join(',') : '*';
500
+ const whereObj = (args?.where ?? {});
501
+ // Build fingerprint for cache lookup
502
+ const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
503
+ const withFp = args?.with ? this.withFingerprint(args.with) : '';
504
+ const orderFp = args?.orderBy
505
+ ? Object.entries(args.orderBy)
506
+ .map(([k, d]) => `${k}:${d}`)
507
+ .join(',')
508
+ : '';
509
+ const cursorFp = args?.cursor
510
+ ? Object.keys(args.cursor)
511
+ .filter((k) => args.cursor[k] !== undefined)
512
+ .sort()
513
+ .join(',')
514
+ : '';
515
+ const distinctFp = args?.distinct ? args.distinct.slice().sort().join(',') : '';
516
+ const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
517
+ const limitFp = effectiveLimit !== undefined ? '1' : '0';
518
+ const offsetFp = args?.offset !== undefined ? '1' : '0';
519
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}`;
520
+ const params = [];
521
+ const entry = this.acquireSql(ck, () => {
522
+ // Fresh build — generates SQL and populates freshParams
523
+ const freshParams = [];
524
+ const { sql: freshWhereSql } = args?.where
525
+ ? (() => {
526
+ const clause = this.buildWhereClause(whereObj, freshParams);
527
+ return { sql: clause ? ` WHERE ${clause}` : '' };
528
+ })()
529
+ : { sql: '' };
530
+ const qt = quoteIdent(this.table);
531
+ let distinctPrefix = '';
532
+ if (args?.distinct && args.distinct.length > 0) {
533
+ const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
534
+ distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
535
+ }
536
+ let selectClause;
537
+ if (args?.with) {
538
+ selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
539
+ }
540
+ else if (columnsList) {
541
+ selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
542
+ }
543
+ else {
544
+ selectClause = `${qt}.*`;
545
+ }
546
+ let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
547
+ if (args?.cursor) {
548
+ const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
549
+ if (cursorEntries.length > 0) {
550
+ const cursorConditions = cursorEntries.map(([k, v]) => {
551
+ const col = this.toSqlColumn(k);
552
+ const dir = args.orderBy?.[k] ?? 'asc';
553
+ const op = dir === 'desc' ? '<' : '>';
554
+ freshParams.push(v);
555
+ return `${qt}.${col} ${op} $${freshParams.length}`;
556
+ });
557
+ if (freshWhereSql) {
558
+ sql += ` AND ${cursorConditions.join(' AND ')}`;
559
+ }
560
+ else {
561
+ sql += ` WHERE ${cursorConditions.join(' AND ')}`;
562
+ }
563
+ }
564
+ }
565
+ if (args?.orderBy) {
566
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
567
+ }
568
+ if (effectiveLimit !== undefined) {
569
+ freshParams.push(Number(effectiveLimit));
570
+ sql += ` LIMIT $${freshParams.length}`;
571
+ }
572
+ if (args?.offset !== undefined) {
573
+ freshParams.push(Number(args.offset));
574
+ sql += ` OFFSET $${freshParams.length}`;
575
+ }
576
+ return sql;
577
+ });
578
+ // Collect params in exact build order:
579
+ // 1. WHERE params
580
+ if (args?.where) {
581
+ this.collectWhereParams(whereObj, params);
401
582
  }
402
- let selectClause;
583
+ // 2. WITH relation params
403
584
  if (args?.with) {
404
- selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
405
- }
406
- else if (columnsList) {
407
- selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
585
+ this.collectWithParams(args.with, params);
408
586
  }
409
- else {
410
- selectClause = `${qt}.*`;
411
- }
412
- let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${whereSql}`;
413
- // Cursor-based pagination: add WHERE condition for cursor
587
+ // 3. Cursor params
414
588
  if (args?.cursor) {
415
589
  const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
416
- if (cursorEntries.length > 0) {
417
- // Determine direction from orderBy (default 'asc')
418
- const cursorConditions = cursorEntries.map(([k, v]) => {
419
- const col = this.toSqlColumn(k);
420
- const dir = args.orderBy?.[k] ?? 'asc';
421
- const op = dir === 'desc' ? '<' : '>';
422
- params.push(v);
423
- return `${qt}.${col} ${op} $${params.length}`;
424
- });
425
- // Append to existing WHERE or create new one
426
- if (whereSql) {
427
- sql += ` AND ${cursorConditions.join(' AND ')}`;
428
- }
429
- else {
430
- sql += ` WHERE ${cursorConditions.join(' AND ')}`;
431
- }
590
+ for (const [, v] of cursorEntries) {
591
+ params.push(v);
432
592
  }
433
593
  }
434
- if (args?.orderBy) {
435
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
436
- }
437
- // take overrides limit when cursor pagination is used; fall back to defaultLimit
438
- const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
594
+ // 4. LIMIT param
439
595
  if (effectiveLimit !== undefined) {
440
596
  params.push(Number(effectiveLimit));
441
- sql += ` LIMIT $${params.length}`;
442
597
  }
598
+ // 5. OFFSET param
443
599
  if (args?.offset !== undefined) {
444
600
  params.push(Number(args.offset));
445
- sql += ` OFFSET $${params.length}`;
446
601
  }
447
602
  return {
448
- sql,
603
+ sql: entry.sql,
449
604
  params,
450
605
  transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
451
606
  tag: `${this.table}.findMany`,
607
+ preparedName: entry.name,
452
608
  };
453
609
  }
454
610
  // -------------------------------------------------------------------------
@@ -458,9 +614,21 @@ export class QueryInterface {
458
614
  * Stream rows from a findMany query using PostgreSQL cursors.
459
615
  * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
460
616
  *
461
- * Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
462
- * The cursor is automatically closed and the connection released when iteration
463
- * completes or is terminated early (e.g. `break` from `for await`).
617
+ * **Speculative fast-path:** Before opening a cursor, issues a single
618
+ * `SELECT ... LIMIT batchSize+1`. If the result fits within `batchSize`,
619
+ * all rows are yielded immediately with zero cursor overhead (no BEGIN /
620
+ * DECLARE / CLOSE / COMMIT). Only when the result overflows does the
621
+ * method fall back to the full cursor path.
622
+ *
623
+ * **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
624
+ * single pooled connection. The cursor is automatically closed and the
625
+ * connection released when iteration completes or is terminated early
626
+ * (e.g. `break` from `for await`).
627
+ *
628
+ * **Snapshot semantics note:** The speculative fast-path runs outside a
629
+ * transaction. If the result overflows and the cursor path is opened, the
630
+ * cursor runs in its own transaction — spanning two separate snapshots.
631
+ * For strict single-snapshot semantics, wrap the call in `$transaction`.
464
632
  *
465
633
  * @example
466
634
  * ```ts
@@ -470,9 +638,23 @@ export class QueryInterface {
470
638
  * ```
471
639
  */
472
640
  async *findManyStream(args) {
473
- const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
474
- const deferred = this.buildFindMany(args);
641
+ const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
475
642
  const hasRelations = !!args?.with;
643
+ // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
644
+ const speculativeDeferred = this.buildFindMany({
645
+ ...args,
646
+ limit: batchSize + 1,
647
+ });
648
+ const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
649
+ if (speculativeResult.rows.length <= batchSize) {
650
+ // Small drain — yield all rows and return, no cursor needed
651
+ for (const row of speculativeResult.rows) {
652
+ yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
653
+ }
654
+ return;
655
+ }
656
+ // --- Overflow: fall back to cursor path from scratch ---
657
+ const deferred = this.buildFindMany(args);
476
658
  // Acquire a dedicated connection — cursors require a single connection in a transaction
477
659
  const client = await this.pool.connect();
478
660
  const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -514,7 +696,7 @@ export class QueryInterface {
514
696
  async findFirst(args) {
515
697
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
516
698
  const deferred = this.buildFindFirst(args);
517
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
699
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
518
700
  return deferred.transform(result);
519
701
  });
520
702
  }
@@ -538,7 +720,7 @@ export class QueryInterface {
538
720
  async findFirstOrThrow(args) {
539
721
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
540
722
  const deferred = this.buildFindFirstOrThrow(args);
541
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
723
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
542
724
  return deferred.transform(result);
543
725
  });
544
726
  }
@@ -567,7 +749,7 @@ export class QueryInterface {
567
749
  async findUniqueOrThrow(args) {
568
750
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
569
751
  const deferred = this.buildFindUniqueOrThrow(args);
570
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
752
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
571
753
  return deferred.transform(result);
572
754
  });
573
755
  }
@@ -596,7 +778,7 @@ export class QueryInterface {
596
778
  async create(args) {
597
779
  return this.executeWithMiddleware('create', args, async () => {
598
780
  const deferred = this.buildCreate(args);
599
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
781
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
600
782
  return deferred.transform(result);
601
783
  });
602
784
  }
@@ -629,7 +811,7 @@ export class QueryInterface {
629
811
  async createMany(args) {
630
812
  return this.executeWithMiddleware('createMany', args, async () => {
631
813
  const deferred = this.buildCreateMany(args);
632
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
814
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
633
815
  return deferred.transform(result);
634
816
  });
635
817
  }
@@ -676,22 +858,35 @@ export class QueryInterface {
676
858
  async update(args) {
677
859
  return this.executeWithMiddleware('update', args, async () => {
678
860
  const deferred = this.buildUpdate(args);
679
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
861
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
680
862
  return deferred.transform(result);
681
863
  });
682
864
  }
683
865
  buildUpdate(args) {
684
- const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
685
- // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
866
+ const dataObj = args.data;
867
+ const whereObj = args.where;
868
+ const setFp = this.fingerprintSet(dataObj);
869
+ const whereFp = this.fingerprintWhere(whereObj);
870
+ const ck = `u:${setFp}|${whereFp}`;
686
871
  const params = [];
687
- const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
688
- // Build WHERE using the shared params array (continues numbering after SET params)
689
- const whereClause = this.buildWhereClause(args.where, params);
690
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
691
- this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
692
- const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
872
+ const entry = this.acquireSql(ck, () => {
873
+ const freshParams = [];
874
+ const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
875
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
876
+ const whereClause = this.buildWhereClause(whereObj, freshParams);
877
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
878
+ this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
879
+ return `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
880
+ });
881
+ // On cache hit, validate predicate
882
+ if (whereFp === '') {
883
+ this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
884
+ }
885
+ // Collect params: SET first, then WHERE (same order as fresh build)
886
+ this.collectSetParams(dataObj, params);
887
+ this.collectWhereParams(whereObj, params);
693
888
  return {
694
- sql,
889
+ sql: entry.sql,
695
890
  params,
696
891
  transform: (result) => {
697
892
  const row = result.rows[0];
@@ -705,6 +900,7 @@ export class QueryInterface {
705
900
  return this.parseRow(row, this.table);
706
901
  },
707
902
  tag: `${this.table}.update`,
903
+ preparedName: entry.name,
708
904
  };
709
905
  }
710
906
  // -------------------------------------------------------------------------
@@ -713,16 +909,31 @@ export class QueryInterface {
713
909
  async delete(args) {
714
910
  return this.executeWithMiddleware('delete', args, async () => {
715
911
  const deferred = this.buildDelete(args);
716
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
912
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
717
913
  return deferred.transform(result);
718
914
  });
719
915
  }
720
916
  buildDelete(args) {
721
- const { sql: whereSql, params } = this.buildWhere(args.where);
722
- this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
723
- const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
917
+ const whereObj = args.where;
918
+ const whereFp = this.fingerprintWhere(whereObj);
919
+ const ck = `d:${whereFp}`;
920
+ const params = [];
921
+ // We need to check the mutation predicate. Build the whereSql to test it.
922
+ // On cache hit we still need to validate (the shape may be empty).
923
+ const entry = this.acquireSql(ck, () => {
924
+ const freshParams = [];
925
+ const clause = this.buildWhereClause(whereObj, freshParams);
926
+ const whereSql = clause ? ` WHERE ${clause}` : '';
927
+ this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
928
+ return `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
929
+ });
930
+ // On cache hit, still validate the predicate
931
+ if (whereFp === '') {
932
+ this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
933
+ }
934
+ this.collectWhereParams(whereObj, params);
724
935
  return {
725
- sql,
936
+ sql: entry.sql,
726
937
  params,
727
938
  transform: (result) => {
728
939
  const row = result.rows[0];
@@ -736,6 +947,7 @@ export class QueryInterface {
736
947
  return this.parseRow(row, this.table);
737
948
  },
738
949
  tag: `${this.table}.delete`,
950
+ preparedName: entry.name,
739
951
  };
740
952
  }
741
953
  // -------------------------------------------------------------------------
@@ -744,7 +956,7 @@ export class QueryInterface {
744
956
  async upsert(args) {
745
957
  return this.executeWithMiddleware('upsert', args, async () => {
746
958
  const deferred = this.buildUpsert(args);
747
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
959
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
748
960
  return deferred.transform(result);
749
961
  });
750
962
  }
@@ -794,25 +1006,37 @@ export class QueryInterface {
794
1006
  async updateMany(args) {
795
1007
  return this.executeWithMiddleware('updateMany', args, async () => {
796
1008
  const deferred = this.buildUpdateMany(args);
797
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
1009
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
798
1010
  return deferred.transform(result);
799
1011
  });
800
1012
  }
801
1013
  buildUpdateMany(args) {
802
- const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
803
- // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
1014
+ const dataObj = args.data;
1015
+ const whereObj = args.where;
1016
+ const setFp = this.fingerprintSet(dataObj);
1017
+ const whereFp = this.fingerprintWhere(whereObj);
1018
+ const ck = `um:${setFp}|${whereFp}`;
804
1019
  const params = [];
805
- const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
806
- // Build WHERE using the shared params array (continues numbering after SET params)
807
- const whereClause = this.buildWhereClause(args.where, params);
808
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
809
- this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
810
- const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1020
+ const entry = this.acquireSql(ck, () => {
1021
+ const freshParams = [];
1022
+ const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1023
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1024
+ const whereClause = this.buildWhereClause(whereObj, freshParams);
1025
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1026
+ this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
1027
+ return `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1028
+ });
1029
+ if (whereFp === '') {
1030
+ this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
1031
+ }
1032
+ this.collectSetParams(dataObj, params);
1033
+ this.collectWhereParams(whereObj, params);
811
1034
  return {
812
- sql,
1035
+ sql: entry.sql,
813
1036
  params,
814
1037
  transform: (result) => ({ count: result.rowCount ?? 0 }),
815
1038
  tag: `${this.table}.updateMany`,
1039
+ preparedName: entry.name,
816
1040
  };
817
1041
  }
818
1042
  // -------------------------------------------------------------------------
@@ -821,19 +1045,32 @@ export class QueryInterface {
821
1045
  async deleteMany(args) {
822
1046
  return this.executeWithMiddleware('deleteMany', args, async () => {
823
1047
  const deferred = this.buildDeleteMany(args);
824
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
1048
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
825
1049
  return deferred.transform(result);
826
1050
  });
827
1051
  }
828
1052
  buildDeleteMany(args) {
829
- const { sql: whereSql, params } = this.buildWhere(args.where);
830
- this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
831
- const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
1053
+ const whereObj = args.where;
1054
+ const whereFp = this.fingerprintWhere(whereObj);
1055
+ const ck = `dm:${whereFp}`;
1056
+ const params = [];
1057
+ const entry = this.acquireSql(ck, () => {
1058
+ const freshParams = [];
1059
+ const clause = this.buildWhereClause(whereObj, freshParams);
1060
+ const whereSql = clause ? ` WHERE ${clause}` : '';
1061
+ this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
1062
+ return `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
1063
+ });
1064
+ if (whereFp === '') {
1065
+ this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
1066
+ }
1067
+ this.collectWhereParams(whereObj, params);
832
1068
  return {
833
- sql,
1069
+ sql: entry.sql,
834
1070
  params,
835
1071
  transform: (result) => ({ count: result.rowCount ?? 0 }),
836
1072
  tag: `${this.table}.deleteMany`,
1073
+ preparedName: entry.name,
837
1074
  };
838
1075
  }
839
1076
  // -------------------------------------------------------------------------
@@ -842,18 +1079,30 @@ export class QueryInterface {
842
1079
  async count(args) {
843
1080
  return this.executeWithMiddleware('count', (args ?? {}), async () => {
844
1081
  const deferred = this.buildCount(args);
845
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
1082
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
846
1083
  return deferred.transform(result);
847
1084
  });
848
1085
  }
849
1086
  buildCount(args) {
850
- const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
851
- const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
1087
+ const whereObj = (args?.where ?? {});
1088
+ const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
1089
+ const ck = `cnt:${whereFp}`;
1090
+ const params = [];
1091
+ const entry = this.acquireSql(ck, () => {
1092
+ const freshParams = [];
1093
+ const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1094
+ const whereSql = clause ? ` WHERE ${clause}` : '';
1095
+ return `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
1096
+ });
1097
+ if (args?.where) {
1098
+ this.collectWhereParams(whereObj, params);
1099
+ }
852
1100
  return {
853
- sql,
1101
+ sql: entry.sql,
854
1102
  params,
855
1103
  transform: (result) => result.rows[0].count,
856
1104
  tag: `${this.table}.count`,
1105
+ preparedName: entry.name,
857
1106
  };
858
1107
  }
859
1108
  // -------------------------------------------------------------------------
@@ -862,7 +1111,7 @@ export class QueryInterface {
862
1111
  async groupBy(args) {
863
1112
  return this.executeWithMiddleware('groupBy', args, async () => {
864
1113
  const deferred = this.buildGroupBy(args);
865
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
1114
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
866
1115
  return deferred.transform(result);
867
1116
  });
868
1117
  }
@@ -890,7 +1139,7 @@ export class QueryInterface {
890
1139
  for (const [field, enabled] of Object.entries(args._sum)) {
891
1140
  if (enabled) {
892
1141
  const col = this.toColumn(field);
893
- selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
1142
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(`_sum_${col}`)}`);
894
1143
  }
895
1144
  }
896
1145
  }
@@ -899,7 +1148,7 @@ export class QueryInterface {
899
1148
  for (const [field, enabled] of Object.entries(args._avg)) {
900
1149
  if (enabled) {
901
1150
  const col = this.toColumn(field);
902
- selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
1151
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(`_avg_${col}`)}`);
903
1152
  }
904
1153
  }
905
1154
  }
@@ -908,7 +1157,7 @@ export class QueryInterface {
908
1157
  for (const [field, enabled] of Object.entries(args._min)) {
909
1158
  if (enabled) {
910
1159
  const col = this.toColumn(field);
911
- selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
1160
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(`_min_${col}`)}`);
912
1161
  }
913
1162
  }
914
1163
  }
@@ -917,7 +1166,7 @@ export class QueryInterface {
917
1166
  for (const [field, enabled] of Object.entries(args._max)) {
918
1167
  if (enabled) {
919
1168
  const col = this.toColumn(field);
920
- selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
1169
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(`_max_${col}`)}`);
921
1170
  }
922
1171
  }
923
1172
  }
@@ -995,7 +1244,7 @@ export class QueryInterface {
995
1244
  async aggregate(args) {
996
1245
  return this.executeWithMiddleware('aggregate', args, async () => {
997
1246
  const deferred = this.buildAggregate(args);
998
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
1247
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
999
1248
  return deferred.transform(result);
1000
1249
  });
1001
1250
  }
@@ -1029,7 +1278,7 @@ export class QueryInterface {
1029
1278
  for (const [field, enabled] of Object.entries(args._count)) {
1030
1279
  if (enabled) {
1031
1280
  const col = this.toColumn(field);
1032
- selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent('_count_' + col)}`);
1281
+ selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent(`_count_${col}`)}`);
1033
1282
  }
1034
1283
  }
1035
1284
  }
@@ -1038,7 +1287,7 @@ export class QueryInterface {
1038
1287
  for (const [field, enabled] of Object.entries(args._sum)) {
1039
1288
  if (enabled) {
1040
1289
  const col = this.toColumn(field);
1041
- selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
1290
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(`_sum_${col}`)}`);
1042
1291
  }
1043
1292
  }
1044
1293
  }
@@ -1047,7 +1296,7 @@ export class QueryInterface {
1047
1296
  for (const [field, enabled] of Object.entries(args._avg)) {
1048
1297
  if (enabled) {
1049
1298
  const col = this.toColumn(field);
1050
- selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
1299
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(`_avg_${col}`)}`);
1051
1300
  }
1052
1301
  }
1053
1302
  }
@@ -1056,7 +1305,7 @@ export class QueryInterface {
1056
1305
  for (const [field, enabled] of Object.entries(args._min)) {
1057
1306
  if (enabled) {
1058
1307
  const col = this.toColumn(field);
1059
- selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
1308
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(`_min_${col}`)}`);
1060
1309
  }
1061
1310
  }
1062
1311
  }
@@ -1065,7 +1314,7 @@ export class QueryInterface {
1065
1314
  for (const [field, enabled] of Object.entries(args._max)) {
1066
1315
  if (enabled) {
1067
1316
  const col = this.toColumn(field);
1068
- selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
1317
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(`_max_${col}`)}`);
1069
1318
  }
1070
1319
  }
1071
1320
  }
@@ -1171,7 +1420,21 @@ export class QueryInterface {
1171
1420
  const mapped = this.tableMeta.columnMap[field];
1172
1421
  if (mapped)
1173
1422
  return mapped;
1174
- return camelToSnake(field);
1423
+ // Fall back to camelToSnake ONLY if that snake_cased name also exists as a
1424
+ // real column on the table. This preserves the convenience of writing
1425
+ // `userId` when the schema exposes `user_id` under an unusual field name,
1426
+ // but rejects arbitrary strings — closing the defense-in-depth gap for
1427
+ // SQL injection and catching typos like `where: { emial: 'x' }` with a
1428
+ // clear error instead of a cryptic Postgres "column does not exist".
1429
+ const snake = camelToSnake(field);
1430
+ if (this.tableMeta.reverseColumnMap?.[snake]) {
1431
+ return snake;
1432
+ }
1433
+ if (this.tableMeta.allColumns?.includes(snake)) {
1434
+ return snake;
1435
+ }
1436
+ throw new ValidationError(`[turbine] Unknown field "${field}" on table "${this.table}". ` +
1437
+ `Known fields: ${Object.keys(this.tableMeta.columnMap).join(', ') || '(none)'}.`);
1175
1438
  }
1176
1439
  /** Convert camelCase field name to a double-quoted SQL identifier */
1177
1440
  toSqlColumn(field) {
@@ -1238,6 +1501,440 @@ export class QueryInterface {
1238
1501
  params.push(value);
1239
1502
  return `${col} = $${params.length}`;
1240
1503
  }
1504
+ // =========================================================================
1505
+ // Fingerprinting — value-invariant shape keys for SQL cache lookup
1506
+ // =========================================================================
1507
+ /**
1508
+ * Produce a value-invariant fingerprint of a where clause.
1509
+ * Same keys + same operator shapes + same combinator structure => same string.
1510
+ * Different values (e.g. id=1 vs id=999) => identical fingerprint.
1511
+ *
1512
+ * @internal Exposed as package-private for testing via class access.
1513
+ */
1514
+ fingerprintWhere(where) {
1515
+ const keys = Object.keys(where)
1516
+ .filter((k) => where[k] !== undefined)
1517
+ .sort();
1518
+ if (keys.length === 0)
1519
+ return '';
1520
+ const parts = [];
1521
+ for (const key of keys) {
1522
+ const value = where[key];
1523
+ if (value === undefined)
1524
+ continue;
1525
+ if (key === 'OR') {
1526
+ const orArr = value;
1527
+ if (!Array.isArray(orArr) || orArr.length === 0)
1528
+ continue;
1529
+ const orParts = orArr.map((cond) => this.fingerprintWhere(cond));
1530
+ parts.push(`OR[${orParts.join(',')}]`);
1531
+ continue;
1532
+ }
1533
+ if (key === 'AND') {
1534
+ const andArr = value;
1535
+ if (!Array.isArray(andArr) || andArr.length === 0)
1536
+ continue;
1537
+ const andParts = andArr.map((cond) => this.fingerprintWhere(cond));
1538
+ parts.push(`AND[${andParts.join(',')}]`);
1539
+ continue;
1540
+ }
1541
+ if (key === 'NOT') {
1542
+ const notCond = value;
1543
+ parts.push(`NOT(${this.fingerprintWhere(notCond)})`);
1544
+ continue;
1545
+ }
1546
+ // Relation filters: { posts: { some: { published: true } } }
1547
+ const relDef = this.tableMeta.relations[key];
1548
+ if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1549
+ const filterObj = value;
1550
+ if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1551
+ const relParts = [];
1552
+ if (filterObj.some !== undefined)
1553
+ relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
1554
+ if (filterObj.every !== undefined)
1555
+ relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
1556
+ if (filterObj.none !== undefined)
1557
+ relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
1558
+ parts.push(`${key}:{${relParts.join(',')}}`);
1559
+ continue;
1560
+ }
1561
+ }
1562
+ // null → distinct from value
1563
+ if (value === null) {
1564
+ parts.push(`${key}:null`);
1565
+ continue;
1566
+ }
1567
+ // Operator objects
1568
+ if (isWhereOperator(value)) {
1569
+ const opKeys = Object.keys(value)
1570
+ .filter((k) => k !== 'mode')
1571
+ .sort();
1572
+ const mode = value.mode;
1573
+ const modeStr = mode === 'insensitive' ? ':i' : '';
1574
+ parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1575
+ continue;
1576
+ }
1577
+ // JSON filter
1578
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1579
+ const jKeys = Object.keys(value).sort();
1580
+ parts.push(`${key}:json(${jKeys.join(',')})`);
1581
+ continue;
1582
+ }
1583
+ // Array filter
1584
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
1585
+ const aKeys = Object.keys(value).sort();
1586
+ parts.push(`${key}:arr(${aKeys.join(',')})`);
1587
+ continue;
1588
+ }
1589
+ // Plain equality
1590
+ parts.push(`${key}:eq`);
1591
+ }
1592
+ return parts.join('&');
1593
+ }
1594
+ /**
1595
+ * Fingerprint a relation filter sub-where for some/every/none.
1596
+ */
1597
+ fingerprintRelFilter(_targetTable, subWhere) {
1598
+ const keys = Object.keys(subWhere)
1599
+ .filter((k) => subWhere[k] !== undefined)
1600
+ .sort();
1601
+ if (keys.length === 0)
1602
+ return '';
1603
+ const parts = [];
1604
+ for (const key of keys) {
1605
+ const value = subWhere[key];
1606
+ if (value === undefined)
1607
+ continue;
1608
+ if (value === null) {
1609
+ parts.push(`${key}:null`);
1610
+ }
1611
+ else if (isWhereOperator(value)) {
1612
+ const opKeys = Object.keys(value)
1613
+ .filter((k) => k !== 'mode')
1614
+ .sort();
1615
+ const mode = value.mode;
1616
+ const modeStr = mode === 'insensitive' ? ':i' : '';
1617
+ parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1618
+ }
1619
+ else {
1620
+ parts.push(`${key}:eq`);
1621
+ }
1622
+ }
1623
+ return parts.join('&');
1624
+ }
1625
+ /**
1626
+ * Walk a where clause and push ONLY values into `params`, in the EXACT same
1627
+ * order that `buildWhereClause` pushes them. Used on cache hit to fill params
1628
+ * without rebuilding SQL.
1629
+ *
1630
+ * @internal Exposed as package-private for testing.
1631
+ */
1632
+ collectWhereParams(where, params) {
1633
+ const keys = Object.keys(where);
1634
+ for (const key of keys) {
1635
+ const value = where[key];
1636
+ if (value === undefined)
1637
+ continue;
1638
+ if (key === 'OR') {
1639
+ const orConditions = value;
1640
+ if (!Array.isArray(orConditions) || orConditions.length === 0)
1641
+ continue;
1642
+ for (const orCond of orConditions) {
1643
+ this.collectWhereParams(orCond, params);
1644
+ }
1645
+ continue;
1646
+ }
1647
+ if (key === 'AND') {
1648
+ const andConditions = value;
1649
+ if (!Array.isArray(andConditions) || andConditions.length === 0)
1650
+ continue;
1651
+ for (const andCond of andConditions) {
1652
+ this.collectWhereParams(andCond, params);
1653
+ }
1654
+ continue;
1655
+ }
1656
+ if (key === 'NOT') {
1657
+ const notCond = value;
1658
+ this.collectWhereParams(notCond, params);
1659
+ continue;
1660
+ }
1661
+ // Relation filters
1662
+ const relationDef = this.tableMeta.relations[key];
1663
+ if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1664
+ const filterObj = value;
1665
+ if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1666
+ if (filterObj.some !== undefined)
1667
+ this.collectRelFilterParams(relationDef.to, filterObj.some, params);
1668
+ if (filterObj.none !== undefined)
1669
+ this.collectRelFilterParams(relationDef.to, filterObj.none, params);
1670
+ if (filterObj.every !== undefined)
1671
+ this.collectRelFilterParams(relationDef.to, filterObj.every, params);
1672
+ continue;
1673
+ }
1674
+ }
1675
+ // null → no param pushed (IS NULL is parameterless)
1676
+ if (value === null)
1677
+ continue;
1678
+ const rawColumn = this.toColumn(key);
1679
+ // JSONB filter
1680
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1681
+ const colType = this.getColumnPgType(rawColumn);
1682
+ if (colType === 'json' || colType === 'jsonb') {
1683
+ this.collectJsonFilterParams(value, params);
1684
+ continue;
1685
+ }
1686
+ }
1687
+ // Array filter
1688
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
1689
+ const colType = this.getColumnPgType(rawColumn);
1690
+ if (colType.startsWith('_')) {
1691
+ this.collectArrayFilterParams(value, params);
1692
+ continue;
1693
+ }
1694
+ }
1695
+ // Operator objects
1696
+ if (isWhereOperator(value)) {
1697
+ this.collectOperatorParams(value, params);
1698
+ continue;
1699
+ }
1700
+ // Plain equality
1701
+ params.push(value);
1702
+ }
1703
+ }
1704
+ /** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
1705
+ collectRelFilterParams(targetTable, subWhere, params) {
1706
+ const meta = this.schema.tables[targetTable];
1707
+ if (!meta)
1708
+ return;
1709
+ for (const [_field, value] of Object.entries(subWhere)) {
1710
+ if (value === undefined)
1711
+ continue;
1712
+ if (value === null)
1713
+ continue;
1714
+ if (isWhereOperator(value)) {
1715
+ this.collectOperatorParams(value, params);
1716
+ continue;
1717
+ }
1718
+ params.push(value);
1719
+ }
1720
+ }
1721
+ /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
1722
+ collectOperatorParams(op, params) {
1723
+ if (op.gt !== undefined)
1724
+ params.push(op.gt);
1725
+ if (op.gte !== undefined)
1726
+ params.push(op.gte);
1727
+ if (op.lt !== undefined)
1728
+ params.push(op.lt);
1729
+ if (op.lte !== undefined)
1730
+ params.push(op.lte);
1731
+ if (op.not !== undefined && op.not !== null)
1732
+ params.push(op.not);
1733
+ if (op.in !== undefined)
1734
+ params.push(op.in);
1735
+ if (op.notIn !== undefined)
1736
+ params.push(op.notIn);
1737
+ if (op.contains !== undefined)
1738
+ params.push(`%${escapeLike(op.contains)}%`);
1739
+ if (op.startsWith !== undefined)
1740
+ params.push(`${escapeLike(op.startsWith)}%`);
1741
+ if (op.endsWith !== undefined)
1742
+ params.push(`%${escapeLike(op.endsWith)}`);
1743
+ }
1744
+ /** Collect params from JSON filter. Mirrors buildJsonFilterClauses. */
1745
+ collectJsonFilterParams(filter, params) {
1746
+ if (filter.path !== undefined && filter.equals !== undefined) {
1747
+ params.push(filter.path);
1748
+ params.push(String(filter.equals));
1749
+ }
1750
+ else if (filter.equals !== undefined) {
1751
+ params.push(JSON.stringify(filter.equals));
1752
+ }
1753
+ if (filter.contains !== undefined) {
1754
+ params.push(JSON.stringify(filter.contains));
1755
+ }
1756
+ if (filter.hasKey !== undefined) {
1757
+ params.push(filter.hasKey);
1758
+ }
1759
+ }
1760
+ /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
1761
+ collectArrayFilterParams(filter, params) {
1762
+ if (filter.has !== undefined)
1763
+ params.push(filter.has);
1764
+ if (filter.hasEvery !== undefined)
1765
+ params.push(filter.hasEvery);
1766
+ if (filter.hasSome !== undefined)
1767
+ params.push(filter.hasSome);
1768
+ // isEmpty has no params (IS NULL / IS NOT NULL)
1769
+ }
1770
+ /**
1771
+ * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1772
+ * buildSelectWithRelations / buildRelationSubquery.
1773
+ *
1774
+ * @internal Exposed as package-private for testing.
1775
+ */
1776
+ withFingerprint(withClause, table, depth = 0) {
1777
+ if (!withClause)
1778
+ return '';
1779
+ const meta = this.schema.tables[table ?? this.table];
1780
+ if (!meta)
1781
+ return '';
1782
+ const relNames = Object.keys(withClause).sort();
1783
+ const parts = [];
1784
+ for (const relName of relNames) {
1785
+ const spec = withClause[relName];
1786
+ if (!spec)
1787
+ continue;
1788
+ const relDef = meta.relations[relName];
1789
+ if (!relDef)
1790
+ continue;
1791
+ if (spec === true) {
1792
+ parts.push(relName);
1793
+ continue;
1794
+ }
1795
+ const opts = spec;
1796
+ const subParts = [];
1797
+ // select/omit shape
1798
+ if (opts.select) {
1799
+ const selKeys = Object.entries(opts.select)
1800
+ .filter(([, v]) => v)
1801
+ .map(([k]) => k)
1802
+ .sort();
1803
+ subParts.push(`sl=${selKeys.join(',')}`);
1804
+ }
1805
+ if (opts.omit) {
1806
+ const omKeys = Object.entries(opts.omit)
1807
+ .filter(([, v]) => v)
1808
+ .map(([k]) => k)
1809
+ .sort();
1810
+ subParts.push(`om=${omKeys.join(',')}`);
1811
+ }
1812
+ // where shape (value-invariant)
1813
+ if (opts.where) {
1814
+ // Use a target-table QI if possible, or a simplified fingerprint
1815
+ const wKeys = Object.keys(opts.where)
1816
+ .filter((k) => opts.where[k] !== undefined)
1817
+ .sort();
1818
+ subParts.push(`w=${wKeys.join(',')}`);
1819
+ }
1820
+ // orderBy shape
1821
+ if (opts.orderBy) {
1822
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
1823
+ subParts.push(`o=${oEntries.join(',')}`);
1824
+ }
1825
+ // limit presence
1826
+ if (opts.limit !== undefined) {
1827
+ subParts.push('l=1');
1828
+ }
1829
+ // nested with (recurse)
1830
+ if (opts.with) {
1831
+ const nested = this.withFingerprint(opts.with, relDef.to, depth + 1);
1832
+ if (nested)
1833
+ subParts.push(`W=(${nested})`);
1834
+ }
1835
+ parts.push(subParts.length > 0 ? `${relName}/{${subParts.join('/')}}` : relName);
1836
+ }
1837
+ return parts.join('|');
1838
+ }
1839
+ /**
1840
+ * Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
1841
+ * buildRelationSubquery param-push order.
1842
+ */
1843
+ collectWithParams(withClause, params, table) {
1844
+ const meta = this.schema.tables[table ?? this.table];
1845
+ if (!meta)
1846
+ return;
1847
+ for (const [relName, relSpec] of Object.entries(withClause)) {
1848
+ const relDef = meta.relations[relName];
1849
+ if (!relDef)
1850
+ continue;
1851
+ this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
1852
+ }
1853
+ }
1854
+ /**
1855
+ * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
1856
+ */
1857
+ collectRelationSubqueryParams(relDef, spec, params, _parentRef, depth = 0) {
1858
+ if (spec === true)
1859
+ return; // No params for default include
1860
+ const targetTable = relDef.to;
1861
+ const targetMeta = this.schema.tables[targetTable];
1862
+ if (!targetMeta)
1863
+ return;
1864
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
1865
+ // Non-wrapped path: nested relations BEFORE where/limit
1866
+ if (!willWrap && spec.with) {
1867
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1868
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1869
+ if (!nestedRelDef)
1870
+ continue;
1871
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
1872
+ }
1873
+ }
1874
+ // where params
1875
+ if (spec.where) {
1876
+ for (const [, v] of Object.entries(spec.where)) {
1877
+ params.push(v);
1878
+ }
1879
+ }
1880
+ // limit param
1881
+ if (spec.limit) {
1882
+ params.push(Number(spec.limit));
1883
+ }
1884
+ // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
1885
+ if (willWrap && spec.with) {
1886
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1887
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1888
+ if (!nestedRelDef)
1889
+ continue;
1890
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
1891
+ }
1892
+ }
1893
+ }
1894
+ /**
1895
+ * Fingerprint SET clauses for update/updateMany.
1896
+ * Captures key names + operator types (set/increment/etc) but not values.
1897
+ */
1898
+ fingerprintSet(data) {
1899
+ const entries = Object.entries(data).filter(([, v]) => v !== undefined);
1900
+ const parts = [];
1901
+ for (const [k, v] of entries) {
1902
+ if (v !== null &&
1903
+ typeof v === 'object' &&
1904
+ !Array.isArray(v) &&
1905
+ !(v instanceof Date) &&
1906
+ !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
1907
+ const keys = Object.keys(v);
1908
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1909
+ parts.push(`${k}:${keys[0]}`);
1910
+ continue;
1911
+ }
1912
+ }
1913
+ parts.push(`${k}:eq`);
1914
+ }
1915
+ return parts.join(',');
1916
+ }
1917
+ /**
1918
+ * Collect SET params for update/updateMany. Mirrors buildSetClause param order.
1919
+ */
1920
+ collectSetParams(data, params) {
1921
+ const entries = Object.entries(data).filter(([, v]) => v !== undefined);
1922
+ for (const [, v] of entries) {
1923
+ if (v !== null &&
1924
+ typeof v === 'object' &&
1925
+ !Array.isArray(v) &&
1926
+ !(v instanceof Date) &&
1927
+ !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
1928
+ const obj = v;
1929
+ const keys = Object.keys(obj);
1930
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1931
+ params.push(obj[keys[0]]);
1932
+ continue;
1933
+ }
1934
+ }
1935
+ params.push(v);
1936
+ }
1937
+ }
1241
1938
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
1242
1939
  buildWhere(where) {
1243
1940
  const params = [];
@@ -1564,28 +2261,53 @@ export class QueryInterface {
1564
2261
  return parsed;
1565
2262
  for (const [relName, relDef] of Object.entries(meta.relations)) {
1566
2263
  const rawValue = row[relName];
1567
- if (rawValue !== undefined) {
1568
- if (typeof rawValue === 'string') {
1569
- try {
1570
- parsed[relName] = JSON.parse(rawValue);
2264
+ if (rawValue === undefined)
2265
+ continue;
2266
+ // --- Short-circuit: skip JSON.parse for common empty/null cases ---
2267
+ // hasMany returns '[]' (from COALESCE(..., '[]'::json)); belongsTo/hasOne returns null
2268
+ if (rawValue === null || rawValue === 'null') {
2269
+ parsed[relName] = null;
2270
+ continue;
2271
+ }
2272
+ if (rawValue === '[]') {
2273
+ parsed[relName] = [];
2274
+ continue;
2275
+ }
2276
+ if (Array.isArray(rawValue) && rawValue.length === 0) {
2277
+ parsed[relName] = [];
2278
+ continue;
2279
+ }
2280
+ // --- Non-empty values: full parse path ---
2281
+ if (typeof rawValue === 'string') {
2282
+ try {
2283
+ const jsonVal = JSON.parse(rawValue);
2284
+ // After parsing, apply parseRow to each item for snake→camel + date coercion
2285
+ if (Array.isArray(jsonVal)) {
2286
+ parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2287
+ ? this.parseRow(item, relDef.to)
2288
+ : item);
1571
2289
  }
1572
- catch {
1573
- console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1574
- parsed[relName] = rawValue;
2290
+ else if (typeof jsonVal === 'object' && jsonVal !== null) {
2291
+ parsed[relName] = this.parseRow(jsonVal, relDef.to);
2292
+ }
2293
+ else {
2294
+ parsed[relName] = jsonVal;
1575
2295
  }
1576
2296
  }
1577
- else if (Array.isArray(rawValue)) {
1578
- parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
1579
- ? this.parseRow(item, relDef.to)
1580
- : item);
1581
- }
1582
- else if (typeof rawValue === 'object' && rawValue !== null) {
1583
- parsed[relName] = this.parseRow(rawValue, relDef.to);
1584
- }
1585
- else {
2297
+ catch {
2298
+ console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1586
2299
  parsed[relName] = rawValue;
1587
2300
  }
1588
2301
  }
2302
+ else if (Array.isArray(rawValue)) {
2303
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null ? this.parseRow(item, relDef.to) : item);
2304
+ }
2305
+ else if (typeof rawValue === 'object' && rawValue !== null) {
2306
+ parsed[relName] = this.parseRow(rawValue, relDef.to);
2307
+ }
2308
+ else {
2309
+ parsed[relName] = rawValue;
2310
+ }
1589
2311
  }
1590
2312
  return parsed;
1591
2313
  }