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