turbine-orm 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -26
- package/dist/cjs/cli/config.js +5 -15
- package/dist/cjs/cli/index.js +240 -41
- package/dist/cjs/cli/migrate.js +71 -46
- package/dist/cjs/cli/ui.js +5 -9
- package/dist/cjs/client.js +109 -46
- package/dist/cjs/errors.js +293 -0
- package/dist/cjs/generate.js +33 -13
- package/dist/cjs/index.js +39 -20
- package/dist/cjs/introspect.js +3 -5
- package/dist/cjs/pipeline.js +9 -2
- package/dist/cjs/query.js +442 -109
- package/dist/cjs/schema-builder.js +93 -24
- package/dist/cjs/schema-sql.js +157 -19
- package/dist/cjs/schema.js +5 -2
- package/dist/cjs/serverless.js +87 -176
- package/dist/cli/config.js +6 -16
- package/dist/cli/index.js +245 -46
- package/dist/cli/migrate.d.ts +6 -1
- package/dist/cli/migrate.js +72 -47
- package/dist/cli/ui.js +5 -9
- package/dist/client.d.ts +77 -4
- package/dist/client.js +109 -46
- package/dist/errors.d.ts +138 -0
- package/dist/errors.js +278 -0
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +36 -16
- 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 +257 -36
- package/dist/query.js +443 -110
- package/dist/schema-builder.d.ts +2 -2
- package/dist/schema-builder.js +93 -25
- package/dist/schema-sql.d.ts +7 -3
- package/dist/schema-sql.js +157 -19
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +5 -2
- package/dist/serverless.d.ts +91 -139
- package/dist/serverless.js +86 -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,22 +47,41 @@ 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']);
|
|
62
78
|
/** Check if a value is a JSONB filter object */
|
|
63
79
|
function isJsonFilter(value) {
|
|
64
|
-
if (value === null ||
|
|
80
|
+
if (value === null ||
|
|
81
|
+
value === undefined ||
|
|
82
|
+
typeof value !== 'object' ||
|
|
83
|
+
Array.isArray(value) ||
|
|
84
|
+
value instanceof Date) {
|
|
65
85
|
return false;
|
|
66
86
|
}
|
|
67
87
|
const keys = Object.keys(value);
|
|
@@ -71,7 +91,11 @@ function isJsonFilter(value) {
|
|
|
71
91
|
const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
72
92
|
/** Check if a value is an Array filter object */
|
|
73
93
|
function isArrayFilter(value) {
|
|
74
|
-
if (value === null ||
|
|
94
|
+
if (value === null ||
|
|
95
|
+
value === undefined ||
|
|
96
|
+
typeof value !== 'object' ||
|
|
97
|
+
Array.isArray(value) ||
|
|
98
|
+
value instanceof Date) {
|
|
75
99
|
return false;
|
|
76
100
|
}
|
|
77
101
|
const keys = Object.keys(value);
|
|
@@ -112,7 +136,9 @@ class LRUCache {
|
|
|
112
136
|
}
|
|
113
137
|
this.cache.set(key, value);
|
|
114
138
|
}
|
|
115
|
-
get size() {
|
|
139
|
+
get size() {
|
|
140
|
+
return this.cache.size;
|
|
141
|
+
}
|
|
116
142
|
}
|
|
117
143
|
class QueryInterface {
|
|
118
144
|
pool;
|
|
@@ -133,7 +159,7 @@ class QueryInterface {
|
|
|
133
159
|
this.schema = schema;
|
|
134
160
|
const meta = schema.tables[table];
|
|
135
161
|
if (!meta) {
|
|
136
|
-
throw new
|
|
162
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
|
|
137
163
|
}
|
|
138
164
|
this.tableMeta = meta;
|
|
139
165
|
this.middlewares = middlewares ?? [];
|
|
@@ -150,18 +176,27 @@ class QueryInterface {
|
|
|
150
176
|
/**
|
|
151
177
|
* Execute a pool.query with an optional timeout.
|
|
152
178
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
179
|
+
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
153
180
|
*/
|
|
154
181
|
async queryWithTimeout(sql, params, timeout) {
|
|
155
182
|
if (!timeout) {
|
|
156
|
-
|
|
183
|
+
try {
|
|
184
|
+
return await this.pool.query(sql, params);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
throw (0, errors_js_1.wrapPgError)(err);
|
|
188
|
+
}
|
|
157
189
|
}
|
|
158
190
|
let timer;
|
|
159
191
|
const timeoutPromise = new Promise((_, reject) => {
|
|
160
|
-
timer = setTimeout(() => reject(new
|
|
192
|
+
timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
|
|
161
193
|
});
|
|
162
194
|
try {
|
|
163
195
|
return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
|
|
164
196
|
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
throw (0, errors_js_1.wrapPgError)(err);
|
|
199
|
+
}
|
|
165
200
|
finally {
|
|
166
201
|
clearTimeout(timer);
|
|
167
202
|
}
|
|
@@ -192,18 +227,6 @@ class QueryInterface {
|
|
|
192
227
|
};
|
|
193
228
|
return next(params);
|
|
194
229
|
}
|
|
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
230
|
// -------------------------------------------------------------------------
|
|
208
231
|
// findUnique
|
|
209
232
|
// -------------------------------------------------------------------------
|
|
@@ -219,10 +242,11 @@ class QueryInterface {
|
|
|
219
242
|
const whereObj = args.where;
|
|
220
243
|
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
221
244
|
const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
|
|
222
|
-
const isSimpleWhere = !whereObj
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
245
|
+
const isSimpleWhere = !whereObj.OR &&
|
|
246
|
+
whereKeys.every((k) => {
|
|
247
|
+
const v = whereObj[k];
|
|
248
|
+
return v !== null && !isWhereOperator(v);
|
|
249
|
+
});
|
|
226
250
|
// For simple queries (no nested with, no operators), use cached SQL template
|
|
227
251
|
if (!args.with && isSimpleWhere) {
|
|
228
252
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
@@ -233,9 +257,7 @@ class QueryInterface {
|
|
|
233
257
|
const qt = quoteIdent(this.table);
|
|
234
258
|
const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
|
|
235
259
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
236
|
-
const selectExpr = columnsList
|
|
237
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
238
|
-
: `${qt}.*`;
|
|
260
|
+
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
239
261
|
sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
240
262
|
this.sqlCache.set(ck, sql);
|
|
241
263
|
}
|
|
@@ -253,9 +275,7 @@ class QueryInterface {
|
|
|
253
275
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
254
276
|
if (!args.with) {
|
|
255
277
|
const qt = quoteIdent(this.table);
|
|
256
|
-
const selectExpr = columnsList
|
|
257
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
258
|
-
: `${qt}.*`;
|
|
278
|
+
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
259
279
|
const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
260
280
|
return {
|
|
261
281
|
sql,
|
|
@@ -296,9 +316,7 @@ class QueryInterface {
|
|
|
296
316
|
});
|
|
297
317
|
}
|
|
298
318
|
buildFindMany(args) {
|
|
299
|
-
const { sql: whereSql, params } = args?.where
|
|
300
|
-
? this.buildWhere(args.where)
|
|
301
|
-
: { sql: '', params: [] };
|
|
319
|
+
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
302
320
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
303
321
|
const qt = quoteIdent(this.table);
|
|
304
322
|
// Distinct support
|
|
@@ -355,13 +373,68 @@ class QueryInterface {
|
|
|
355
373
|
return {
|
|
356
374
|
sql,
|
|
357
375
|
params,
|
|
358
|
-
transform: (result) => result.rows.map((row) => args?.with
|
|
359
|
-
? this.parseNestedRow(row, this.table)
|
|
360
|
-
: this.parseRow(row, this.table)),
|
|
376
|
+
transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
|
|
361
377
|
tag: `${this.table}.findMany`,
|
|
362
378
|
};
|
|
363
379
|
}
|
|
364
380
|
// -------------------------------------------------------------------------
|
|
381
|
+
// findManyStream — async iterable using PostgreSQL cursors
|
|
382
|
+
// -------------------------------------------------------------------------
|
|
383
|
+
/**
|
|
384
|
+
* Stream rows from a findMany query using PostgreSQL cursors.
|
|
385
|
+
* Returns an AsyncIterable that yields individual rows, fetching in batches internally.
|
|
386
|
+
*
|
|
387
|
+
* Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
|
|
388
|
+
* The cursor is automatically closed and the connection released when iteration
|
|
389
|
+
* completes or is terminated early (e.g. `break` from `for await`).
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* ```ts
|
|
393
|
+
* for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
|
|
394
|
+
* process.stdout.write(`${user.email}\n`);
|
|
395
|
+
* }
|
|
396
|
+
* ```
|
|
397
|
+
*/
|
|
398
|
+
async *findManyStream(args) {
|
|
399
|
+
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
|
|
400
|
+
const deferred = this.buildFindMany(args);
|
|
401
|
+
const hasRelations = !!args?.with;
|
|
402
|
+
// Acquire a dedicated connection — cursors require a single connection in a transaction
|
|
403
|
+
const client = await this.pool.connect();
|
|
404
|
+
const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
405
|
+
const quotedCursor = quoteIdent(cursorName);
|
|
406
|
+
try {
|
|
407
|
+
await client.query('BEGIN');
|
|
408
|
+
await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
|
|
409
|
+
while (true) {
|
|
410
|
+
const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
|
|
411
|
+
if (batch.rows.length === 0)
|
|
412
|
+
break;
|
|
413
|
+
for (const row of batch.rows) {
|
|
414
|
+
yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
|
|
415
|
+
}
|
|
416
|
+
if (batch.rows.length < batchSize)
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
await client.query(`CLOSE ${quotedCursor}`);
|
|
420
|
+
await client.query('COMMIT');
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
// Rollback on error (also closes cursor implicitly)
|
|
424
|
+
try {
|
|
425
|
+
await client.query('ROLLBACK');
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Connection may already be broken — ignore rollback error
|
|
429
|
+
}
|
|
430
|
+
// Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
|
|
431
|
+
throw (0, errors_js_1.wrapPgError)(err);
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
client.release();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// -------------------------------------------------------------------------
|
|
365
438
|
// findFirst — like findMany but returns a single row or null
|
|
366
439
|
// -------------------------------------------------------------------------
|
|
367
440
|
async findFirst(args) {
|
|
@@ -403,7 +476,11 @@ class QueryInterface {
|
|
|
403
476
|
transform: (result) => {
|
|
404
477
|
const row = inner.transform(result);
|
|
405
478
|
if (row === null) {
|
|
406
|
-
throw new
|
|
479
|
+
throw new errors_js_1.NotFoundError({
|
|
480
|
+
table: this.table,
|
|
481
|
+
where: args?.where,
|
|
482
|
+
operation: 'findFirstOrThrow',
|
|
483
|
+
});
|
|
407
484
|
}
|
|
408
485
|
return row;
|
|
409
486
|
},
|
|
@@ -428,7 +505,11 @@ class QueryInterface {
|
|
|
428
505
|
transform: (result) => {
|
|
429
506
|
const row = inner.transform(result);
|
|
430
507
|
if (row === null) {
|
|
431
|
-
throw new
|
|
508
|
+
throw new errors_js_1.NotFoundError({
|
|
509
|
+
table: this.table,
|
|
510
|
+
where: args.where,
|
|
511
|
+
operation: 'findUniqueOrThrow',
|
|
512
|
+
});
|
|
432
513
|
}
|
|
433
514
|
return row;
|
|
434
515
|
},
|
|
@@ -456,8 +537,13 @@ class QueryInterface {
|
|
|
456
537
|
params,
|
|
457
538
|
transform: (result) => {
|
|
458
539
|
const row = result.rows[0];
|
|
459
|
-
if (!row)
|
|
460
|
-
throw new
|
|
540
|
+
if (!row) {
|
|
541
|
+
throw new errors_js_1.NotFoundError({
|
|
542
|
+
table: this.table,
|
|
543
|
+
operation: 'create',
|
|
544
|
+
message: `[turbine] create on "${this.table}" returned no row from RETURNING * — this should never happen.`,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
461
547
|
return this.parseRow(row, this.table);
|
|
462
548
|
},
|
|
463
549
|
tag: `${this.table}.create`,
|
|
@@ -522,23 +608,26 @@ class QueryInterface {
|
|
|
522
608
|
}
|
|
523
609
|
buildUpdate(args) {
|
|
524
610
|
const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
525
|
-
// Build SET params first
|
|
611
|
+
// Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
|
|
526
612
|
const params = [];
|
|
527
|
-
const setClauses = setEntries.map(([k, v]) =>
|
|
528
|
-
params.push(v);
|
|
529
|
-
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
530
|
-
});
|
|
613
|
+
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
|
|
531
614
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
532
615
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
533
616
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
617
|
+
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
534
618
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
|
|
535
619
|
return {
|
|
536
620
|
sql,
|
|
537
621
|
params,
|
|
538
622
|
transform: (result) => {
|
|
539
623
|
const row = result.rows[0];
|
|
540
|
-
if (!row)
|
|
541
|
-
throw new
|
|
624
|
+
if (!row) {
|
|
625
|
+
throw new errors_js_1.NotFoundError({
|
|
626
|
+
table: this.table,
|
|
627
|
+
where: args.where,
|
|
628
|
+
operation: 'update',
|
|
629
|
+
});
|
|
630
|
+
}
|
|
542
631
|
return this.parseRow(row, this.table);
|
|
543
632
|
},
|
|
544
633
|
tag: `${this.table}.update`,
|
|
@@ -556,14 +645,20 @@ class QueryInterface {
|
|
|
556
645
|
}
|
|
557
646
|
buildDelete(args) {
|
|
558
647
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
648
|
+
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
559
649
|
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
|
|
560
650
|
return {
|
|
561
651
|
sql,
|
|
562
652
|
params,
|
|
563
653
|
transform: (result) => {
|
|
564
654
|
const row = result.rows[0];
|
|
565
|
-
if (!row)
|
|
566
|
-
throw new
|
|
655
|
+
if (!row) {
|
|
656
|
+
throw new errors_js_1.NotFoundError({
|
|
657
|
+
table: this.table,
|
|
658
|
+
where: args.where,
|
|
659
|
+
operation: 'delete',
|
|
660
|
+
});
|
|
661
|
+
}
|
|
567
662
|
return this.parseRow(row, this.table);
|
|
568
663
|
},
|
|
569
664
|
tag: `${this.table}.delete`,
|
|
@@ -606,8 +701,14 @@ class QueryInterface {
|
|
|
606
701
|
params,
|
|
607
702
|
transform: (result) => {
|
|
608
703
|
const row = result.rows[0];
|
|
609
|
-
if (!row)
|
|
610
|
-
throw new
|
|
704
|
+
if (!row) {
|
|
705
|
+
throw new errors_js_1.NotFoundError({
|
|
706
|
+
table: this.table,
|
|
707
|
+
where: args.where,
|
|
708
|
+
operation: 'upsert',
|
|
709
|
+
message: `[turbine] upsert on "${this.table}" returned no row from RETURNING * — this should never happen.`,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
611
712
|
return this.parseRow(row, this.table);
|
|
612
713
|
},
|
|
613
714
|
tag: `${this.table}.upsert`,
|
|
@@ -625,15 +726,13 @@ class QueryInterface {
|
|
|
625
726
|
}
|
|
626
727
|
buildUpdateMany(args) {
|
|
627
728
|
const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
628
|
-
// Build SET params first
|
|
729
|
+
// Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
|
|
629
730
|
const params = [];
|
|
630
|
-
const setClauses = setEntries.map(([k, v]) =>
|
|
631
|
-
params.push(v);
|
|
632
|
-
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
633
|
-
});
|
|
731
|
+
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
|
|
634
732
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
635
733
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
636
734
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
735
|
+
this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
|
|
637
736
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
638
737
|
return {
|
|
639
738
|
sql,
|
|
@@ -654,6 +753,7 @@ class QueryInterface {
|
|
|
654
753
|
}
|
|
655
754
|
buildDeleteMany(args) {
|
|
656
755
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
756
|
+
this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
|
|
657
757
|
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
658
758
|
return {
|
|
659
759
|
sql,
|
|
@@ -673,9 +773,7 @@ class QueryInterface {
|
|
|
673
773
|
});
|
|
674
774
|
}
|
|
675
775
|
buildCount(args) {
|
|
676
|
-
const { sql: whereSql, params } = args?.where
|
|
677
|
-
? this.buildWhere(args.where)
|
|
678
|
-
: { sql: '', params: [] };
|
|
776
|
+
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
679
777
|
const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
680
778
|
return {
|
|
681
779
|
sql,
|
|
@@ -695,11 +793,17 @@ class QueryInterface {
|
|
|
695
793
|
});
|
|
696
794
|
}
|
|
697
795
|
buildGroupBy(args) {
|
|
796
|
+
const meta = this.schema.tables[this.table];
|
|
797
|
+
if (meta) {
|
|
798
|
+
for (const key of args.by) {
|
|
799
|
+
if (!(key in meta.columnMap)) {
|
|
800
|
+
throw new errors_js_1.ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
698
804
|
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
699
805
|
const groupCols = groupColsRaw.map((c) => quoteIdent(c));
|
|
700
|
-
const { sql: whereSql, params } = args.where
|
|
701
|
-
? this.buildWhere(args.where)
|
|
702
|
-
: { sql: '', params: [] };
|
|
806
|
+
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
703
807
|
// Build SELECT expressions: group-by columns + aggregate functions
|
|
704
808
|
const selectExprs = [...groupCols];
|
|
705
809
|
// _count
|
|
@@ -712,7 +816,7 @@ class QueryInterface {
|
|
|
712
816
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
713
817
|
if (enabled) {
|
|
714
818
|
const col = this.toColumn(field);
|
|
715
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS
|
|
819
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
|
|
716
820
|
}
|
|
717
821
|
}
|
|
718
822
|
}
|
|
@@ -721,7 +825,7 @@ class QueryInterface {
|
|
|
721
825
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
722
826
|
if (enabled) {
|
|
723
827
|
const col = this.toColumn(field);
|
|
724
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS
|
|
828
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
|
|
725
829
|
}
|
|
726
830
|
}
|
|
727
831
|
}
|
|
@@ -730,7 +834,7 @@ class QueryInterface {
|
|
|
730
834
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
731
835
|
if (enabled) {
|
|
732
836
|
const col = this.toColumn(field);
|
|
733
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS
|
|
837
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
|
|
734
838
|
}
|
|
735
839
|
}
|
|
736
840
|
}
|
|
@@ -739,7 +843,7 @@ class QueryInterface {
|
|
|
739
843
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
740
844
|
if (enabled) {
|
|
741
845
|
const col = this.toColumn(field);
|
|
742
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS
|
|
846
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
|
|
743
847
|
}
|
|
744
848
|
}
|
|
745
849
|
}
|
|
@@ -822,9 +926,26 @@ class QueryInterface {
|
|
|
822
926
|
});
|
|
823
927
|
}
|
|
824
928
|
buildAggregate(args) {
|
|
825
|
-
const { sql: whereSql, params } = args.where
|
|
826
|
-
|
|
827
|
-
|
|
929
|
+
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
930
|
+
const meta = this.schema.tables[this.table];
|
|
931
|
+
if (meta) {
|
|
932
|
+
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
933
|
+
if (group && typeof group === 'object') {
|
|
934
|
+
for (const key of Object.keys(group)) {
|
|
935
|
+
if (!(key in meta.columnMap)) {
|
|
936
|
+
throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (args._count && typeof args._count === 'object') {
|
|
942
|
+
for (const key of Object.keys(args._count)) {
|
|
943
|
+
if (!(key in meta.columnMap)) {
|
|
944
|
+
throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
828
949
|
const selectExprs = [];
|
|
829
950
|
// _count
|
|
830
951
|
if (args._count === true) {
|
|
@@ -834,7 +955,7 @@ class QueryInterface {
|
|
|
834
955
|
for (const [field, enabled] of Object.entries(args._count)) {
|
|
835
956
|
if (enabled) {
|
|
836
957
|
const col = this.toColumn(field);
|
|
837
|
-
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS
|
|
958
|
+
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent('_count_' + col)}`);
|
|
838
959
|
}
|
|
839
960
|
}
|
|
840
961
|
}
|
|
@@ -843,7 +964,7 @@ class QueryInterface {
|
|
|
843
964
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
844
965
|
if (enabled) {
|
|
845
966
|
const col = this.toColumn(field);
|
|
846
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS
|
|
967
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
|
|
847
968
|
}
|
|
848
969
|
}
|
|
849
970
|
}
|
|
@@ -852,7 +973,7 @@ class QueryInterface {
|
|
|
852
973
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
853
974
|
if (enabled) {
|
|
854
975
|
const col = this.toColumn(field);
|
|
855
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS
|
|
976
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
|
|
856
977
|
}
|
|
857
978
|
}
|
|
858
979
|
}
|
|
@@ -861,7 +982,7 @@ class QueryInterface {
|
|
|
861
982
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
862
983
|
if (enabled) {
|
|
863
984
|
const col = this.toColumn(field);
|
|
864
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS
|
|
985
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
|
|
865
986
|
}
|
|
866
987
|
}
|
|
867
988
|
}
|
|
@@ -870,7 +991,7 @@ class QueryInterface {
|
|
|
870
991
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
871
992
|
if (enabled) {
|
|
872
993
|
const col = this.toColumn(field);
|
|
873
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS
|
|
994
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
|
|
874
995
|
}
|
|
875
996
|
}
|
|
876
997
|
}
|
|
@@ -982,6 +1103,67 @@ class QueryInterface {
|
|
|
982
1103
|
toSqlColumn(field) {
|
|
983
1104
|
return quoteIdent(this.toColumn(field));
|
|
984
1105
|
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Build a single SET clause entry for update/updateMany.
|
|
1108
|
+
*
|
|
1109
|
+
* Supports plain values and atomic operator objects ({ set, increment,
|
|
1110
|
+
* decrement, multiply, divide }). An operator object is detected ONLY when
|
|
1111
|
+
* it has EXACTLY one key that is one of the 5 operator keys — this avoids
|
|
1112
|
+
* misinterpreting JSON column values like `{ set: 'x' }` as operators
|
|
1113
|
+
* (real operator objects always have exactly one key, and a plain JSON
|
|
1114
|
+
* payload that happens to have a single `set` key is extremely unusual).
|
|
1115
|
+
* Multi-key objects are always treated as plain (JSON) values.
|
|
1116
|
+
*
|
|
1117
|
+
* Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
|
|
1118
|
+
* pushes any required params onto the shared params array so that WHERE
|
|
1119
|
+
* clause numbering continues correctly afterward.
|
|
1120
|
+
*/
|
|
1121
|
+
buildSetClause(key, value, params) {
|
|
1122
|
+
const col = this.toSqlColumn(key);
|
|
1123
|
+
// Detect atomic-operator object: plain object (not null, not array, not
|
|
1124
|
+
// Date, not Buffer) with EXACTLY one key matching an operator name.
|
|
1125
|
+
if (value !== null &&
|
|
1126
|
+
typeof value === 'object' &&
|
|
1127
|
+
!Array.isArray(value) &&
|
|
1128
|
+
!(value instanceof Date) &&
|
|
1129
|
+
!Buffer.isBuffer(value)) {
|
|
1130
|
+
const v = value;
|
|
1131
|
+
const keys = Object.keys(v);
|
|
1132
|
+
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
1133
|
+
const op = keys[0];
|
|
1134
|
+
const opValue = v[op];
|
|
1135
|
+
if (op === 'set') {
|
|
1136
|
+
params.push(opValue);
|
|
1137
|
+
return `${col} = $${params.length}`;
|
|
1138
|
+
}
|
|
1139
|
+
// Arithmetic operators: must be finite numbers
|
|
1140
|
+
if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
|
|
1141
|
+
throw new errors_js_1.ValidationError(`[turbine] update operator "${op}" on "${this.table}.${key}" requires a finite number, got ${typeof opValue}`);
|
|
1142
|
+
}
|
|
1143
|
+
if (op === 'increment') {
|
|
1144
|
+
params.push(opValue);
|
|
1145
|
+
return `${col} = ${col} + $${params.length}`;
|
|
1146
|
+
}
|
|
1147
|
+
if (op === 'decrement') {
|
|
1148
|
+
params.push(opValue);
|
|
1149
|
+
return `${col} = ${col} - $${params.length}`;
|
|
1150
|
+
}
|
|
1151
|
+
if (op === 'multiply') {
|
|
1152
|
+
params.push(opValue);
|
|
1153
|
+
return `${col} = ${col} * $${params.length}`;
|
|
1154
|
+
}
|
|
1155
|
+
if (op === 'divide') {
|
|
1156
|
+
params.push(opValue);
|
|
1157
|
+
return `${col} = ${col} / $${params.length}`;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
// Fall through: multi-key objects or non-operator single-key objects
|
|
1161
|
+
// are treated as plain values (e.g., JSONB column payloads).
|
|
1162
|
+
}
|
|
1163
|
+
// Plain value (including null, Date, Buffer, arrays, JSON objects)
|
|
1164
|
+
params.push(value);
|
|
1165
|
+
return `${col} = $${params.length}`;
|
|
1166
|
+
}
|
|
985
1167
|
/** Build WHERE clause from a where object (supports operators, NULL, OR) */
|
|
986
1168
|
buildWhere(where) {
|
|
987
1169
|
const params = [];
|
|
@@ -990,6 +1172,22 @@ class QueryInterface {
|
|
|
990
1172
|
return { sql: '', params: [] };
|
|
991
1173
|
return { sql: ` WHERE ${clause}`, params };
|
|
992
1174
|
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Refuse mutations with an empty predicate unless explicitly opted in.
|
|
1177
|
+
*
|
|
1178
|
+
* An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
|
|
1179
|
+
* mutation with no filter — a common footgun when a caller's filter
|
|
1180
|
+
* value accidentally resolves to `undefined`. This guard throws
|
|
1181
|
+
* `ValidationError` in that case unless `allowFullTableScan: true`.
|
|
1182
|
+
*/
|
|
1183
|
+
assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
|
|
1184
|
+
if (whereSql.length > 0)
|
|
1185
|
+
return;
|
|
1186
|
+
if (allowFullTableScan === true)
|
|
1187
|
+
return;
|
|
1188
|
+
throw new errors_js_1.ValidationError(`[turbine] ${operation} on "${this.table}" refused: the \`where\` clause is empty. ` +
|
|
1189
|
+
`Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
|
|
1190
|
+
}
|
|
993
1191
|
/**
|
|
994
1192
|
* Build the inner WHERE expression (without the WHERE keyword).
|
|
995
1193
|
* Returns null if no conditions exist.
|
|
@@ -1226,8 +1424,12 @@ class QueryInterface {
|
|
|
1226
1424
|
}
|
|
1227
1425
|
/** Build ORDER BY clause from an object */
|
|
1228
1426
|
buildOrderBy(orderBy) {
|
|
1427
|
+
const meta = this.schema.tables[this.table];
|
|
1229
1428
|
return Object.entries(orderBy)
|
|
1230
1429
|
.map(([key, dir]) => {
|
|
1430
|
+
if (meta && !(key in meta.columnMap)) {
|
|
1431
|
+
throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
|
|
1432
|
+
}
|
|
1231
1433
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1232
1434
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
1233
1435
|
})
|
|
@@ -1276,6 +1478,7 @@ class QueryInterface {
|
|
|
1276
1478
|
parsed[relName] = JSON.parse(rawValue);
|
|
1277
1479
|
}
|
|
1278
1480
|
catch {
|
|
1481
|
+
console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
|
|
1279
1482
|
parsed[relName] = rawValue;
|
|
1280
1483
|
}
|
|
1281
1484
|
}
|
|
@@ -1295,52 +1498,173 @@ class QueryInterface {
|
|
|
1295
1498
|
return parsed;
|
|
1296
1499
|
}
|
|
1297
1500
|
/**
|
|
1298
|
-
* Build a SELECT clause
|
|
1501
|
+
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
1502
|
+
*
|
|
1503
|
+
* For each relation specified in the `with` clause, this method generates a correlated
|
|
1504
|
+
* subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
|
|
1505
|
+
* is a single SQL SELECT clause that resolves the full object tree in one query --
|
|
1506
|
+
* no N+1 problem.
|
|
1299
1507
|
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1508
|
+
* **How it works:**
|
|
1509
|
+
* 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
|
|
1510
|
+
* 2. Iterates over each key in the `with` clause, looking up the relation definition.
|
|
1511
|
+
* 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
|
|
1512
|
+
* correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
|
|
1513
|
+
* 4. Each subquery is aliased as the relation name in the final SELECT.
|
|
1302
1514
|
*
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1515
|
+
* **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
|
|
1516
|
+
* Each call to `buildRelationSubquery` increments it to produce unique table aliases
|
|
1517
|
+
* (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
|
|
1518
|
+
* collisions in the generated SQL.
|
|
1519
|
+
*
|
|
1520
|
+
* **Example output:**
|
|
1521
|
+
* ```sql
|
|
1522
|
+
* "users"."id", "users"."name", "users"."email",
|
|
1523
|
+
* (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
|
|
1524
|
+
* FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
|
|
1525
|
+
* ```
|
|
1526
|
+
*
|
|
1527
|
+
* @param table - The root table name (e.g. `"users"`).
|
|
1528
|
+
* @param withClause - An object mapping relation names to their include specs
|
|
1529
|
+
* (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
|
|
1530
|
+
* @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
|
|
1531
|
+
* Nested where/limit values are pushed here to prevent SQL injection.
|
|
1532
|
+
* @param columnsList - Optional subset of columns to include in the SELECT. When `null`
|
|
1533
|
+
* or omitted, all columns from the table's schema metadata are used.
|
|
1534
|
+
* @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
|
|
1535
|
+
* for circular-relation detection. Defaults to `0` at the top level.
|
|
1536
|
+
* @param path - Breadcrumb trail of relation names traversed so far, used in error
|
|
1537
|
+
* messages when circular or too-deep nesting is detected.
|
|
1538
|
+
* @returns A complete SELECT clause string (without the `SELECT` keyword) containing
|
|
1539
|
+
* base columns and relation subqueries.
|
|
1305
1540
|
*/
|
|
1306
|
-
buildSelectWithRelations(table, withClause, params, columnsList) {
|
|
1541
|
+
buildSelectWithRelations(table, withClause, params, columnsList, depth, path) {
|
|
1307
1542
|
const meta = this.schema.tables[table];
|
|
1308
1543
|
if (!meta)
|
|
1309
|
-
throw new
|
|
1544
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}"`);
|
|
1310
1545
|
const cols = columnsList ?? meta.allColumns;
|
|
1311
1546
|
const qtbl = quoteIdent(table);
|
|
1312
|
-
const baseCols = cols
|
|
1313
|
-
.map((col) => `${qtbl}.${quoteIdent(col)}`)
|
|
1314
|
-
.join(', ');
|
|
1547
|
+
const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
|
|
1315
1548
|
const relationSelects = [];
|
|
1316
1549
|
const aliasCounter = { n: 0 };
|
|
1317
1550
|
for (const [relName, relSpec] of Object.entries(withClause)) {
|
|
1318
1551
|
const relDef = meta.relations[relName];
|
|
1319
1552
|
if (!relDef) {
|
|
1320
|
-
throw new
|
|
1553
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
1321
1554
|
`Available: ${Object.keys(meta.relations).join(', ')}`);
|
|
1322
1555
|
}
|
|
1323
1556
|
// The main table is not aliased, so pass table name as parentRef
|
|
1324
|
-
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
|
|
1557
|
+
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
1325
1558
|
relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
|
|
1326
1559
|
}
|
|
1327
1560
|
return [baseCols, ...relationSelects].join(', ');
|
|
1328
1561
|
}
|
|
1329
1562
|
/**
|
|
1330
|
-
*
|
|
1563
|
+
* Generate a correlated subquery that returns JSON for a single relation.
|
|
1564
|
+
*
|
|
1565
|
+
* This is the core of Turbine's single-query nested relation strategy. For a given
|
|
1566
|
+
* relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
|
|
1567
|
+
* that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
|
|
1568
|
+
* a single JSON object (belongsTo / hasOne).
|
|
1569
|
+
*
|
|
1570
|
+
* ### Algorithm overview
|
|
1331
1571
|
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1572
|
+
* 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
|
|
1573
|
+
* `aliasCounter` so that deeply nested subqueries never collide.
|
|
1334
1574
|
*
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1575
|
+
* 2. **Column resolution:** Honors `select` / `omit` options to control which columns
|
|
1576
|
+
* appear in the output JSON.
|
|
1577
|
+
*
|
|
1578
|
+
* 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
|
|
1579
|
+
* field names to their column values:
|
|
1580
|
+
* ```sql
|
|
1581
|
+
* json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
|
|
1582
|
+
* ```
|
|
1583
|
+
*
|
|
1584
|
+
* 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
|
|
1585
|
+
* `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
|
|
1586
|
+
* into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
|
|
1587
|
+
* For belongsTo / hasOne, no aggregation is used -- just the single JSON object
|
|
1588
|
+
* with `LIMIT 1`.
|
|
1589
|
+
*
|
|
1590
|
+
* 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
|
|
1591
|
+
* - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
|
|
1592
|
+
* (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
|
|
1593
|
+
* - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
|
|
1594
|
+
* (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
|
|
1595
|
+
*
|
|
1596
|
+
* 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
|
|
1597
|
+
* itself recursively for each nested relation, passing the current alias as
|
|
1598
|
+
* `parentRef`. The nested subquery appears as an additional key in the
|
|
1599
|
+
* `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
|
|
1600
|
+
* Depth is incremented and capped at 10 to guard against circular relations.
|
|
1601
|
+
*
|
|
1602
|
+
* 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
|
|
1603
|
+
* the query is restructured into a two-level form:
|
|
1604
|
+
* ```sql
|
|
1605
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
1606
|
+
* FROM (
|
|
1607
|
+
* SELECT t0.* FROM "posts" t0
|
|
1608
|
+
* WHERE t0."user_id" = "users"."id"
|
|
1609
|
+
* ORDER BY t0."created_at" DESC
|
|
1610
|
+
* LIMIT $1
|
|
1611
|
+
* ) t0i
|
|
1612
|
+
* ```
|
|
1613
|
+
* This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
|
|
1614
|
+
* aggregation. Without the inner subquery, LIMIT would be meaningless because
|
|
1615
|
+
* `json_agg` produces a single aggregated row.
|
|
1616
|
+
*
|
|
1617
|
+
* 8. **Parameter threading:** All user-supplied values (where filters, limit) are
|
|
1618
|
+
* pushed to the shared `params` array with `$N` placeholders. No string
|
|
1619
|
+
* interpolation of user data ever occurs -- all identifiers go through
|
|
1620
|
+
* `quoteIdent()` and all values are parameterized.
|
|
1621
|
+
*
|
|
1622
|
+
* ### Example output (hasMany with nested relation)
|
|
1623
|
+
* ```sql
|
|
1624
|
+
* SELECT COALESCE(json_agg(json_build_object(
|
|
1625
|
+
* 'id', t0."id",
|
|
1626
|
+
* 'title', t0."title",
|
|
1627
|
+
* 'comments', COALESCE((
|
|
1628
|
+
* SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
|
|
1629
|
+
* FROM "comments" t1 WHERE t1."post_id" = t0."id"
|
|
1630
|
+
* ), '[]'::json)
|
|
1631
|
+
* )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
|
|
1632
|
+
* ```
|
|
1633
|
+
*
|
|
1634
|
+
* @param relDef - The relation definition from schema metadata (contains `to`, `type`,
|
|
1635
|
+
* `foreignKey`, `referenceKey`).
|
|
1636
|
+
* @param spec - Either `true` (include with defaults) or a `WithOptions` object that
|
|
1637
|
+
* can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
|
|
1638
|
+
* @param params - Shared parameter array. User-supplied values are pushed here and
|
|
1639
|
+
* referenced as `$1`, `$2`, etc. in the generated SQL.
|
|
1640
|
+
* @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
|
|
1641
|
+
* parent query. Used to build the correlated WHERE clause that ties
|
|
1642
|
+
* child rows to their parent row.
|
|
1643
|
+
* @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
|
|
1644
|
+
* table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
|
|
1645
|
+
* Each call increments `n` by 1.
|
|
1646
|
+
* @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
|
|
1647
|
+
* call. If it reaches 10, a {@link CircularRelationError} is thrown.
|
|
1648
|
+
* @param path - Breadcrumb trail of relation/table names traversed so far
|
|
1649
|
+
* (e.g. `["users", "posts", "comments"]`). Used in the error message
|
|
1650
|
+
* when circular or too-deep nesting is detected.
|
|
1651
|
+
* @returns A complete SQL subquery string (without surrounding parentheses) that
|
|
1652
|
+
* evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
|
|
1338
1653
|
*/
|
|
1339
|
-
buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter) {
|
|
1654
|
+
buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter, depth, path) {
|
|
1655
|
+
const currentDepth = depth ?? 0;
|
|
1656
|
+
const currentPath = path ?? [this.table];
|
|
1340
1657
|
const targetTable = relDef.to;
|
|
1658
|
+
// Hard depth cap — the `with` clause is a finite JSON structure so users can't
|
|
1659
|
+
// create true infinite recursion, but extremely deep nesting (10+ levels) produces
|
|
1660
|
+
// unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
|
|
1661
|
+
// since they are legitimate queries (Prisma supports the same pattern).
|
|
1662
|
+
if (currentDepth >= 10) {
|
|
1663
|
+
throw new errors_js_1.CircularRelationError([...currentPath, targetTable]);
|
|
1664
|
+
}
|
|
1341
1665
|
const targetMeta = this.schema.tables[targetTable];
|
|
1342
1666
|
if (!targetMeta)
|
|
1343
|
-
throw new
|
|
1667
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${targetTable}"`);
|
|
1344
1668
|
// Generate a unique alias: t0, t1, t2, ...
|
|
1345
1669
|
const alias = `t${aliasCounter.n++}`;
|
|
1346
1670
|
// Resolve which columns to include based on select/omit
|
|
@@ -1359,17 +1683,23 @@ class QueryInterface {
|
|
|
1359
1683
|
}
|
|
1360
1684
|
// Build json_build_object pairs for resolved columns
|
|
1361
1685
|
const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col))}', ${alias}.${quoteIdent(col)}`);
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1686
|
+
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
1687
|
+
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
1688
|
+
// so we must NOT build them here (they would push orphaned params).
|
|
1689
|
+
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
1690
|
+
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
1691
|
+
if (!willWrap && spec !== true && spec.with) {
|
|
1364
1692
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1365
1693
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1366
1694
|
if (!nestedRelDef) {
|
|
1367
|
-
throw new
|
|
1695
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1368
1696
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
1369
1697
|
}
|
|
1370
1698
|
// Recursively build nested subquery, passing THIS alias as the parent reference
|
|
1371
|
-
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter);
|
|
1372
|
-
|
|
1699
|
+
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
1700
|
+
// Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
|
|
1701
|
+
const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
|
|
1702
|
+
jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), ${fallback})`);
|
|
1373
1703
|
}
|
|
1374
1704
|
}
|
|
1375
1705
|
const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
|
|
@@ -1383,7 +1713,7 @@ class QueryInterface {
|
|
|
1383
1713
|
.map(([k, dir]) => {
|
|
1384
1714
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
1385
1715
|
if (!targetMeta.allColumns.includes(col)) {
|
|
1386
|
-
throw new
|
|
1716
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
1387
1717
|
}
|
|
1388
1718
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1389
1719
|
return `${alias}.${quoteIdent(col)} ${safeDir}`;
|
|
@@ -1406,7 +1736,7 @@ class QueryInterface {
|
|
|
1406
1736
|
for (const [k, v] of Object.entries(spec.where)) {
|
|
1407
1737
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
1408
1738
|
if (!targetMeta.allColumns.includes(col)) {
|
|
1409
|
-
throw new
|
|
1739
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
1410
1740
|
}
|
|
1411
1741
|
params.push(v);
|
|
1412
1742
|
whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
|
|
@@ -1428,14 +1758,17 @@ class QueryInterface {
|
|
|
1428
1758
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1429
1759
|
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
1430
1760
|
const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col))}', ${innerAlias}.${quoteIdent(col)}`);
|
|
1431
|
-
//
|
|
1761
|
+
// Build nested relation subqueries referencing innerAlias
|
|
1432
1762
|
if (spec !== true && spec.with) {
|
|
1433
|
-
for (const [nestedRelName] of Object.entries(spec.with)) {
|
|
1763
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1434
1764
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1435
|
-
if (nestedRelDef) {
|
|
1436
|
-
|
|
1437
|
-
|
|
1765
|
+
if (!nestedRelDef) {
|
|
1766
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1767
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
1438
1768
|
}
|
|
1769
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
1770
|
+
const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
|
|
1771
|
+
innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), ${fallback})`);
|
|
1439
1772
|
}
|
|
1440
1773
|
}
|
|
1441
1774
|
const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
|