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