turbine-orm 0.19.0 → 0.19.2
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 +83 -15
- package/dist/adapters/index.d.ts +3 -2
- package/dist/cjs/cli/index.js +43 -13
- package/dist/cjs/cli/loader.js +62 -7
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +25 -35
- package/dist/cjs/client.js +20 -13
- package/dist/cjs/query/builder.js +342 -104
- package/dist/cjs/query/utils.js +1 -0
- package/dist/cli/index.js +45 -15
- package/dist/cli/loader.d.ts +22 -5
- package/dist/cli/loader.js +61 -7
- package/dist/cli/migrate.d.ts +2 -2
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +9 -14
- package/dist/cli/studio.js +25 -34
- package/dist/client.d.ts +12 -13
- package/dist/client.js +20 -13
- package/dist/index.d.ts +1 -1
- package/dist/query/builder.d.ts +43 -6
- package/dist/query/builder.js +342 -104
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +62 -12
- package/dist/query/utils.js +1 -0
- package/package.json +4 -4
- package/dist/cjs/query.js +0 -2711
- package/dist/query.d.ts +0 -878
- package/dist/query.js +0 -2705
package/dist/query.js
DELETED
|
@@ -1,2705 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* turbine-orm — Query builder
|
|
3
|
-
*
|
|
4
|
-
* Each table accessor (db.users, db.posts, etc.) returns a QueryInterface<T>
|
|
5
|
-
* that builds parameterized SQL and executes it through the connection pool.
|
|
6
|
-
*
|
|
7
|
-
* Nested relations use json_build_object + json_agg subqueries for single-query
|
|
8
|
-
* resolution — a PostgreSQL-native approach that eliminates N+1 query patterns.
|
|
9
|
-
*
|
|
10
|
-
* Schema-driven: all column names, types, and relations come from introspected
|
|
11
|
-
* metadata — nothing is hardcoded.
|
|
12
|
-
*/
|
|
13
|
-
import { CircularRelationError, NotFoundError, RelationError, TimeoutError, ValidationError, wrapPgError, } from './errors.js';
|
|
14
|
-
import { camelToSnake, snakeToCamel } from './schema.js';
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// Identifier quoting — prevents SQL injection via table/column names
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
/**
|
|
19
|
-
* Quote a SQL identifier (table name, column name) using Postgres double-quote
|
|
20
|
-
* rules: wrap in double quotes, escape internal double quotes by doubling them.
|
|
21
|
-
*
|
|
22
|
-
* @example
|
|
23
|
-
* quoteIdent('users') → '"users"'
|
|
24
|
-
* quoteIdent('my"table') → '"my""table"'
|
|
25
|
-
* quoteIdent('user name') → '"user name"'
|
|
26
|
-
*/
|
|
27
|
-
export function quoteIdent(name) {
|
|
28
|
-
return `"${name.replace(/"/g, '""')}"`;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Escape single quotes for use as string keys in json_build_object().
|
|
32
|
-
* Doubles single quotes per SQL quoting rules.
|
|
33
|
-
*/
|
|
34
|
-
function escSingleQuote(s) {
|
|
35
|
-
return s.replace(/'/g, "''");
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Escape LIKE pattern metacharacters: %, _, and \.
|
|
39
|
-
* Must be used with `ESCAPE '\'` in the LIKE clause.
|
|
40
|
-
*/
|
|
41
|
-
function escapeLike(value) {
|
|
42
|
-
return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
43
|
-
}
|
|
44
|
-
/** Known operator keys — used to detect operator objects vs plain values */
|
|
45
|
-
const OPERATOR_KEYS = new Set([
|
|
46
|
-
'gt',
|
|
47
|
-
'gte',
|
|
48
|
-
'lt',
|
|
49
|
-
'lte',
|
|
50
|
-
'not',
|
|
51
|
-
'in',
|
|
52
|
-
'notIn',
|
|
53
|
-
'contains',
|
|
54
|
-
'startsWith',
|
|
55
|
-
'endsWith',
|
|
56
|
-
'mode',
|
|
57
|
-
]);
|
|
58
|
-
/** Check if a value is a where operator object (has at least one known operator key) */
|
|
59
|
-
function isWhereOperator(value) {
|
|
60
|
-
if (value === null ||
|
|
61
|
-
value === undefined ||
|
|
62
|
-
typeof value !== 'object' ||
|
|
63
|
-
Array.isArray(value) ||
|
|
64
|
-
value instanceof Date) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
const keys = Object.keys(value);
|
|
68
|
-
return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
|
|
69
|
-
}
|
|
70
|
-
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
71
|
-
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
72
|
-
/** Known JSONB operator keys */
|
|
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']);
|
|
82
|
-
/** Check if a value is a JSONB filter object */
|
|
83
|
-
function isJsonFilter(value) {
|
|
84
|
-
if (value === null ||
|
|
85
|
-
value === undefined ||
|
|
86
|
-
typeof value !== 'object' ||
|
|
87
|
-
Array.isArray(value) ||
|
|
88
|
-
value instanceof Date) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
const keys = Object.keys(value);
|
|
92
|
-
return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
|
|
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
|
-
}
|
|
105
|
-
/** Known Array operator keys */
|
|
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']);
|
|
115
|
-
/** Check if a value is an Array filter object */
|
|
116
|
-
function isArrayFilter(value) {
|
|
117
|
-
if (value === null ||
|
|
118
|
-
value === undefined ||
|
|
119
|
-
typeof value !== 'object' ||
|
|
120
|
-
Array.isArray(value) ||
|
|
121
|
-
value instanceof Date) {
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
const keys = Object.keys(value);
|
|
125
|
-
return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
|
|
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
|
-
}
|
|
138
|
-
// ---------------------------------------------------------------------------
|
|
139
|
-
// LRU cache — bounded SQL template cache to prevent memory leaks
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
/**
|
|
142
|
-
* Simple LRU (Least Recently Used) cache with a fixed maximum size.
|
|
143
|
-
* When the cache exceeds maxSize, the oldest (least recently used) entry is evicted.
|
|
144
|
-
* Uses Map insertion order for O(1) eviction.
|
|
145
|
-
*/
|
|
146
|
-
class LRUCache {
|
|
147
|
-
maxSize;
|
|
148
|
-
cache = new Map();
|
|
149
|
-
constructor(maxSize) {
|
|
150
|
-
this.maxSize = maxSize;
|
|
151
|
-
}
|
|
152
|
-
get(key) {
|
|
153
|
-
const value = this.cache.get(key);
|
|
154
|
-
if (value !== undefined) {
|
|
155
|
-
// Move to end (most recently used)
|
|
156
|
-
this.cache.delete(key);
|
|
157
|
-
this.cache.set(key, value);
|
|
158
|
-
}
|
|
159
|
-
return value;
|
|
160
|
-
}
|
|
161
|
-
set(key, value) {
|
|
162
|
-
if (this.cache.has(key)) {
|
|
163
|
-
this.cache.delete(key);
|
|
164
|
-
}
|
|
165
|
-
else if (this.cache.size >= this.maxSize) {
|
|
166
|
-
// Delete oldest (first) entry
|
|
167
|
-
const firstKey = this.cache.keys().next().value;
|
|
168
|
-
if (firstKey !== undefined)
|
|
169
|
-
this.cache.delete(firstKey);
|
|
170
|
-
}
|
|
171
|
-
this.cache.set(key, value);
|
|
172
|
-
}
|
|
173
|
-
get size() {
|
|
174
|
-
return this.cache.size;
|
|
175
|
-
}
|
|
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
|
-
}
|
|
203
|
-
export class QueryInterface {
|
|
204
|
-
pool;
|
|
205
|
-
table;
|
|
206
|
-
schema;
|
|
207
|
-
tableMeta;
|
|
208
|
-
/** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
|
|
209
|
-
sqlTemplateCache = new LRUCache(1000);
|
|
210
|
-
middlewares;
|
|
211
|
-
defaultLimit;
|
|
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;
|
|
227
|
-
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
228
|
-
columnPgTypeMap;
|
|
229
|
-
columnArrayTypeMap;
|
|
230
|
-
constructor(pool, table, schema, middlewares, options) {
|
|
231
|
-
this.pool = pool;
|
|
232
|
-
this.table = table;
|
|
233
|
-
this.schema = schema;
|
|
234
|
-
const meta = schema.tables[table];
|
|
235
|
-
if (!meta) {
|
|
236
|
-
throw new ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
|
|
237
|
-
}
|
|
238
|
-
this.tableMeta = meta;
|
|
239
|
-
this.middlewares = middlewares ?? [];
|
|
240
|
-
this.defaultLimit = options?.defaultLimit;
|
|
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;
|
|
247
|
-
// Pre-compute column type lookup maps (TASK-26)
|
|
248
|
-
this.columnPgTypeMap = new Map();
|
|
249
|
-
this.columnArrayTypeMap = new Map();
|
|
250
|
-
for (const col of this.tableMeta.columns) {
|
|
251
|
-
this.columnPgTypeMap.set(col.name, col.pgType);
|
|
252
|
-
this.columnArrayTypeMap.set(col.name, col.pgArrayType);
|
|
253
|
-
}
|
|
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
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Execute a pool.query with an optional timeout.
|
|
302
|
-
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
303
|
-
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
304
|
-
*/
|
|
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);
|
|
312
|
-
if (!timeout) {
|
|
313
|
-
try {
|
|
314
|
-
return await exec;
|
|
315
|
-
}
|
|
316
|
-
catch (err) {
|
|
317
|
-
throw wrapPgError(err);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
let timer;
|
|
321
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
322
|
-
timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
|
|
323
|
-
});
|
|
324
|
-
try {
|
|
325
|
-
return await Promise.race([exec, timeoutPromise]);
|
|
326
|
-
}
|
|
327
|
-
catch (err) {
|
|
328
|
-
throw wrapPgError(err);
|
|
329
|
-
}
|
|
330
|
-
finally {
|
|
331
|
-
clearTimeout(timer);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
/**
|
|
335
|
-
* Execute a query through the middleware chain.
|
|
336
|
-
* If no middlewares are registered, executes directly.
|
|
337
|
-
*
|
|
338
|
-
* Middleware can inspect and log query parameters, modify results after execution,
|
|
339
|
-
* and measure timing. Note: query SQL is generated before middleware runs, so
|
|
340
|
-
* modifying params.args in middleware will NOT affect the executed SQL.
|
|
341
|
-
* To intercept queries before SQL generation, use the raw() method instead.
|
|
342
|
-
*/
|
|
343
|
-
async executeWithMiddleware(action, args, executor) {
|
|
344
|
-
if (this.middlewares.length === 0) {
|
|
345
|
-
return executor();
|
|
346
|
-
}
|
|
347
|
-
const params = { model: this.table, action, args: { ...args } };
|
|
348
|
-
// Build middleware chain
|
|
349
|
-
let index = 0;
|
|
350
|
-
const next = async (p) => {
|
|
351
|
-
if (index < this.middlewares.length) {
|
|
352
|
-
const mw = this.middlewares[index++];
|
|
353
|
-
return mw(p, next);
|
|
354
|
-
}
|
|
355
|
-
// End of chain — execute the actual query
|
|
356
|
-
return executor();
|
|
357
|
-
};
|
|
358
|
-
return next(params);
|
|
359
|
-
}
|
|
360
|
-
// -------------------------------------------------------------------------
|
|
361
|
-
// findUnique
|
|
362
|
-
// -------------------------------------------------------------------------
|
|
363
|
-
async findUnique(args) {
|
|
364
|
-
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
365
|
-
const deferred = this.buildFindUnique(args);
|
|
366
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
367
|
-
return deferred.transform(result);
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
buildFindUnique(args) {
|
|
371
|
-
const columnsList = this.resolveColumns(args.select, args.omit);
|
|
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 = [];
|
|
378
|
-
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
379
|
-
const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
|
|
380
|
-
const isSimpleWhere = !whereObj.OR &&
|
|
381
|
-
!whereObj.AND &&
|
|
382
|
-
!whereObj.NOT &&
|
|
383
|
-
whereKeys.every((k) => {
|
|
384
|
-
const v = whereObj[k];
|
|
385
|
-
return v !== null && !isWhereOperator(v) && !this.tableMeta.relations[k];
|
|
386
|
-
});
|
|
387
|
-
// Simple path: plain equality, no operators/null/OR
|
|
388
|
-
if (!args.with && isSimpleWhere) {
|
|
389
|
-
const entry = this.acquireSql(ck, () => {
|
|
390
|
-
const qt = quoteIdent(this.table);
|
|
391
|
-
const tempParams = whereKeys.map((k) => whereObj[k]);
|
|
392
|
-
const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
|
|
393
|
-
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
394
|
-
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
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]);
|
|
401
|
-
}
|
|
402
|
-
return {
|
|
403
|
-
sql: entry.sql,
|
|
404
|
-
params,
|
|
405
|
-
transform: (result) => {
|
|
406
|
-
const row = result.rows[0];
|
|
407
|
-
return row ? this.parseRow(row, this.table) : null;
|
|
408
|
-
},
|
|
409
|
-
tag: `${this.table}.findUnique`,
|
|
410
|
-
preparedName: entry.name,
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
// General path (with operators, null, OR, with clause)
|
|
414
|
-
if (!args.with) {
|
|
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);
|
|
425
|
-
return {
|
|
426
|
-
sql: entry.sql,
|
|
427
|
-
params,
|
|
428
|
-
transform: (result) => {
|
|
429
|
-
const row = result.rows[0];
|
|
430
|
-
return row ? this.parseRow(row, this.table) : null;
|
|
431
|
-
},
|
|
432
|
-
tag: `${this.table}.findUnique`,
|
|
433
|
-
preparedName: entry.name,
|
|
434
|
-
};
|
|
435
|
-
}
|
|
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);
|
|
451
|
-
return {
|
|
452
|
-
sql: entry.sql,
|
|
453
|
-
params,
|
|
454
|
-
transform: (result) => {
|
|
455
|
-
const row = result.rows[0];
|
|
456
|
-
return row ? this.parseNestedRow(row, this.table) : null;
|
|
457
|
-
},
|
|
458
|
-
tag: `${this.table}.findUnique`,
|
|
459
|
-
preparedName: entry.name,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
// -------------------------------------------------------------------------
|
|
463
|
-
// findMany
|
|
464
|
-
// -------------------------------------------------------------------------
|
|
465
|
-
async findMany(args) {
|
|
466
|
-
this.maybeWarnUnlimited(args);
|
|
467
|
-
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
468
|
-
const deferred = this.buildFindMany(args);
|
|
469
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
470
|
-
return deferred.transform(result);
|
|
471
|
-
});
|
|
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
|
-
}
|
|
497
|
-
buildFindMany(args) {
|
|
498
|
-
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
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);
|
|
582
|
-
}
|
|
583
|
-
// 2. WITH relation params
|
|
584
|
-
if (args?.with) {
|
|
585
|
-
this.collectWithParams(args.with, params);
|
|
586
|
-
}
|
|
587
|
-
// 3. Cursor params
|
|
588
|
-
if (args?.cursor) {
|
|
589
|
-
const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
|
|
590
|
-
for (const [, v] of cursorEntries) {
|
|
591
|
-
params.push(v);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
// 4. LIMIT param
|
|
595
|
-
if (effectiveLimit !== undefined) {
|
|
596
|
-
params.push(Number(effectiveLimit));
|
|
597
|
-
}
|
|
598
|
-
// 5. OFFSET param
|
|
599
|
-
if (args?.offset !== undefined) {
|
|
600
|
-
params.push(Number(args.offset));
|
|
601
|
-
}
|
|
602
|
-
return {
|
|
603
|
-
sql: entry.sql,
|
|
604
|
-
params,
|
|
605
|
-
transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
|
|
606
|
-
tag: `${this.table}.findMany`,
|
|
607
|
-
preparedName: entry.name,
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
// -------------------------------------------------------------------------
|
|
611
|
-
// findManyStream — async iterable using PostgreSQL cursors
|
|
612
|
-
// -------------------------------------------------------------------------
|
|
613
|
-
/**
|
|
614
|
-
* Stream rows from a findMany query using PostgreSQL cursors.
|
|
615
|
-
* Returns an AsyncIterable that yields individual rows, fetching in batches internally.
|
|
616
|
-
*
|
|
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`.
|
|
632
|
-
*
|
|
633
|
-
* @example
|
|
634
|
-
* ```ts
|
|
635
|
-
* for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
|
|
636
|
-
* process.stdout.write(`${user.email}\n`);
|
|
637
|
-
* }
|
|
638
|
-
* ```
|
|
639
|
-
*/
|
|
640
|
-
async *findManyStream(args) {
|
|
641
|
-
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
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);
|
|
658
|
-
// Acquire a dedicated connection — cursors require a single connection in a transaction
|
|
659
|
-
const client = await this.pool.connect();
|
|
660
|
-
const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
661
|
-
const quotedCursor = quoteIdent(cursorName);
|
|
662
|
-
try {
|
|
663
|
-
await client.query('BEGIN');
|
|
664
|
-
await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
|
|
665
|
-
while (true) {
|
|
666
|
-
const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
|
|
667
|
-
if (batch.rows.length === 0)
|
|
668
|
-
break;
|
|
669
|
-
for (const row of batch.rows) {
|
|
670
|
-
yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
|
|
671
|
-
}
|
|
672
|
-
if (batch.rows.length < batchSize)
|
|
673
|
-
break;
|
|
674
|
-
}
|
|
675
|
-
await client.query(`CLOSE ${quotedCursor}`);
|
|
676
|
-
await client.query('COMMIT');
|
|
677
|
-
}
|
|
678
|
-
catch (err) {
|
|
679
|
-
// Rollback on error (also closes cursor implicitly)
|
|
680
|
-
try {
|
|
681
|
-
await client.query('ROLLBACK');
|
|
682
|
-
}
|
|
683
|
-
catch {
|
|
684
|
-
// Connection may already be broken — ignore rollback error
|
|
685
|
-
}
|
|
686
|
-
// Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
|
|
687
|
-
throw wrapPgError(err);
|
|
688
|
-
}
|
|
689
|
-
finally {
|
|
690
|
-
client.release();
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
// -------------------------------------------------------------------------
|
|
694
|
-
// findFirst — like findMany but returns a single row or null
|
|
695
|
-
// -------------------------------------------------------------------------
|
|
696
|
-
async findFirst(args) {
|
|
697
|
-
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
698
|
-
const deferred = this.buildFindFirst(args);
|
|
699
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
700
|
-
return deferred.transform(result);
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
buildFindFirst(args) {
|
|
704
|
-
// Reuse findMany's SQL builder but force LIMIT 1
|
|
705
|
-
const findManyArgs = { ...args, limit: 1 };
|
|
706
|
-
const deferred = this.buildFindMany(findManyArgs);
|
|
707
|
-
return {
|
|
708
|
-
sql: deferred.sql,
|
|
709
|
-
params: deferred.params,
|
|
710
|
-
transform: (result) => {
|
|
711
|
-
const rows = deferred.transform(result);
|
|
712
|
-
return rows.length > 0 ? rows[0] : null;
|
|
713
|
-
},
|
|
714
|
-
tag: `${this.table}.findFirst`,
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
// -------------------------------------------------------------------------
|
|
718
|
-
// findFirstOrThrow — like findFirst but throws if no record found
|
|
719
|
-
// -------------------------------------------------------------------------
|
|
720
|
-
async findFirstOrThrow(args) {
|
|
721
|
-
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
722
|
-
const deferred = this.buildFindFirstOrThrow(args);
|
|
723
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
724
|
-
return deferred.transform(result);
|
|
725
|
-
});
|
|
726
|
-
}
|
|
727
|
-
buildFindFirstOrThrow(args) {
|
|
728
|
-
const inner = this.buildFindFirst(args);
|
|
729
|
-
return {
|
|
730
|
-
sql: inner.sql,
|
|
731
|
-
params: inner.params,
|
|
732
|
-
transform: (result) => {
|
|
733
|
-
const row = inner.transform(result);
|
|
734
|
-
if (row === null) {
|
|
735
|
-
throw new NotFoundError({
|
|
736
|
-
table: this.table,
|
|
737
|
-
where: args?.where,
|
|
738
|
-
operation: 'findFirstOrThrow',
|
|
739
|
-
});
|
|
740
|
-
}
|
|
741
|
-
return row;
|
|
742
|
-
},
|
|
743
|
-
tag: `${this.table}.findFirstOrThrow`,
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
// -------------------------------------------------------------------------
|
|
747
|
-
// findUniqueOrThrow — like findUnique but throws if no record found
|
|
748
|
-
// -------------------------------------------------------------------------
|
|
749
|
-
async findUniqueOrThrow(args) {
|
|
750
|
-
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
751
|
-
const deferred = this.buildFindUniqueOrThrow(args);
|
|
752
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
753
|
-
return deferred.transform(result);
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
buildFindUniqueOrThrow(args) {
|
|
757
|
-
const inner = this.buildFindUnique(args);
|
|
758
|
-
return {
|
|
759
|
-
sql: inner.sql,
|
|
760
|
-
params: inner.params,
|
|
761
|
-
transform: (result) => {
|
|
762
|
-
const row = inner.transform(result);
|
|
763
|
-
if (row === null) {
|
|
764
|
-
throw new NotFoundError({
|
|
765
|
-
table: this.table,
|
|
766
|
-
where: args.where,
|
|
767
|
-
operation: 'findUniqueOrThrow',
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
return row;
|
|
771
|
-
},
|
|
772
|
-
tag: `${this.table}.findUniqueOrThrow`,
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
// -------------------------------------------------------------------------
|
|
776
|
-
// create
|
|
777
|
-
// -------------------------------------------------------------------------
|
|
778
|
-
async create(args) {
|
|
779
|
-
return this.executeWithMiddleware('create', args, async () => {
|
|
780
|
-
const deferred = this.buildCreate(args);
|
|
781
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
782
|
-
return deferred.transform(result);
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
buildCreate(args) {
|
|
786
|
-
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
787
|
-
const columns = entries.map(([k]) => this.toSqlColumn(k));
|
|
788
|
-
const params = entries.map(([, v]) => v);
|
|
789
|
-
const placeholders = entries.map((_, i) => `$${i + 1}`);
|
|
790
|
-
const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
|
|
791
|
-
return {
|
|
792
|
-
sql,
|
|
793
|
-
params,
|
|
794
|
-
transform: (result) => {
|
|
795
|
-
const row = result.rows[0];
|
|
796
|
-
if (!row) {
|
|
797
|
-
throw new NotFoundError({
|
|
798
|
-
table: this.table,
|
|
799
|
-
operation: 'create',
|
|
800
|
-
message: `[turbine] create on "${this.table}" returned no row from RETURNING * — this should never happen.`,
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
return this.parseRow(row, this.table);
|
|
804
|
-
},
|
|
805
|
-
tag: `${this.table}.create`,
|
|
806
|
-
};
|
|
807
|
-
}
|
|
808
|
-
// -------------------------------------------------------------------------
|
|
809
|
-
// createMany — uses UNNEST for performance
|
|
810
|
-
// -------------------------------------------------------------------------
|
|
811
|
-
async createMany(args) {
|
|
812
|
-
return this.executeWithMiddleware('createMany', args, async () => {
|
|
813
|
-
const deferred = this.buildCreateMany(args);
|
|
814
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
815
|
-
return deferred.transform(result);
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
buildCreateMany(args) {
|
|
819
|
-
const qt = quoteIdent(this.table);
|
|
820
|
-
if (args.data.length === 0) {
|
|
821
|
-
return {
|
|
822
|
-
sql: `SELECT * FROM ${qt} WHERE false`,
|
|
823
|
-
params: [],
|
|
824
|
-
transform: () => [],
|
|
825
|
-
tag: `${this.table}.createMany`,
|
|
826
|
-
};
|
|
827
|
-
}
|
|
828
|
-
const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
|
|
829
|
-
const columns = keys.map((k) => this.toColumn(k));
|
|
830
|
-
// Build column arrays for UNNEST
|
|
831
|
-
const columnArrays = keys.map(() => []);
|
|
832
|
-
for (const row of args.data) {
|
|
833
|
-
const record = row;
|
|
834
|
-
keys.forEach((key, i) => {
|
|
835
|
-
columnArrays[i].push(record[key]);
|
|
836
|
-
});
|
|
837
|
-
}
|
|
838
|
-
// Use actual Postgres types for array casts
|
|
839
|
-
const typeCasts = columns.map((col) => this.getColumnArrayType(col));
|
|
840
|
-
const unnestArgs = columnArrays.map((_, i) => `$${i + 1}::${typeCasts[i]}`);
|
|
841
|
-
const quotedColumns = columns.map((c) => quoteIdent(c));
|
|
842
|
-
let sql = `INSERT INTO ${qt} (${quotedColumns.join(', ')}) SELECT * FROM UNNEST(${unnestArgs.join(', ')})`;
|
|
843
|
-
// skipDuplicates: add ON CONFLICT DO NOTHING
|
|
844
|
-
if (args.skipDuplicates) {
|
|
845
|
-
sql += ` ON CONFLICT DO NOTHING`;
|
|
846
|
-
}
|
|
847
|
-
sql += ` RETURNING *`;
|
|
848
|
-
return {
|
|
849
|
-
sql,
|
|
850
|
-
params: columnArrays,
|
|
851
|
-
transform: (result) => result.rows.map((row) => this.parseRow(row, this.table)),
|
|
852
|
-
tag: `${this.table}.createMany`,
|
|
853
|
-
};
|
|
854
|
-
}
|
|
855
|
-
// -------------------------------------------------------------------------
|
|
856
|
-
// update
|
|
857
|
-
// -------------------------------------------------------------------------
|
|
858
|
-
async update(args) {
|
|
859
|
-
return this.executeWithMiddleware('update', args, async () => {
|
|
860
|
-
const deferred = this.buildUpdate(args);
|
|
861
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
862
|
-
return deferred.transform(result);
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
buildUpdate(args) {
|
|
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}`;
|
|
871
|
-
const params = [];
|
|
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);
|
|
888
|
-
return {
|
|
889
|
-
sql: entry.sql,
|
|
890
|
-
params,
|
|
891
|
-
transform: (result) => {
|
|
892
|
-
const row = result.rows[0];
|
|
893
|
-
if (!row) {
|
|
894
|
-
throw new NotFoundError({
|
|
895
|
-
table: this.table,
|
|
896
|
-
where: args.where,
|
|
897
|
-
operation: 'update',
|
|
898
|
-
});
|
|
899
|
-
}
|
|
900
|
-
return this.parseRow(row, this.table);
|
|
901
|
-
},
|
|
902
|
-
tag: `${this.table}.update`,
|
|
903
|
-
preparedName: entry.name,
|
|
904
|
-
};
|
|
905
|
-
}
|
|
906
|
-
// -------------------------------------------------------------------------
|
|
907
|
-
// delete
|
|
908
|
-
// -------------------------------------------------------------------------
|
|
909
|
-
async delete(args) {
|
|
910
|
-
return this.executeWithMiddleware('delete', args, async () => {
|
|
911
|
-
const deferred = this.buildDelete(args);
|
|
912
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
913
|
-
return deferred.transform(result);
|
|
914
|
-
});
|
|
915
|
-
}
|
|
916
|
-
buildDelete(args) {
|
|
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);
|
|
935
|
-
return {
|
|
936
|
-
sql: entry.sql,
|
|
937
|
-
params,
|
|
938
|
-
transform: (result) => {
|
|
939
|
-
const row = result.rows[0];
|
|
940
|
-
if (!row) {
|
|
941
|
-
throw new NotFoundError({
|
|
942
|
-
table: this.table,
|
|
943
|
-
where: args.where,
|
|
944
|
-
operation: 'delete',
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
return this.parseRow(row, this.table);
|
|
948
|
-
},
|
|
949
|
-
tag: `${this.table}.delete`,
|
|
950
|
-
preparedName: entry.name,
|
|
951
|
-
};
|
|
952
|
-
}
|
|
953
|
-
// -------------------------------------------------------------------------
|
|
954
|
-
// upsert — INSERT ... ON CONFLICT ... DO UPDATE
|
|
955
|
-
// -------------------------------------------------------------------------
|
|
956
|
-
async upsert(args) {
|
|
957
|
-
return this.executeWithMiddleware('upsert', args, async () => {
|
|
958
|
-
const deferred = this.buildUpsert(args);
|
|
959
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
960
|
-
return deferred.transform(result);
|
|
961
|
-
});
|
|
962
|
-
}
|
|
963
|
-
buildUpsert(args) {
|
|
964
|
-
// Build the INSERT part from create data
|
|
965
|
-
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
966
|
-
const columns = createEntries.map(([k]) => this.toSqlColumn(k));
|
|
967
|
-
const createParams = createEntries.map(([, v]) => v);
|
|
968
|
-
const placeholders = createEntries.map((_, i) => `$${i + 1}`);
|
|
969
|
-
// The conflict target comes from `where` keys — must be unique/PK columns
|
|
970
|
-
const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
|
|
971
|
-
const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
|
|
972
|
-
// Build the UPDATE SET part
|
|
973
|
-
const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
|
|
974
|
-
let paramIdx = createParams.length + 1;
|
|
975
|
-
const setClauses = updateEntries.map(([k]) => {
|
|
976
|
-
const clause = `${this.toSqlColumn(k)} = $${paramIdx}`;
|
|
977
|
-
paramIdx++;
|
|
978
|
-
return clause;
|
|
979
|
-
});
|
|
980
|
-
const updateParams = updateEntries.map(([, v]) => v);
|
|
981
|
-
const params = [...createParams, ...updateParams];
|
|
982
|
-
const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})` +
|
|
983
|
-
` ON CONFLICT (${conflictColumns.join(', ')}) DO UPDATE SET ${setClauses.join(', ')}` +
|
|
984
|
-
` RETURNING *`;
|
|
985
|
-
return {
|
|
986
|
-
sql,
|
|
987
|
-
params,
|
|
988
|
-
transform: (result) => {
|
|
989
|
-
const row = result.rows[0];
|
|
990
|
-
if (!row) {
|
|
991
|
-
throw new NotFoundError({
|
|
992
|
-
table: this.table,
|
|
993
|
-
where: args.where,
|
|
994
|
-
operation: 'upsert',
|
|
995
|
-
message: `[turbine] upsert on "${this.table}" returned no row from RETURNING * — this should never happen.`,
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
return this.parseRow(row, this.table);
|
|
999
|
-
},
|
|
1000
|
-
tag: `${this.table}.upsert`,
|
|
1001
|
-
};
|
|
1002
|
-
}
|
|
1003
|
-
// -------------------------------------------------------------------------
|
|
1004
|
-
// updateMany — UPDATE ... WHERE ... returning count
|
|
1005
|
-
// -------------------------------------------------------------------------
|
|
1006
|
-
async updateMany(args) {
|
|
1007
|
-
return this.executeWithMiddleware('updateMany', args, async () => {
|
|
1008
|
-
const deferred = this.buildUpdateMany(args);
|
|
1009
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
1010
|
-
return deferred.transform(result);
|
|
1011
|
-
});
|
|
1012
|
-
}
|
|
1013
|
-
buildUpdateMany(args) {
|
|
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}`;
|
|
1019
|
-
const params = [];
|
|
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);
|
|
1034
|
-
return {
|
|
1035
|
-
sql: entry.sql,
|
|
1036
|
-
params,
|
|
1037
|
-
transform: (result) => ({ count: result.rowCount ?? 0 }),
|
|
1038
|
-
tag: `${this.table}.updateMany`,
|
|
1039
|
-
preparedName: entry.name,
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
// -------------------------------------------------------------------------
|
|
1043
|
-
// deleteMany — DELETE ... WHERE ... returning count
|
|
1044
|
-
// -------------------------------------------------------------------------
|
|
1045
|
-
async deleteMany(args) {
|
|
1046
|
-
return this.executeWithMiddleware('deleteMany', args, async () => {
|
|
1047
|
-
const deferred = this.buildDeleteMany(args);
|
|
1048
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
1049
|
-
return deferred.transform(result);
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
|
-
buildDeleteMany(args) {
|
|
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);
|
|
1068
|
-
return {
|
|
1069
|
-
sql: entry.sql,
|
|
1070
|
-
params,
|
|
1071
|
-
transform: (result) => ({ count: result.rowCount ?? 0 }),
|
|
1072
|
-
tag: `${this.table}.deleteMany`,
|
|
1073
|
-
preparedName: entry.name,
|
|
1074
|
-
};
|
|
1075
|
-
}
|
|
1076
|
-
// -------------------------------------------------------------------------
|
|
1077
|
-
// count
|
|
1078
|
-
// -------------------------------------------------------------------------
|
|
1079
|
-
async count(args) {
|
|
1080
|
-
return this.executeWithMiddleware('count', (args ?? {}), async () => {
|
|
1081
|
-
const deferred = this.buildCount(args);
|
|
1082
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
1083
|
-
return deferred.transform(result);
|
|
1084
|
-
});
|
|
1085
|
-
}
|
|
1086
|
-
buildCount(args) {
|
|
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
|
-
}
|
|
1100
|
-
return {
|
|
1101
|
-
sql: entry.sql,
|
|
1102
|
-
params,
|
|
1103
|
-
transform: (result) => result.rows[0].count,
|
|
1104
|
-
tag: `${this.table}.count`,
|
|
1105
|
-
preparedName: entry.name,
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
// -------------------------------------------------------------------------
|
|
1109
|
-
// groupBy (with aggregate functions)
|
|
1110
|
-
// -------------------------------------------------------------------------
|
|
1111
|
-
async groupBy(args) {
|
|
1112
|
-
return this.executeWithMiddleware('groupBy', args, async () => {
|
|
1113
|
-
const deferred = this.buildGroupBy(args);
|
|
1114
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
1115
|
-
return deferred.transform(result);
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
1118
|
-
buildGroupBy(args) {
|
|
1119
|
-
const meta = this.schema.tables[this.table];
|
|
1120
|
-
if (meta) {
|
|
1121
|
-
for (const key of args.by) {
|
|
1122
|
-
if (!(key in meta.columnMap)) {
|
|
1123
|
-
throw new ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
1128
|
-
const groupCols = groupColsRaw.map((c) => quoteIdent(c));
|
|
1129
|
-
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
1130
|
-
// Build SELECT expressions: group-by columns + aggregate functions
|
|
1131
|
-
const selectExprs = [...groupCols];
|
|
1132
|
-
// _count
|
|
1133
|
-
if (args._count === true || args._count === undefined) {
|
|
1134
|
-
// default: always include count
|
|
1135
|
-
selectExprs.push('COUNT(*)::int AS _count');
|
|
1136
|
-
}
|
|
1137
|
-
// _sum
|
|
1138
|
-
if (args._sum) {
|
|
1139
|
-
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
1140
|
-
if (enabled) {
|
|
1141
|
-
const col = this.toColumn(field);
|
|
1142
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(`_sum_${col}`)}`);
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
// _avg
|
|
1147
|
-
if (args._avg) {
|
|
1148
|
-
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
1149
|
-
if (enabled) {
|
|
1150
|
-
const col = this.toColumn(field);
|
|
1151
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(`_avg_${col}`)}`);
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
// _min
|
|
1156
|
-
if (args._min) {
|
|
1157
|
-
for (const [field, enabled] of Object.entries(args._min)) {
|
|
1158
|
-
if (enabled) {
|
|
1159
|
-
const col = this.toColumn(field);
|
|
1160
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(`_min_${col}`)}`);
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
// _max
|
|
1165
|
-
if (args._max) {
|
|
1166
|
-
for (const [field, enabled] of Object.entries(args._max)) {
|
|
1167
|
-
if (enabled) {
|
|
1168
|
-
const col = this.toColumn(field);
|
|
1169
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(`_max_${col}`)}`);
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
let sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
|
|
1174
|
-
// ORDER BY
|
|
1175
|
-
if (args.orderBy) {
|
|
1176
|
-
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
1177
|
-
}
|
|
1178
|
-
return {
|
|
1179
|
-
sql,
|
|
1180
|
-
params,
|
|
1181
|
-
transform: (result) => result.rows.map((row) => {
|
|
1182
|
-
const parsed = this.parseRow(row, this.table);
|
|
1183
|
-
// Restructure aggregate results into nested objects (Prisma-style)
|
|
1184
|
-
const restructured = {};
|
|
1185
|
-
// Copy group-by fields
|
|
1186
|
-
for (const field of args.by) {
|
|
1187
|
-
restructured[field] = parsed[field];
|
|
1188
|
-
}
|
|
1189
|
-
// _count
|
|
1190
|
-
if ('_count' in row) {
|
|
1191
|
-
restructured._count = row._count;
|
|
1192
|
-
}
|
|
1193
|
-
else if ('count' in row) {
|
|
1194
|
-
restructured._count = row.count;
|
|
1195
|
-
}
|
|
1196
|
-
// Collect aggregates into nested objects
|
|
1197
|
-
const sumObj = {};
|
|
1198
|
-
const avgObj = {};
|
|
1199
|
-
const minObj = {};
|
|
1200
|
-
const maxObj = {};
|
|
1201
|
-
let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
|
|
1202
|
-
for (const [rawKey, rawValue] of Object.entries(row)) {
|
|
1203
|
-
if (rawKey.startsWith('_sum_')) {
|
|
1204
|
-
const col = rawKey.slice(5);
|
|
1205
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1206
|
-
sumObj[field] = rawValue !== null ? Number(rawValue) : null;
|
|
1207
|
-
hasSums = true;
|
|
1208
|
-
}
|
|
1209
|
-
else if (rawKey.startsWith('_avg_')) {
|
|
1210
|
-
const col = rawKey.slice(5);
|
|
1211
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1212
|
-
avgObj[field] = rawValue !== null ? Number(rawValue) : null;
|
|
1213
|
-
hasAvgs = true;
|
|
1214
|
-
}
|
|
1215
|
-
else if (rawKey.startsWith('_min_')) {
|
|
1216
|
-
const col = rawKey.slice(5);
|
|
1217
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1218
|
-
minObj[field] = rawValue;
|
|
1219
|
-
hasMins = true;
|
|
1220
|
-
}
|
|
1221
|
-
else if (rawKey.startsWith('_max_')) {
|
|
1222
|
-
const col = rawKey.slice(5);
|
|
1223
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1224
|
-
maxObj[field] = rawValue;
|
|
1225
|
-
hasMaxs = true;
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
if (hasSums)
|
|
1229
|
-
restructured._sum = sumObj;
|
|
1230
|
-
if (hasAvgs)
|
|
1231
|
-
restructured._avg = avgObj;
|
|
1232
|
-
if (hasMins)
|
|
1233
|
-
restructured._min = minObj;
|
|
1234
|
-
if (hasMaxs)
|
|
1235
|
-
restructured._max = maxObj;
|
|
1236
|
-
return restructured;
|
|
1237
|
-
}),
|
|
1238
|
-
tag: `${this.table}.groupBy`,
|
|
1239
|
-
};
|
|
1240
|
-
}
|
|
1241
|
-
// -------------------------------------------------------------------------
|
|
1242
|
-
// aggregate — standalone aggregation without groupBy
|
|
1243
|
-
// -------------------------------------------------------------------------
|
|
1244
|
-
async aggregate(args) {
|
|
1245
|
-
return this.executeWithMiddleware('aggregate', args, async () => {
|
|
1246
|
-
const deferred = this.buildAggregate(args);
|
|
1247
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
1248
|
-
return deferred.transform(result);
|
|
1249
|
-
});
|
|
1250
|
-
}
|
|
1251
|
-
buildAggregate(args) {
|
|
1252
|
-
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
1253
|
-
const meta = this.schema.tables[this.table];
|
|
1254
|
-
if (meta) {
|
|
1255
|
-
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
1256
|
-
if (group && typeof group === 'object') {
|
|
1257
|
-
for (const key of Object.keys(group)) {
|
|
1258
|
-
if (!(key in meta.columnMap)) {
|
|
1259
|
-
throw new ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
if (args._count && typeof args._count === 'object') {
|
|
1265
|
-
for (const key of Object.keys(args._count)) {
|
|
1266
|
-
if (!(key in meta.columnMap)) {
|
|
1267
|
-
throw new ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
const selectExprs = [];
|
|
1273
|
-
// _count
|
|
1274
|
-
if (args._count === true) {
|
|
1275
|
-
selectExprs.push('COUNT(*)::int AS _count');
|
|
1276
|
-
}
|
|
1277
|
-
else if (args._count && typeof args._count === 'object') {
|
|
1278
|
-
for (const [field, enabled] of Object.entries(args._count)) {
|
|
1279
|
-
if (enabled) {
|
|
1280
|
-
const col = this.toColumn(field);
|
|
1281
|
-
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent(`_count_${col}`)}`);
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
// _sum
|
|
1286
|
-
if (args._sum) {
|
|
1287
|
-
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
1288
|
-
if (enabled) {
|
|
1289
|
-
const col = this.toColumn(field);
|
|
1290
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(`_sum_${col}`)}`);
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
// _avg
|
|
1295
|
-
if (args._avg) {
|
|
1296
|
-
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
1297
|
-
if (enabled) {
|
|
1298
|
-
const col = this.toColumn(field);
|
|
1299
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(`_avg_${col}`)}`);
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
// _min
|
|
1304
|
-
if (args._min) {
|
|
1305
|
-
for (const [field, enabled] of Object.entries(args._min)) {
|
|
1306
|
-
if (enabled) {
|
|
1307
|
-
const col = this.toColumn(field);
|
|
1308
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(`_min_${col}`)}`);
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
// _max
|
|
1313
|
-
if (args._max) {
|
|
1314
|
-
for (const [field, enabled] of Object.entries(args._max)) {
|
|
1315
|
-
if (enabled) {
|
|
1316
|
-
const col = this.toColumn(field);
|
|
1317
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(`_max_${col}`)}`);
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
if (selectExprs.length === 0) {
|
|
1322
|
-
selectExprs.push('COUNT(*)::int AS _count');
|
|
1323
|
-
}
|
|
1324
|
-
const sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
1325
|
-
return {
|
|
1326
|
-
sql,
|
|
1327
|
-
params,
|
|
1328
|
-
transform: (result) => {
|
|
1329
|
-
const row = result.rows[0];
|
|
1330
|
-
const aggResult = {};
|
|
1331
|
-
// _count
|
|
1332
|
-
if (row._count !== undefined) {
|
|
1333
|
-
aggResult._count = row._count;
|
|
1334
|
-
}
|
|
1335
|
-
else {
|
|
1336
|
-
// Check for per-column counts
|
|
1337
|
-
const countObj = {};
|
|
1338
|
-
let hasCountFields = false;
|
|
1339
|
-
for (const [key, val] of Object.entries(row)) {
|
|
1340
|
-
if (key.startsWith('_count_')) {
|
|
1341
|
-
const col = key.slice(7);
|
|
1342
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1343
|
-
countObj[field] = val;
|
|
1344
|
-
hasCountFields = true;
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
if (hasCountFields)
|
|
1348
|
-
aggResult._count = countObj;
|
|
1349
|
-
}
|
|
1350
|
-
// Build nested aggregate objects
|
|
1351
|
-
const sumObj = {};
|
|
1352
|
-
const avgObj = {};
|
|
1353
|
-
const minObj = {};
|
|
1354
|
-
const maxObj = {};
|
|
1355
|
-
let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
|
|
1356
|
-
for (const [key, val] of Object.entries(row)) {
|
|
1357
|
-
if (key.startsWith('_sum_')) {
|
|
1358
|
-
const col = key.slice(5);
|
|
1359
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1360
|
-
sumObj[field] = val !== null ? Number(val) : null;
|
|
1361
|
-
hasSums = true;
|
|
1362
|
-
}
|
|
1363
|
-
else if (key.startsWith('_avg_')) {
|
|
1364
|
-
const col = key.slice(5);
|
|
1365
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1366
|
-
avgObj[field] = val !== null ? Number(val) : null;
|
|
1367
|
-
hasAvgs = true;
|
|
1368
|
-
}
|
|
1369
|
-
else if (key.startsWith('_min_')) {
|
|
1370
|
-
const col = key.slice(5);
|
|
1371
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1372
|
-
minObj[field] = val;
|
|
1373
|
-
hasMins = true;
|
|
1374
|
-
}
|
|
1375
|
-
else if (key.startsWith('_max_')) {
|
|
1376
|
-
const col = key.slice(5);
|
|
1377
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1378
|
-
maxObj[field] = val;
|
|
1379
|
-
hasMaxs = true;
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
if (hasSums)
|
|
1383
|
-
aggResult._sum = sumObj;
|
|
1384
|
-
if (hasAvgs)
|
|
1385
|
-
aggResult._avg = avgObj;
|
|
1386
|
-
if (hasMins)
|
|
1387
|
-
aggResult._min = minObj;
|
|
1388
|
-
if (hasMaxs)
|
|
1389
|
-
aggResult._max = maxObj;
|
|
1390
|
-
return aggResult;
|
|
1391
|
-
},
|
|
1392
|
-
tag: `${this.table}.aggregate`,
|
|
1393
|
-
};
|
|
1394
|
-
}
|
|
1395
|
-
// =========================================================================
|
|
1396
|
-
// Internal helpers
|
|
1397
|
-
// =========================================================================
|
|
1398
|
-
/**
|
|
1399
|
-
* Resolve select/omit options into a list of snake_case column names.
|
|
1400
|
-
* Returns null if neither is provided (meaning all columns).
|
|
1401
|
-
*/
|
|
1402
|
-
resolveColumns(select, omit) {
|
|
1403
|
-
if (select) {
|
|
1404
|
-
// Only include columns where value is true
|
|
1405
|
-
return Object.entries(select)
|
|
1406
|
-
.filter(([, v]) => v)
|
|
1407
|
-
.map(([k]) => this.toColumn(k));
|
|
1408
|
-
}
|
|
1409
|
-
if (omit) {
|
|
1410
|
-
// Include all columns except those where value is true
|
|
1411
|
-
const omitCols = new Set(Object.entries(omit)
|
|
1412
|
-
.filter(([, v]) => v)
|
|
1413
|
-
.map(([k]) => this.toColumn(k)));
|
|
1414
|
-
return this.tableMeta.allColumns.filter((col) => !omitCols.has(col));
|
|
1415
|
-
}
|
|
1416
|
-
return null;
|
|
1417
|
-
}
|
|
1418
|
-
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
1419
|
-
toColumn(field) {
|
|
1420
|
-
const mapped = this.tableMeta.columnMap[field];
|
|
1421
|
-
if (mapped)
|
|
1422
|
-
return mapped;
|
|
1423
|
-
// Fall back to camelToSnake ONLY if that snake_cased name also exists as a
|
|
1424
|
-
// real column on the table. This preserves the convenience of writing
|
|
1425
|
-
// `userId` when the schema exposes `user_id` under an unusual field name,
|
|
1426
|
-
// but rejects arbitrary strings — closing the defense-in-depth gap for
|
|
1427
|
-
// SQL injection and catching typos like `where: { emial: 'x' }` with a
|
|
1428
|
-
// clear error instead of a cryptic Postgres "column does not exist".
|
|
1429
|
-
const snake = camelToSnake(field);
|
|
1430
|
-
if (this.tableMeta.reverseColumnMap?.[snake]) {
|
|
1431
|
-
return snake;
|
|
1432
|
-
}
|
|
1433
|
-
if (this.tableMeta.allColumns?.includes(snake)) {
|
|
1434
|
-
return snake;
|
|
1435
|
-
}
|
|
1436
|
-
throw new ValidationError(`[turbine] Unknown field "${field}" on table "${this.table}". ` +
|
|
1437
|
-
`Known fields: ${Object.keys(this.tableMeta.columnMap).join(', ') || '(none)'}.`);
|
|
1438
|
-
}
|
|
1439
|
-
/** Convert camelCase field name to a double-quoted SQL identifier */
|
|
1440
|
-
toSqlColumn(field) {
|
|
1441
|
-
return quoteIdent(this.toColumn(field));
|
|
1442
|
-
}
|
|
1443
|
-
/**
|
|
1444
|
-
* Build a single SET clause entry for update/updateMany.
|
|
1445
|
-
*
|
|
1446
|
-
* Supports plain values and atomic operator objects ({ set, increment,
|
|
1447
|
-
* decrement, multiply, divide }). An operator object is detected ONLY when
|
|
1448
|
-
* it has EXACTLY one key that is one of the 5 operator keys — this avoids
|
|
1449
|
-
* misinterpreting JSON column values like `{ set: 'x' }` as operators
|
|
1450
|
-
* (real operator objects always have exactly one key, and a plain JSON
|
|
1451
|
-
* payload that happens to have a single `set` key is extremely unusual).
|
|
1452
|
-
* Multi-key objects are always treated as plain (JSON) values.
|
|
1453
|
-
*
|
|
1454
|
-
* Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
|
|
1455
|
-
* pushes any required params onto the shared params array so that WHERE
|
|
1456
|
-
* clause numbering continues correctly afterward.
|
|
1457
|
-
*/
|
|
1458
|
-
buildSetClause(key, value, params) {
|
|
1459
|
-
const col = this.toSqlColumn(key);
|
|
1460
|
-
// Detect atomic-operator object: plain object (not null, not array, not
|
|
1461
|
-
// Date, not Buffer) with EXACTLY one key matching an operator name.
|
|
1462
|
-
if (value !== null &&
|
|
1463
|
-
typeof value === 'object' &&
|
|
1464
|
-
!Array.isArray(value) &&
|
|
1465
|
-
!(value instanceof Date) &&
|
|
1466
|
-
!Buffer.isBuffer(value)) {
|
|
1467
|
-
const v = value;
|
|
1468
|
-
const keys = Object.keys(v);
|
|
1469
|
-
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
1470
|
-
const op = keys[0];
|
|
1471
|
-
const opValue = v[op];
|
|
1472
|
-
if (op === 'set') {
|
|
1473
|
-
params.push(opValue);
|
|
1474
|
-
return `${col} = $${params.length}`;
|
|
1475
|
-
}
|
|
1476
|
-
// Arithmetic operators: must be finite numbers
|
|
1477
|
-
if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
|
|
1478
|
-
throw new ValidationError(`[turbine] update operator "${op}" on "${this.table}.${key}" requires a finite number, got ${typeof opValue}`);
|
|
1479
|
-
}
|
|
1480
|
-
if (op === 'increment') {
|
|
1481
|
-
params.push(opValue);
|
|
1482
|
-
return `${col} = ${col} + $${params.length}`;
|
|
1483
|
-
}
|
|
1484
|
-
if (op === 'decrement') {
|
|
1485
|
-
params.push(opValue);
|
|
1486
|
-
return `${col} = ${col} - $${params.length}`;
|
|
1487
|
-
}
|
|
1488
|
-
if (op === 'multiply') {
|
|
1489
|
-
params.push(opValue);
|
|
1490
|
-
return `${col} = ${col} * $${params.length}`;
|
|
1491
|
-
}
|
|
1492
|
-
if (op === 'divide') {
|
|
1493
|
-
params.push(opValue);
|
|
1494
|
-
return `${col} = ${col} / $${params.length}`;
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
// Fall through: multi-key objects or non-operator single-key objects
|
|
1498
|
-
// are treated as plain values (e.g., JSONB column payloads).
|
|
1499
|
-
}
|
|
1500
|
-
// Plain value (including null, Date, Buffer, arrays, JSON objects)
|
|
1501
|
-
params.push(value);
|
|
1502
|
-
return `${col} = $${params.length}`;
|
|
1503
|
-
}
|
|
1504
|
-
// =========================================================================
|
|
1505
|
-
// Fingerprinting — value-invariant shape keys for SQL cache lookup
|
|
1506
|
-
// =========================================================================
|
|
1507
|
-
/**
|
|
1508
|
-
* Produce a value-invariant fingerprint of a where clause.
|
|
1509
|
-
* Same keys + same operator shapes + same combinator structure => same string.
|
|
1510
|
-
* Different values (e.g. id=1 vs id=999) => identical fingerprint.
|
|
1511
|
-
*
|
|
1512
|
-
* @internal Exposed as package-private for testing via class access.
|
|
1513
|
-
*/
|
|
1514
|
-
fingerprintWhere(where) {
|
|
1515
|
-
const keys = Object.keys(where)
|
|
1516
|
-
.filter((k) => where[k] !== undefined)
|
|
1517
|
-
.sort();
|
|
1518
|
-
if (keys.length === 0)
|
|
1519
|
-
return '';
|
|
1520
|
-
const parts = [];
|
|
1521
|
-
for (const key of keys) {
|
|
1522
|
-
const value = where[key];
|
|
1523
|
-
if (value === undefined)
|
|
1524
|
-
continue;
|
|
1525
|
-
if (key === 'OR') {
|
|
1526
|
-
const orArr = value;
|
|
1527
|
-
if (!Array.isArray(orArr) || orArr.length === 0)
|
|
1528
|
-
continue;
|
|
1529
|
-
const orParts = orArr.map((cond) => this.fingerprintWhere(cond));
|
|
1530
|
-
parts.push(`OR[${orParts.join(',')}]`);
|
|
1531
|
-
continue;
|
|
1532
|
-
}
|
|
1533
|
-
if (key === 'AND') {
|
|
1534
|
-
const andArr = value;
|
|
1535
|
-
if (!Array.isArray(andArr) || andArr.length === 0)
|
|
1536
|
-
continue;
|
|
1537
|
-
const andParts = andArr.map((cond) => this.fingerprintWhere(cond));
|
|
1538
|
-
parts.push(`AND[${andParts.join(',')}]`);
|
|
1539
|
-
continue;
|
|
1540
|
-
}
|
|
1541
|
-
if (key === 'NOT') {
|
|
1542
|
-
const notCond = value;
|
|
1543
|
-
parts.push(`NOT(${this.fingerprintWhere(notCond)})`);
|
|
1544
|
-
continue;
|
|
1545
|
-
}
|
|
1546
|
-
// Relation filters: { posts: { some: { published: true } } }
|
|
1547
|
-
const relDef = this.tableMeta.relations[key];
|
|
1548
|
-
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1549
|
-
const filterObj = value;
|
|
1550
|
-
if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
|
|
1551
|
-
const relParts = [];
|
|
1552
|
-
if (filterObj.some !== undefined)
|
|
1553
|
-
relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
|
|
1554
|
-
if (filterObj.every !== undefined)
|
|
1555
|
-
relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
|
|
1556
|
-
if (filterObj.none !== undefined)
|
|
1557
|
-
relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
|
|
1558
|
-
parts.push(`${key}:{${relParts.join(',')}}`);
|
|
1559
|
-
continue;
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
// null → distinct from value
|
|
1563
|
-
if (value === null) {
|
|
1564
|
-
parts.push(`${key}:null`);
|
|
1565
|
-
continue;
|
|
1566
|
-
}
|
|
1567
|
-
// Operator objects
|
|
1568
|
-
if (isWhereOperator(value)) {
|
|
1569
|
-
const opKeys = Object.keys(value)
|
|
1570
|
-
.filter((k) => k !== 'mode')
|
|
1571
|
-
.sort();
|
|
1572
|
-
const mode = value.mode;
|
|
1573
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1574
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1575
|
-
continue;
|
|
1576
|
-
}
|
|
1577
|
-
// JSON filter
|
|
1578
|
-
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1579
|
-
const jKeys = Object.keys(value).sort();
|
|
1580
|
-
parts.push(`${key}:json(${jKeys.join(',')})`);
|
|
1581
|
-
continue;
|
|
1582
|
-
}
|
|
1583
|
-
// Array filter
|
|
1584
|
-
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
1585
|
-
const aKeys = Object.keys(value).sort();
|
|
1586
|
-
parts.push(`${key}:arr(${aKeys.join(',')})`);
|
|
1587
|
-
continue;
|
|
1588
|
-
}
|
|
1589
|
-
// Plain equality
|
|
1590
|
-
parts.push(`${key}:eq`);
|
|
1591
|
-
}
|
|
1592
|
-
return parts.join('&');
|
|
1593
|
-
}
|
|
1594
|
-
/**
|
|
1595
|
-
* Fingerprint a relation filter sub-where for some/every/none.
|
|
1596
|
-
*/
|
|
1597
|
-
fingerprintRelFilter(_targetTable, subWhere) {
|
|
1598
|
-
const keys = Object.keys(subWhere)
|
|
1599
|
-
.filter((k) => subWhere[k] !== undefined)
|
|
1600
|
-
.sort();
|
|
1601
|
-
if (keys.length === 0)
|
|
1602
|
-
return '';
|
|
1603
|
-
const parts = [];
|
|
1604
|
-
for (const key of keys) {
|
|
1605
|
-
const value = subWhere[key];
|
|
1606
|
-
if (value === undefined)
|
|
1607
|
-
continue;
|
|
1608
|
-
if (value === null) {
|
|
1609
|
-
parts.push(`${key}:null`);
|
|
1610
|
-
}
|
|
1611
|
-
else if (isWhereOperator(value)) {
|
|
1612
|
-
const opKeys = Object.keys(value)
|
|
1613
|
-
.filter((k) => k !== 'mode')
|
|
1614
|
-
.sort();
|
|
1615
|
-
const mode = value.mode;
|
|
1616
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1617
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1618
|
-
}
|
|
1619
|
-
else {
|
|
1620
|
-
parts.push(`${key}:eq`);
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
return parts.join('&');
|
|
1624
|
-
}
|
|
1625
|
-
/**
|
|
1626
|
-
* Walk a where clause and push ONLY values into `params`, in the EXACT same
|
|
1627
|
-
* order that `buildWhereClause` pushes them. Used on cache hit to fill params
|
|
1628
|
-
* without rebuilding SQL.
|
|
1629
|
-
*
|
|
1630
|
-
* @internal Exposed as package-private for testing.
|
|
1631
|
-
*/
|
|
1632
|
-
collectWhereParams(where, params) {
|
|
1633
|
-
const keys = Object.keys(where);
|
|
1634
|
-
for (const key of keys) {
|
|
1635
|
-
const value = where[key];
|
|
1636
|
-
if (value === undefined)
|
|
1637
|
-
continue;
|
|
1638
|
-
if (key === 'OR') {
|
|
1639
|
-
const orConditions = value;
|
|
1640
|
-
if (!Array.isArray(orConditions) || orConditions.length === 0)
|
|
1641
|
-
continue;
|
|
1642
|
-
for (const orCond of orConditions) {
|
|
1643
|
-
this.collectWhereParams(orCond, params);
|
|
1644
|
-
}
|
|
1645
|
-
continue;
|
|
1646
|
-
}
|
|
1647
|
-
if (key === 'AND') {
|
|
1648
|
-
const andConditions = value;
|
|
1649
|
-
if (!Array.isArray(andConditions) || andConditions.length === 0)
|
|
1650
|
-
continue;
|
|
1651
|
-
for (const andCond of andConditions) {
|
|
1652
|
-
this.collectWhereParams(andCond, params);
|
|
1653
|
-
}
|
|
1654
|
-
continue;
|
|
1655
|
-
}
|
|
1656
|
-
if (key === 'NOT') {
|
|
1657
|
-
const notCond = value;
|
|
1658
|
-
this.collectWhereParams(notCond, params);
|
|
1659
|
-
continue;
|
|
1660
|
-
}
|
|
1661
|
-
// Relation filters
|
|
1662
|
-
const relationDef = this.tableMeta.relations[key];
|
|
1663
|
-
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1664
|
-
const filterObj = value;
|
|
1665
|
-
if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
|
|
1666
|
-
if (filterObj.some !== undefined)
|
|
1667
|
-
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
1668
|
-
if (filterObj.none !== undefined)
|
|
1669
|
-
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
1670
|
-
if (filterObj.every !== undefined)
|
|
1671
|
-
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
1672
|
-
continue;
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
// null → no param pushed (IS NULL is parameterless)
|
|
1676
|
-
if (value === null)
|
|
1677
|
-
continue;
|
|
1678
|
-
const rawColumn = this.toColumn(key);
|
|
1679
|
-
// JSONB filter
|
|
1680
|
-
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1681
|
-
const colType = this.getColumnPgType(rawColumn);
|
|
1682
|
-
if (colType === 'json' || colType === 'jsonb') {
|
|
1683
|
-
this.collectJsonFilterParams(value, params);
|
|
1684
|
-
continue;
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
// Array filter
|
|
1688
|
-
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
1689
|
-
const colType = this.getColumnPgType(rawColumn);
|
|
1690
|
-
if (colType.startsWith('_')) {
|
|
1691
|
-
this.collectArrayFilterParams(value, params);
|
|
1692
|
-
continue;
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
// Operator objects
|
|
1696
|
-
if (isWhereOperator(value)) {
|
|
1697
|
-
this.collectOperatorParams(value, params);
|
|
1698
|
-
continue;
|
|
1699
|
-
}
|
|
1700
|
-
// Plain equality
|
|
1701
|
-
params.push(value);
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
/** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
|
|
1705
|
-
collectRelFilterParams(targetTable, subWhere, params) {
|
|
1706
|
-
const meta = this.schema.tables[targetTable];
|
|
1707
|
-
if (!meta)
|
|
1708
|
-
return;
|
|
1709
|
-
for (const [_field, value] of Object.entries(subWhere)) {
|
|
1710
|
-
if (value === undefined)
|
|
1711
|
-
continue;
|
|
1712
|
-
if (value === null)
|
|
1713
|
-
continue;
|
|
1714
|
-
if (isWhereOperator(value)) {
|
|
1715
|
-
this.collectOperatorParams(value, params);
|
|
1716
|
-
continue;
|
|
1717
|
-
}
|
|
1718
|
-
params.push(value);
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
/** Collect params from operator clauses. Mirrors buildOperatorClauses. */
|
|
1722
|
-
collectOperatorParams(op, params) {
|
|
1723
|
-
if (op.gt !== undefined)
|
|
1724
|
-
params.push(op.gt);
|
|
1725
|
-
if (op.gte !== undefined)
|
|
1726
|
-
params.push(op.gte);
|
|
1727
|
-
if (op.lt !== undefined)
|
|
1728
|
-
params.push(op.lt);
|
|
1729
|
-
if (op.lte !== undefined)
|
|
1730
|
-
params.push(op.lte);
|
|
1731
|
-
if (op.not !== undefined && op.not !== null)
|
|
1732
|
-
params.push(op.not);
|
|
1733
|
-
if (op.in !== undefined)
|
|
1734
|
-
params.push(op.in);
|
|
1735
|
-
if (op.notIn !== undefined)
|
|
1736
|
-
params.push(op.notIn);
|
|
1737
|
-
if (op.contains !== undefined)
|
|
1738
|
-
params.push(`%${escapeLike(op.contains)}%`);
|
|
1739
|
-
if (op.startsWith !== undefined)
|
|
1740
|
-
params.push(`${escapeLike(op.startsWith)}%`);
|
|
1741
|
-
if (op.endsWith !== undefined)
|
|
1742
|
-
params.push(`%${escapeLike(op.endsWith)}`);
|
|
1743
|
-
}
|
|
1744
|
-
/** Collect params from JSON filter. Mirrors buildJsonFilterClauses. */
|
|
1745
|
-
collectJsonFilterParams(filter, params) {
|
|
1746
|
-
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
1747
|
-
params.push(filter.path);
|
|
1748
|
-
params.push(String(filter.equals));
|
|
1749
|
-
}
|
|
1750
|
-
else if (filter.equals !== undefined) {
|
|
1751
|
-
params.push(JSON.stringify(filter.equals));
|
|
1752
|
-
}
|
|
1753
|
-
if (filter.contains !== undefined) {
|
|
1754
|
-
params.push(JSON.stringify(filter.contains));
|
|
1755
|
-
}
|
|
1756
|
-
if (filter.hasKey !== undefined) {
|
|
1757
|
-
params.push(filter.hasKey);
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
1761
|
-
collectArrayFilterParams(filter, params) {
|
|
1762
|
-
if (filter.has !== undefined)
|
|
1763
|
-
params.push(filter.has);
|
|
1764
|
-
if (filter.hasEvery !== undefined)
|
|
1765
|
-
params.push(filter.hasEvery);
|
|
1766
|
-
if (filter.hasSome !== undefined)
|
|
1767
|
-
params.push(filter.hasSome);
|
|
1768
|
-
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
1769
|
-
}
|
|
1770
|
-
/**
|
|
1771
|
-
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
1772
|
-
* buildSelectWithRelations / buildRelationSubquery.
|
|
1773
|
-
*
|
|
1774
|
-
* @internal Exposed as package-private for testing.
|
|
1775
|
-
*/
|
|
1776
|
-
withFingerprint(withClause, table, depth = 0) {
|
|
1777
|
-
if (!withClause)
|
|
1778
|
-
return '';
|
|
1779
|
-
const meta = this.schema.tables[table ?? this.table];
|
|
1780
|
-
if (!meta)
|
|
1781
|
-
return '';
|
|
1782
|
-
const relNames = Object.keys(withClause).sort();
|
|
1783
|
-
const parts = [];
|
|
1784
|
-
for (const relName of relNames) {
|
|
1785
|
-
const spec = withClause[relName];
|
|
1786
|
-
if (!spec)
|
|
1787
|
-
continue;
|
|
1788
|
-
const relDef = meta.relations[relName];
|
|
1789
|
-
if (!relDef)
|
|
1790
|
-
continue;
|
|
1791
|
-
if (spec === true) {
|
|
1792
|
-
parts.push(relName);
|
|
1793
|
-
continue;
|
|
1794
|
-
}
|
|
1795
|
-
const opts = spec;
|
|
1796
|
-
const subParts = [];
|
|
1797
|
-
// select/omit shape
|
|
1798
|
-
if (opts.select) {
|
|
1799
|
-
const selKeys = Object.entries(opts.select)
|
|
1800
|
-
.filter(([, v]) => v)
|
|
1801
|
-
.map(([k]) => k)
|
|
1802
|
-
.sort();
|
|
1803
|
-
subParts.push(`sl=${selKeys.join(',')}`);
|
|
1804
|
-
}
|
|
1805
|
-
if (opts.omit) {
|
|
1806
|
-
const omKeys = Object.entries(opts.omit)
|
|
1807
|
-
.filter(([, v]) => v)
|
|
1808
|
-
.map(([k]) => k)
|
|
1809
|
-
.sort();
|
|
1810
|
-
subParts.push(`om=${omKeys.join(',')}`);
|
|
1811
|
-
}
|
|
1812
|
-
// where shape (value-invariant)
|
|
1813
|
-
if (opts.where) {
|
|
1814
|
-
// Use a target-table QI if possible, or a simplified fingerprint
|
|
1815
|
-
const wKeys = Object.keys(opts.where)
|
|
1816
|
-
.filter((k) => opts.where[k] !== undefined)
|
|
1817
|
-
.sort();
|
|
1818
|
-
subParts.push(`w=${wKeys.join(',')}`);
|
|
1819
|
-
}
|
|
1820
|
-
// orderBy shape
|
|
1821
|
-
if (opts.orderBy) {
|
|
1822
|
-
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
|
|
1823
|
-
subParts.push(`o=${oEntries.join(',')}`);
|
|
1824
|
-
}
|
|
1825
|
-
// limit presence
|
|
1826
|
-
if (opts.limit !== undefined) {
|
|
1827
|
-
subParts.push('l=1');
|
|
1828
|
-
}
|
|
1829
|
-
// nested with (recurse)
|
|
1830
|
-
if (opts.with) {
|
|
1831
|
-
const nested = this.withFingerprint(opts.with, relDef.to, depth + 1);
|
|
1832
|
-
if (nested)
|
|
1833
|
-
subParts.push(`W=(${nested})`);
|
|
1834
|
-
}
|
|
1835
|
-
parts.push(subParts.length > 0 ? `${relName}/{${subParts.join('/')}}` : relName);
|
|
1836
|
-
}
|
|
1837
|
-
return parts.join('|');
|
|
1838
|
-
}
|
|
1839
|
-
/**
|
|
1840
|
-
* Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
|
|
1841
|
-
* buildRelationSubquery param-push order.
|
|
1842
|
-
*/
|
|
1843
|
-
collectWithParams(withClause, params, table) {
|
|
1844
|
-
const meta = this.schema.tables[table ?? this.table];
|
|
1845
|
-
if (!meta)
|
|
1846
|
-
return;
|
|
1847
|
-
for (const [relName, relSpec] of Object.entries(withClause)) {
|
|
1848
|
-
const relDef = meta.relations[relName];
|
|
1849
|
-
if (!relDef)
|
|
1850
|
-
continue;
|
|
1851
|
-
this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
/**
|
|
1855
|
-
* Collect params from a single relation subquery. Mirrors buildRelationSubquery.
|
|
1856
|
-
*/
|
|
1857
|
-
collectRelationSubqueryParams(relDef, spec, params, _parentRef, depth = 0) {
|
|
1858
|
-
if (spec === true)
|
|
1859
|
-
return; // No params for default include
|
|
1860
|
-
const targetTable = relDef.to;
|
|
1861
|
-
const targetMeta = this.schema.tables[targetTable];
|
|
1862
|
-
if (!targetMeta)
|
|
1863
|
-
return;
|
|
1864
|
-
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
1865
|
-
// Non-wrapped path: nested relations BEFORE where/limit
|
|
1866
|
-
if (!willWrap && spec.with) {
|
|
1867
|
-
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1868
|
-
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1869
|
-
if (!nestedRelDef)
|
|
1870
|
-
continue;
|
|
1871
|
-
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
1872
|
-
}
|
|
1873
|
-
}
|
|
1874
|
-
// where params
|
|
1875
|
-
if (spec.where) {
|
|
1876
|
-
for (const [, v] of Object.entries(spec.where)) {
|
|
1877
|
-
params.push(v);
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
// limit param
|
|
1881
|
-
if (spec.limit) {
|
|
1882
|
-
params.push(Number(spec.limit));
|
|
1883
|
-
}
|
|
1884
|
-
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
1885
|
-
if (willWrap && spec.with) {
|
|
1886
|
-
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1887
|
-
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1888
|
-
if (!nestedRelDef)
|
|
1889
|
-
continue;
|
|
1890
|
-
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
}
|
|
1894
|
-
/**
|
|
1895
|
-
* Fingerprint SET clauses for update/updateMany.
|
|
1896
|
-
* Captures key names + operator types (set/increment/etc) but not values.
|
|
1897
|
-
*/
|
|
1898
|
-
fingerprintSet(data) {
|
|
1899
|
-
const entries = Object.entries(data).filter(([, v]) => v !== undefined);
|
|
1900
|
-
const parts = [];
|
|
1901
|
-
for (const [k, v] of entries) {
|
|
1902
|
-
if (v !== null &&
|
|
1903
|
-
typeof v === 'object' &&
|
|
1904
|
-
!Array.isArray(v) &&
|
|
1905
|
-
!(v instanceof Date) &&
|
|
1906
|
-
!(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
|
|
1907
|
-
const keys = Object.keys(v);
|
|
1908
|
-
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
1909
|
-
parts.push(`${k}:${keys[0]}`);
|
|
1910
|
-
continue;
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
parts.push(`${k}:eq`);
|
|
1914
|
-
}
|
|
1915
|
-
return parts.join(',');
|
|
1916
|
-
}
|
|
1917
|
-
/**
|
|
1918
|
-
* Collect SET params for update/updateMany. Mirrors buildSetClause param order.
|
|
1919
|
-
*/
|
|
1920
|
-
collectSetParams(data, params) {
|
|
1921
|
-
const entries = Object.entries(data).filter(([, v]) => v !== undefined);
|
|
1922
|
-
for (const [, v] of entries) {
|
|
1923
|
-
if (v !== null &&
|
|
1924
|
-
typeof v === 'object' &&
|
|
1925
|
-
!Array.isArray(v) &&
|
|
1926
|
-
!(v instanceof Date) &&
|
|
1927
|
-
!(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
|
|
1928
|
-
const obj = v;
|
|
1929
|
-
const keys = Object.keys(obj);
|
|
1930
|
-
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
1931
|
-
params.push(obj[keys[0]]);
|
|
1932
|
-
continue;
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
params.push(v);
|
|
1936
|
-
}
|
|
1937
|
-
}
|
|
1938
|
-
/** Build WHERE clause from a where object (supports operators, NULL, OR) */
|
|
1939
|
-
buildWhere(where) {
|
|
1940
|
-
const params = [];
|
|
1941
|
-
const clause = this.buildWhereClause(where, params);
|
|
1942
|
-
if (!clause)
|
|
1943
|
-
return { sql: '', params: [] };
|
|
1944
|
-
return { sql: ` WHERE ${clause}`, params };
|
|
1945
|
-
}
|
|
1946
|
-
/**
|
|
1947
|
-
* Refuse mutations with an empty predicate unless explicitly opted in.
|
|
1948
|
-
*
|
|
1949
|
-
* An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
|
|
1950
|
-
* mutation with no filter — a common footgun when a caller's filter
|
|
1951
|
-
* value accidentally resolves to `undefined`. This guard throws
|
|
1952
|
-
* `ValidationError` in that case unless `allowFullTableScan: true`.
|
|
1953
|
-
*/
|
|
1954
|
-
assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
|
|
1955
|
-
if (whereSql.length > 0)
|
|
1956
|
-
return;
|
|
1957
|
-
if (allowFullTableScan === true)
|
|
1958
|
-
return;
|
|
1959
|
-
throw new ValidationError(`[turbine] ${operation} on "${this.table}" refused: the \`where\` clause is empty. ` +
|
|
1960
|
-
`Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
|
|
1961
|
-
}
|
|
1962
|
-
/**
|
|
1963
|
-
* Build the inner WHERE expression (without the WHERE keyword).
|
|
1964
|
-
* Returns null if no conditions exist.
|
|
1965
|
-
* Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
|
|
1966
|
-
*/
|
|
1967
|
-
buildWhereClause(where, params) {
|
|
1968
|
-
const keys = Object.keys(where);
|
|
1969
|
-
if (keys.length === 0)
|
|
1970
|
-
return null;
|
|
1971
|
-
const andClauses = [];
|
|
1972
|
-
for (const key of keys) {
|
|
1973
|
-
const value = where[key];
|
|
1974
|
-
if (value === undefined)
|
|
1975
|
-
continue;
|
|
1976
|
-
// Handle OR special key
|
|
1977
|
-
if (key === 'OR') {
|
|
1978
|
-
const orConditions = value;
|
|
1979
|
-
if (!Array.isArray(orConditions) || orConditions.length === 0)
|
|
1980
|
-
continue;
|
|
1981
|
-
const orClauses = [];
|
|
1982
|
-
for (const orCond of orConditions) {
|
|
1983
|
-
const sub = this.buildWhereClause(orCond, params);
|
|
1984
|
-
if (sub)
|
|
1985
|
-
orClauses.push(sub);
|
|
1986
|
-
}
|
|
1987
|
-
if (orClauses.length > 0) {
|
|
1988
|
-
andClauses.push(`(${orClauses.join(' OR ')})`);
|
|
1989
|
-
}
|
|
1990
|
-
continue;
|
|
1991
|
-
}
|
|
1992
|
-
// Handle AND special key
|
|
1993
|
-
if (key === 'AND') {
|
|
1994
|
-
const andConditions = value;
|
|
1995
|
-
if (!Array.isArray(andConditions) || andConditions.length === 0)
|
|
1996
|
-
continue;
|
|
1997
|
-
for (const andCond of andConditions) {
|
|
1998
|
-
const sub = this.buildWhereClause(andCond, params);
|
|
1999
|
-
if (sub)
|
|
2000
|
-
andClauses.push(sub);
|
|
2001
|
-
}
|
|
2002
|
-
continue;
|
|
2003
|
-
}
|
|
2004
|
-
// Handle NOT special key
|
|
2005
|
-
if (key === 'NOT') {
|
|
2006
|
-
const notCond = value;
|
|
2007
|
-
const sub = this.buildWhereClause(notCond, params);
|
|
2008
|
-
if (sub)
|
|
2009
|
-
andClauses.push(`NOT (${sub})`);
|
|
2010
|
-
continue;
|
|
2011
|
-
}
|
|
2012
|
-
// Handle relation filters: { posts: { some: { published: true } } }
|
|
2013
|
-
const relationDef = this.tableMeta.relations[key];
|
|
2014
|
-
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2015
|
-
const filterObj = value;
|
|
2016
|
-
// Check if this is a relation filter (has some/every/none keys)
|
|
2017
|
-
if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
|
|
2018
|
-
const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
|
|
2019
|
-
if (relClause)
|
|
2020
|
-
andClauses.push(relClause);
|
|
2021
|
-
continue;
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
const rawColumn = this.toColumn(key);
|
|
2025
|
-
const column = quoteIdent(rawColumn);
|
|
2026
|
-
// Handle null → IS NULL
|
|
2027
|
-
if (value === null) {
|
|
2028
|
-
andClauses.push(`${column} IS NULL`);
|
|
2029
|
-
continue;
|
|
2030
|
-
}
|
|
2031
|
-
// Handle JSONB filter operators (for json/jsonb columns)
|
|
2032
|
-
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2033
|
-
const colType = this.getColumnPgType(rawColumn);
|
|
2034
|
-
if (colType === 'json' || colType === 'jsonb') {
|
|
2035
|
-
const jsonClauses = this.buildJsonFilterClauses(column, value, params);
|
|
2036
|
-
andClauses.push(...jsonClauses);
|
|
2037
|
-
continue;
|
|
2038
|
-
}
|
|
2039
|
-
// Strict validation: a JSON-only operator on a non-JSON column was almost
|
|
2040
|
-
// certainly a typo or schema mismatch. Silently falling through to plain
|
|
2041
|
-
// equality (the previous behaviour) wasted hours of debugging time. Only
|
|
2042
|
-
// throw when the operator is unambiguously JSON-specific — `contains` is
|
|
2043
|
-
// shared with WhereOperator's LIKE so it must continue to fall through.
|
|
2044
|
-
const jsonKey = findJsonUniqueKey(value);
|
|
2045
|
-
if (jsonKey) {
|
|
2046
|
-
throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
|
|
2047
|
-
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
// Handle Array filter operators (for array columns)
|
|
2051
|
-
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
2052
|
-
const colType = this.getColumnPgType(rawColumn);
|
|
2053
|
-
if (colType.startsWith('_')) {
|
|
2054
|
-
const arrayClauses = this.buildArrayFilterClauses(column, value, params, colType);
|
|
2055
|
-
andClauses.push(...arrayClauses);
|
|
2056
|
-
continue;
|
|
2057
|
-
}
|
|
2058
|
-
// Strict validation: array operators (`has`, `hasEvery`, ...) on a
|
|
2059
|
-
// non-array column always indicate a mistake. None of these keys
|
|
2060
|
-
// overlap with other filter shapes so we can throw unconditionally.
|
|
2061
|
-
const arrayKey = findArrayUniqueKey(value);
|
|
2062
|
-
if (arrayKey) {
|
|
2063
|
-
throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
|
|
2064
|
-
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
2065
|
-
}
|
|
2066
|
-
}
|
|
2067
|
-
// Handle operator objects
|
|
2068
|
-
if (isWhereOperator(value)) {
|
|
2069
|
-
const opClauses = this.buildOperatorClauses(column, value, params);
|
|
2070
|
-
andClauses.push(...opClauses);
|
|
2071
|
-
continue;
|
|
2072
|
-
}
|
|
2073
|
-
// Plain equality
|
|
2074
|
-
params.push(value);
|
|
2075
|
-
andClauses.push(`${column} = $${params.length}`);
|
|
2076
|
-
}
|
|
2077
|
-
if (andClauses.length === 0)
|
|
2078
|
-
return null;
|
|
2079
|
-
return andClauses.join(' AND ');
|
|
2080
|
-
}
|
|
2081
|
-
/**
|
|
2082
|
-
* Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
|
|
2083
|
-
* Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
|
|
2084
|
-
*/
|
|
2085
|
-
buildRelationFilter(_relName, relDef, filterObj, params) {
|
|
2086
|
-
const targetTable = relDef.to;
|
|
2087
|
-
const targetMeta = this.schema.tables[targetTable];
|
|
2088
|
-
if (!targetMeta)
|
|
2089
|
-
return null;
|
|
2090
|
-
const qt = quoteIdent(targetTable);
|
|
2091
|
-
const qSelf = quoteIdent(this.table);
|
|
2092
|
-
const clauses = [];
|
|
2093
|
-
// Correlation: link child table to parent table
|
|
2094
|
-
let correlation;
|
|
2095
|
-
if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
|
|
2096
|
-
// parent.pk = child.fk
|
|
2097
|
-
correlation = `${qt}.${quoteIdent(relDef.foreignKey)} = ${qSelf}.${quoteIdent(relDef.referenceKey)}`;
|
|
2098
|
-
}
|
|
2099
|
-
else {
|
|
2100
|
-
// belongsTo: parent.fk = child.pk
|
|
2101
|
-
correlation = `${qt}.${quoteIdent(relDef.referenceKey)} = ${qSelf}.${quoteIdent(relDef.foreignKey)}`;
|
|
2102
|
-
}
|
|
2103
|
-
// "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
|
|
2104
|
-
if (filterObj.some !== undefined) {
|
|
2105
|
-
const subWhere = filterObj.some;
|
|
2106
|
-
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2107
|
-
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2108
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2109
|
-
}
|
|
2110
|
-
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
|
|
2111
|
-
if (filterObj.none !== undefined) {
|
|
2112
|
-
const subWhere = filterObj.none;
|
|
2113
|
-
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2114
|
-
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2115
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2116
|
-
}
|
|
2117
|
-
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
|
|
2118
|
-
if (filterObj.every !== undefined) {
|
|
2119
|
-
const subWhere = filterObj.every;
|
|
2120
|
-
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2121
|
-
if (filterClause) {
|
|
2122
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation} AND NOT (${filterClause}))`);
|
|
2123
|
-
}
|
|
2124
|
-
else {
|
|
2125
|
-
// "every" with empty filter = true (all match trivially)
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2129
|
-
}
|
|
2130
|
-
/**
|
|
2131
|
-
* Build WHERE clause conditions for a relation filter subquery.
|
|
2132
|
-
* Uses the target table's column mapping to resolve field names.
|
|
2133
|
-
*/
|
|
2134
|
-
buildSubWhereForRelation(targetTable, subWhere, params) {
|
|
2135
|
-
const meta = this.schema.tables[targetTable];
|
|
2136
|
-
if (!meta)
|
|
2137
|
-
return null;
|
|
2138
|
-
const qt = quoteIdent(targetTable);
|
|
2139
|
-
const conditions = [];
|
|
2140
|
-
for (const [field, value] of Object.entries(subWhere)) {
|
|
2141
|
-
if (value === undefined)
|
|
2142
|
-
continue;
|
|
2143
|
-
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2144
|
-
const qCol = `${qt}.${quoteIdent(col)}`;
|
|
2145
|
-
if (value === null) {
|
|
2146
|
-
conditions.push(`${qCol} IS NULL`);
|
|
2147
|
-
continue;
|
|
2148
|
-
}
|
|
2149
|
-
if (isWhereOperator(value)) {
|
|
2150
|
-
const opClauses = this.buildOperatorClauses(qCol, value, params);
|
|
2151
|
-
conditions.push(...opClauses);
|
|
2152
|
-
continue;
|
|
2153
|
-
}
|
|
2154
|
-
params.push(value);
|
|
2155
|
-
conditions.push(`${qCol} = $${params.length}`);
|
|
2156
|
-
}
|
|
2157
|
-
return conditions.length > 0 ? conditions.join(' AND ') : null;
|
|
2158
|
-
}
|
|
2159
|
-
/**
|
|
2160
|
-
* Build SQL clauses for a single operator object on a column.
|
|
2161
|
-
* Each operator key becomes its own clause, all ANDed together.
|
|
2162
|
-
*/
|
|
2163
|
-
buildOperatorClauses(column, op, params) {
|
|
2164
|
-
const clauses = [];
|
|
2165
|
-
if (op.gt !== undefined) {
|
|
2166
|
-
params.push(op.gt);
|
|
2167
|
-
clauses.push(`${column} > $${params.length}`);
|
|
2168
|
-
}
|
|
2169
|
-
if (op.gte !== undefined) {
|
|
2170
|
-
params.push(op.gte);
|
|
2171
|
-
clauses.push(`${column} >= $${params.length}`);
|
|
2172
|
-
}
|
|
2173
|
-
if (op.lt !== undefined) {
|
|
2174
|
-
params.push(op.lt);
|
|
2175
|
-
clauses.push(`${column} < $${params.length}`);
|
|
2176
|
-
}
|
|
2177
|
-
if (op.lte !== undefined) {
|
|
2178
|
-
params.push(op.lte);
|
|
2179
|
-
clauses.push(`${column} <= $${params.length}`);
|
|
2180
|
-
}
|
|
2181
|
-
if (op.not !== undefined) {
|
|
2182
|
-
if (op.not === null) {
|
|
2183
|
-
clauses.push(`${column} IS NOT NULL`);
|
|
2184
|
-
}
|
|
2185
|
-
else {
|
|
2186
|
-
params.push(op.not);
|
|
2187
|
-
clauses.push(`${column} != $${params.length}`);
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
|
-
if (op.in !== undefined) {
|
|
2191
|
-
params.push(op.in);
|
|
2192
|
-
clauses.push(`${column} = ANY($${params.length})`);
|
|
2193
|
-
}
|
|
2194
|
-
if (op.notIn !== undefined) {
|
|
2195
|
-
params.push(op.notIn);
|
|
2196
|
-
clauses.push(`${column} != ALL($${params.length})`);
|
|
2197
|
-
}
|
|
2198
|
-
// Use ILIKE for case-insensitive mode, LIKE otherwise
|
|
2199
|
-
const likeOp = op.mode === 'insensitive' ? 'ILIKE' : 'LIKE';
|
|
2200
|
-
if (op.contains !== undefined) {
|
|
2201
|
-
params.push(`%${escapeLike(op.contains)}%`);
|
|
2202
|
-
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
2203
|
-
}
|
|
2204
|
-
if (op.startsWith !== undefined) {
|
|
2205
|
-
params.push(`${escapeLike(op.startsWith)}%`);
|
|
2206
|
-
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
2207
|
-
}
|
|
2208
|
-
if (op.endsWith !== undefined) {
|
|
2209
|
-
params.push(`%${escapeLike(op.endsWith)}`);
|
|
2210
|
-
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
2211
|
-
}
|
|
2212
|
-
return clauses;
|
|
2213
|
-
}
|
|
2214
|
-
/** Build ORDER BY clause from an object */
|
|
2215
|
-
buildOrderBy(orderBy) {
|
|
2216
|
-
const meta = this.schema.tables[this.table];
|
|
2217
|
-
return Object.entries(orderBy)
|
|
2218
|
-
.map(([key, dir]) => {
|
|
2219
|
-
if (meta && !(key in meta.columnMap)) {
|
|
2220
|
-
throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
|
|
2221
|
-
}
|
|
2222
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2223
|
-
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
2224
|
-
})
|
|
2225
|
-
.join(', ');
|
|
2226
|
-
}
|
|
2227
|
-
/** Parse a flat row: convert snake_case to camelCase + Date coercion */
|
|
2228
|
-
parseRow(row, table) {
|
|
2229
|
-
const parsed = {};
|
|
2230
|
-
const meta = this.schema.tables[table];
|
|
2231
|
-
if (meta) {
|
|
2232
|
-
// Fast path: use pre-computed maps (avoids regex per column per row)
|
|
2233
|
-
const reverseMap = meta.reverseColumnMap;
|
|
2234
|
-
const dateCols = meta.dateColumns;
|
|
2235
|
-
const keys = Object.keys(row);
|
|
2236
|
-
for (let i = 0; i < keys.length; i++) {
|
|
2237
|
-
const col = keys[i];
|
|
2238
|
-
const value = row[col];
|
|
2239
|
-
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
2240
|
-
if (dateCols.has(col) && value !== null && !(value instanceof Date)) {
|
|
2241
|
-
parsed[field] = new Date(value);
|
|
2242
|
-
}
|
|
2243
|
-
else {
|
|
2244
|
-
parsed[field] = value;
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
}
|
|
2248
|
-
else {
|
|
2249
|
-
// Fallback: no metadata, use regex conversion
|
|
2250
|
-
for (const [col, value] of Object.entries(row)) {
|
|
2251
|
-
parsed[snakeToCamel(col)] = value;
|
|
2252
|
-
}
|
|
2253
|
-
}
|
|
2254
|
-
return parsed;
|
|
2255
|
-
}
|
|
2256
|
-
/** Parse a row that may contain JSON nested relation columns */
|
|
2257
|
-
parseNestedRow(row, table) {
|
|
2258
|
-
const parsed = this.parseRow(row, table);
|
|
2259
|
-
const meta = this.schema.tables[table];
|
|
2260
|
-
if (!meta)
|
|
2261
|
-
return parsed;
|
|
2262
|
-
for (const [relName, relDef] of Object.entries(meta.relations)) {
|
|
2263
|
-
const rawValue = row[relName];
|
|
2264
|
-
if (rawValue === undefined)
|
|
2265
|
-
continue;
|
|
2266
|
-
// --- Short-circuit: skip JSON.parse for common empty/null cases ---
|
|
2267
|
-
// hasMany returns '[]' (from COALESCE(..., '[]'::json)); belongsTo/hasOne returns null
|
|
2268
|
-
if (rawValue === null || rawValue === 'null') {
|
|
2269
|
-
parsed[relName] = null;
|
|
2270
|
-
continue;
|
|
2271
|
-
}
|
|
2272
|
-
if (rawValue === '[]') {
|
|
2273
|
-
parsed[relName] = [];
|
|
2274
|
-
continue;
|
|
2275
|
-
}
|
|
2276
|
-
if (Array.isArray(rawValue) && rawValue.length === 0) {
|
|
2277
|
-
parsed[relName] = [];
|
|
2278
|
-
continue;
|
|
2279
|
-
}
|
|
2280
|
-
// --- Non-empty values: full parse path ---
|
|
2281
|
-
if (typeof rawValue === 'string') {
|
|
2282
|
-
try {
|
|
2283
|
-
const jsonVal = JSON.parse(rawValue);
|
|
2284
|
-
// After parsing, apply parseRow to each item for snake→camel + date coercion
|
|
2285
|
-
if (Array.isArray(jsonVal)) {
|
|
2286
|
-
parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
|
|
2287
|
-
? this.parseRow(item, relDef.to)
|
|
2288
|
-
: item);
|
|
2289
|
-
}
|
|
2290
|
-
else if (typeof jsonVal === 'object' && jsonVal !== null) {
|
|
2291
|
-
parsed[relName] = this.parseRow(jsonVal, relDef.to);
|
|
2292
|
-
}
|
|
2293
|
-
else {
|
|
2294
|
-
parsed[relName] = jsonVal;
|
|
2295
|
-
}
|
|
2296
|
-
}
|
|
2297
|
-
catch {
|
|
2298
|
-
console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
|
|
2299
|
-
parsed[relName] = rawValue;
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
|
-
else if (Array.isArray(rawValue)) {
|
|
2303
|
-
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null ? this.parseRow(item, relDef.to) : item);
|
|
2304
|
-
}
|
|
2305
|
-
else if (typeof rawValue === 'object' && rawValue !== null) {
|
|
2306
|
-
parsed[relName] = this.parseRow(rawValue, relDef.to);
|
|
2307
|
-
}
|
|
2308
|
-
else {
|
|
2309
|
-
parsed[relName] = rawValue;
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
return parsed;
|
|
2313
|
-
}
|
|
2314
|
-
/**
|
|
2315
|
-
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
2316
|
-
*
|
|
2317
|
-
* For each relation specified in the `with` clause, this method generates a correlated
|
|
2318
|
-
* subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
|
|
2319
|
-
* is a single SQL SELECT clause that resolves the full object tree in one query --
|
|
2320
|
-
* no N+1 problem.
|
|
2321
|
-
*
|
|
2322
|
-
* **How it works:**
|
|
2323
|
-
* 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
|
|
2324
|
-
* 2. Iterates over each key in the `with` clause, looking up the relation definition.
|
|
2325
|
-
* 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
|
|
2326
|
-
* correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
|
|
2327
|
-
* 4. Each subquery is aliased as the relation name in the final SELECT.
|
|
2328
|
-
*
|
|
2329
|
-
* **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
|
|
2330
|
-
* Each call to `buildRelationSubquery` increments it to produce unique table aliases
|
|
2331
|
-
* (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
|
|
2332
|
-
* collisions in the generated SQL.
|
|
2333
|
-
*
|
|
2334
|
-
* **Example output:**
|
|
2335
|
-
* ```sql
|
|
2336
|
-
* "users"."id", "users"."name", "users"."email",
|
|
2337
|
-
* (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
|
|
2338
|
-
* FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
|
|
2339
|
-
* ```
|
|
2340
|
-
*
|
|
2341
|
-
* @param table - The root table name (e.g. `"users"`).
|
|
2342
|
-
* @param withClause - An object mapping relation names to their include specs
|
|
2343
|
-
* (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
|
|
2344
|
-
* @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
|
|
2345
|
-
* Nested where/limit values are pushed here to prevent SQL injection.
|
|
2346
|
-
* @param columnsList - Optional subset of columns to include in the SELECT. When `null`
|
|
2347
|
-
* or omitted, all columns from the table's schema metadata are used.
|
|
2348
|
-
* @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
|
|
2349
|
-
* for circular-relation detection. Defaults to `0` at the top level.
|
|
2350
|
-
* @param path - Breadcrumb trail of relation names traversed so far, used in error
|
|
2351
|
-
* messages when circular or too-deep nesting is detected.
|
|
2352
|
-
* @returns A complete SELECT clause string (without the `SELECT` keyword) containing
|
|
2353
|
-
* base columns and relation subqueries.
|
|
2354
|
-
*/
|
|
2355
|
-
buildSelectWithRelations(table, withClause, params, columnsList, depth, path) {
|
|
2356
|
-
const meta = this.schema.tables[table];
|
|
2357
|
-
if (!meta)
|
|
2358
|
-
throw new ValidationError(`[turbine] Unknown table "${table}"`);
|
|
2359
|
-
const cols = columnsList ?? meta.allColumns;
|
|
2360
|
-
const qtbl = quoteIdent(table);
|
|
2361
|
-
const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
|
|
2362
|
-
const relationSelects = [];
|
|
2363
|
-
const aliasCounter = { n: 0 };
|
|
2364
|
-
for (const [relName, relSpec] of Object.entries(withClause)) {
|
|
2365
|
-
const relDef = meta.relations[relName];
|
|
2366
|
-
if (!relDef) {
|
|
2367
|
-
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
2368
|
-
`Available: ${Object.keys(meta.relations).join(', ')}`);
|
|
2369
|
-
}
|
|
2370
|
-
// The main table is not aliased, so pass table name as parentRef
|
|
2371
|
-
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
2372
|
-
relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
|
|
2373
|
-
}
|
|
2374
|
-
return [baseCols, ...relationSelects].join(', ');
|
|
2375
|
-
}
|
|
2376
|
-
/**
|
|
2377
|
-
* Generate a correlated subquery that returns JSON for a single relation.
|
|
2378
|
-
*
|
|
2379
|
-
* This is the core of Turbine's single-query nested relation strategy. For a given
|
|
2380
|
-
* relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
|
|
2381
|
-
* that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
|
|
2382
|
-
* a single JSON object (belongsTo / hasOne).
|
|
2383
|
-
*
|
|
2384
|
-
* ### Algorithm overview
|
|
2385
|
-
*
|
|
2386
|
-
* 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
|
|
2387
|
-
* `aliasCounter` so that deeply nested subqueries never collide.
|
|
2388
|
-
*
|
|
2389
|
-
* 2. **Column resolution:** Honors `select` / `omit` options to control which columns
|
|
2390
|
-
* appear in the output JSON.
|
|
2391
|
-
*
|
|
2392
|
-
* 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
|
|
2393
|
-
* field names to their column values:
|
|
2394
|
-
* ```sql
|
|
2395
|
-
* json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
|
|
2396
|
-
* ```
|
|
2397
|
-
*
|
|
2398
|
-
* 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
|
|
2399
|
-
* `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
|
|
2400
|
-
* into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
|
|
2401
|
-
* For belongsTo / hasOne, no aggregation is used -- just the single JSON object
|
|
2402
|
-
* with `LIMIT 1`.
|
|
2403
|
-
*
|
|
2404
|
-
* 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
|
|
2405
|
-
* - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
|
|
2406
|
-
* (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
|
|
2407
|
-
* - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
|
|
2408
|
-
* (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
|
|
2409
|
-
*
|
|
2410
|
-
* 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
|
|
2411
|
-
* itself recursively for each nested relation, passing the current alias as
|
|
2412
|
-
* `parentRef`. The nested subquery appears as an additional key in the
|
|
2413
|
-
* `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
|
|
2414
|
-
* Depth is incremented and capped at 10 to guard against circular relations.
|
|
2415
|
-
*
|
|
2416
|
-
* 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
|
|
2417
|
-
* the query is restructured into a two-level form:
|
|
2418
|
-
* ```sql
|
|
2419
|
-
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
2420
|
-
* FROM (
|
|
2421
|
-
* SELECT t0.* FROM "posts" t0
|
|
2422
|
-
* WHERE t0."user_id" = "users"."id"
|
|
2423
|
-
* ORDER BY t0."created_at" DESC
|
|
2424
|
-
* LIMIT $1
|
|
2425
|
-
* ) t0i
|
|
2426
|
-
* ```
|
|
2427
|
-
* This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
|
|
2428
|
-
* aggregation. Without the inner subquery, LIMIT would be meaningless because
|
|
2429
|
-
* `json_agg` produces a single aggregated row.
|
|
2430
|
-
*
|
|
2431
|
-
* 8. **Parameter threading:** All user-supplied values (where filters, limit) are
|
|
2432
|
-
* pushed to the shared `params` array with `$N` placeholders. No string
|
|
2433
|
-
* interpolation of user data ever occurs -- all identifiers go through
|
|
2434
|
-
* `quoteIdent()` and all values are parameterized.
|
|
2435
|
-
*
|
|
2436
|
-
* ### Example output (hasMany with nested relation)
|
|
2437
|
-
* ```sql
|
|
2438
|
-
* SELECT COALESCE(json_agg(json_build_object(
|
|
2439
|
-
* 'id', t0."id",
|
|
2440
|
-
* 'title', t0."title",
|
|
2441
|
-
* 'comments', COALESCE((
|
|
2442
|
-
* SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
|
|
2443
|
-
* FROM "comments" t1 WHERE t1."post_id" = t0."id"
|
|
2444
|
-
* ), '[]'::json)
|
|
2445
|
-
* )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
|
|
2446
|
-
* ```
|
|
2447
|
-
*
|
|
2448
|
-
* @param relDef - The relation definition from schema metadata (contains `to`, `type`,
|
|
2449
|
-
* `foreignKey`, `referenceKey`).
|
|
2450
|
-
* @param spec - Either `true` (include with defaults) or a `WithOptions` object that
|
|
2451
|
-
* can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
|
|
2452
|
-
* @param params - Shared parameter array. User-supplied values are pushed here and
|
|
2453
|
-
* referenced as `$1`, `$2`, etc. in the generated SQL.
|
|
2454
|
-
* @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
|
|
2455
|
-
* parent query. Used to build the correlated WHERE clause that ties
|
|
2456
|
-
* child rows to their parent row.
|
|
2457
|
-
* @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
|
|
2458
|
-
* table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
|
|
2459
|
-
* Each call increments `n` by 1.
|
|
2460
|
-
* @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
|
|
2461
|
-
* call. If it reaches 10, a {@link CircularRelationError} is thrown.
|
|
2462
|
-
* @param path - Breadcrumb trail of relation/table names traversed so far
|
|
2463
|
-
* (e.g. `["users", "posts", "comments"]`). Used in the error message
|
|
2464
|
-
* when circular or too-deep nesting is detected.
|
|
2465
|
-
* @returns A complete SQL subquery string (without surrounding parentheses) that
|
|
2466
|
-
* evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
|
|
2467
|
-
*/
|
|
2468
|
-
buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter, depth, path) {
|
|
2469
|
-
const currentDepth = depth ?? 0;
|
|
2470
|
-
const currentPath = path ?? [this.table];
|
|
2471
|
-
const targetTable = relDef.to;
|
|
2472
|
-
// Hard depth cap — the `with` clause is a finite JSON structure so users can't
|
|
2473
|
-
// create true infinite recursion, but extremely deep nesting (10+ levels) produces
|
|
2474
|
-
// unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
|
|
2475
|
-
// since they are legitimate queries (Prisma supports the same pattern).
|
|
2476
|
-
if (currentDepth >= 10) {
|
|
2477
|
-
throw new CircularRelationError([...currentPath, targetTable]);
|
|
2478
|
-
}
|
|
2479
|
-
const targetMeta = this.schema.tables[targetTable];
|
|
2480
|
-
if (!targetMeta)
|
|
2481
|
-
throw new RelationError(`[turbine] Unknown relation target "${targetTable}"`);
|
|
2482
|
-
// Generate a unique alias: t0, t1, t2, ...
|
|
2483
|
-
const alias = `t${aliasCounter.n++}`;
|
|
2484
|
-
// Resolve which columns to include based on select/omit
|
|
2485
|
-
let targetColumns = targetMeta.allColumns;
|
|
2486
|
-
if (spec !== true && spec.select) {
|
|
2487
|
-
const selectedFields = Object.entries(spec.select)
|
|
2488
|
-
.filter(([, v]) => v)
|
|
2489
|
-
.map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k));
|
|
2490
|
-
targetColumns = selectedFields.filter((col) => targetMeta.allColumns.includes(col));
|
|
2491
|
-
}
|
|
2492
|
-
else if (spec !== true && spec.omit) {
|
|
2493
|
-
const omittedFields = new Set(Object.entries(spec.omit)
|
|
2494
|
-
.filter(([, v]) => v)
|
|
2495
|
-
.map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
|
|
2496
|
-
targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
2497
|
-
}
|
|
2498
|
-
// Build json_build_object pairs for resolved columns
|
|
2499
|
-
const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${alias}.${quoteIdent(col)}`);
|
|
2500
|
-
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
2501
|
-
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
2502
|
-
// so we must NOT build them here (they would push orphaned params).
|
|
2503
|
-
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
2504
|
-
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
2505
|
-
if (!willWrap && spec !== true && spec.with) {
|
|
2506
|
-
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
2507
|
-
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2508
|
-
if (!nestedRelDef) {
|
|
2509
|
-
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
2510
|
-
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
2511
|
-
}
|
|
2512
|
-
// Recursively build nested subquery, passing THIS alias as the parent reference
|
|
2513
|
-
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
2514
|
-
// Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
|
|
2515
|
-
const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
|
|
2516
|
-
jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), ${fallback})`);
|
|
2517
|
-
}
|
|
2518
|
-
}
|
|
2519
|
-
const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
|
|
2520
|
-
// Quote parent ref — can be a table name or auto-generated alias
|
|
2521
|
-
const qParent = quoteIdent(parentRef);
|
|
2522
|
-
const qTarget = quoteIdent(targetTable);
|
|
2523
|
-
// Build ORDER BY for json_agg
|
|
2524
|
-
let orderClause = '';
|
|
2525
|
-
if (spec !== true && spec.orderBy) {
|
|
2526
|
-
const orders = Object.entries(spec.orderBy)
|
|
2527
|
-
.map(([k, dir]) => {
|
|
2528
|
-
const col = camelToSnake(k);
|
|
2529
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
2530
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
2531
|
-
}
|
|
2532
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2533
|
-
return `${alias}.${quoteIdent(col)} ${safeDir}`;
|
|
2534
|
-
})
|
|
2535
|
-
.join(', ');
|
|
2536
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
2537
|
-
}
|
|
2538
|
-
// Build WHERE — correlate to parent via parentRef (alias or table name).
|
|
2539
|
-
// For hasMany: target has FK, so alias.fk = parentRef.pk
|
|
2540
|
-
// For belongsTo: source has FK, so alias.pk = parentRef.fk (reversed)
|
|
2541
|
-
let whereClause;
|
|
2542
|
-
if (relDef.type === 'belongsTo' || relDef.type === 'hasOne') {
|
|
2543
|
-
whereClause = `${alias}.${quoteIdent(relDef.referenceKey)} = ${qParent}.${quoteIdent(relDef.foreignKey)}`;
|
|
2544
|
-
}
|
|
2545
|
-
else {
|
|
2546
|
-
whereClause = `${alias}.${quoteIdent(relDef.foreignKey)} = ${qParent}.${quoteIdent(relDef.referenceKey)}`;
|
|
2547
|
-
}
|
|
2548
|
-
// Additional filters — properly parameterized
|
|
2549
|
-
if (spec !== true && spec.where) {
|
|
2550
|
-
for (const [k, v] of Object.entries(spec.where)) {
|
|
2551
|
-
const col = camelToSnake(k);
|
|
2552
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
2553
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
2554
|
-
}
|
|
2555
|
-
params.push(v);
|
|
2556
|
-
whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
|
|
2557
|
-
}
|
|
2558
|
-
}
|
|
2559
|
-
// LIMIT
|
|
2560
|
-
let limitClause = '';
|
|
2561
|
-
if (spec !== true && spec.limit) {
|
|
2562
|
-
params.push(Number(spec.limit));
|
|
2563
|
-
limitClause = ` LIMIT $${params.length}`;
|
|
2564
|
-
}
|
|
2565
|
-
if (relDef.type === 'hasMany') {
|
|
2566
|
-
// When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
|
|
2567
|
-
// BEFORE json_agg aggregation (otherwise LIMIT on aggregated result is meaningless)
|
|
2568
|
-
if (limitClause || orderClause) {
|
|
2569
|
-
const innerAlias = `${alias}i`;
|
|
2570
|
-
// Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
|
|
2571
|
-
// Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
|
|
2572
|
-
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
2573
|
-
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
2574
|
-
const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${innerAlias}.${quoteIdent(col)}`);
|
|
2575
|
-
// Build nested relation subqueries referencing innerAlias
|
|
2576
|
-
if (spec !== true && spec.with) {
|
|
2577
|
-
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
2578
|
-
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2579
|
-
if (!nestedRelDef) {
|
|
2580
|
-
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
2581
|
-
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
2582
|
-
}
|
|
2583
|
-
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
2584
|
-
const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
|
|
2585
|
-
innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), ${fallback})`);
|
|
2586
|
-
}
|
|
2587
|
-
}
|
|
2588
|
-
const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
|
|
2589
|
-
return `SELECT COALESCE(json_agg(${innerJsonObj}), '[]'::json) FROM (${innerSql}) ${innerAlias}`;
|
|
2590
|
-
}
|
|
2591
|
-
return `SELECT COALESCE(json_agg(${jsonObj}${orderClause}), '[]'::json) FROM ${qTarget} ${alias} WHERE ${whereClause}`;
|
|
2592
|
-
}
|
|
2593
|
-
// belongsTo / hasOne — return single object
|
|
2594
|
-
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
|
|
2595
|
-
}
|
|
2596
|
-
/**
|
|
2597
|
-
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
2598
|
-
* Used to detect JSONB/array columns for specialized operators.
|
|
2599
|
-
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
2600
|
-
*/
|
|
2601
|
-
getColumnPgType(column) {
|
|
2602
|
-
return this.columnPgTypeMap.get(column) ?? 'text';
|
|
2603
|
-
}
|
|
2604
|
-
/**
|
|
2605
|
-
* Get the Postgres base element type for an array column.
|
|
2606
|
-
* E.g. '_text' → 'text', '_int4' → 'integer'
|
|
2607
|
-
*/
|
|
2608
|
-
getArrayElementType(pgType) {
|
|
2609
|
-
const baseType = pgType.startsWith('_') ? pgType.slice(1) : pgType;
|
|
2610
|
-
const typeMap = {
|
|
2611
|
-
int2: 'smallint',
|
|
2612
|
-
int4: 'integer',
|
|
2613
|
-
int8: 'bigint',
|
|
2614
|
-
float4: 'real',
|
|
2615
|
-
float8: 'double precision',
|
|
2616
|
-
bool: 'boolean',
|
|
2617
|
-
text: 'text',
|
|
2618
|
-
varchar: 'text',
|
|
2619
|
-
uuid: 'uuid',
|
|
2620
|
-
timestamptz: 'timestamptz',
|
|
2621
|
-
timestamp: 'timestamp',
|
|
2622
|
-
jsonb: 'jsonb',
|
|
2623
|
-
json: 'json',
|
|
2624
|
-
};
|
|
2625
|
-
return typeMap[baseType] ?? 'text';
|
|
2626
|
-
}
|
|
2627
|
-
/**
|
|
2628
|
-
* Build SQL clauses for JSONB filter operators on a column.
|
|
2629
|
-
* Supports: path, equals, contains, hasKey.
|
|
2630
|
-
*/
|
|
2631
|
-
buildJsonFilterClauses(column, filter, params) {
|
|
2632
|
-
const clauses = [];
|
|
2633
|
-
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
2634
|
-
// Path access + equals: column #>> $N::text[] = $M
|
|
2635
|
-
params.push(filter.path);
|
|
2636
|
-
const pathParam = params.length;
|
|
2637
|
-
params.push(String(filter.equals));
|
|
2638
|
-
clauses.push(`${column} #>> $${pathParam}::text[] = $${params.length}`);
|
|
2639
|
-
}
|
|
2640
|
-
else if (filter.equals !== undefined) {
|
|
2641
|
-
// Containment equality: column @> $N::jsonb
|
|
2642
|
-
params.push(JSON.stringify(filter.equals));
|
|
2643
|
-
clauses.push(`${column} @> $${params.length}::jsonb`);
|
|
2644
|
-
}
|
|
2645
|
-
if (filter.contains !== undefined) {
|
|
2646
|
-
// Containment: column @> $N::jsonb
|
|
2647
|
-
params.push(JSON.stringify(filter.contains));
|
|
2648
|
-
clauses.push(`${column} @> $${params.length}::jsonb`);
|
|
2649
|
-
}
|
|
2650
|
-
if (filter.hasKey !== undefined) {
|
|
2651
|
-
// Key existence: column ? $N
|
|
2652
|
-
params.push(filter.hasKey);
|
|
2653
|
-
clauses.push(`${column} ? $${params.length}`);
|
|
2654
|
-
}
|
|
2655
|
-
return clauses;
|
|
2656
|
-
}
|
|
2657
|
-
/**
|
|
2658
|
-
* Build SQL clauses for Array filter operators on a column.
|
|
2659
|
-
* Supports: has, hasEvery, hasSome, isEmpty.
|
|
2660
|
-
*/
|
|
2661
|
-
buildArrayFilterClauses(column, filter, params, pgType) {
|
|
2662
|
-
const clauses = [];
|
|
2663
|
-
const elementType = this.getArrayElementType(pgType);
|
|
2664
|
-
if (filter.has !== undefined) {
|
|
2665
|
-
// value = ANY(column)
|
|
2666
|
-
params.push(filter.has);
|
|
2667
|
-
clauses.push(`$${params.length} = ANY(${column})`);
|
|
2668
|
-
}
|
|
2669
|
-
if (filter.hasEvery !== undefined) {
|
|
2670
|
-
// column @> ARRAY[...]::type[]
|
|
2671
|
-
params.push(filter.hasEvery);
|
|
2672
|
-
clauses.push(`${column} @> $${params.length}::${elementType}[]`);
|
|
2673
|
-
}
|
|
2674
|
-
if (filter.hasSome !== undefined) {
|
|
2675
|
-
// column && ARRAY[...]::type[]
|
|
2676
|
-
params.push(filter.hasSome);
|
|
2677
|
-
clauses.push(`${column} && $${params.length}::${elementType}[]`);
|
|
2678
|
-
}
|
|
2679
|
-
if (filter.isEmpty === true) {
|
|
2680
|
-
// array_length(column, 1) IS NULL
|
|
2681
|
-
clauses.push(`array_length(${column}, 1) IS NULL`);
|
|
2682
|
-
}
|
|
2683
|
-
else if (filter.isEmpty === false) {
|
|
2684
|
-
// array_length(column, 1) IS NOT NULL
|
|
2685
|
-
clauses.push(`array_length(${column}, 1) IS NOT NULL`);
|
|
2686
|
-
}
|
|
2687
|
-
return clauses;
|
|
2688
|
-
}
|
|
2689
|
-
/**
|
|
2690
|
-
* Get the Postgres array type for a column (used by UNNEST in createMany).
|
|
2691
|
-
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
2692
|
-
*/
|
|
2693
|
-
getColumnArrayType(column) {
|
|
2694
|
-
const arrayType = this.columnArrayTypeMap.get(column);
|
|
2695
|
-
if (arrayType)
|
|
2696
|
-
return arrayType;
|
|
2697
|
-
// Fallback heuristic for unknown columns
|
|
2698
|
-
if (column === 'id' || column.endsWith('_id'))
|
|
2699
|
-
return 'bigint[]';
|
|
2700
|
-
if (column.endsWith('_at'))
|
|
2701
|
-
return 'timestamptz[]';
|
|
2702
|
-
return 'text[]';
|
|
2703
|
-
}
|
|
2704
|
-
}
|
|
2705
|
-
//# sourceMappingURL=query.js.map
|