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