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