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/cjs/query.js CHANGED
@@ -14,6 +14,8 @@
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.QueryInterface = void 0;
16
16
  exports.quoteIdent = quoteIdent;
17
+ exports.fnv1a64Hex = fnv1a64Hex;
18
+ exports.sqlToPreparedName = sqlToPreparedName;
17
19
  const errors_js_1 = require("./errors.js");
18
20
  const schema_js_1 = require("./schema.js");
19
21
  // ---------------------------------------------------------------------------
@@ -75,6 +77,14 @@ function isWhereOperator(value) {
75
77
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
76
78
  /** Known JSONB operator keys */
77
79
  const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
80
+ /**
81
+ * JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
82
+ * appear in any other where-filter shape, so the presence of one of these is
83
+ * an unambiguous signal that the user meant a JSON filter. Used by the
84
+ * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
85
+ * `WhereOperator` for LIKE) is not misclassified.
86
+ */
87
+ const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
78
88
  /** Check if a value is a JSONB filter object */
79
89
  function isJsonFilter(value) {
80
90
  if (value === null ||
@@ -87,8 +97,27 @@ function isJsonFilter(value) {
87
97
  const keys = Object.keys(value);
88
98
  return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
89
99
  }
100
+ /**
101
+ * Returns the first JSON-unique key found in `value`, or `null` if none.
102
+ * Used to drive the strict-validation error message.
103
+ */
104
+ function findJsonUniqueKey(value) {
105
+ for (const k of Object.keys(value)) {
106
+ if (JSONB_UNIQUE_KEYS.has(k))
107
+ return k;
108
+ }
109
+ return null;
110
+ }
90
111
  /** Known Array operator keys */
91
112
  const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
113
+ /**
114
+ * Array operator keys that are *unique* to {@link ArrayFilter}. None of the
115
+ * array operators currently overlap with `WhereOperator` or `JsonFilter`, so
116
+ * this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
117
+ * constant so a future overlap (e.g. a `contains` for arrays) is easy to
118
+ * carve out.
119
+ */
120
+ const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
92
121
  /** Check if a value is an Array filter object */
93
122
  function isArrayFilter(value) {
94
123
  if (value === null ||
@@ -101,6 +130,17 @@ function isArrayFilter(value) {
101
130
  const keys = Object.keys(value);
102
131
  return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
103
132
  }
133
+ /**
134
+ * Returns the first array-unique key found in `value`, or `null` if none.
135
+ * Used to drive the strict-validation error message.
136
+ */
137
+ function findArrayUniqueKey(value) {
138
+ for (const k of Object.keys(value)) {
139
+ if (ARRAY_UNIQUE_KEYS.has(k))
140
+ return k;
141
+ }
142
+ return null;
143
+ }
104
144
  // ---------------------------------------------------------------------------
105
145
  // LRU cache — bounded SQL template cache to prevent memory leaks
106
146
  // ---------------------------------------------------------------------------
@@ -140,16 +180,56 @@ class LRUCache {
140
180
  return this.cache.size;
141
181
  }
142
182
  }
183
+ /**
184
+ * FNV-1a 64-bit hash returning 16 lowercase hex chars.
185
+ * Single-loop string iteration. Uses BigInt for 64-bit math.
186
+ *
187
+ * @internal Exported for testing only.
188
+ */
189
+ function fnv1a64Hex(s) {
190
+ // FNV-1a offset basis and prime for 64-bit
191
+ let hash = 0xcbf29ce484222325n;
192
+ const prime = 0x100000001b3n;
193
+ const mask = 0xffffffffffffffffn; // 64-bit mask
194
+ for (let i = 0; i < s.length; i++) {
195
+ hash ^= BigInt(s.charCodeAt(i));
196
+ hash = (hash * prime) & mask;
197
+ }
198
+ return hash.toString(16).padStart(16, '0');
199
+ }
200
+ /**
201
+ * Derive a prepared-statement name from a SQL string.
202
+ * Format: `t_<16hex>` — always 18 chars, well under NAMEDATALEN (63).
203
+ *
204
+ * @internal Exported for testing only.
205
+ */
206
+ function sqlToPreparedName(sql) {
207
+ return `t_${fnv1a64Hex(sql)}`;
208
+ }
143
209
  class QueryInterface {
144
210
  pool;
145
211
  table;
146
212
  schema;
147
213
  tableMeta;
148
- /** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
149
- sqlCache = new LRUCache(1000);
214
+ /** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
215
+ sqlTemplateCache = new LRUCache(1000);
150
216
  middlewares;
151
217
  defaultLimit;
152
218
  warnOnUnlimited;
219
+ preparedStatementsEnabled;
220
+ sqlCacheEnabled;
221
+ /**
222
+ * Tracks tables that have already triggered an unlimited-query warning so
223
+ * the user is not spammed once per row. Per-instance state — each
224
+ * QueryInterface is bound to a single table, so this set will only ever
225
+ * contain at most one entry, but using a Set keeps the API consistent with
226
+ * the audit's "Set<string>" guidance and leaves room for future
227
+ * cross-table sharing.
228
+ */
229
+ warnedTables = new Set();
230
+ /** Cache hit/miss counters for diagnostics */
231
+ cacheHits = 0;
232
+ cacheMisses = 0;
153
233
  /** Pre-computed column type lookups (avoids linear scans per query) */
154
234
  columnPgTypeMap;
155
235
  columnArrayTypeMap;
@@ -164,7 +244,12 @@ class QueryInterface {
164
244
  this.tableMeta = meta;
165
245
  this.middlewares = middlewares ?? [];
166
246
  this.defaultLimit = options?.defaultLimit;
167
- this.warnOnUnlimited = options?.warnOnUnlimited ?? false;
247
+ // Default to ON: surfacing accidental full-table scans is more valuable
248
+ // than the (small) risk of noisy logs. Callers explicitly opt out with
249
+ // `warnOnUnlimited: false`.
250
+ this.warnOnUnlimited = options?.warnOnUnlimited !== false;
251
+ this.preparedStatementsEnabled = options?.preparedStatements ?? true;
252
+ this.sqlCacheEnabled = options?.sqlCache !== false;
168
253
  // Pre-compute column type lookup maps (TASK-26)
169
254
  this.columnPgTypeMap = new Map();
170
255
  this.columnArrayTypeMap = new Map();
@@ -173,15 +258,66 @@ class QueryInterface {
173
258
  this.columnArrayTypeMap.set(col.name, col.pgArrayType);
174
259
  }
175
260
  }
261
+ /**
262
+ * Return cache hit/miss statistics for this QueryInterface instance.
263
+ * Useful for monitoring and benchmarking.
264
+ */
265
+ cacheStats() {
266
+ const total = this.cacheHits + this.cacheMisses;
267
+ return {
268
+ hits: this.cacheHits,
269
+ misses: this.cacheMisses,
270
+ hitRate: total > 0 ? this.cacheHits / total : 0,
271
+ size: this.sqlTemplateCache.size,
272
+ };
273
+ }
274
+ /**
275
+ * Look up or build a SQL template in the cache.
276
+ * On miss, calls `build()` to generate the SQL, stores the entry, and returns it.
277
+ * On hit, increments counters and returns the cached entry.
278
+ *
279
+ * When `sqlCache` is disabled, always calls `build()` without caching.
280
+ */
281
+ acquireSql(cacheKey, build) {
282
+ if (!this.sqlCacheEnabled) {
283
+ const sql = build();
284
+ this.cacheMisses++;
285
+ return { sql, name: sqlToPreparedName(sql) };
286
+ }
287
+ const cached = this.sqlTemplateCache.get(cacheKey);
288
+ if (cached) {
289
+ this.cacheHits++;
290
+ return cached;
291
+ }
292
+ const sql = build();
293
+ const entry = { sql, name: sqlToPreparedName(sql) };
294
+ this.sqlTemplateCache.set(cacheKey, entry);
295
+ this.cacheMisses++;
296
+ return entry;
297
+ }
298
+ /**
299
+ * Reset the per-instance unlimited-query warning dedupe set.
300
+ * Exposed for tests so a single test process can verify the warning fires
301
+ * exactly once per table without bleeding state between assertions.
302
+ */
303
+ resetUnlimitedWarnings() {
304
+ this.warnedTables.clear();
305
+ }
176
306
  /**
177
307
  * Execute a pool.query with an optional timeout.
178
308
  * If timeout is set, races the query against a timer and rejects on expiry.
179
309
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
180
310
  */
181
- async queryWithTimeout(sql, params, timeout) {
311
+ async queryWithTimeout(sql, params, timeout, preparedName) {
312
+ // Build the query argument — use object form with `name` for prepared
313
+ // statements, or the plain (text, values) form otherwise.
314
+ const usePrepared = preparedName && this.preparedStatementsEnabled;
315
+ const exec = usePrepared
316
+ ? this.pool.query({ name: preparedName, text: sql, values: params })
317
+ : this.pool.query(sql, params);
182
318
  if (!timeout) {
183
319
  try {
184
- return await this.pool.query(sql, params);
320
+ return await exec;
185
321
  }
186
322
  catch (err) {
187
323
  throw (0, errors_js_1.wrapPgError)(err);
@@ -192,7 +328,7 @@ class QueryInterface {
192
328
  timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
193
329
  });
194
330
  try {
195
- return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
331
+ return await Promise.race([exec, timeoutPromise]);
196
332
  }
197
333
  catch (err) {
198
334
  throw (0, errors_js_1.wrapPgError)(err);
@@ -233,148 +369,248 @@ class QueryInterface {
233
369
  async findUnique(args) {
234
370
  return this.executeWithMiddleware('findUnique', args, async () => {
235
371
  const deferred = this.buildFindUnique(args);
236
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
372
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
237
373
  return deferred.transform(result);
238
374
  });
239
375
  }
240
376
  buildFindUnique(args) {
241
377
  const columnsList = this.resolveColumns(args.select, args.omit);
242
378
  const whereObj = args.where;
379
+ const colKey = columnsList ? columnsList.join(',') : '*';
380
+ const whereFingerprint = this.fingerprintWhere(whereObj);
381
+ const withFp = args.with ? this.withFingerprint(args.with) : '';
382
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
383
+ const params = [];
243
384
  // Check if all where values are simple (plain equality, no operators/null/OR)
244
385
  const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
245
386
  const isSimpleWhere = !whereObj.OR &&
387
+ !whereObj.AND &&
388
+ !whereObj.NOT &&
246
389
  whereKeys.every((k) => {
247
390
  const v = whereObj[k];
248
- return v !== null && !isWhereOperator(v);
391
+ return v !== null && !isWhereOperator(v) && !this.tableMeta.relations[k];
249
392
  });
250
- // For simple queries (no nested with, no operators), use cached SQL template
393
+ // Simple path: plain equality, no operators/null/OR
251
394
  if (!args.with && isSimpleWhere) {
252
- const colKey = columnsList ? columnsList.join(',') : '*';
253
- const ck = `fu:${whereKeys.sort().join(',')}:c=${colKey}`;
254
- let sql = this.sqlCache.get(ck);
255
- const params = whereKeys.map((k) => whereObj[k]);
256
- if (!sql) {
395
+ const entry = this.acquireSql(ck, () => {
257
396
  const qt = quoteIdent(this.table);
397
+ const tempParams = whereKeys.map((k) => whereObj[k]);
258
398
  const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
259
399
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
260
400
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
261
- sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
262
- this.sqlCache.set(ck, sql);
401
+ void tempParams; // params are positional, SQL is value-invariant
402
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
403
+ });
404
+ // Collect params (same order as build)
405
+ for (const k of whereKeys) {
406
+ params.push(whereObj[k]);
263
407
  }
264
408
  return {
265
- sql,
409
+ sql: entry.sql,
266
410
  params,
267
411
  transform: (result) => {
268
412
  const row = result.rows[0];
269
413
  return row ? this.parseRow(row, this.table) : null;
270
414
  },
271
415
  tag: `${this.table}.findUnique`,
416
+ preparedName: entry.name,
272
417
  };
273
418
  }
274
- // General path: supports operators, null, OR, nested with
275
- const { sql: whereSql, params } = this.buildWhere(args.where);
419
+ // General path (with operators, null, OR, with clause)
276
420
  if (!args.with) {
277
- const qt = quoteIdent(this.table);
278
- const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
279
- const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
421
+ const entry = this.acquireSql(ck, () => {
422
+ const freshParams = [];
423
+ const clause = this.buildWhereClause(whereObj, freshParams);
424
+ const whereSql = clause ? ` WHERE ${clause}` : '';
425
+ const qt = quoteIdent(this.table);
426
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
427
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
428
+ });
429
+ // Collect params
430
+ this.collectWhereParams(whereObj, params);
280
431
  return {
281
- sql,
432
+ sql: entry.sql,
282
433
  params,
283
434
  transform: (result) => {
284
435
  const row = result.rows[0];
285
436
  return row ? this.parseRow(row, this.table) : null;
286
437
  },
287
438
  tag: `${this.table}.findUnique`,
439
+ preparedName: entry.name,
288
440
  };
289
441
  }
290
- // Nested queries: build fresh each time (with clause affects params)
291
- const selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
292
- const sql = `SELECT ${selectClause} FROM ${quoteIdent(this.table)}${whereSql} LIMIT 1`;
442
+ // Nested queries with `with` clause.
443
+ // The param order in the original code is:
444
+ // 1. buildWhere pushes where params
445
+ // 2. buildSelectWithRelations pushes relation params to same array
446
+ // We must preserve this exact order.
447
+ const entry = this.acquireSql(ck, () => {
448
+ const freshParams = [];
449
+ const clause = this.buildWhereClause(whereObj, freshParams);
450
+ const whereSql = clause ? ` WHERE ${clause}` : '';
451
+ const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
452
+ return `SELECT ${selectClause} FROM ${quoteIdent(this.table)}${whereSql} LIMIT 1`;
453
+ });
454
+ // Collect params in exact build order: where first, then with-clause relations
455
+ this.collectWhereParams(whereObj, params);
456
+ this.collectWithParams(args.with, params);
293
457
  return {
294
- sql,
458
+ sql: entry.sql,
295
459
  params,
296
460
  transform: (result) => {
297
461
  const row = result.rows[0];
298
462
  return row ? this.parseNestedRow(row, this.table) : null;
299
463
  },
300
464
  tag: `${this.table}.findUnique`,
465
+ preparedName: entry.name,
301
466
  };
302
467
  }
303
468
  // -------------------------------------------------------------------------
304
469
  // findMany
305
470
  // -------------------------------------------------------------------------
306
471
  async findMany(args) {
307
- // Warn if no limit specified and warnOnUnlimited is enabled
308
- const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
309
- if (this.warnOnUnlimited && !hasExplicitLimit) {
310
- console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
311
- }
472
+ this.maybeWarnUnlimited(args);
312
473
  return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
313
474
  const deferred = this.buildFindMany(args);
314
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
475
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
315
476
  return deferred.transform(result);
316
477
  });
317
478
  }
479
+ /**
480
+ * Emit a one-time `console.warn` when {@link findMany} is called without an
481
+ * explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
482
+ *
483
+ * Deduped per QueryInterface instance via {@link warnedTables} so a busy
484
+ * loop calling `db.users.findMany()` thousands of times only logs once.
485
+ * Suppressed when `defaultLimit` is configured (the caller has already
486
+ * opted in to a bounded query) and when the user passed an explicit
487
+ * `limit`, `take`, or `cursor`.
488
+ */
489
+ maybeWarnUnlimited(args) {
490
+ if (!this.warnOnUnlimited)
491
+ return;
492
+ if (this.defaultLimit !== undefined)
493
+ return;
494
+ const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined || args?.cursor !== undefined;
495
+ if (hasExplicitLimit)
496
+ return;
497
+ if (this.warnedTables.has(this.table))
498
+ return;
499
+ this.warnedTables.add(this.table);
500
+ console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
501
+ 'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
502
+ }
318
503
  buildFindMany(args) {
319
- const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
320
504
  const columnsList = this.resolveColumns(args?.select, args?.omit);
321
- const qt = quoteIdent(this.table);
322
- // Distinct support
323
- let distinctPrefix = '';
324
- if (args?.distinct && args.distinct.length > 0) {
325
- const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
326
- distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
505
+ const colKey = columnsList ? columnsList.join(',') : '*';
506
+ const whereObj = (args?.where ?? {});
507
+ // Build fingerprint for cache lookup
508
+ const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
509
+ const withFp = args?.with ? this.withFingerprint(args.with) : '';
510
+ const orderFp = args?.orderBy
511
+ ? Object.entries(args.orderBy)
512
+ .map(([k, d]) => `${k}:${d}`)
513
+ .join(',')
514
+ : '';
515
+ const cursorFp = args?.cursor
516
+ ? Object.keys(args.cursor)
517
+ .filter((k) => args.cursor[k] !== undefined)
518
+ .sort()
519
+ .join(',')
520
+ : '';
521
+ const distinctFp = args?.distinct ? args.distinct.slice().sort().join(',') : '';
522
+ const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
523
+ const limitFp = effectiveLimit !== undefined ? '1' : '0';
524
+ const offsetFp = args?.offset !== undefined ? '1' : '0';
525
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}`;
526
+ const params = [];
527
+ const entry = this.acquireSql(ck, () => {
528
+ // Fresh build — generates SQL and populates freshParams
529
+ const freshParams = [];
530
+ const { sql: freshWhereSql } = args?.where
531
+ ? (() => {
532
+ const clause = this.buildWhereClause(whereObj, freshParams);
533
+ return { sql: clause ? ` WHERE ${clause}` : '' };
534
+ })()
535
+ : { sql: '' };
536
+ const qt = quoteIdent(this.table);
537
+ let distinctPrefix = '';
538
+ if (args?.distinct && args.distinct.length > 0) {
539
+ const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
540
+ distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
541
+ }
542
+ let selectClause;
543
+ if (args?.with) {
544
+ selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
545
+ }
546
+ else if (columnsList) {
547
+ selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
548
+ }
549
+ else {
550
+ selectClause = `${qt}.*`;
551
+ }
552
+ let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
553
+ if (args?.cursor) {
554
+ const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
555
+ if (cursorEntries.length > 0) {
556
+ const cursorConditions = cursorEntries.map(([k, v]) => {
557
+ const col = this.toSqlColumn(k);
558
+ const dir = args.orderBy?.[k] ?? 'asc';
559
+ const op = dir === 'desc' ? '<' : '>';
560
+ freshParams.push(v);
561
+ return `${qt}.${col} ${op} $${freshParams.length}`;
562
+ });
563
+ if (freshWhereSql) {
564
+ sql += ` AND ${cursorConditions.join(' AND ')}`;
565
+ }
566
+ else {
567
+ sql += ` WHERE ${cursorConditions.join(' AND ')}`;
568
+ }
569
+ }
570
+ }
571
+ if (args?.orderBy) {
572
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
573
+ }
574
+ if (effectiveLimit !== undefined) {
575
+ freshParams.push(Number(effectiveLimit));
576
+ sql += ` LIMIT $${freshParams.length}`;
577
+ }
578
+ if (args?.offset !== undefined) {
579
+ freshParams.push(Number(args.offset));
580
+ sql += ` OFFSET $${freshParams.length}`;
581
+ }
582
+ return sql;
583
+ });
584
+ // Collect params in exact build order:
585
+ // 1. WHERE params
586
+ if (args?.where) {
587
+ this.collectWhereParams(whereObj, params);
327
588
  }
328
- let selectClause;
589
+ // 2. WITH relation params
329
590
  if (args?.with) {
330
- selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
331
- }
332
- else if (columnsList) {
333
- selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
591
+ this.collectWithParams(args.with, params);
334
592
  }
335
- else {
336
- selectClause = `${qt}.*`;
337
- }
338
- let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${whereSql}`;
339
- // Cursor-based pagination: add WHERE condition for cursor
593
+ // 3. Cursor params
340
594
  if (args?.cursor) {
341
595
  const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
342
- if (cursorEntries.length > 0) {
343
- // Determine direction from orderBy (default 'asc')
344
- const cursorConditions = cursorEntries.map(([k, v]) => {
345
- const col = this.toSqlColumn(k);
346
- const dir = args.orderBy?.[k] ?? 'asc';
347
- const op = dir === 'desc' ? '<' : '>';
348
- params.push(v);
349
- return `${qt}.${col} ${op} $${params.length}`;
350
- });
351
- // Append to existing WHERE or create new one
352
- if (whereSql) {
353
- sql += ` AND ${cursorConditions.join(' AND ')}`;
354
- }
355
- else {
356
- sql += ` WHERE ${cursorConditions.join(' AND ')}`;
357
- }
596
+ for (const [, v] of cursorEntries) {
597
+ params.push(v);
358
598
  }
359
599
  }
360
- if (args?.orderBy) {
361
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
362
- }
363
- // take overrides limit when cursor pagination is used; fall back to defaultLimit
364
- const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
600
+ // 4. LIMIT param
365
601
  if (effectiveLimit !== undefined) {
366
602
  params.push(Number(effectiveLimit));
367
- sql += ` LIMIT $${params.length}`;
368
603
  }
604
+ // 5. OFFSET param
369
605
  if (args?.offset !== undefined) {
370
606
  params.push(Number(args.offset));
371
- sql += ` OFFSET $${params.length}`;
372
607
  }
373
608
  return {
374
- sql,
609
+ sql: entry.sql,
375
610
  params,
376
611
  transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
377
612
  tag: `${this.table}.findMany`,
613
+ preparedName: entry.name,
378
614
  };
379
615
  }
380
616
  // -------------------------------------------------------------------------
@@ -384,9 +620,21 @@ class QueryInterface {
384
620
  * Stream rows from a findMany query using PostgreSQL cursors.
385
621
  * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
386
622
  *
387
- * Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
388
- * The cursor is automatically closed and the connection released when iteration
389
- * completes or is terminated early (e.g. `break` from `for await`).
623
+ * **Speculative fast-path:** Before opening a cursor, issues a single
624
+ * `SELECT ... LIMIT batchSize+1`. If the result fits within `batchSize`,
625
+ * all rows are yielded immediately with zero cursor overhead (no BEGIN /
626
+ * DECLARE / CLOSE / COMMIT). Only when the result overflows does the
627
+ * method fall back to the full cursor path.
628
+ *
629
+ * **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
630
+ * single pooled connection. The cursor is automatically closed and the
631
+ * connection released when iteration completes or is terminated early
632
+ * (e.g. `break` from `for await`).
633
+ *
634
+ * **Snapshot semantics note:** The speculative fast-path runs outside a
635
+ * transaction. If the result overflows and the cursor path is opened, the
636
+ * cursor runs in its own transaction — spanning two separate snapshots.
637
+ * For strict single-snapshot semantics, wrap the call in `$transaction`.
390
638
  *
391
639
  * @example
392
640
  * ```ts
@@ -396,9 +644,23 @@ class QueryInterface {
396
644
  * ```
397
645
  */
398
646
  async *findManyStream(args) {
399
- const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
400
- const deferred = this.buildFindMany(args);
647
+ const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
401
648
  const hasRelations = !!args?.with;
649
+ // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
650
+ const speculativeDeferred = this.buildFindMany({
651
+ ...args,
652
+ limit: batchSize + 1,
653
+ });
654
+ const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
655
+ if (speculativeResult.rows.length <= batchSize) {
656
+ // Small drain — yield all rows and return, no cursor needed
657
+ for (const row of speculativeResult.rows) {
658
+ yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
659
+ }
660
+ return;
661
+ }
662
+ // --- Overflow: fall back to cursor path from scratch ---
663
+ const deferred = this.buildFindMany(args);
402
664
  // Acquire a dedicated connection — cursors require a single connection in a transaction
403
665
  const client = await this.pool.connect();
404
666
  const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -440,7 +702,7 @@ class QueryInterface {
440
702
  async findFirst(args) {
441
703
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
442
704
  const deferred = this.buildFindFirst(args);
443
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
705
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
444
706
  return deferred.transform(result);
445
707
  });
446
708
  }
@@ -464,7 +726,7 @@ class QueryInterface {
464
726
  async findFirstOrThrow(args) {
465
727
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
466
728
  const deferred = this.buildFindFirstOrThrow(args);
467
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
729
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
468
730
  return deferred.transform(result);
469
731
  });
470
732
  }
@@ -493,7 +755,7 @@ class QueryInterface {
493
755
  async findUniqueOrThrow(args) {
494
756
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
495
757
  const deferred = this.buildFindUniqueOrThrow(args);
496
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
758
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
497
759
  return deferred.transform(result);
498
760
  });
499
761
  }
@@ -522,7 +784,7 @@ class QueryInterface {
522
784
  async create(args) {
523
785
  return this.executeWithMiddleware('create', args, async () => {
524
786
  const deferred = this.buildCreate(args);
525
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
787
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
526
788
  return deferred.transform(result);
527
789
  });
528
790
  }
@@ -555,7 +817,7 @@ class QueryInterface {
555
817
  async createMany(args) {
556
818
  return this.executeWithMiddleware('createMany', args, async () => {
557
819
  const deferred = this.buildCreateMany(args);
558
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
820
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
559
821
  return deferred.transform(result);
560
822
  });
561
823
  }
@@ -602,22 +864,35 @@ class QueryInterface {
602
864
  async update(args) {
603
865
  return this.executeWithMiddleware('update', args, async () => {
604
866
  const deferred = this.buildUpdate(args);
605
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
867
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
606
868
  return deferred.transform(result);
607
869
  });
608
870
  }
609
871
  buildUpdate(args) {
610
- const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
611
- // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
872
+ const dataObj = args.data;
873
+ const whereObj = args.where;
874
+ const setFp = this.fingerprintSet(dataObj);
875
+ const whereFp = this.fingerprintWhere(whereObj);
876
+ const ck = `u:${setFp}|${whereFp}`;
612
877
  const params = [];
613
- const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
614
- // Build WHERE using the shared params array (continues numbering after SET params)
615
- const whereClause = this.buildWhereClause(args.where, params);
616
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
617
- this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
618
- const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
878
+ const entry = this.acquireSql(ck, () => {
879
+ const freshParams = [];
880
+ const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
881
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
882
+ const whereClause = this.buildWhereClause(whereObj, freshParams);
883
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
884
+ this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
885
+ return `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
886
+ });
887
+ // On cache hit, validate predicate
888
+ if (whereFp === '') {
889
+ this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
890
+ }
891
+ // Collect params: SET first, then WHERE (same order as fresh build)
892
+ this.collectSetParams(dataObj, params);
893
+ this.collectWhereParams(whereObj, params);
619
894
  return {
620
- sql,
895
+ sql: entry.sql,
621
896
  params,
622
897
  transform: (result) => {
623
898
  const row = result.rows[0];
@@ -631,6 +906,7 @@ class QueryInterface {
631
906
  return this.parseRow(row, this.table);
632
907
  },
633
908
  tag: `${this.table}.update`,
909
+ preparedName: entry.name,
634
910
  };
635
911
  }
636
912
  // -------------------------------------------------------------------------
@@ -639,16 +915,31 @@ class QueryInterface {
639
915
  async delete(args) {
640
916
  return this.executeWithMiddleware('delete', args, async () => {
641
917
  const deferred = this.buildDelete(args);
642
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
918
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
643
919
  return deferred.transform(result);
644
920
  });
645
921
  }
646
922
  buildDelete(args) {
647
- const { sql: whereSql, params } = this.buildWhere(args.where);
648
- this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
649
- const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
923
+ const whereObj = args.where;
924
+ const whereFp = this.fingerprintWhere(whereObj);
925
+ const ck = `d:${whereFp}`;
926
+ const params = [];
927
+ // We need to check the mutation predicate. Build the whereSql to test it.
928
+ // On cache hit we still need to validate (the shape may be empty).
929
+ const entry = this.acquireSql(ck, () => {
930
+ const freshParams = [];
931
+ const clause = this.buildWhereClause(whereObj, freshParams);
932
+ const whereSql = clause ? ` WHERE ${clause}` : '';
933
+ this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
934
+ return `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
935
+ });
936
+ // On cache hit, still validate the predicate
937
+ if (whereFp === '') {
938
+ this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
939
+ }
940
+ this.collectWhereParams(whereObj, params);
650
941
  return {
651
- sql,
942
+ sql: entry.sql,
652
943
  params,
653
944
  transform: (result) => {
654
945
  const row = result.rows[0];
@@ -662,6 +953,7 @@ class QueryInterface {
662
953
  return this.parseRow(row, this.table);
663
954
  },
664
955
  tag: `${this.table}.delete`,
956
+ preparedName: entry.name,
665
957
  };
666
958
  }
667
959
  // -------------------------------------------------------------------------
@@ -670,7 +962,7 @@ class QueryInterface {
670
962
  async upsert(args) {
671
963
  return this.executeWithMiddleware('upsert', args, async () => {
672
964
  const deferred = this.buildUpsert(args);
673
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
965
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
674
966
  return deferred.transform(result);
675
967
  });
676
968
  }
@@ -720,25 +1012,37 @@ class QueryInterface {
720
1012
  async updateMany(args) {
721
1013
  return this.executeWithMiddleware('updateMany', args, async () => {
722
1014
  const deferred = this.buildUpdateMany(args);
723
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
1015
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
724
1016
  return deferred.transform(result);
725
1017
  });
726
1018
  }
727
1019
  buildUpdateMany(args) {
728
- const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
729
- // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
1020
+ const dataObj = args.data;
1021
+ const whereObj = args.where;
1022
+ const setFp = this.fingerprintSet(dataObj);
1023
+ const whereFp = this.fingerprintWhere(whereObj);
1024
+ const ck = `um:${setFp}|${whereFp}`;
730
1025
  const params = [];
731
- const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
732
- // Build WHERE using the shared params array (continues numbering after SET params)
733
- const whereClause = this.buildWhereClause(args.where, params);
734
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
735
- this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
736
- const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1026
+ const entry = this.acquireSql(ck, () => {
1027
+ const freshParams = [];
1028
+ const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1029
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1030
+ const whereClause = this.buildWhereClause(whereObj, freshParams);
1031
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1032
+ this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
1033
+ return `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1034
+ });
1035
+ if (whereFp === '') {
1036
+ this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
1037
+ }
1038
+ this.collectSetParams(dataObj, params);
1039
+ this.collectWhereParams(whereObj, params);
737
1040
  return {
738
- sql,
1041
+ sql: entry.sql,
739
1042
  params,
740
1043
  transform: (result) => ({ count: result.rowCount ?? 0 }),
741
1044
  tag: `${this.table}.updateMany`,
1045
+ preparedName: entry.name,
742
1046
  };
743
1047
  }
744
1048
  // -------------------------------------------------------------------------
@@ -747,19 +1051,32 @@ class QueryInterface {
747
1051
  async deleteMany(args) {
748
1052
  return this.executeWithMiddleware('deleteMany', args, async () => {
749
1053
  const deferred = this.buildDeleteMany(args);
750
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
1054
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
751
1055
  return deferred.transform(result);
752
1056
  });
753
1057
  }
754
1058
  buildDeleteMany(args) {
755
- const { sql: whereSql, params } = this.buildWhere(args.where);
756
- this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
757
- const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
1059
+ const whereObj = args.where;
1060
+ const whereFp = this.fingerprintWhere(whereObj);
1061
+ const ck = `dm:${whereFp}`;
1062
+ const params = [];
1063
+ const entry = this.acquireSql(ck, () => {
1064
+ const freshParams = [];
1065
+ const clause = this.buildWhereClause(whereObj, freshParams);
1066
+ const whereSql = clause ? ` WHERE ${clause}` : '';
1067
+ this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
1068
+ return `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
1069
+ });
1070
+ if (whereFp === '') {
1071
+ this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
1072
+ }
1073
+ this.collectWhereParams(whereObj, params);
758
1074
  return {
759
- sql,
1075
+ sql: entry.sql,
760
1076
  params,
761
1077
  transform: (result) => ({ count: result.rowCount ?? 0 }),
762
1078
  tag: `${this.table}.deleteMany`,
1079
+ preparedName: entry.name,
763
1080
  };
764
1081
  }
765
1082
  // -------------------------------------------------------------------------
@@ -768,18 +1085,30 @@ class QueryInterface {
768
1085
  async count(args) {
769
1086
  return this.executeWithMiddleware('count', (args ?? {}), async () => {
770
1087
  const deferred = this.buildCount(args);
771
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
1088
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
772
1089
  return deferred.transform(result);
773
1090
  });
774
1091
  }
775
1092
  buildCount(args) {
776
- const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
777
- const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
1093
+ const whereObj = (args?.where ?? {});
1094
+ const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
1095
+ const ck = `cnt:${whereFp}`;
1096
+ const params = [];
1097
+ const entry = this.acquireSql(ck, () => {
1098
+ const freshParams = [];
1099
+ const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1100
+ const whereSql = clause ? ` WHERE ${clause}` : '';
1101
+ return `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
1102
+ });
1103
+ if (args?.where) {
1104
+ this.collectWhereParams(whereObj, params);
1105
+ }
778
1106
  return {
779
- sql,
1107
+ sql: entry.sql,
780
1108
  params,
781
1109
  transform: (result) => result.rows[0].count,
782
1110
  tag: `${this.table}.count`,
1111
+ preparedName: entry.name,
783
1112
  };
784
1113
  }
785
1114
  // -------------------------------------------------------------------------
@@ -788,7 +1117,7 @@ class QueryInterface {
788
1117
  async groupBy(args) {
789
1118
  return this.executeWithMiddleware('groupBy', args, async () => {
790
1119
  const deferred = this.buildGroupBy(args);
791
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
1120
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
792
1121
  return deferred.transform(result);
793
1122
  });
794
1123
  }
@@ -921,7 +1250,7 @@ class QueryInterface {
921
1250
  async aggregate(args) {
922
1251
  return this.executeWithMiddleware('aggregate', args, async () => {
923
1252
  const deferred = this.buildAggregate(args);
924
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
1253
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
925
1254
  return deferred.transform(result);
926
1255
  });
927
1256
  }
@@ -1164,6 +1493,440 @@ class QueryInterface {
1164
1493
  params.push(value);
1165
1494
  return `${col} = $${params.length}`;
1166
1495
  }
1496
+ // =========================================================================
1497
+ // Fingerprinting — value-invariant shape keys for SQL cache lookup
1498
+ // =========================================================================
1499
+ /**
1500
+ * Produce a value-invariant fingerprint of a where clause.
1501
+ * Same keys + same operator shapes + same combinator structure => same string.
1502
+ * Different values (e.g. id=1 vs id=999) => identical fingerprint.
1503
+ *
1504
+ * @internal Exposed as package-private for testing via class access.
1505
+ */
1506
+ fingerprintWhere(where) {
1507
+ const keys = Object.keys(where)
1508
+ .filter((k) => where[k] !== undefined)
1509
+ .sort();
1510
+ if (keys.length === 0)
1511
+ return '';
1512
+ const parts = [];
1513
+ for (const key of keys) {
1514
+ const value = where[key];
1515
+ if (value === undefined)
1516
+ continue;
1517
+ if (key === 'OR') {
1518
+ const orArr = value;
1519
+ if (!Array.isArray(orArr) || orArr.length === 0)
1520
+ continue;
1521
+ const orParts = orArr.map((cond) => this.fingerprintWhere(cond));
1522
+ parts.push(`OR[${orParts.join(',')}]`);
1523
+ continue;
1524
+ }
1525
+ if (key === 'AND') {
1526
+ const andArr = value;
1527
+ if (!Array.isArray(andArr) || andArr.length === 0)
1528
+ continue;
1529
+ const andParts = andArr.map((cond) => this.fingerprintWhere(cond));
1530
+ parts.push(`AND[${andParts.join(',')}]`);
1531
+ continue;
1532
+ }
1533
+ if (key === 'NOT') {
1534
+ const notCond = value;
1535
+ parts.push(`NOT(${this.fingerprintWhere(notCond)})`);
1536
+ continue;
1537
+ }
1538
+ // Relation filters: { posts: { some: { published: true } } }
1539
+ const relDef = this.tableMeta.relations[key];
1540
+ if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1541
+ const filterObj = value;
1542
+ if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1543
+ const relParts = [];
1544
+ if (filterObj.some !== undefined)
1545
+ relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
1546
+ if (filterObj.every !== undefined)
1547
+ relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
1548
+ if (filterObj.none !== undefined)
1549
+ relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
1550
+ parts.push(`${key}:{${relParts.join(',')}}`);
1551
+ continue;
1552
+ }
1553
+ }
1554
+ // null → distinct from value
1555
+ if (value === null) {
1556
+ parts.push(`${key}:null`);
1557
+ continue;
1558
+ }
1559
+ // Operator objects
1560
+ if (isWhereOperator(value)) {
1561
+ const opKeys = Object.keys(value)
1562
+ .filter((k) => k !== 'mode')
1563
+ .sort();
1564
+ const mode = value.mode;
1565
+ const modeStr = mode === 'insensitive' ? ':i' : '';
1566
+ parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1567
+ continue;
1568
+ }
1569
+ // JSON filter
1570
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1571
+ const jKeys = Object.keys(value).sort();
1572
+ parts.push(`${key}:json(${jKeys.join(',')})`);
1573
+ continue;
1574
+ }
1575
+ // Array filter
1576
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
1577
+ const aKeys = Object.keys(value).sort();
1578
+ parts.push(`${key}:arr(${aKeys.join(',')})`);
1579
+ continue;
1580
+ }
1581
+ // Plain equality
1582
+ parts.push(`${key}:eq`);
1583
+ }
1584
+ return parts.join('&');
1585
+ }
1586
+ /**
1587
+ * Fingerprint a relation filter sub-where for some/every/none.
1588
+ */
1589
+ fingerprintRelFilter(targetTable, subWhere) {
1590
+ const keys = Object.keys(subWhere)
1591
+ .filter((k) => subWhere[k] !== undefined)
1592
+ .sort();
1593
+ if (keys.length === 0)
1594
+ return '';
1595
+ const parts = [];
1596
+ for (const key of keys) {
1597
+ const value = subWhere[key];
1598
+ if (value === undefined)
1599
+ continue;
1600
+ if (value === null) {
1601
+ parts.push(`${key}:null`);
1602
+ }
1603
+ else if (isWhereOperator(value)) {
1604
+ const opKeys = Object.keys(value)
1605
+ .filter((k) => k !== 'mode')
1606
+ .sort();
1607
+ const mode = value.mode;
1608
+ const modeStr = mode === 'insensitive' ? ':i' : '';
1609
+ parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1610
+ }
1611
+ else {
1612
+ parts.push(`${key}:eq`);
1613
+ }
1614
+ }
1615
+ return parts.join('&');
1616
+ }
1617
+ /**
1618
+ * Walk a where clause and push ONLY values into `params`, in the EXACT same
1619
+ * order that `buildWhereClause` pushes them. Used on cache hit to fill params
1620
+ * without rebuilding SQL.
1621
+ *
1622
+ * @internal Exposed as package-private for testing.
1623
+ */
1624
+ collectWhereParams(where, params) {
1625
+ const keys = Object.keys(where);
1626
+ for (const key of keys) {
1627
+ const value = where[key];
1628
+ if (value === undefined)
1629
+ continue;
1630
+ if (key === 'OR') {
1631
+ const orConditions = value;
1632
+ if (!Array.isArray(orConditions) || orConditions.length === 0)
1633
+ continue;
1634
+ for (const orCond of orConditions) {
1635
+ this.collectWhereParams(orCond, params);
1636
+ }
1637
+ continue;
1638
+ }
1639
+ if (key === 'AND') {
1640
+ const andConditions = value;
1641
+ if (!Array.isArray(andConditions) || andConditions.length === 0)
1642
+ continue;
1643
+ for (const andCond of andConditions) {
1644
+ this.collectWhereParams(andCond, params);
1645
+ }
1646
+ continue;
1647
+ }
1648
+ if (key === 'NOT') {
1649
+ const notCond = value;
1650
+ this.collectWhereParams(notCond, params);
1651
+ continue;
1652
+ }
1653
+ // Relation filters
1654
+ const relationDef = this.tableMeta.relations[key];
1655
+ if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1656
+ const filterObj = value;
1657
+ if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1658
+ if (filterObj.some !== undefined)
1659
+ this.collectRelFilterParams(relationDef.to, filterObj.some, params);
1660
+ if (filterObj.none !== undefined)
1661
+ this.collectRelFilterParams(relationDef.to, filterObj.none, params);
1662
+ if (filterObj.every !== undefined)
1663
+ this.collectRelFilterParams(relationDef.to, filterObj.every, params);
1664
+ continue;
1665
+ }
1666
+ }
1667
+ // null → no param pushed (IS NULL is parameterless)
1668
+ if (value === null)
1669
+ continue;
1670
+ const rawColumn = this.toColumn(key);
1671
+ // JSONB filter
1672
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1673
+ const colType = this.getColumnPgType(rawColumn);
1674
+ if (colType === 'json' || colType === 'jsonb') {
1675
+ this.collectJsonFilterParams(value, params);
1676
+ continue;
1677
+ }
1678
+ }
1679
+ // Array filter
1680
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
1681
+ const colType = this.getColumnPgType(rawColumn);
1682
+ if (colType.startsWith('_')) {
1683
+ this.collectArrayFilterParams(value, params);
1684
+ continue;
1685
+ }
1686
+ }
1687
+ // Operator objects
1688
+ if (isWhereOperator(value)) {
1689
+ this.collectOperatorParams(value, params);
1690
+ continue;
1691
+ }
1692
+ // Plain equality
1693
+ params.push(value);
1694
+ }
1695
+ }
1696
+ /** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
1697
+ collectRelFilterParams(targetTable, subWhere, params) {
1698
+ const meta = this.schema.tables[targetTable];
1699
+ if (!meta)
1700
+ return;
1701
+ for (const [field, value] of Object.entries(subWhere)) {
1702
+ if (value === undefined)
1703
+ continue;
1704
+ if (value === null)
1705
+ continue;
1706
+ if (isWhereOperator(value)) {
1707
+ this.collectOperatorParams(value, params);
1708
+ continue;
1709
+ }
1710
+ params.push(value);
1711
+ }
1712
+ }
1713
+ /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
1714
+ collectOperatorParams(op, params) {
1715
+ if (op.gt !== undefined)
1716
+ params.push(op.gt);
1717
+ if (op.gte !== undefined)
1718
+ params.push(op.gte);
1719
+ if (op.lt !== undefined)
1720
+ params.push(op.lt);
1721
+ if (op.lte !== undefined)
1722
+ params.push(op.lte);
1723
+ if (op.not !== undefined && op.not !== null)
1724
+ params.push(op.not);
1725
+ if (op.in !== undefined)
1726
+ params.push(op.in);
1727
+ if (op.notIn !== undefined)
1728
+ params.push(op.notIn);
1729
+ if (op.contains !== undefined)
1730
+ params.push(`%${escapeLike(op.contains)}%`);
1731
+ if (op.startsWith !== undefined)
1732
+ params.push(`${escapeLike(op.startsWith)}%`);
1733
+ if (op.endsWith !== undefined)
1734
+ params.push(`%${escapeLike(op.endsWith)}`);
1735
+ }
1736
+ /** Collect params from JSON filter. Mirrors buildJsonFilterClauses. */
1737
+ collectJsonFilterParams(filter, params) {
1738
+ if (filter.path !== undefined && filter.equals !== undefined) {
1739
+ params.push(filter.path);
1740
+ params.push(String(filter.equals));
1741
+ }
1742
+ else if (filter.equals !== undefined) {
1743
+ params.push(JSON.stringify(filter.equals));
1744
+ }
1745
+ if (filter.contains !== undefined) {
1746
+ params.push(JSON.stringify(filter.contains));
1747
+ }
1748
+ if (filter.hasKey !== undefined) {
1749
+ params.push(filter.hasKey);
1750
+ }
1751
+ }
1752
+ /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
1753
+ collectArrayFilterParams(filter, params) {
1754
+ if (filter.has !== undefined)
1755
+ params.push(filter.has);
1756
+ if (filter.hasEvery !== undefined)
1757
+ params.push(filter.hasEvery);
1758
+ if (filter.hasSome !== undefined)
1759
+ params.push(filter.hasSome);
1760
+ // isEmpty has no params (IS NULL / IS NOT NULL)
1761
+ }
1762
+ /**
1763
+ * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1764
+ * buildSelectWithRelations / buildRelationSubquery.
1765
+ *
1766
+ * @internal Exposed as package-private for testing.
1767
+ */
1768
+ withFingerprint(withClause, table, depth = 0) {
1769
+ if (!withClause)
1770
+ return '';
1771
+ const meta = this.schema.tables[table ?? this.table];
1772
+ if (!meta)
1773
+ return '';
1774
+ const relNames = Object.keys(withClause).sort();
1775
+ const parts = [];
1776
+ for (const relName of relNames) {
1777
+ const spec = withClause[relName];
1778
+ if (!spec)
1779
+ continue;
1780
+ const relDef = meta.relations[relName];
1781
+ if (!relDef)
1782
+ continue;
1783
+ if (spec === true) {
1784
+ parts.push(relName);
1785
+ continue;
1786
+ }
1787
+ const opts = spec;
1788
+ const subParts = [];
1789
+ // select/omit shape
1790
+ if (opts.select) {
1791
+ const selKeys = Object.entries(opts.select)
1792
+ .filter(([, v]) => v)
1793
+ .map(([k]) => k)
1794
+ .sort();
1795
+ subParts.push(`sl=${selKeys.join(',')}`);
1796
+ }
1797
+ if (opts.omit) {
1798
+ const omKeys = Object.entries(opts.omit)
1799
+ .filter(([, v]) => v)
1800
+ .map(([k]) => k)
1801
+ .sort();
1802
+ subParts.push(`om=${omKeys.join(',')}`);
1803
+ }
1804
+ // where shape (value-invariant)
1805
+ if (opts.where) {
1806
+ // Use a target-table QI if possible, or a simplified fingerprint
1807
+ const wKeys = Object.keys(opts.where)
1808
+ .filter((k) => opts.where[k] !== undefined)
1809
+ .sort();
1810
+ subParts.push(`w=${wKeys.join(',')}`);
1811
+ }
1812
+ // orderBy shape
1813
+ if (opts.orderBy) {
1814
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
1815
+ subParts.push(`o=${oEntries.join(',')}`);
1816
+ }
1817
+ // limit presence
1818
+ if (opts.limit !== undefined) {
1819
+ subParts.push('l=1');
1820
+ }
1821
+ // nested with (recurse)
1822
+ if (opts.with) {
1823
+ const nested = this.withFingerprint(opts.with, relDef.to, depth + 1);
1824
+ if (nested)
1825
+ subParts.push(`W=(${nested})`);
1826
+ }
1827
+ parts.push(subParts.length > 0 ? `${relName}/{${subParts.join('/')}}` : relName);
1828
+ }
1829
+ return parts.join('|');
1830
+ }
1831
+ /**
1832
+ * Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
1833
+ * buildRelationSubquery param-push order.
1834
+ */
1835
+ collectWithParams(withClause, params, table) {
1836
+ const meta = this.schema.tables[table ?? this.table];
1837
+ if (!meta)
1838
+ return;
1839
+ for (const [relName, relSpec] of Object.entries(withClause)) {
1840
+ const relDef = meta.relations[relName];
1841
+ if (!relDef)
1842
+ continue;
1843
+ this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
1844
+ }
1845
+ }
1846
+ /**
1847
+ * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
1848
+ */
1849
+ collectRelationSubqueryParams(relDef, spec, params, _parentRef, depth = 0) {
1850
+ if (spec === true)
1851
+ return; // No params for default include
1852
+ const targetTable = relDef.to;
1853
+ const targetMeta = this.schema.tables[targetTable];
1854
+ if (!targetMeta)
1855
+ return;
1856
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
1857
+ // Non-wrapped path: nested relations BEFORE where/limit
1858
+ if (!willWrap && spec.with) {
1859
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1860
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1861
+ if (!nestedRelDef)
1862
+ continue;
1863
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
1864
+ }
1865
+ }
1866
+ // where params
1867
+ if (spec.where) {
1868
+ for (const [, v] of Object.entries(spec.where)) {
1869
+ params.push(v);
1870
+ }
1871
+ }
1872
+ // limit param
1873
+ if (spec.limit) {
1874
+ params.push(Number(spec.limit));
1875
+ }
1876
+ // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
1877
+ if (willWrap && spec.with) {
1878
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1879
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1880
+ if (!nestedRelDef)
1881
+ continue;
1882
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
1883
+ }
1884
+ }
1885
+ }
1886
+ /**
1887
+ * Fingerprint SET clauses for update/updateMany.
1888
+ * Captures key names + operator types (set/increment/etc) but not values.
1889
+ */
1890
+ fingerprintSet(data) {
1891
+ const entries = Object.entries(data).filter(([, v]) => v !== undefined);
1892
+ const parts = [];
1893
+ for (const [k, v] of entries) {
1894
+ if (v !== null &&
1895
+ typeof v === 'object' &&
1896
+ !Array.isArray(v) &&
1897
+ !(v instanceof Date) &&
1898
+ !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
1899
+ const keys = Object.keys(v);
1900
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1901
+ parts.push(`${k}:${keys[0]}`);
1902
+ continue;
1903
+ }
1904
+ }
1905
+ parts.push(`${k}:eq`);
1906
+ }
1907
+ return parts.join(',');
1908
+ }
1909
+ /**
1910
+ * Collect SET params for update/updateMany. Mirrors buildSetClause param order.
1911
+ */
1912
+ collectSetParams(data, params) {
1913
+ const entries = Object.entries(data).filter(([, v]) => v !== undefined);
1914
+ for (const [, v] of entries) {
1915
+ if (v !== null &&
1916
+ typeof v === 'object' &&
1917
+ !Array.isArray(v) &&
1918
+ !(v instanceof Date) &&
1919
+ !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
1920
+ const obj = v;
1921
+ const keys = Object.keys(obj);
1922
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1923
+ params.push(obj[keys[0]]);
1924
+ continue;
1925
+ }
1926
+ }
1927
+ params.push(v);
1928
+ }
1929
+ }
1167
1930
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
1168
1931
  buildWhere(where) {
1169
1932
  const params = [];
@@ -1265,6 +2028,16 @@ class QueryInterface {
1265
2028
  andClauses.push(...jsonClauses);
1266
2029
  continue;
1267
2030
  }
2031
+ // Strict validation: a JSON-only operator on a non-JSON column was almost
2032
+ // certainly a typo or schema mismatch. Silently falling through to plain
2033
+ // equality (the previous behaviour) wasted hours of debugging time. Only
2034
+ // throw when the operator is unambiguously JSON-specific — `contains` is
2035
+ // shared with WhereOperator's LIKE so it must continue to fall through.
2036
+ const jsonKey = findJsonUniqueKey(value);
2037
+ if (jsonKey) {
2038
+ throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
2039
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
2040
+ }
1268
2041
  }
1269
2042
  // Handle Array filter operators (for array columns)
1270
2043
  if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
@@ -1274,6 +2047,14 @@ class QueryInterface {
1274
2047
  andClauses.push(...arrayClauses);
1275
2048
  continue;
1276
2049
  }
2050
+ // Strict validation: array operators (`has`, `hasEvery`, ...) on a
2051
+ // non-array column always indicate a mistake. None of these keys
2052
+ // overlap with other filter shapes so we can throw unconditionally.
2053
+ const arrayKey = findArrayUniqueKey(value);
2054
+ if (arrayKey) {
2055
+ throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
2056
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
2057
+ }
1277
2058
  }
1278
2059
  // Handle operator objects
1279
2060
  if (isWhereOperator(value)) {
@@ -1472,28 +2253,53 @@ class QueryInterface {
1472
2253
  return parsed;
1473
2254
  for (const [relName, relDef] of Object.entries(meta.relations)) {
1474
2255
  const rawValue = row[relName];
1475
- if (rawValue !== undefined) {
1476
- if (typeof rawValue === 'string') {
1477
- try {
1478
- parsed[relName] = JSON.parse(rawValue);
2256
+ if (rawValue === undefined)
2257
+ continue;
2258
+ // --- Short-circuit: skip JSON.parse for common empty/null cases ---
2259
+ // hasMany returns '[]' (from COALESCE(..., '[]'::json)); belongsTo/hasOne returns null
2260
+ if (rawValue === null || rawValue === 'null') {
2261
+ parsed[relName] = null;
2262
+ continue;
2263
+ }
2264
+ if (rawValue === '[]') {
2265
+ parsed[relName] = [];
2266
+ continue;
2267
+ }
2268
+ if (Array.isArray(rawValue) && rawValue.length === 0) {
2269
+ parsed[relName] = [];
2270
+ continue;
2271
+ }
2272
+ // --- Non-empty values: full parse path ---
2273
+ if (typeof rawValue === 'string') {
2274
+ try {
2275
+ const jsonVal = JSON.parse(rawValue);
2276
+ // After parsing, apply parseRow to each item for snake→camel + date coercion
2277
+ if (Array.isArray(jsonVal)) {
2278
+ parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2279
+ ? this.parseRow(item, relDef.to)
2280
+ : item);
1479
2281
  }
1480
- catch {
1481
- console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1482
- parsed[relName] = rawValue;
2282
+ else if (typeof jsonVal === 'object' && jsonVal !== null) {
2283
+ parsed[relName] = this.parseRow(jsonVal, relDef.to);
2284
+ }
2285
+ else {
2286
+ parsed[relName] = jsonVal;
1483
2287
  }
1484
2288
  }
1485
- else if (Array.isArray(rawValue)) {
1486
- parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
1487
- ? this.parseRow(item, relDef.to)
1488
- : item);
1489
- }
1490
- else if (typeof rawValue === 'object' && rawValue !== null) {
1491
- parsed[relName] = this.parseRow(rawValue, relDef.to);
1492
- }
1493
- else {
2289
+ catch {
2290
+ console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1494
2291
  parsed[relName] = rawValue;
1495
2292
  }
1496
2293
  }
2294
+ else if (Array.isArray(rawValue)) {
2295
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null ? this.parseRow(item, relDef.to) : item);
2296
+ }
2297
+ else if (typeof rawValue === 'object' && rawValue !== null) {
2298
+ parsed[relName] = this.parseRow(rawValue, relDef.to);
2299
+ }
2300
+ else {
2301
+ parsed[relName] = rawValue;
2302
+ }
1497
2303
  }
1498
2304
  return parsed;
1499
2305
  }