turbine-orm 0.7.0 → 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
@@ -71,6 +71,14 @@ function isWhereOperator(value) {
71
71
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
72
72
  /** Known JSONB operator keys */
73
73
  const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
74
+ /**
75
+ * JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
76
+ * appear in any other where-filter shape, so the presence of one of these is
77
+ * an unambiguous signal that the user meant a JSON filter. Used by the
78
+ * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
79
+ * `WhereOperator` for LIKE) is not misclassified.
80
+ */
81
+ const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
74
82
  /** Check if a value is a JSONB filter object */
75
83
  function isJsonFilter(value) {
76
84
  if (value === null ||
@@ -83,8 +91,27 @@ function isJsonFilter(value) {
83
91
  const keys = Object.keys(value);
84
92
  return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
85
93
  }
94
+ /**
95
+ * Returns the first JSON-unique key found in `value`, or `null` if none.
96
+ * Used to drive the strict-validation error message.
97
+ */
98
+ function findJsonUniqueKey(value) {
99
+ for (const k of Object.keys(value)) {
100
+ if (JSONB_UNIQUE_KEYS.has(k))
101
+ return k;
102
+ }
103
+ return null;
104
+ }
86
105
  /** Known Array operator keys */
87
106
  const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
107
+ /**
108
+ * Array operator keys that are *unique* to {@link ArrayFilter}. None of the
109
+ * array operators currently overlap with `WhereOperator` or `JsonFilter`, so
110
+ * this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
111
+ * constant so a future overlap (e.g. a `contains` for arrays) is easy to
112
+ * carve out.
113
+ */
114
+ const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
88
115
  /** Check if a value is an Array filter object */
89
116
  function isArrayFilter(value) {
90
117
  if (value === null ||
@@ -97,6 +124,17 @@ function isArrayFilter(value) {
97
124
  const keys = Object.keys(value);
98
125
  return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
99
126
  }
127
+ /**
128
+ * Returns the first array-unique key found in `value`, or `null` if none.
129
+ * Used to drive the strict-validation error message.
130
+ */
131
+ function findArrayUniqueKey(value) {
132
+ for (const k of Object.keys(value)) {
133
+ if (ARRAY_UNIQUE_KEYS.has(k))
134
+ return k;
135
+ }
136
+ return null;
137
+ }
100
138
  // ---------------------------------------------------------------------------
101
139
  // LRU cache — bounded SQL template cache to prevent memory leaks
102
140
  // ---------------------------------------------------------------------------
@@ -136,16 +174,56 @@ class LRUCache {
136
174
  return this.cache.size;
137
175
  }
138
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
+ }
139
203
  export class QueryInterface {
140
204
  pool;
141
205
  table;
142
206
  schema;
143
207
  tableMeta;
144
- /** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
145
- sqlCache = new LRUCache(1000);
208
+ /** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
209
+ sqlTemplateCache = new LRUCache(1000);
146
210
  middlewares;
147
211
  defaultLimit;
148
212
  warnOnUnlimited;
213
+ preparedStatementsEnabled;
214
+ sqlCacheEnabled;
215
+ /**
216
+ * Tracks tables that have already triggered an unlimited-query warning so
217
+ * the user is not spammed once per row. Per-instance state — each
218
+ * QueryInterface is bound to a single table, so this set will only ever
219
+ * contain at most one entry, but using a Set keeps the API consistent with
220
+ * the audit's "Set<string>" guidance and leaves room for future
221
+ * cross-table sharing.
222
+ */
223
+ warnedTables = new Set();
224
+ /** Cache hit/miss counters for diagnostics */
225
+ cacheHits = 0;
226
+ cacheMisses = 0;
149
227
  /** Pre-computed column type lookups (avoids linear scans per query) */
150
228
  columnPgTypeMap;
151
229
  columnArrayTypeMap;
@@ -160,7 +238,12 @@ export class QueryInterface {
160
238
  this.tableMeta = meta;
161
239
  this.middlewares = middlewares ?? [];
162
240
  this.defaultLimit = options?.defaultLimit;
163
- this.warnOnUnlimited = options?.warnOnUnlimited ?? false;
241
+ // Default to ON: surfacing accidental full-table scans is more valuable
242
+ // than the (small) risk of noisy logs. Callers explicitly opt out with
243
+ // `warnOnUnlimited: false`.
244
+ this.warnOnUnlimited = options?.warnOnUnlimited !== false;
245
+ this.preparedStatementsEnabled = options?.preparedStatements ?? true;
246
+ this.sqlCacheEnabled = options?.sqlCache !== false;
164
247
  // Pre-compute column type lookup maps (TASK-26)
165
248
  this.columnPgTypeMap = new Map();
166
249
  this.columnArrayTypeMap = new Map();
@@ -169,15 +252,66 @@ export class QueryInterface {
169
252
  this.columnArrayTypeMap.set(col.name, col.pgArrayType);
170
253
  }
171
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
+ }
292
+ /**
293
+ * Reset the per-instance unlimited-query warning dedupe set.
294
+ * Exposed for tests so a single test process can verify the warning fires
295
+ * exactly once per table without bleeding state between assertions.
296
+ */
297
+ resetUnlimitedWarnings() {
298
+ this.warnedTables.clear();
299
+ }
172
300
  /**
173
301
  * Execute a pool.query with an optional timeout.
174
302
  * If timeout is set, races the query against a timer and rejects on expiry.
175
303
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
176
304
  */
177
- 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);
178
312
  if (!timeout) {
179
313
  try {
180
- return await this.pool.query(sql, params);
314
+ return await exec;
181
315
  }
182
316
  catch (err) {
183
317
  throw wrapPgError(err);
@@ -188,7 +322,7 @@ export class QueryInterface {
188
322
  timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
189
323
  });
190
324
  try {
191
- return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
325
+ return await Promise.race([exec, timeoutPromise]);
192
326
  }
193
327
  catch (err) {
194
328
  throw wrapPgError(err);
@@ -229,148 +363,248 @@ export class QueryInterface {
229
363
  async findUnique(args) {
230
364
  return this.executeWithMiddleware('findUnique', args, async () => {
231
365
  const deferred = this.buildFindUnique(args);
232
- 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);
233
367
  return deferred.transform(result);
234
368
  });
235
369
  }
236
370
  buildFindUnique(args) {
237
371
  const columnsList = this.resolveColumns(args.select, args.omit);
238
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 = [];
239
378
  // Check if all where values are simple (plain equality, no operators/null/OR)
240
379
  const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
241
380
  const isSimpleWhere = !whereObj.OR &&
381
+ !whereObj.AND &&
382
+ !whereObj.NOT &&
242
383
  whereKeys.every((k) => {
243
384
  const v = whereObj[k];
244
- return v !== null && !isWhereOperator(v);
385
+ return v !== null && !isWhereOperator(v) && !this.tableMeta.relations[k];
245
386
  });
246
- // For simple queries (no nested with, no operators), use cached SQL template
387
+ // Simple path: plain equality, no operators/null/OR
247
388
  if (!args.with && isSimpleWhere) {
248
- const colKey = columnsList ? columnsList.join(',') : '*';
249
- const ck = `fu:${whereKeys.sort().join(',')}:c=${colKey}`;
250
- let sql = this.sqlCache.get(ck);
251
- const params = whereKeys.map((k) => whereObj[k]);
252
- if (!sql) {
389
+ const entry = this.acquireSql(ck, () => {
253
390
  const qt = quoteIdent(this.table);
391
+ const tempParams = whereKeys.map((k) => whereObj[k]);
254
392
  const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
255
393
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
256
394
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
257
- sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
258
- 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]);
259
401
  }
260
402
  return {
261
- sql,
403
+ sql: entry.sql,
262
404
  params,
263
405
  transform: (result) => {
264
406
  const row = result.rows[0];
265
407
  return row ? this.parseRow(row, this.table) : null;
266
408
  },
267
409
  tag: `${this.table}.findUnique`,
410
+ preparedName: entry.name,
268
411
  };
269
412
  }
270
- // General path: supports operators, null, OR, nested with
271
- const { sql: whereSql, params } = this.buildWhere(args.where);
413
+ // General path (with operators, null, OR, with clause)
272
414
  if (!args.with) {
273
- const qt = quoteIdent(this.table);
274
- const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
275
- 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);
276
425
  return {
277
- sql,
426
+ sql: entry.sql,
278
427
  params,
279
428
  transform: (result) => {
280
429
  const row = result.rows[0];
281
430
  return row ? this.parseRow(row, this.table) : null;
282
431
  },
283
432
  tag: `${this.table}.findUnique`,
433
+ preparedName: entry.name,
284
434
  };
285
435
  }
286
- // Nested queries: build fresh each time (with clause affects params)
287
- const selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
288
- 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);
289
451
  return {
290
- sql,
452
+ sql: entry.sql,
291
453
  params,
292
454
  transform: (result) => {
293
455
  const row = result.rows[0];
294
456
  return row ? this.parseNestedRow(row, this.table) : null;
295
457
  },
296
458
  tag: `${this.table}.findUnique`,
459
+ preparedName: entry.name,
297
460
  };
298
461
  }
299
462
  // -------------------------------------------------------------------------
300
463
  // findMany
301
464
  // -------------------------------------------------------------------------
302
465
  async findMany(args) {
303
- // Warn if no limit specified and warnOnUnlimited is enabled
304
- const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
305
- if (this.warnOnUnlimited && !hasExplicitLimit) {
306
- console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
307
- }
466
+ this.maybeWarnUnlimited(args);
308
467
  return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
309
468
  const deferred = this.buildFindMany(args);
310
- 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);
311
470
  return deferred.transform(result);
312
471
  });
313
472
  }
473
+ /**
474
+ * Emit a one-time `console.warn` when {@link findMany} is called without an
475
+ * explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
476
+ *
477
+ * Deduped per QueryInterface instance via {@link warnedTables} so a busy
478
+ * loop calling `db.users.findMany()` thousands of times only logs once.
479
+ * Suppressed when `defaultLimit` is configured (the caller has already
480
+ * opted in to a bounded query) and when the user passed an explicit
481
+ * `limit`, `take`, or `cursor`.
482
+ */
483
+ maybeWarnUnlimited(args) {
484
+ if (!this.warnOnUnlimited)
485
+ return;
486
+ if (this.defaultLimit !== undefined)
487
+ return;
488
+ const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined || args?.cursor !== undefined;
489
+ if (hasExplicitLimit)
490
+ return;
491
+ if (this.warnedTables.has(this.table))
492
+ return;
493
+ this.warnedTables.add(this.table);
494
+ console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
495
+ 'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
496
+ }
314
497
  buildFindMany(args) {
315
- const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
316
498
  const columnsList = this.resolveColumns(args?.select, args?.omit);
317
- const qt = quoteIdent(this.table);
318
- // Distinct support
319
- let distinctPrefix = '';
320
- if (args?.distinct && args.distinct.length > 0) {
321
- const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
322
- 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);
323
582
  }
324
- let selectClause;
583
+ // 2. WITH relation params
325
584
  if (args?.with) {
326
- selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
327
- }
328
- else if (columnsList) {
329
- selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
585
+ this.collectWithParams(args.with, params);
330
586
  }
331
- else {
332
- selectClause = `${qt}.*`;
333
- }
334
- let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${whereSql}`;
335
- // Cursor-based pagination: add WHERE condition for cursor
587
+ // 3. Cursor params
336
588
  if (args?.cursor) {
337
589
  const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
338
- if (cursorEntries.length > 0) {
339
- // Determine direction from orderBy (default 'asc')
340
- const cursorConditions = cursorEntries.map(([k, v]) => {
341
- const col = this.toSqlColumn(k);
342
- const dir = args.orderBy?.[k] ?? 'asc';
343
- const op = dir === 'desc' ? '<' : '>';
344
- params.push(v);
345
- return `${qt}.${col} ${op} $${params.length}`;
346
- });
347
- // Append to existing WHERE or create new one
348
- if (whereSql) {
349
- sql += ` AND ${cursorConditions.join(' AND ')}`;
350
- }
351
- else {
352
- sql += ` WHERE ${cursorConditions.join(' AND ')}`;
353
- }
590
+ for (const [, v] of cursorEntries) {
591
+ params.push(v);
354
592
  }
355
593
  }
356
- if (args?.orderBy) {
357
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
358
- }
359
- // take overrides limit when cursor pagination is used; fall back to defaultLimit
360
- const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
594
+ // 4. LIMIT param
361
595
  if (effectiveLimit !== undefined) {
362
596
  params.push(Number(effectiveLimit));
363
- sql += ` LIMIT $${params.length}`;
364
597
  }
598
+ // 5. OFFSET param
365
599
  if (args?.offset !== undefined) {
366
600
  params.push(Number(args.offset));
367
- sql += ` OFFSET $${params.length}`;
368
601
  }
369
602
  return {
370
- sql,
603
+ sql: entry.sql,
371
604
  params,
372
605
  transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
373
606
  tag: `${this.table}.findMany`,
607
+ preparedName: entry.name,
374
608
  };
375
609
  }
376
610
  // -------------------------------------------------------------------------
@@ -380,9 +614,21 @@ export class QueryInterface {
380
614
  * Stream rows from a findMany query using PostgreSQL cursors.
381
615
  * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
382
616
  *
383
- * Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
384
- * The cursor is automatically closed and the connection released when iteration
385
- * 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`.
386
632
  *
387
633
  * @example
388
634
  * ```ts
@@ -392,9 +638,23 @@ export class QueryInterface {
392
638
  * ```
393
639
  */
394
640
  async *findManyStream(args) {
395
- const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
396
- const deferred = this.buildFindMany(args);
641
+ const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
397
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);
398
658
  // Acquire a dedicated connection — cursors require a single connection in a transaction
399
659
  const client = await this.pool.connect();
400
660
  const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -436,7 +696,7 @@ export class QueryInterface {
436
696
  async findFirst(args) {
437
697
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
438
698
  const deferred = this.buildFindFirst(args);
439
- 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);
440
700
  return deferred.transform(result);
441
701
  });
442
702
  }
@@ -460,7 +720,7 @@ export class QueryInterface {
460
720
  async findFirstOrThrow(args) {
461
721
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
462
722
  const deferred = this.buildFindFirstOrThrow(args);
463
- 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);
464
724
  return deferred.transform(result);
465
725
  });
466
726
  }
@@ -489,7 +749,7 @@ export class QueryInterface {
489
749
  async findUniqueOrThrow(args) {
490
750
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
491
751
  const deferred = this.buildFindUniqueOrThrow(args);
492
- 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);
493
753
  return deferred.transform(result);
494
754
  });
495
755
  }
@@ -518,7 +778,7 @@ export class QueryInterface {
518
778
  async create(args) {
519
779
  return this.executeWithMiddleware('create', args, async () => {
520
780
  const deferred = this.buildCreate(args);
521
- 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);
522
782
  return deferred.transform(result);
523
783
  });
524
784
  }
@@ -551,7 +811,7 @@ export class QueryInterface {
551
811
  async createMany(args) {
552
812
  return this.executeWithMiddleware('createMany', args, async () => {
553
813
  const deferred = this.buildCreateMany(args);
554
- 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);
555
815
  return deferred.transform(result);
556
816
  });
557
817
  }
@@ -598,22 +858,35 @@ export class QueryInterface {
598
858
  async update(args) {
599
859
  return this.executeWithMiddleware('update', args, async () => {
600
860
  const deferred = this.buildUpdate(args);
601
- 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);
602
862
  return deferred.transform(result);
603
863
  });
604
864
  }
605
865
  buildUpdate(args) {
606
- const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
607
- // 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}`;
608
871
  const params = [];
609
- const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
610
- // Build WHERE using the shared params array (continues numbering after SET params)
611
- const whereClause = this.buildWhereClause(args.where, params);
612
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
613
- this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
614
- 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);
615
888
  return {
616
- sql,
889
+ sql: entry.sql,
617
890
  params,
618
891
  transform: (result) => {
619
892
  const row = result.rows[0];
@@ -627,6 +900,7 @@ export class QueryInterface {
627
900
  return this.parseRow(row, this.table);
628
901
  },
629
902
  tag: `${this.table}.update`,
903
+ preparedName: entry.name,
630
904
  };
631
905
  }
632
906
  // -------------------------------------------------------------------------
@@ -635,16 +909,31 @@ export class QueryInterface {
635
909
  async delete(args) {
636
910
  return this.executeWithMiddleware('delete', args, async () => {
637
911
  const deferred = this.buildDelete(args);
638
- 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);
639
913
  return deferred.transform(result);
640
914
  });
641
915
  }
642
916
  buildDelete(args) {
643
- const { sql: whereSql, params } = this.buildWhere(args.where);
644
- this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
645
- 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);
646
935
  return {
647
- sql,
936
+ sql: entry.sql,
648
937
  params,
649
938
  transform: (result) => {
650
939
  const row = result.rows[0];
@@ -658,6 +947,7 @@ export class QueryInterface {
658
947
  return this.parseRow(row, this.table);
659
948
  },
660
949
  tag: `${this.table}.delete`,
950
+ preparedName: entry.name,
661
951
  };
662
952
  }
663
953
  // -------------------------------------------------------------------------
@@ -666,7 +956,7 @@ export class QueryInterface {
666
956
  async upsert(args) {
667
957
  return this.executeWithMiddleware('upsert', args, async () => {
668
958
  const deferred = this.buildUpsert(args);
669
- 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);
670
960
  return deferred.transform(result);
671
961
  });
672
962
  }
@@ -716,25 +1006,37 @@ export class QueryInterface {
716
1006
  async updateMany(args) {
717
1007
  return this.executeWithMiddleware('updateMany', args, async () => {
718
1008
  const deferred = this.buildUpdateMany(args);
719
- 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);
720
1010
  return deferred.transform(result);
721
1011
  });
722
1012
  }
723
1013
  buildUpdateMany(args) {
724
- const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
725
- // 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}`;
726
1019
  const params = [];
727
- const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
728
- // Build WHERE using the shared params array (continues numbering after SET params)
729
- const whereClause = this.buildWhereClause(args.where, params);
730
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
731
- this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
732
- 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);
733
1034
  return {
734
- sql,
1035
+ sql: entry.sql,
735
1036
  params,
736
1037
  transform: (result) => ({ count: result.rowCount ?? 0 }),
737
1038
  tag: `${this.table}.updateMany`,
1039
+ preparedName: entry.name,
738
1040
  };
739
1041
  }
740
1042
  // -------------------------------------------------------------------------
@@ -743,19 +1045,32 @@ export class QueryInterface {
743
1045
  async deleteMany(args) {
744
1046
  return this.executeWithMiddleware('deleteMany', args, async () => {
745
1047
  const deferred = this.buildDeleteMany(args);
746
- 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);
747
1049
  return deferred.transform(result);
748
1050
  });
749
1051
  }
750
1052
  buildDeleteMany(args) {
751
- const { sql: whereSql, params } = this.buildWhere(args.where);
752
- this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
753
- 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);
754
1068
  return {
755
- sql,
1069
+ sql: entry.sql,
756
1070
  params,
757
1071
  transform: (result) => ({ count: result.rowCount ?? 0 }),
758
1072
  tag: `${this.table}.deleteMany`,
1073
+ preparedName: entry.name,
759
1074
  };
760
1075
  }
761
1076
  // -------------------------------------------------------------------------
@@ -764,18 +1079,30 @@ export class QueryInterface {
764
1079
  async count(args) {
765
1080
  return this.executeWithMiddleware('count', (args ?? {}), async () => {
766
1081
  const deferred = this.buildCount(args);
767
- 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);
768
1083
  return deferred.transform(result);
769
1084
  });
770
1085
  }
771
1086
  buildCount(args) {
772
- const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
773
- 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
+ }
774
1100
  return {
775
- sql,
1101
+ sql: entry.sql,
776
1102
  params,
777
1103
  transform: (result) => result.rows[0].count,
778
1104
  tag: `${this.table}.count`,
1105
+ preparedName: entry.name,
779
1106
  };
780
1107
  }
781
1108
  // -------------------------------------------------------------------------
@@ -784,7 +1111,7 @@ export class QueryInterface {
784
1111
  async groupBy(args) {
785
1112
  return this.executeWithMiddleware('groupBy', args, async () => {
786
1113
  const deferred = this.buildGroupBy(args);
787
- 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);
788
1115
  return deferred.transform(result);
789
1116
  });
790
1117
  }
@@ -917,7 +1244,7 @@ export class QueryInterface {
917
1244
  async aggregate(args) {
918
1245
  return this.executeWithMiddleware('aggregate', args, async () => {
919
1246
  const deferred = this.buildAggregate(args);
920
- 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);
921
1248
  return deferred.transform(result);
922
1249
  });
923
1250
  }
@@ -1160,6 +1487,440 @@ export class QueryInterface {
1160
1487
  params.push(value);
1161
1488
  return `${col} = $${params.length}`;
1162
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
+ }
1163
1924
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
1164
1925
  buildWhere(where) {
1165
1926
  const params = [];
@@ -1261,6 +2022,16 @@ export class QueryInterface {
1261
2022
  andClauses.push(...jsonClauses);
1262
2023
  continue;
1263
2024
  }
2025
+ // Strict validation: a JSON-only operator on a non-JSON column was almost
2026
+ // certainly a typo or schema mismatch. Silently falling through to plain
2027
+ // equality (the previous behaviour) wasted hours of debugging time. Only
2028
+ // throw when the operator is unambiguously JSON-specific — `contains` is
2029
+ // shared with WhereOperator's LIKE so it must continue to fall through.
2030
+ const jsonKey = findJsonUniqueKey(value);
2031
+ if (jsonKey) {
2032
+ throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
2033
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
2034
+ }
1264
2035
  }
1265
2036
  // Handle Array filter operators (for array columns)
1266
2037
  if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
@@ -1270,6 +2041,14 @@ export class QueryInterface {
1270
2041
  andClauses.push(...arrayClauses);
1271
2042
  continue;
1272
2043
  }
2044
+ // Strict validation: array operators (`has`, `hasEvery`, ...) on a
2045
+ // non-array column always indicate a mistake. None of these keys
2046
+ // overlap with other filter shapes so we can throw unconditionally.
2047
+ const arrayKey = findArrayUniqueKey(value);
2048
+ if (arrayKey) {
2049
+ throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
2050
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
2051
+ }
1273
2052
  }
1274
2053
  // Handle operator objects
1275
2054
  if (isWhereOperator(value)) {
@@ -1468,28 +2247,53 @@ export class QueryInterface {
1468
2247
  return parsed;
1469
2248
  for (const [relName, relDef] of Object.entries(meta.relations)) {
1470
2249
  const rawValue = row[relName];
1471
- if (rawValue !== undefined) {
1472
- if (typeof rawValue === 'string') {
1473
- try {
1474
- 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);
1475
2275
  }
1476
- catch {
1477
- console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1478
- 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;
1479
2281
  }
1480
2282
  }
1481
- else if (Array.isArray(rawValue)) {
1482
- parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
1483
- ? this.parseRow(item, relDef.to)
1484
- : item);
1485
- }
1486
- else if (typeof rawValue === 'object' && rawValue !== null) {
1487
- parsed[relName] = this.parseRow(rawValue, relDef.to);
1488
- }
1489
- else {
2283
+ catch {
2284
+ console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1490
2285
  parsed[relName] = rawValue;
1491
2286
  }
1492
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
+ }
1493
2297
  }
1494
2298
  return parsed;
1495
2299
  }