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