turbine-orm 0.7.1 → 0.8.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(', ');
408
- }
409
- else {
410
- selectClause = `${qt}.*`;
585
+ this.collectWithParams(args.with, params);
411
586
  }
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
  }
@@ -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
  }
@@ -1238,6 +1487,440 @@ export class QueryInterface {
1238
1487
  params.push(value);
1239
1488
  return `${col} = $${params.length}`;
1240
1489
  }
1490
+ // =========================================================================
1491
+ // Fingerprinting — value-invariant shape keys for SQL cache lookup
1492
+ // =========================================================================
1493
+ /**
1494
+ * Produce a value-invariant fingerprint of a where clause.
1495
+ * Same keys + same operator shapes + same combinator structure => same string.
1496
+ * Different values (e.g. id=1 vs id=999) => identical fingerprint.
1497
+ *
1498
+ * @internal Exposed as package-private for testing via class access.
1499
+ */
1500
+ fingerprintWhere(where) {
1501
+ const keys = Object.keys(where)
1502
+ .filter((k) => where[k] !== undefined)
1503
+ .sort();
1504
+ if (keys.length === 0)
1505
+ return '';
1506
+ const parts = [];
1507
+ for (const key of keys) {
1508
+ const value = where[key];
1509
+ if (value === undefined)
1510
+ continue;
1511
+ if (key === 'OR') {
1512
+ const orArr = value;
1513
+ if (!Array.isArray(orArr) || orArr.length === 0)
1514
+ continue;
1515
+ const orParts = orArr.map((cond) => this.fingerprintWhere(cond));
1516
+ parts.push(`OR[${orParts.join(',')}]`);
1517
+ continue;
1518
+ }
1519
+ if (key === 'AND') {
1520
+ const andArr = value;
1521
+ if (!Array.isArray(andArr) || andArr.length === 0)
1522
+ continue;
1523
+ const andParts = andArr.map((cond) => this.fingerprintWhere(cond));
1524
+ parts.push(`AND[${andParts.join(',')}]`);
1525
+ continue;
1526
+ }
1527
+ if (key === 'NOT') {
1528
+ const notCond = value;
1529
+ parts.push(`NOT(${this.fingerprintWhere(notCond)})`);
1530
+ continue;
1531
+ }
1532
+ // Relation filters: { posts: { some: { published: true } } }
1533
+ const relDef = this.tableMeta.relations[key];
1534
+ if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1535
+ const filterObj = value;
1536
+ if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1537
+ const relParts = [];
1538
+ if (filterObj.some !== undefined)
1539
+ relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
1540
+ if (filterObj.every !== undefined)
1541
+ relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
1542
+ if (filterObj.none !== undefined)
1543
+ relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
1544
+ parts.push(`${key}:{${relParts.join(',')}}`);
1545
+ continue;
1546
+ }
1547
+ }
1548
+ // null → distinct from value
1549
+ if (value === null) {
1550
+ parts.push(`${key}:null`);
1551
+ continue;
1552
+ }
1553
+ // Operator objects
1554
+ if (isWhereOperator(value)) {
1555
+ const opKeys = Object.keys(value)
1556
+ .filter((k) => k !== 'mode')
1557
+ .sort();
1558
+ const mode = value.mode;
1559
+ const modeStr = mode === 'insensitive' ? ':i' : '';
1560
+ parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1561
+ continue;
1562
+ }
1563
+ // JSON filter
1564
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1565
+ const jKeys = Object.keys(value).sort();
1566
+ parts.push(`${key}:json(${jKeys.join(',')})`);
1567
+ continue;
1568
+ }
1569
+ // Array filter
1570
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
1571
+ const aKeys = Object.keys(value).sort();
1572
+ parts.push(`${key}:arr(${aKeys.join(',')})`);
1573
+ continue;
1574
+ }
1575
+ // Plain equality
1576
+ parts.push(`${key}:eq`);
1577
+ }
1578
+ return parts.join('&');
1579
+ }
1580
+ /**
1581
+ * Fingerprint a relation filter sub-where for some/every/none.
1582
+ */
1583
+ fingerprintRelFilter(targetTable, subWhere) {
1584
+ const keys = Object.keys(subWhere)
1585
+ .filter((k) => subWhere[k] !== undefined)
1586
+ .sort();
1587
+ if (keys.length === 0)
1588
+ return '';
1589
+ const parts = [];
1590
+ for (const key of keys) {
1591
+ const value = subWhere[key];
1592
+ if (value === undefined)
1593
+ continue;
1594
+ if (value === null) {
1595
+ parts.push(`${key}:null`);
1596
+ }
1597
+ else if (isWhereOperator(value)) {
1598
+ const opKeys = Object.keys(value)
1599
+ .filter((k) => k !== 'mode')
1600
+ .sort();
1601
+ const mode = value.mode;
1602
+ const modeStr = mode === 'insensitive' ? ':i' : '';
1603
+ parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1604
+ }
1605
+ else {
1606
+ parts.push(`${key}:eq`);
1607
+ }
1608
+ }
1609
+ return parts.join('&');
1610
+ }
1611
+ /**
1612
+ * Walk a where clause and push ONLY values into `params`, in the EXACT same
1613
+ * order that `buildWhereClause` pushes them. Used on cache hit to fill params
1614
+ * without rebuilding SQL.
1615
+ *
1616
+ * @internal Exposed as package-private for testing.
1617
+ */
1618
+ collectWhereParams(where, params) {
1619
+ const keys = Object.keys(where);
1620
+ for (const key of keys) {
1621
+ const value = where[key];
1622
+ if (value === undefined)
1623
+ continue;
1624
+ if (key === 'OR') {
1625
+ const orConditions = value;
1626
+ if (!Array.isArray(orConditions) || orConditions.length === 0)
1627
+ continue;
1628
+ for (const orCond of orConditions) {
1629
+ this.collectWhereParams(orCond, params);
1630
+ }
1631
+ continue;
1632
+ }
1633
+ if (key === 'AND') {
1634
+ const andConditions = value;
1635
+ if (!Array.isArray(andConditions) || andConditions.length === 0)
1636
+ continue;
1637
+ for (const andCond of andConditions) {
1638
+ this.collectWhereParams(andCond, params);
1639
+ }
1640
+ continue;
1641
+ }
1642
+ if (key === 'NOT') {
1643
+ const notCond = value;
1644
+ this.collectWhereParams(notCond, params);
1645
+ continue;
1646
+ }
1647
+ // Relation filters
1648
+ const relationDef = this.tableMeta.relations[key];
1649
+ if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1650
+ const filterObj = value;
1651
+ if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1652
+ if (filterObj.some !== undefined)
1653
+ this.collectRelFilterParams(relationDef.to, filterObj.some, params);
1654
+ if (filterObj.none !== undefined)
1655
+ this.collectRelFilterParams(relationDef.to, filterObj.none, params);
1656
+ if (filterObj.every !== undefined)
1657
+ this.collectRelFilterParams(relationDef.to, filterObj.every, params);
1658
+ continue;
1659
+ }
1660
+ }
1661
+ // null → no param pushed (IS NULL is parameterless)
1662
+ if (value === null)
1663
+ continue;
1664
+ const rawColumn = this.toColumn(key);
1665
+ // JSONB filter
1666
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1667
+ const colType = this.getColumnPgType(rawColumn);
1668
+ if (colType === 'json' || colType === 'jsonb') {
1669
+ this.collectJsonFilterParams(value, params);
1670
+ continue;
1671
+ }
1672
+ }
1673
+ // Array filter
1674
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
1675
+ const colType = this.getColumnPgType(rawColumn);
1676
+ if (colType.startsWith('_')) {
1677
+ this.collectArrayFilterParams(value, params);
1678
+ continue;
1679
+ }
1680
+ }
1681
+ // Operator objects
1682
+ if (isWhereOperator(value)) {
1683
+ this.collectOperatorParams(value, params);
1684
+ continue;
1685
+ }
1686
+ // Plain equality
1687
+ params.push(value);
1688
+ }
1689
+ }
1690
+ /** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
1691
+ collectRelFilterParams(targetTable, subWhere, params) {
1692
+ const meta = this.schema.tables[targetTable];
1693
+ if (!meta)
1694
+ return;
1695
+ for (const [field, value] of Object.entries(subWhere)) {
1696
+ if (value === undefined)
1697
+ continue;
1698
+ if (value === null)
1699
+ continue;
1700
+ if (isWhereOperator(value)) {
1701
+ this.collectOperatorParams(value, params);
1702
+ continue;
1703
+ }
1704
+ params.push(value);
1705
+ }
1706
+ }
1707
+ /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
1708
+ collectOperatorParams(op, params) {
1709
+ if (op.gt !== undefined)
1710
+ params.push(op.gt);
1711
+ if (op.gte !== undefined)
1712
+ params.push(op.gte);
1713
+ if (op.lt !== undefined)
1714
+ params.push(op.lt);
1715
+ if (op.lte !== undefined)
1716
+ params.push(op.lte);
1717
+ if (op.not !== undefined && op.not !== null)
1718
+ params.push(op.not);
1719
+ if (op.in !== undefined)
1720
+ params.push(op.in);
1721
+ if (op.notIn !== undefined)
1722
+ params.push(op.notIn);
1723
+ if (op.contains !== undefined)
1724
+ params.push(`%${escapeLike(op.contains)}%`);
1725
+ if (op.startsWith !== undefined)
1726
+ params.push(`${escapeLike(op.startsWith)}%`);
1727
+ if (op.endsWith !== undefined)
1728
+ params.push(`%${escapeLike(op.endsWith)}`);
1729
+ }
1730
+ /** Collect params from JSON filter. Mirrors buildJsonFilterClauses. */
1731
+ collectJsonFilterParams(filter, params) {
1732
+ if (filter.path !== undefined && filter.equals !== undefined) {
1733
+ params.push(filter.path);
1734
+ params.push(String(filter.equals));
1735
+ }
1736
+ else if (filter.equals !== undefined) {
1737
+ params.push(JSON.stringify(filter.equals));
1738
+ }
1739
+ if (filter.contains !== undefined) {
1740
+ params.push(JSON.stringify(filter.contains));
1741
+ }
1742
+ if (filter.hasKey !== undefined) {
1743
+ params.push(filter.hasKey);
1744
+ }
1745
+ }
1746
+ /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
1747
+ collectArrayFilterParams(filter, params) {
1748
+ if (filter.has !== undefined)
1749
+ params.push(filter.has);
1750
+ if (filter.hasEvery !== undefined)
1751
+ params.push(filter.hasEvery);
1752
+ if (filter.hasSome !== undefined)
1753
+ params.push(filter.hasSome);
1754
+ // isEmpty has no params (IS NULL / IS NOT NULL)
1755
+ }
1756
+ /**
1757
+ * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1758
+ * buildSelectWithRelations / buildRelationSubquery.
1759
+ *
1760
+ * @internal Exposed as package-private for testing.
1761
+ */
1762
+ withFingerprint(withClause, table, depth = 0) {
1763
+ if (!withClause)
1764
+ return '';
1765
+ const meta = this.schema.tables[table ?? this.table];
1766
+ if (!meta)
1767
+ return '';
1768
+ const relNames = Object.keys(withClause).sort();
1769
+ const parts = [];
1770
+ for (const relName of relNames) {
1771
+ const spec = withClause[relName];
1772
+ if (!spec)
1773
+ continue;
1774
+ const relDef = meta.relations[relName];
1775
+ if (!relDef)
1776
+ continue;
1777
+ if (spec === true) {
1778
+ parts.push(relName);
1779
+ continue;
1780
+ }
1781
+ const opts = spec;
1782
+ const subParts = [];
1783
+ // select/omit shape
1784
+ if (opts.select) {
1785
+ const selKeys = Object.entries(opts.select)
1786
+ .filter(([, v]) => v)
1787
+ .map(([k]) => k)
1788
+ .sort();
1789
+ subParts.push(`sl=${selKeys.join(',')}`);
1790
+ }
1791
+ if (opts.omit) {
1792
+ const omKeys = Object.entries(opts.omit)
1793
+ .filter(([, v]) => v)
1794
+ .map(([k]) => k)
1795
+ .sort();
1796
+ subParts.push(`om=${omKeys.join(',')}`);
1797
+ }
1798
+ // where shape (value-invariant)
1799
+ if (opts.where) {
1800
+ // Use a target-table QI if possible, or a simplified fingerprint
1801
+ const wKeys = Object.keys(opts.where)
1802
+ .filter((k) => opts.where[k] !== undefined)
1803
+ .sort();
1804
+ subParts.push(`w=${wKeys.join(',')}`);
1805
+ }
1806
+ // orderBy shape
1807
+ if (opts.orderBy) {
1808
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
1809
+ subParts.push(`o=${oEntries.join(',')}`);
1810
+ }
1811
+ // limit presence
1812
+ if (opts.limit !== undefined) {
1813
+ subParts.push('l=1');
1814
+ }
1815
+ // nested with (recurse)
1816
+ if (opts.with) {
1817
+ const nested = this.withFingerprint(opts.with, relDef.to, depth + 1);
1818
+ if (nested)
1819
+ subParts.push(`W=(${nested})`);
1820
+ }
1821
+ parts.push(subParts.length > 0 ? `${relName}/{${subParts.join('/')}}` : relName);
1822
+ }
1823
+ return parts.join('|');
1824
+ }
1825
+ /**
1826
+ * Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
1827
+ * buildRelationSubquery param-push order.
1828
+ */
1829
+ collectWithParams(withClause, params, table) {
1830
+ const meta = this.schema.tables[table ?? this.table];
1831
+ if (!meta)
1832
+ return;
1833
+ for (const [relName, relSpec] of Object.entries(withClause)) {
1834
+ const relDef = meta.relations[relName];
1835
+ if (!relDef)
1836
+ continue;
1837
+ this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
1838
+ }
1839
+ }
1840
+ /**
1841
+ * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
1842
+ */
1843
+ collectRelationSubqueryParams(relDef, spec, params, _parentRef, depth = 0) {
1844
+ if (spec === true)
1845
+ return; // No params for default include
1846
+ const targetTable = relDef.to;
1847
+ const targetMeta = this.schema.tables[targetTable];
1848
+ if (!targetMeta)
1849
+ return;
1850
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
1851
+ // Non-wrapped path: nested relations BEFORE where/limit
1852
+ if (!willWrap && spec.with) {
1853
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1854
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1855
+ if (!nestedRelDef)
1856
+ continue;
1857
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
1858
+ }
1859
+ }
1860
+ // where params
1861
+ if (spec.where) {
1862
+ for (const [, v] of Object.entries(spec.where)) {
1863
+ params.push(v);
1864
+ }
1865
+ }
1866
+ // limit param
1867
+ if (spec.limit) {
1868
+ params.push(Number(spec.limit));
1869
+ }
1870
+ // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
1871
+ if (willWrap && spec.with) {
1872
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1873
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1874
+ if (!nestedRelDef)
1875
+ continue;
1876
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
1877
+ }
1878
+ }
1879
+ }
1880
+ /**
1881
+ * Fingerprint SET clauses for update/updateMany.
1882
+ * Captures key names + operator types (set/increment/etc) but not values.
1883
+ */
1884
+ fingerprintSet(data) {
1885
+ const entries = Object.entries(data).filter(([, v]) => v !== undefined);
1886
+ const parts = [];
1887
+ for (const [k, v] of entries) {
1888
+ if (v !== null &&
1889
+ typeof v === 'object' &&
1890
+ !Array.isArray(v) &&
1891
+ !(v instanceof Date) &&
1892
+ !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
1893
+ const keys = Object.keys(v);
1894
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1895
+ parts.push(`${k}:${keys[0]}`);
1896
+ continue;
1897
+ }
1898
+ }
1899
+ parts.push(`${k}:eq`);
1900
+ }
1901
+ return parts.join(',');
1902
+ }
1903
+ /**
1904
+ * Collect SET params for update/updateMany. Mirrors buildSetClause param order.
1905
+ */
1906
+ collectSetParams(data, params) {
1907
+ const entries = Object.entries(data).filter(([, v]) => v !== undefined);
1908
+ for (const [, v] of entries) {
1909
+ if (v !== null &&
1910
+ typeof v === 'object' &&
1911
+ !Array.isArray(v) &&
1912
+ !(v instanceof Date) &&
1913
+ !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
1914
+ const obj = v;
1915
+ const keys = Object.keys(obj);
1916
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1917
+ params.push(obj[keys[0]]);
1918
+ continue;
1919
+ }
1920
+ }
1921
+ params.push(v);
1922
+ }
1923
+ }
1241
1924
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
1242
1925
  buildWhere(where) {
1243
1926
  const params = [];
@@ -1564,28 +2247,53 @@ export class QueryInterface {
1564
2247
  return parsed;
1565
2248
  for (const [relName, relDef] of Object.entries(meta.relations)) {
1566
2249
  const rawValue = row[relName];
1567
- if (rawValue !== undefined) {
1568
- if (typeof rawValue === 'string') {
1569
- try {
1570
- parsed[relName] = JSON.parse(rawValue);
2250
+ if (rawValue === undefined)
2251
+ continue;
2252
+ // --- Short-circuit: skip JSON.parse for common empty/null cases ---
2253
+ // hasMany returns '[]' (from COALESCE(..., '[]'::json)); belongsTo/hasOne returns null
2254
+ if (rawValue === null || rawValue === 'null') {
2255
+ parsed[relName] = null;
2256
+ continue;
2257
+ }
2258
+ if (rawValue === '[]') {
2259
+ parsed[relName] = [];
2260
+ continue;
2261
+ }
2262
+ if (Array.isArray(rawValue) && rawValue.length === 0) {
2263
+ parsed[relName] = [];
2264
+ continue;
2265
+ }
2266
+ // --- Non-empty values: full parse path ---
2267
+ if (typeof rawValue === 'string') {
2268
+ try {
2269
+ const jsonVal = JSON.parse(rawValue);
2270
+ // After parsing, apply parseRow to each item for snake→camel + date coercion
2271
+ if (Array.isArray(jsonVal)) {
2272
+ parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2273
+ ? this.parseRow(item, relDef.to)
2274
+ : item);
1571
2275
  }
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;
2276
+ else if (typeof jsonVal === 'object' && jsonVal !== null) {
2277
+ parsed[relName] = this.parseRow(jsonVal, relDef.to);
2278
+ }
2279
+ else {
2280
+ parsed[relName] = jsonVal;
1575
2281
  }
1576
2282
  }
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 {
2283
+ catch {
2284
+ console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1586
2285
  parsed[relName] = rawValue;
1587
2286
  }
1588
2287
  }
2288
+ else if (Array.isArray(rawValue)) {
2289
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null ? this.parseRow(item, relDef.to) : item);
2290
+ }
2291
+ else if (typeof rawValue === 'object' && rawValue !== null) {
2292
+ parsed[relName] = this.parseRow(rawValue, relDef.to);
2293
+ }
2294
+ else {
2295
+ parsed[relName] = rawValue;
2296
+ }
1589
2297
  }
1590
2298
  return parsed;
1591
2299
  }