turbine-orm 0.5.0 → 0.7.1
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 +292 -26
- package/dist/cjs/cli/config.js +5 -15
- package/dist/cjs/cli/index.js +311 -43
- package/dist/cjs/cli/loader.js +129 -0
- package/dist/cjs/cli/migrate.js +96 -47
- package/dist/cjs/cli/ui.js +5 -9
- package/dist/cjs/client.js +158 -49
- package/dist/cjs/errors.js +424 -0
- package/dist/cjs/generate.js +145 -14
- package/dist/cjs/index.js +43 -20
- package/dist/cjs/introspect.js +3 -5
- package/dist/cjs/pipeline.js +9 -2
- package/dist/cjs/query.js +544 -115
- package/dist/cjs/schema-builder.js +150 -30
- package/dist/cjs/schema-sql.js +241 -37
- package/dist/cjs/schema.js +5 -2
- package/dist/cjs/serverless.js +88 -176
- package/dist/cli/config.js +6 -16
- package/dist/cli/index.js +316 -48
- package/dist/cli/loader.d.ts +45 -0
- package/dist/cli/loader.js +91 -0
- package/dist/cli/migrate.d.ts +13 -2
- package/dist/cli/migrate.js +97 -48
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +5 -9
- package/dist/client.d.ts +92 -4
- package/dist/client.js +158 -49
- package/dist/errors.d.ts +225 -0
- package/dist/errors.js +405 -0
- package/dist/generate.d.ts +7 -1
- package/dist/generate.js +148 -18
- package/dist/index.d.ts +11 -9
- package/dist/index.js +16 -12
- package/dist/introspect.d.ts +1 -1
- package/dist/introspect.js +4 -6
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +9 -2
- package/dist/query.d.ts +374 -38
- package/dist/query.js +545 -116
- package/dist/schema-builder.d.ts +38 -5
- package/dist/schema-builder.js +150 -31
- package/dist/schema-sql.d.ts +7 -3
- package/dist/schema-sql.js +241 -37
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +5 -2
- package/dist/serverless.d.ts +92 -139
- package/dist/serverless.js +87 -173
- package/package.json +33 -16
- package/dist/types.d.ts +0 -93
- package/dist/types.js +0 -126
package/dist/query.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* turbine-orm — Query builder
|
|
3
3
|
*
|
|
4
4
|
* Each table accessor (db.users, db.posts, etc.) returns a QueryInterface<T>
|
|
5
5
|
* that builds parameterized SQL and executes it through the connection pool.
|
|
6
6
|
*
|
|
7
7
|
* Nested relations use json_build_object + json_agg subqueries for single-query
|
|
8
|
-
* resolution —
|
|
8
|
+
* resolution — a PostgreSQL-native approach that eliminates N+1 query patterns.
|
|
9
9
|
*
|
|
10
10
|
* Schema-driven: all column names, types, and relations come from introspected
|
|
11
11
|
* metadata — nothing is hardcoded.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { CircularRelationError, NotFoundError, RelationError, TimeoutError, ValidationError, wrapPgError, } from './errors.js';
|
|
14
|
+
import { camelToSnake, snakeToCamel } from './schema.js';
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Identifier quoting — prevents SQL injection via table/column names
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
@@ -42,37 +43,98 @@ function escapeLike(value) {
|
|
|
42
43
|
}
|
|
43
44
|
/** Known operator keys — used to detect operator objects vs plain values */
|
|
44
45
|
const OPERATOR_KEYS = new Set([
|
|
45
|
-
'gt',
|
|
46
|
-
'
|
|
46
|
+
'gt',
|
|
47
|
+
'gte',
|
|
48
|
+
'lt',
|
|
49
|
+
'lte',
|
|
50
|
+
'not',
|
|
51
|
+
'in',
|
|
52
|
+
'notIn',
|
|
53
|
+
'contains',
|
|
54
|
+
'startsWith',
|
|
55
|
+
'endsWith',
|
|
56
|
+
'mode',
|
|
47
57
|
]);
|
|
48
58
|
/** Check if a value is a where operator object (has at least one known operator key) */
|
|
49
59
|
function isWhereOperator(value) {
|
|
50
|
-
if (value === null ||
|
|
60
|
+
if (value === null ||
|
|
61
|
+
value === undefined ||
|
|
62
|
+
typeof value !== 'object' ||
|
|
63
|
+
Array.isArray(value) ||
|
|
64
|
+
value instanceof Date) {
|
|
51
65
|
return false;
|
|
52
66
|
}
|
|
53
67
|
const keys = Object.keys(value);
|
|
54
68
|
return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
|
|
55
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']);
|
|
56
72
|
/** Known JSONB operator keys */
|
|
57
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']);
|
|
58
82
|
/** Check if a value is a JSONB filter object */
|
|
59
83
|
function isJsonFilter(value) {
|
|
60
|
-
if (value === null ||
|
|
84
|
+
if (value === null ||
|
|
85
|
+
value === undefined ||
|
|
86
|
+
typeof value !== 'object' ||
|
|
87
|
+
Array.isArray(value) ||
|
|
88
|
+
value instanceof Date) {
|
|
61
89
|
return false;
|
|
62
90
|
}
|
|
63
91
|
const keys = Object.keys(value);
|
|
64
92
|
return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
|
|
65
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
|
+
}
|
|
66
105
|
/** Known Array operator keys */
|
|
67
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']);
|
|
68
115
|
/** Check if a value is an Array filter object */
|
|
69
116
|
function isArrayFilter(value) {
|
|
70
|
-
if (value === null ||
|
|
117
|
+
if (value === null ||
|
|
118
|
+
value === undefined ||
|
|
119
|
+
typeof value !== 'object' ||
|
|
120
|
+
Array.isArray(value) ||
|
|
121
|
+
value instanceof Date) {
|
|
71
122
|
return false;
|
|
72
123
|
}
|
|
73
124
|
const keys = Object.keys(value);
|
|
74
125
|
return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
|
|
75
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
|
+
}
|
|
76
138
|
// ---------------------------------------------------------------------------
|
|
77
139
|
// LRU cache — bounded SQL template cache to prevent memory leaks
|
|
78
140
|
// ---------------------------------------------------------------------------
|
|
@@ -108,7 +170,9 @@ class LRUCache {
|
|
|
108
170
|
}
|
|
109
171
|
this.cache.set(key, value);
|
|
110
172
|
}
|
|
111
|
-
get size() {
|
|
173
|
+
get size() {
|
|
174
|
+
return this.cache.size;
|
|
175
|
+
}
|
|
112
176
|
}
|
|
113
177
|
export class QueryInterface {
|
|
114
178
|
pool;
|
|
@@ -120,6 +184,15 @@ export class QueryInterface {
|
|
|
120
184
|
middlewares;
|
|
121
185
|
defaultLimit;
|
|
122
186
|
warnOnUnlimited;
|
|
187
|
+
/**
|
|
188
|
+
* Tracks tables that have already triggered an unlimited-query warning so
|
|
189
|
+
* the user is not spammed once per row. Per-instance state — each
|
|
190
|
+
* QueryInterface is bound to a single table, so this set will only ever
|
|
191
|
+
* contain at most one entry, but using a Set keeps the API consistent with
|
|
192
|
+
* the audit's "Set<string>" guidance and leaves room for future
|
|
193
|
+
* cross-table sharing.
|
|
194
|
+
*/
|
|
195
|
+
warnedTables = new Set();
|
|
123
196
|
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
124
197
|
columnPgTypeMap;
|
|
125
198
|
columnArrayTypeMap;
|
|
@@ -129,12 +202,15 @@ export class QueryInterface {
|
|
|
129
202
|
this.schema = schema;
|
|
130
203
|
const meta = schema.tables[table];
|
|
131
204
|
if (!meta) {
|
|
132
|
-
throw new
|
|
205
|
+
throw new ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
|
|
133
206
|
}
|
|
134
207
|
this.tableMeta = meta;
|
|
135
208
|
this.middlewares = middlewares ?? [];
|
|
136
209
|
this.defaultLimit = options?.defaultLimit;
|
|
137
|
-
|
|
210
|
+
// Default to ON: surfacing accidental full-table scans is more valuable
|
|
211
|
+
// than the (small) risk of noisy logs. Callers explicitly opt out with
|
|
212
|
+
// `warnOnUnlimited: false`.
|
|
213
|
+
this.warnOnUnlimited = options?.warnOnUnlimited !== false;
|
|
138
214
|
// Pre-compute column type lookup maps (TASK-26)
|
|
139
215
|
this.columnPgTypeMap = new Map();
|
|
140
216
|
this.columnArrayTypeMap = new Map();
|
|
@@ -143,21 +219,38 @@ export class QueryInterface {
|
|
|
143
219
|
this.columnArrayTypeMap.set(col.name, col.pgArrayType);
|
|
144
220
|
}
|
|
145
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Reset the per-instance unlimited-query warning dedupe set.
|
|
224
|
+
* Exposed for tests so a single test process can verify the warning fires
|
|
225
|
+
* exactly once per table without bleeding state between assertions.
|
|
226
|
+
*/
|
|
227
|
+
resetUnlimitedWarnings() {
|
|
228
|
+
this.warnedTables.clear();
|
|
229
|
+
}
|
|
146
230
|
/**
|
|
147
231
|
* Execute a pool.query with an optional timeout.
|
|
148
232
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
233
|
+
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
149
234
|
*/
|
|
150
235
|
async queryWithTimeout(sql, params, timeout) {
|
|
151
236
|
if (!timeout) {
|
|
152
|
-
|
|
237
|
+
try {
|
|
238
|
+
return await this.pool.query(sql, params);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
throw wrapPgError(err);
|
|
242
|
+
}
|
|
153
243
|
}
|
|
154
244
|
let timer;
|
|
155
245
|
const timeoutPromise = new Promise((_, reject) => {
|
|
156
|
-
timer = setTimeout(() => reject(new
|
|
246
|
+
timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
|
|
157
247
|
});
|
|
158
248
|
try {
|
|
159
249
|
return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
|
|
160
250
|
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
throw wrapPgError(err);
|
|
253
|
+
}
|
|
161
254
|
finally {
|
|
162
255
|
clearTimeout(timer);
|
|
163
256
|
}
|
|
@@ -188,18 +281,6 @@ export class QueryInterface {
|
|
|
188
281
|
};
|
|
189
282
|
return next(params);
|
|
190
283
|
}
|
|
191
|
-
/**
|
|
192
|
-
* Generate a cache key for a query shape.
|
|
193
|
-
* Same where-keys + same with-clause = same SQL template.
|
|
194
|
-
*/
|
|
195
|
-
cacheKey(op, whereKeys, withClause, extra) {
|
|
196
|
-
let key = `${op}:${whereKeys.sort().join(',')}`;
|
|
197
|
-
if (withClause)
|
|
198
|
-
key += `:w=${JSON.stringify(Object.keys(withClause).sort())}`;
|
|
199
|
-
if (extra)
|
|
200
|
-
key += `:${extra}`;
|
|
201
|
-
return key;
|
|
202
|
-
}
|
|
203
284
|
// -------------------------------------------------------------------------
|
|
204
285
|
// findUnique
|
|
205
286
|
// -------------------------------------------------------------------------
|
|
@@ -215,10 +296,11 @@ export class QueryInterface {
|
|
|
215
296
|
const whereObj = args.where;
|
|
216
297
|
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
217
298
|
const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
|
|
218
|
-
const isSimpleWhere = !whereObj
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
299
|
+
const isSimpleWhere = !whereObj.OR &&
|
|
300
|
+
whereKeys.every((k) => {
|
|
301
|
+
const v = whereObj[k];
|
|
302
|
+
return v !== null && !isWhereOperator(v);
|
|
303
|
+
});
|
|
222
304
|
// For simple queries (no nested with, no operators), use cached SQL template
|
|
223
305
|
if (!args.with && isSimpleWhere) {
|
|
224
306
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
@@ -229,9 +311,7 @@ export class QueryInterface {
|
|
|
229
311
|
const qt = quoteIdent(this.table);
|
|
230
312
|
const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
|
|
231
313
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
232
|
-
const selectExpr = columnsList
|
|
233
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
234
|
-
: `${qt}.*`;
|
|
314
|
+
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
235
315
|
sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
236
316
|
this.sqlCache.set(ck, sql);
|
|
237
317
|
}
|
|
@@ -249,9 +329,7 @@ export class QueryInterface {
|
|
|
249
329
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
250
330
|
if (!args.with) {
|
|
251
331
|
const qt = quoteIdent(this.table);
|
|
252
|
-
const selectExpr = columnsList
|
|
253
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
254
|
-
: `${qt}.*`;
|
|
332
|
+
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
255
333
|
const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
256
334
|
return {
|
|
257
335
|
sql,
|
|
@@ -280,21 +358,39 @@ export class QueryInterface {
|
|
|
280
358
|
// findMany
|
|
281
359
|
// -------------------------------------------------------------------------
|
|
282
360
|
async findMany(args) {
|
|
283
|
-
|
|
284
|
-
const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
|
|
285
|
-
if (this.warnOnUnlimited && !hasExplicitLimit) {
|
|
286
|
-
console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
|
|
287
|
-
}
|
|
361
|
+
this.maybeWarnUnlimited(args);
|
|
288
362
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
289
363
|
const deferred = this.buildFindMany(args);
|
|
290
364
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
291
365
|
return deferred.transform(result);
|
|
292
366
|
});
|
|
293
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Emit a one-time `console.warn` when {@link findMany} is called without an
|
|
370
|
+
* explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
|
|
371
|
+
*
|
|
372
|
+
* Deduped per QueryInterface instance via {@link warnedTables} so a busy
|
|
373
|
+
* loop calling `db.users.findMany()` thousands of times only logs once.
|
|
374
|
+
* Suppressed when `defaultLimit` is configured (the caller has already
|
|
375
|
+
* opted in to a bounded query) and when the user passed an explicit
|
|
376
|
+
* `limit`, `take`, or `cursor`.
|
|
377
|
+
*/
|
|
378
|
+
maybeWarnUnlimited(args) {
|
|
379
|
+
if (!this.warnOnUnlimited)
|
|
380
|
+
return;
|
|
381
|
+
if (this.defaultLimit !== undefined)
|
|
382
|
+
return;
|
|
383
|
+
const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined || args?.cursor !== undefined;
|
|
384
|
+
if (hasExplicitLimit)
|
|
385
|
+
return;
|
|
386
|
+
if (this.warnedTables.has(this.table))
|
|
387
|
+
return;
|
|
388
|
+
this.warnedTables.add(this.table);
|
|
389
|
+
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
|
|
390
|
+
'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
|
|
391
|
+
}
|
|
294
392
|
buildFindMany(args) {
|
|
295
|
-
const { sql: whereSql, params } = args?.where
|
|
296
|
-
? this.buildWhere(args.where)
|
|
297
|
-
: { sql: '', params: [] };
|
|
393
|
+
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
298
394
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
299
395
|
const qt = quoteIdent(this.table);
|
|
300
396
|
// Distinct support
|
|
@@ -351,13 +447,68 @@ export class QueryInterface {
|
|
|
351
447
|
return {
|
|
352
448
|
sql,
|
|
353
449
|
params,
|
|
354
|
-
transform: (result) => result.rows.map((row) => args?.with
|
|
355
|
-
? this.parseNestedRow(row, this.table)
|
|
356
|
-
: this.parseRow(row, this.table)),
|
|
450
|
+
transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
|
|
357
451
|
tag: `${this.table}.findMany`,
|
|
358
452
|
};
|
|
359
453
|
}
|
|
360
454
|
// -------------------------------------------------------------------------
|
|
455
|
+
// findManyStream — async iterable using PostgreSQL cursors
|
|
456
|
+
// -------------------------------------------------------------------------
|
|
457
|
+
/**
|
|
458
|
+
* Stream rows from a findMany query using PostgreSQL cursors.
|
|
459
|
+
* Returns an AsyncIterable that yields individual rows, fetching in batches internally.
|
|
460
|
+
*
|
|
461
|
+
* Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
|
|
462
|
+
* The cursor is automatically closed and the connection released when iteration
|
|
463
|
+
* completes or is terminated early (e.g. `break` from `for await`).
|
|
464
|
+
*
|
|
465
|
+
* @example
|
|
466
|
+
* ```ts
|
|
467
|
+
* for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
|
|
468
|
+
* process.stdout.write(`${user.email}\n`);
|
|
469
|
+
* }
|
|
470
|
+
* ```
|
|
471
|
+
*/
|
|
472
|
+
async *findManyStream(args) {
|
|
473
|
+
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
|
|
474
|
+
const deferred = this.buildFindMany(args);
|
|
475
|
+
const hasRelations = !!args?.with;
|
|
476
|
+
// Acquire a dedicated connection — cursors require a single connection in a transaction
|
|
477
|
+
const client = await this.pool.connect();
|
|
478
|
+
const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
479
|
+
const quotedCursor = quoteIdent(cursorName);
|
|
480
|
+
try {
|
|
481
|
+
await client.query('BEGIN');
|
|
482
|
+
await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
|
|
483
|
+
while (true) {
|
|
484
|
+
const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
|
|
485
|
+
if (batch.rows.length === 0)
|
|
486
|
+
break;
|
|
487
|
+
for (const row of batch.rows) {
|
|
488
|
+
yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
|
|
489
|
+
}
|
|
490
|
+
if (batch.rows.length < batchSize)
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
await client.query(`CLOSE ${quotedCursor}`);
|
|
494
|
+
await client.query('COMMIT');
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
// Rollback on error (also closes cursor implicitly)
|
|
498
|
+
try {
|
|
499
|
+
await client.query('ROLLBACK');
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// Connection may already be broken — ignore rollback error
|
|
503
|
+
}
|
|
504
|
+
// Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
|
|
505
|
+
throw wrapPgError(err);
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
client.release();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// -------------------------------------------------------------------------
|
|
361
512
|
// findFirst — like findMany but returns a single row or null
|
|
362
513
|
// -------------------------------------------------------------------------
|
|
363
514
|
async findFirst(args) {
|
|
@@ -399,7 +550,11 @@ export class QueryInterface {
|
|
|
399
550
|
transform: (result) => {
|
|
400
551
|
const row = inner.transform(result);
|
|
401
552
|
if (row === null) {
|
|
402
|
-
throw new
|
|
553
|
+
throw new NotFoundError({
|
|
554
|
+
table: this.table,
|
|
555
|
+
where: args?.where,
|
|
556
|
+
operation: 'findFirstOrThrow',
|
|
557
|
+
});
|
|
403
558
|
}
|
|
404
559
|
return row;
|
|
405
560
|
},
|
|
@@ -424,7 +579,11 @@ export class QueryInterface {
|
|
|
424
579
|
transform: (result) => {
|
|
425
580
|
const row = inner.transform(result);
|
|
426
581
|
if (row === null) {
|
|
427
|
-
throw new
|
|
582
|
+
throw new NotFoundError({
|
|
583
|
+
table: this.table,
|
|
584
|
+
where: args.where,
|
|
585
|
+
operation: 'findUniqueOrThrow',
|
|
586
|
+
});
|
|
428
587
|
}
|
|
429
588
|
return row;
|
|
430
589
|
},
|
|
@@ -452,8 +611,13 @@ export class QueryInterface {
|
|
|
452
611
|
params,
|
|
453
612
|
transform: (result) => {
|
|
454
613
|
const row = result.rows[0];
|
|
455
|
-
if (!row)
|
|
456
|
-
throw new
|
|
614
|
+
if (!row) {
|
|
615
|
+
throw new NotFoundError({
|
|
616
|
+
table: this.table,
|
|
617
|
+
operation: 'create',
|
|
618
|
+
message: `[turbine] create on "${this.table}" returned no row from RETURNING * — this should never happen.`,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
457
621
|
return this.parseRow(row, this.table);
|
|
458
622
|
},
|
|
459
623
|
tag: `${this.table}.create`,
|
|
@@ -518,23 +682,26 @@ export class QueryInterface {
|
|
|
518
682
|
}
|
|
519
683
|
buildUpdate(args) {
|
|
520
684
|
const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
521
|
-
// Build SET params first
|
|
685
|
+
// Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
|
|
522
686
|
const params = [];
|
|
523
|
-
const setClauses = setEntries.map(([k, v]) =>
|
|
524
|
-
params.push(v);
|
|
525
|
-
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
526
|
-
});
|
|
687
|
+
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
|
|
527
688
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
528
689
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
529
690
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
691
|
+
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
530
692
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
|
|
531
693
|
return {
|
|
532
694
|
sql,
|
|
533
695
|
params,
|
|
534
696
|
transform: (result) => {
|
|
535
697
|
const row = result.rows[0];
|
|
536
|
-
if (!row)
|
|
537
|
-
throw new
|
|
698
|
+
if (!row) {
|
|
699
|
+
throw new NotFoundError({
|
|
700
|
+
table: this.table,
|
|
701
|
+
where: args.where,
|
|
702
|
+
operation: 'update',
|
|
703
|
+
});
|
|
704
|
+
}
|
|
538
705
|
return this.parseRow(row, this.table);
|
|
539
706
|
},
|
|
540
707
|
tag: `${this.table}.update`,
|
|
@@ -552,14 +719,20 @@ export class QueryInterface {
|
|
|
552
719
|
}
|
|
553
720
|
buildDelete(args) {
|
|
554
721
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
722
|
+
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
555
723
|
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
|
|
556
724
|
return {
|
|
557
725
|
sql,
|
|
558
726
|
params,
|
|
559
727
|
transform: (result) => {
|
|
560
728
|
const row = result.rows[0];
|
|
561
|
-
if (!row)
|
|
562
|
-
throw new
|
|
729
|
+
if (!row) {
|
|
730
|
+
throw new NotFoundError({
|
|
731
|
+
table: this.table,
|
|
732
|
+
where: args.where,
|
|
733
|
+
operation: 'delete',
|
|
734
|
+
});
|
|
735
|
+
}
|
|
563
736
|
return this.parseRow(row, this.table);
|
|
564
737
|
},
|
|
565
738
|
tag: `${this.table}.delete`,
|
|
@@ -602,8 +775,14 @@ export class QueryInterface {
|
|
|
602
775
|
params,
|
|
603
776
|
transform: (result) => {
|
|
604
777
|
const row = result.rows[0];
|
|
605
|
-
if (!row)
|
|
606
|
-
throw new
|
|
778
|
+
if (!row) {
|
|
779
|
+
throw new NotFoundError({
|
|
780
|
+
table: this.table,
|
|
781
|
+
where: args.where,
|
|
782
|
+
operation: 'upsert',
|
|
783
|
+
message: `[turbine] upsert on "${this.table}" returned no row from RETURNING * — this should never happen.`,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
607
786
|
return this.parseRow(row, this.table);
|
|
608
787
|
},
|
|
609
788
|
tag: `${this.table}.upsert`,
|
|
@@ -621,15 +800,13 @@ export class QueryInterface {
|
|
|
621
800
|
}
|
|
622
801
|
buildUpdateMany(args) {
|
|
623
802
|
const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
624
|
-
// Build SET params first
|
|
803
|
+
// Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
|
|
625
804
|
const params = [];
|
|
626
|
-
const setClauses = setEntries.map(([k, v]) =>
|
|
627
|
-
params.push(v);
|
|
628
|
-
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
629
|
-
});
|
|
805
|
+
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
|
|
630
806
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
631
807
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
632
808
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
809
|
+
this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
|
|
633
810
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
634
811
|
return {
|
|
635
812
|
sql,
|
|
@@ -650,6 +827,7 @@ export class QueryInterface {
|
|
|
650
827
|
}
|
|
651
828
|
buildDeleteMany(args) {
|
|
652
829
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
830
|
+
this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
|
|
653
831
|
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
654
832
|
return {
|
|
655
833
|
sql,
|
|
@@ -669,9 +847,7 @@ export class QueryInterface {
|
|
|
669
847
|
});
|
|
670
848
|
}
|
|
671
849
|
buildCount(args) {
|
|
672
|
-
const { sql: whereSql, params } = args?.where
|
|
673
|
-
? this.buildWhere(args.where)
|
|
674
|
-
: { sql: '', params: [] };
|
|
850
|
+
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
675
851
|
const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
676
852
|
return {
|
|
677
853
|
sql,
|
|
@@ -691,11 +867,17 @@ export class QueryInterface {
|
|
|
691
867
|
});
|
|
692
868
|
}
|
|
693
869
|
buildGroupBy(args) {
|
|
870
|
+
const meta = this.schema.tables[this.table];
|
|
871
|
+
if (meta) {
|
|
872
|
+
for (const key of args.by) {
|
|
873
|
+
if (!(key in meta.columnMap)) {
|
|
874
|
+
throw new ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
694
878
|
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
695
879
|
const groupCols = groupColsRaw.map((c) => quoteIdent(c));
|
|
696
|
-
const { sql: whereSql, params } = args.where
|
|
697
|
-
? this.buildWhere(args.where)
|
|
698
|
-
: { sql: '', params: [] };
|
|
880
|
+
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
699
881
|
// Build SELECT expressions: group-by columns + aggregate functions
|
|
700
882
|
const selectExprs = [...groupCols];
|
|
701
883
|
// _count
|
|
@@ -708,7 +890,7 @@ export class QueryInterface {
|
|
|
708
890
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
709
891
|
if (enabled) {
|
|
710
892
|
const col = this.toColumn(field);
|
|
711
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS
|
|
893
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
|
|
712
894
|
}
|
|
713
895
|
}
|
|
714
896
|
}
|
|
@@ -717,7 +899,7 @@ export class QueryInterface {
|
|
|
717
899
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
718
900
|
if (enabled) {
|
|
719
901
|
const col = this.toColumn(field);
|
|
720
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS
|
|
902
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
|
|
721
903
|
}
|
|
722
904
|
}
|
|
723
905
|
}
|
|
@@ -726,7 +908,7 @@ export class QueryInterface {
|
|
|
726
908
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
727
909
|
if (enabled) {
|
|
728
910
|
const col = this.toColumn(field);
|
|
729
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS
|
|
911
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
|
|
730
912
|
}
|
|
731
913
|
}
|
|
732
914
|
}
|
|
@@ -735,7 +917,7 @@ export class QueryInterface {
|
|
|
735
917
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
736
918
|
if (enabled) {
|
|
737
919
|
const col = this.toColumn(field);
|
|
738
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS
|
|
920
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
|
|
739
921
|
}
|
|
740
922
|
}
|
|
741
923
|
}
|
|
@@ -818,9 +1000,26 @@ export class QueryInterface {
|
|
|
818
1000
|
});
|
|
819
1001
|
}
|
|
820
1002
|
buildAggregate(args) {
|
|
821
|
-
const { sql: whereSql, params } = args.where
|
|
822
|
-
|
|
823
|
-
|
|
1003
|
+
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
1004
|
+
const meta = this.schema.tables[this.table];
|
|
1005
|
+
if (meta) {
|
|
1006
|
+
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
1007
|
+
if (group && typeof group === 'object') {
|
|
1008
|
+
for (const key of Object.keys(group)) {
|
|
1009
|
+
if (!(key in meta.columnMap)) {
|
|
1010
|
+
throw new ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (args._count && typeof args._count === 'object') {
|
|
1016
|
+
for (const key of Object.keys(args._count)) {
|
|
1017
|
+
if (!(key in meta.columnMap)) {
|
|
1018
|
+
throw new ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
824
1023
|
const selectExprs = [];
|
|
825
1024
|
// _count
|
|
826
1025
|
if (args._count === true) {
|
|
@@ -830,7 +1029,7 @@ export class QueryInterface {
|
|
|
830
1029
|
for (const [field, enabled] of Object.entries(args._count)) {
|
|
831
1030
|
if (enabled) {
|
|
832
1031
|
const col = this.toColumn(field);
|
|
833
|
-
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS
|
|
1032
|
+
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent('_count_' + col)}`);
|
|
834
1033
|
}
|
|
835
1034
|
}
|
|
836
1035
|
}
|
|
@@ -839,7 +1038,7 @@ export class QueryInterface {
|
|
|
839
1038
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
840
1039
|
if (enabled) {
|
|
841
1040
|
const col = this.toColumn(field);
|
|
842
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS
|
|
1041
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
|
|
843
1042
|
}
|
|
844
1043
|
}
|
|
845
1044
|
}
|
|
@@ -848,7 +1047,7 @@ export class QueryInterface {
|
|
|
848
1047
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
849
1048
|
if (enabled) {
|
|
850
1049
|
const col = this.toColumn(field);
|
|
851
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS
|
|
1050
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
|
|
852
1051
|
}
|
|
853
1052
|
}
|
|
854
1053
|
}
|
|
@@ -857,7 +1056,7 @@ export class QueryInterface {
|
|
|
857
1056
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
858
1057
|
if (enabled) {
|
|
859
1058
|
const col = this.toColumn(field);
|
|
860
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS
|
|
1059
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
|
|
861
1060
|
}
|
|
862
1061
|
}
|
|
863
1062
|
}
|
|
@@ -866,7 +1065,7 @@ export class QueryInterface {
|
|
|
866
1065
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
867
1066
|
if (enabled) {
|
|
868
1067
|
const col = this.toColumn(field);
|
|
869
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS
|
|
1068
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
|
|
870
1069
|
}
|
|
871
1070
|
}
|
|
872
1071
|
}
|
|
@@ -978,6 +1177,67 @@ export class QueryInterface {
|
|
|
978
1177
|
toSqlColumn(field) {
|
|
979
1178
|
return quoteIdent(this.toColumn(field));
|
|
980
1179
|
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Build a single SET clause entry for update/updateMany.
|
|
1182
|
+
*
|
|
1183
|
+
* Supports plain values and atomic operator objects ({ set, increment,
|
|
1184
|
+
* decrement, multiply, divide }). An operator object is detected ONLY when
|
|
1185
|
+
* it has EXACTLY one key that is one of the 5 operator keys — this avoids
|
|
1186
|
+
* misinterpreting JSON column values like `{ set: 'x' }` as operators
|
|
1187
|
+
* (real operator objects always have exactly one key, and a plain JSON
|
|
1188
|
+
* payload that happens to have a single `set` key is extremely unusual).
|
|
1189
|
+
* Multi-key objects are always treated as plain (JSON) values.
|
|
1190
|
+
*
|
|
1191
|
+
* Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
|
|
1192
|
+
* pushes any required params onto the shared params array so that WHERE
|
|
1193
|
+
* clause numbering continues correctly afterward.
|
|
1194
|
+
*/
|
|
1195
|
+
buildSetClause(key, value, params) {
|
|
1196
|
+
const col = this.toSqlColumn(key);
|
|
1197
|
+
// Detect atomic-operator object: plain object (not null, not array, not
|
|
1198
|
+
// Date, not Buffer) with EXACTLY one key matching an operator name.
|
|
1199
|
+
if (value !== null &&
|
|
1200
|
+
typeof value === 'object' &&
|
|
1201
|
+
!Array.isArray(value) &&
|
|
1202
|
+
!(value instanceof Date) &&
|
|
1203
|
+
!Buffer.isBuffer(value)) {
|
|
1204
|
+
const v = value;
|
|
1205
|
+
const keys = Object.keys(v);
|
|
1206
|
+
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
1207
|
+
const op = keys[0];
|
|
1208
|
+
const opValue = v[op];
|
|
1209
|
+
if (op === 'set') {
|
|
1210
|
+
params.push(opValue);
|
|
1211
|
+
return `${col} = $${params.length}`;
|
|
1212
|
+
}
|
|
1213
|
+
// Arithmetic operators: must be finite numbers
|
|
1214
|
+
if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
|
|
1215
|
+
throw new ValidationError(`[turbine] update operator "${op}" on "${this.table}.${key}" requires a finite number, got ${typeof opValue}`);
|
|
1216
|
+
}
|
|
1217
|
+
if (op === 'increment') {
|
|
1218
|
+
params.push(opValue);
|
|
1219
|
+
return `${col} = ${col} + $${params.length}`;
|
|
1220
|
+
}
|
|
1221
|
+
if (op === 'decrement') {
|
|
1222
|
+
params.push(opValue);
|
|
1223
|
+
return `${col} = ${col} - $${params.length}`;
|
|
1224
|
+
}
|
|
1225
|
+
if (op === 'multiply') {
|
|
1226
|
+
params.push(opValue);
|
|
1227
|
+
return `${col} = ${col} * $${params.length}`;
|
|
1228
|
+
}
|
|
1229
|
+
if (op === 'divide') {
|
|
1230
|
+
params.push(opValue);
|
|
1231
|
+
return `${col} = ${col} / $${params.length}`;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
// Fall through: multi-key objects or non-operator single-key objects
|
|
1235
|
+
// are treated as plain values (e.g., JSONB column payloads).
|
|
1236
|
+
}
|
|
1237
|
+
// Plain value (including null, Date, Buffer, arrays, JSON objects)
|
|
1238
|
+
params.push(value);
|
|
1239
|
+
return `${col} = $${params.length}`;
|
|
1240
|
+
}
|
|
981
1241
|
/** Build WHERE clause from a where object (supports operators, NULL, OR) */
|
|
982
1242
|
buildWhere(where) {
|
|
983
1243
|
const params = [];
|
|
@@ -986,6 +1246,22 @@ export class QueryInterface {
|
|
|
986
1246
|
return { sql: '', params: [] };
|
|
987
1247
|
return { sql: ` WHERE ${clause}`, params };
|
|
988
1248
|
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Refuse mutations with an empty predicate unless explicitly opted in.
|
|
1251
|
+
*
|
|
1252
|
+
* An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
|
|
1253
|
+
* mutation with no filter — a common footgun when a caller's filter
|
|
1254
|
+
* value accidentally resolves to `undefined`. This guard throws
|
|
1255
|
+
* `ValidationError` in that case unless `allowFullTableScan: true`.
|
|
1256
|
+
*/
|
|
1257
|
+
assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
|
|
1258
|
+
if (whereSql.length > 0)
|
|
1259
|
+
return;
|
|
1260
|
+
if (allowFullTableScan === true)
|
|
1261
|
+
return;
|
|
1262
|
+
throw new ValidationError(`[turbine] ${operation} on "${this.table}" refused: the \`where\` clause is empty. ` +
|
|
1263
|
+
`Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
|
|
1264
|
+
}
|
|
989
1265
|
/**
|
|
990
1266
|
* Build the inner WHERE expression (without the WHERE keyword).
|
|
991
1267
|
* Returns null if no conditions exist.
|
|
@@ -1063,6 +1339,16 @@ export class QueryInterface {
|
|
|
1063
1339
|
andClauses.push(...jsonClauses);
|
|
1064
1340
|
continue;
|
|
1065
1341
|
}
|
|
1342
|
+
// Strict validation: a JSON-only operator on a non-JSON column was almost
|
|
1343
|
+
// certainly a typo or schema mismatch. Silently falling through to plain
|
|
1344
|
+
// equality (the previous behaviour) wasted hours of debugging time. Only
|
|
1345
|
+
// throw when the operator is unambiguously JSON-specific — `contains` is
|
|
1346
|
+
// shared with WhereOperator's LIKE so it must continue to fall through.
|
|
1347
|
+
const jsonKey = findJsonUniqueKey(value);
|
|
1348
|
+
if (jsonKey) {
|
|
1349
|
+
throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
|
|
1350
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
1351
|
+
}
|
|
1066
1352
|
}
|
|
1067
1353
|
// Handle Array filter operators (for array columns)
|
|
1068
1354
|
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
@@ -1072,6 +1358,14 @@ export class QueryInterface {
|
|
|
1072
1358
|
andClauses.push(...arrayClauses);
|
|
1073
1359
|
continue;
|
|
1074
1360
|
}
|
|
1361
|
+
// Strict validation: array operators (`has`, `hasEvery`, ...) on a
|
|
1362
|
+
// non-array column always indicate a mistake. None of these keys
|
|
1363
|
+
// overlap with other filter shapes so we can throw unconditionally.
|
|
1364
|
+
const arrayKey = findArrayUniqueKey(value);
|
|
1365
|
+
if (arrayKey) {
|
|
1366
|
+
throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
|
|
1367
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
1368
|
+
}
|
|
1075
1369
|
}
|
|
1076
1370
|
// Handle operator objects
|
|
1077
1371
|
if (isWhereOperator(value)) {
|
|
@@ -1222,8 +1516,12 @@ export class QueryInterface {
|
|
|
1222
1516
|
}
|
|
1223
1517
|
/** Build ORDER BY clause from an object */
|
|
1224
1518
|
buildOrderBy(orderBy) {
|
|
1519
|
+
const meta = this.schema.tables[this.table];
|
|
1225
1520
|
return Object.entries(orderBy)
|
|
1226
1521
|
.map(([key, dir]) => {
|
|
1522
|
+
if (meta && !(key in meta.columnMap)) {
|
|
1523
|
+
throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
|
|
1524
|
+
}
|
|
1227
1525
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1228
1526
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
1229
1527
|
})
|
|
@@ -1272,6 +1570,7 @@ export class QueryInterface {
|
|
|
1272
1570
|
parsed[relName] = JSON.parse(rawValue);
|
|
1273
1571
|
}
|
|
1274
1572
|
catch {
|
|
1573
|
+
console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
|
|
1275
1574
|
parsed[relName] = rawValue;
|
|
1276
1575
|
}
|
|
1277
1576
|
}
|
|
@@ -1291,52 +1590,173 @@ export class QueryInterface {
|
|
|
1291
1590
|
return parsed;
|
|
1292
1591
|
}
|
|
1293
1592
|
/**
|
|
1294
|
-
* Build a SELECT clause
|
|
1593
|
+
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
1594
|
+
*
|
|
1595
|
+
* For each relation specified in the `with` clause, this method generates a correlated
|
|
1596
|
+
* subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
|
|
1597
|
+
* is a single SQL SELECT clause that resolves the full object tree in one query --
|
|
1598
|
+
* no N+1 problem.
|
|
1599
|
+
*
|
|
1600
|
+
* **How it works:**
|
|
1601
|
+
* 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
|
|
1602
|
+
* 2. Iterates over each key in the `with` clause, looking up the relation definition.
|
|
1603
|
+
* 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
|
|
1604
|
+
* correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
|
|
1605
|
+
* 4. Each subquery is aliased as the relation name in the final SELECT.
|
|
1606
|
+
*
|
|
1607
|
+
* **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
|
|
1608
|
+
* Each call to `buildRelationSubquery` increments it to produce unique table aliases
|
|
1609
|
+
* (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
|
|
1610
|
+
* collisions in the generated SQL.
|
|
1295
1611
|
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1612
|
+
* **Example output:**
|
|
1613
|
+
* ```sql
|
|
1614
|
+
* "users"."id", "users"."name", "users"."email",
|
|
1615
|
+
* (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
|
|
1616
|
+
* FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
|
|
1617
|
+
* ```
|
|
1298
1618
|
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1619
|
+
* @param table - The root table name (e.g. `"users"`).
|
|
1620
|
+
* @param withClause - An object mapping relation names to their include specs
|
|
1621
|
+
* (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
|
|
1622
|
+
* @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
|
|
1623
|
+
* Nested where/limit values are pushed here to prevent SQL injection.
|
|
1624
|
+
* @param columnsList - Optional subset of columns to include in the SELECT. When `null`
|
|
1625
|
+
* or omitted, all columns from the table's schema metadata are used.
|
|
1626
|
+
* @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
|
|
1627
|
+
* for circular-relation detection. Defaults to `0` at the top level.
|
|
1628
|
+
* @param path - Breadcrumb trail of relation names traversed so far, used in error
|
|
1629
|
+
* messages when circular or too-deep nesting is detected.
|
|
1630
|
+
* @returns A complete SELECT clause string (without the `SELECT` keyword) containing
|
|
1631
|
+
* base columns and relation subqueries.
|
|
1301
1632
|
*/
|
|
1302
|
-
buildSelectWithRelations(table, withClause, params, columnsList) {
|
|
1633
|
+
buildSelectWithRelations(table, withClause, params, columnsList, depth, path) {
|
|
1303
1634
|
const meta = this.schema.tables[table];
|
|
1304
1635
|
if (!meta)
|
|
1305
|
-
throw new
|
|
1636
|
+
throw new ValidationError(`[turbine] Unknown table "${table}"`);
|
|
1306
1637
|
const cols = columnsList ?? meta.allColumns;
|
|
1307
1638
|
const qtbl = quoteIdent(table);
|
|
1308
|
-
const baseCols = cols
|
|
1309
|
-
.map((col) => `${qtbl}.${quoteIdent(col)}`)
|
|
1310
|
-
.join(', ');
|
|
1639
|
+
const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
|
|
1311
1640
|
const relationSelects = [];
|
|
1312
1641
|
const aliasCounter = { n: 0 };
|
|
1313
1642
|
for (const [relName, relSpec] of Object.entries(withClause)) {
|
|
1314
1643
|
const relDef = meta.relations[relName];
|
|
1315
1644
|
if (!relDef) {
|
|
1316
|
-
throw new
|
|
1645
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
1317
1646
|
`Available: ${Object.keys(meta.relations).join(', ')}`);
|
|
1318
1647
|
}
|
|
1319
1648
|
// The main table is not aliased, so pass table name as parentRef
|
|
1320
|
-
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
|
|
1649
|
+
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
1321
1650
|
relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
|
|
1322
1651
|
}
|
|
1323
1652
|
return [baseCols, ...relationSelects].join(', ');
|
|
1324
1653
|
}
|
|
1325
1654
|
/**
|
|
1326
|
-
*
|
|
1655
|
+
* Generate a correlated subquery that returns JSON for a single relation.
|
|
1656
|
+
*
|
|
1657
|
+
* This is the core of Turbine's single-query nested relation strategy. For a given
|
|
1658
|
+
* relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
|
|
1659
|
+
* that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
|
|
1660
|
+
* a single JSON object (belongsTo / hasOne).
|
|
1661
|
+
*
|
|
1662
|
+
* ### Algorithm overview
|
|
1663
|
+
*
|
|
1664
|
+
* 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
|
|
1665
|
+
* `aliasCounter` so that deeply nested subqueries never collide.
|
|
1666
|
+
*
|
|
1667
|
+
* 2. **Column resolution:** Honors `select` / `omit` options to control which columns
|
|
1668
|
+
* appear in the output JSON.
|
|
1669
|
+
*
|
|
1670
|
+
* 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
|
|
1671
|
+
* field names to their column values:
|
|
1672
|
+
* ```sql
|
|
1673
|
+
* json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
|
|
1674
|
+
* ```
|
|
1327
1675
|
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1676
|
+
* 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
|
|
1677
|
+
* `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
|
|
1678
|
+
* into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
|
|
1679
|
+
* For belongsTo / hasOne, no aggregation is used -- just the single JSON object
|
|
1680
|
+
* with `LIMIT 1`.
|
|
1330
1681
|
*
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1682
|
+
* 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
|
|
1683
|
+
* - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
|
|
1684
|
+
* (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
|
|
1685
|
+
* - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
|
|
1686
|
+
* (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
|
|
1687
|
+
*
|
|
1688
|
+
* 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
|
|
1689
|
+
* itself recursively for each nested relation, passing the current alias as
|
|
1690
|
+
* `parentRef`. The nested subquery appears as an additional key in the
|
|
1691
|
+
* `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
|
|
1692
|
+
* Depth is incremented and capped at 10 to guard against circular relations.
|
|
1693
|
+
*
|
|
1694
|
+
* 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
|
|
1695
|
+
* the query is restructured into a two-level form:
|
|
1696
|
+
* ```sql
|
|
1697
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
1698
|
+
* FROM (
|
|
1699
|
+
* SELECT t0.* FROM "posts" t0
|
|
1700
|
+
* WHERE t0."user_id" = "users"."id"
|
|
1701
|
+
* ORDER BY t0."created_at" DESC
|
|
1702
|
+
* LIMIT $1
|
|
1703
|
+
* ) t0i
|
|
1704
|
+
* ```
|
|
1705
|
+
* This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
|
|
1706
|
+
* aggregation. Without the inner subquery, LIMIT would be meaningless because
|
|
1707
|
+
* `json_agg` produces a single aggregated row.
|
|
1708
|
+
*
|
|
1709
|
+
* 8. **Parameter threading:** All user-supplied values (where filters, limit) are
|
|
1710
|
+
* pushed to the shared `params` array with `$N` placeholders. No string
|
|
1711
|
+
* interpolation of user data ever occurs -- all identifiers go through
|
|
1712
|
+
* `quoteIdent()` and all values are parameterized.
|
|
1713
|
+
*
|
|
1714
|
+
* ### Example output (hasMany with nested relation)
|
|
1715
|
+
* ```sql
|
|
1716
|
+
* SELECT COALESCE(json_agg(json_build_object(
|
|
1717
|
+
* 'id', t0."id",
|
|
1718
|
+
* 'title', t0."title",
|
|
1719
|
+
* 'comments', COALESCE((
|
|
1720
|
+
* SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
|
|
1721
|
+
* FROM "comments" t1 WHERE t1."post_id" = t0."id"
|
|
1722
|
+
* ), '[]'::json)
|
|
1723
|
+
* )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
|
|
1724
|
+
* ```
|
|
1725
|
+
*
|
|
1726
|
+
* @param relDef - The relation definition from schema metadata (contains `to`, `type`,
|
|
1727
|
+
* `foreignKey`, `referenceKey`).
|
|
1728
|
+
* @param spec - Either `true` (include with defaults) or a `WithOptions` object that
|
|
1729
|
+
* can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
|
|
1730
|
+
* @param params - Shared parameter array. User-supplied values are pushed here and
|
|
1731
|
+
* referenced as `$1`, `$2`, etc. in the generated SQL.
|
|
1732
|
+
* @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
|
|
1733
|
+
* parent query. Used to build the correlated WHERE clause that ties
|
|
1734
|
+
* child rows to their parent row.
|
|
1735
|
+
* @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
|
|
1736
|
+
* table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
|
|
1737
|
+
* Each call increments `n` by 1.
|
|
1738
|
+
* @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
|
|
1739
|
+
* call. If it reaches 10, a {@link CircularRelationError} is thrown.
|
|
1740
|
+
* @param path - Breadcrumb trail of relation/table names traversed so far
|
|
1741
|
+
* (e.g. `["users", "posts", "comments"]`). Used in the error message
|
|
1742
|
+
* when circular or too-deep nesting is detected.
|
|
1743
|
+
* @returns A complete SQL subquery string (without surrounding parentheses) that
|
|
1744
|
+
* evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
|
|
1334
1745
|
*/
|
|
1335
|
-
buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter) {
|
|
1746
|
+
buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter, depth, path) {
|
|
1747
|
+
const currentDepth = depth ?? 0;
|
|
1748
|
+
const currentPath = path ?? [this.table];
|
|
1336
1749
|
const targetTable = relDef.to;
|
|
1750
|
+
// Hard depth cap — the `with` clause is a finite JSON structure so users can't
|
|
1751
|
+
// create true infinite recursion, but extremely deep nesting (10+ levels) produces
|
|
1752
|
+
// unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
|
|
1753
|
+
// since they are legitimate queries (Prisma supports the same pattern).
|
|
1754
|
+
if (currentDepth >= 10) {
|
|
1755
|
+
throw new CircularRelationError([...currentPath, targetTable]);
|
|
1756
|
+
}
|
|
1337
1757
|
const targetMeta = this.schema.tables[targetTable];
|
|
1338
1758
|
if (!targetMeta)
|
|
1339
|
-
throw new
|
|
1759
|
+
throw new RelationError(`[turbine] Unknown relation target "${targetTable}"`);
|
|
1340
1760
|
// Generate a unique alias: t0, t1, t2, ...
|
|
1341
1761
|
const alias = `t${aliasCounter.n++}`;
|
|
1342
1762
|
// Resolve which columns to include based on select/omit
|
|
@@ -1355,17 +1775,23 @@ export class QueryInterface {
|
|
|
1355
1775
|
}
|
|
1356
1776
|
// Build json_build_object pairs for resolved columns
|
|
1357
1777
|
const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${alias}.${quoteIdent(col)}`);
|
|
1358
|
-
//
|
|
1359
|
-
|
|
1778
|
+
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
1779
|
+
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
1780
|
+
// so we must NOT build them here (they would push orphaned params).
|
|
1781
|
+
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
1782
|
+
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
1783
|
+
if (!willWrap && spec !== true && spec.with) {
|
|
1360
1784
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1361
1785
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1362
1786
|
if (!nestedRelDef) {
|
|
1363
|
-
throw new
|
|
1787
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1364
1788
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
1365
1789
|
}
|
|
1366
1790
|
// Recursively build nested subquery, passing THIS alias as the parent reference
|
|
1367
|
-
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter);
|
|
1368
|
-
|
|
1791
|
+
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
1792
|
+
// Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
|
|
1793
|
+
const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
|
|
1794
|
+
jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), ${fallback})`);
|
|
1369
1795
|
}
|
|
1370
1796
|
}
|
|
1371
1797
|
const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
|
|
@@ -1379,7 +1805,7 @@ export class QueryInterface {
|
|
|
1379
1805
|
.map(([k, dir]) => {
|
|
1380
1806
|
const col = camelToSnake(k);
|
|
1381
1807
|
if (!targetMeta.allColumns.includes(col)) {
|
|
1382
|
-
throw new
|
|
1808
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
1383
1809
|
}
|
|
1384
1810
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1385
1811
|
return `${alias}.${quoteIdent(col)} ${safeDir}`;
|
|
@@ -1402,7 +1828,7 @@ export class QueryInterface {
|
|
|
1402
1828
|
for (const [k, v] of Object.entries(spec.where)) {
|
|
1403
1829
|
const col = camelToSnake(k);
|
|
1404
1830
|
if (!targetMeta.allColumns.includes(col)) {
|
|
1405
|
-
throw new
|
|
1831
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
1406
1832
|
}
|
|
1407
1833
|
params.push(v);
|
|
1408
1834
|
whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
|
|
@@ -1424,14 +1850,17 @@ export class QueryInterface {
|
|
|
1424
1850
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1425
1851
|
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
1426
1852
|
const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${innerAlias}.${quoteIdent(col)}`);
|
|
1427
|
-
//
|
|
1853
|
+
// Build nested relation subqueries referencing innerAlias
|
|
1428
1854
|
if (spec !== true && spec.with) {
|
|
1429
|
-
for (const [nestedRelName] of Object.entries(spec.with)) {
|
|
1855
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1430
1856
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1431
|
-
if (nestedRelDef) {
|
|
1432
|
-
|
|
1433
|
-
|
|
1857
|
+
if (!nestedRelDef) {
|
|
1858
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1859
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
1434
1860
|
}
|
|
1861
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
1862
|
+
const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
|
|
1863
|
+
innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), ${fallback})`);
|
|
1435
1864
|
}
|
|
1436
1865
|
}
|
|
1437
1866
|
const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
|