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