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/query.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* turbine-orm — Query builder
|
|
3
3
|
*
|
|
4
4
|
* Each table accessor (db.users, db.posts, etc.) returns a QueryInterface<T>
|
|
5
5
|
* that builds parameterized SQL and executes it through the connection pool.
|
|
6
6
|
*
|
|
7
7
|
* Nested relations use json_build_object + json_agg subqueries for single-query
|
|
8
|
-
* resolution —
|
|
8
|
+
* resolution — a PostgreSQL-native approach that eliminates N+1 query patterns.
|
|
9
9
|
*
|
|
10
10
|
* Schema-driven: all column names, types, and relations come from introspected
|
|
11
11
|
* metadata — nothing is hardcoded.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { CircularRelationError, NotFoundError, RelationError, TimeoutError, ValidationError, wrapPgError, } from './errors.js';
|
|
14
|
+
import { camelToSnake, snakeToCamel } from './schema.js';
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Identifier quoting — prevents SQL injection via table/column names
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
@@ -42,22 +43,41 @@ function escapeLike(value) {
|
|
|
42
43
|
}
|
|
43
44
|
/** Known operator keys — used to detect operator objects vs plain values */
|
|
44
45
|
const OPERATOR_KEYS = new Set([
|
|
45
|
-
'gt',
|
|
46
|
-
'
|
|
46
|
+
'gt',
|
|
47
|
+
'gte',
|
|
48
|
+
'lt',
|
|
49
|
+
'lte',
|
|
50
|
+
'not',
|
|
51
|
+
'in',
|
|
52
|
+
'notIn',
|
|
53
|
+
'contains',
|
|
54
|
+
'startsWith',
|
|
55
|
+
'endsWith',
|
|
56
|
+
'mode',
|
|
47
57
|
]);
|
|
48
58
|
/** Check if a value is a where operator object (has at least one known operator key) */
|
|
49
59
|
function isWhereOperator(value) {
|
|
50
|
-
if (value === null ||
|
|
60
|
+
if (value === null ||
|
|
61
|
+
value === undefined ||
|
|
62
|
+
typeof value !== 'object' ||
|
|
63
|
+
Array.isArray(value) ||
|
|
64
|
+
value instanceof Date) {
|
|
51
65
|
return false;
|
|
52
66
|
}
|
|
53
67
|
const keys = Object.keys(value);
|
|
54
68
|
return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
|
|
55
69
|
}
|
|
70
|
+
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
71
|
+
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
56
72
|
/** Known JSONB operator keys */
|
|
57
73
|
const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
58
74
|
/** Check if a value is a JSONB filter object */
|
|
59
75
|
function isJsonFilter(value) {
|
|
60
|
-
if (value === null ||
|
|
76
|
+
if (value === null ||
|
|
77
|
+
value === undefined ||
|
|
78
|
+
typeof value !== 'object' ||
|
|
79
|
+
Array.isArray(value) ||
|
|
80
|
+
value instanceof Date) {
|
|
61
81
|
return false;
|
|
62
82
|
}
|
|
63
83
|
const keys = Object.keys(value);
|
|
@@ -67,7 +87,11 @@ function isJsonFilter(value) {
|
|
|
67
87
|
const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
68
88
|
/** Check if a value is an Array filter object */
|
|
69
89
|
function isArrayFilter(value) {
|
|
70
|
-
if (value === null ||
|
|
90
|
+
if (value === null ||
|
|
91
|
+
value === undefined ||
|
|
92
|
+
typeof value !== 'object' ||
|
|
93
|
+
Array.isArray(value) ||
|
|
94
|
+
value instanceof Date) {
|
|
71
95
|
return false;
|
|
72
96
|
}
|
|
73
97
|
const keys = Object.keys(value);
|
|
@@ -108,7 +132,9 @@ class LRUCache {
|
|
|
108
132
|
}
|
|
109
133
|
this.cache.set(key, value);
|
|
110
134
|
}
|
|
111
|
-
get size() {
|
|
135
|
+
get size() {
|
|
136
|
+
return this.cache.size;
|
|
137
|
+
}
|
|
112
138
|
}
|
|
113
139
|
export class QueryInterface {
|
|
114
140
|
pool;
|
|
@@ -129,7 +155,7 @@ export class QueryInterface {
|
|
|
129
155
|
this.schema = schema;
|
|
130
156
|
const meta = schema.tables[table];
|
|
131
157
|
if (!meta) {
|
|
132
|
-
throw new
|
|
158
|
+
throw new ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
|
|
133
159
|
}
|
|
134
160
|
this.tableMeta = meta;
|
|
135
161
|
this.middlewares = middlewares ?? [];
|
|
@@ -146,18 +172,27 @@ export class QueryInterface {
|
|
|
146
172
|
/**
|
|
147
173
|
* Execute a pool.query with an optional timeout.
|
|
148
174
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
175
|
+
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
149
176
|
*/
|
|
150
177
|
async queryWithTimeout(sql, params, timeout) {
|
|
151
178
|
if (!timeout) {
|
|
152
|
-
|
|
179
|
+
try {
|
|
180
|
+
return await this.pool.query(sql, params);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
throw wrapPgError(err);
|
|
184
|
+
}
|
|
153
185
|
}
|
|
154
186
|
let timer;
|
|
155
187
|
const timeoutPromise = new Promise((_, reject) => {
|
|
156
|
-
timer = setTimeout(() => reject(new
|
|
188
|
+
timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
|
|
157
189
|
});
|
|
158
190
|
try {
|
|
159
191
|
return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
|
|
160
192
|
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
throw wrapPgError(err);
|
|
195
|
+
}
|
|
161
196
|
finally {
|
|
162
197
|
clearTimeout(timer);
|
|
163
198
|
}
|
|
@@ -188,18 +223,6 @@ export class QueryInterface {
|
|
|
188
223
|
};
|
|
189
224
|
return next(params);
|
|
190
225
|
}
|
|
191
|
-
/**
|
|
192
|
-
* Generate a cache key for a query shape.
|
|
193
|
-
* Same where-keys + same with-clause = same SQL template.
|
|
194
|
-
*/
|
|
195
|
-
cacheKey(op, whereKeys, withClause, extra) {
|
|
196
|
-
let key = `${op}:${whereKeys.sort().join(',')}`;
|
|
197
|
-
if (withClause)
|
|
198
|
-
key += `:w=${JSON.stringify(Object.keys(withClause).sort())}`;
|
|
199
|
-
if (extra)
|
|
200
|
-
key += `:${extra}`;
|
|
201
|
-
return key;
|
|
202
|
-
}
|
|
203
226
|
// -------------------------------------------------------------------------
|
|
204
227
|
// findUnique
|
|
205
228
|
// -------------------------------------------------------------------------
|
|
@@ -215,10 +238,11 @@ export class QueryInterface {
|
|
|
215
238
|
const whereObj = args.where;
|
|
216
239
|
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
217
240
|
const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
|
|
218
|
-
const isSimpleWhere = !whereObj
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
241
|
+
const isSimpleWhere = !whereObj.OR &&
|
|
242
|
+
whereKeys.every((k) => {
|
|
243
|
+
const v = whereObj[k];
|
|
244
|
+
return v !== null && !isWhereOperator(v);
|
|
245
|
+
});
|
|
222
246
|
// For simple queries (no nested with, no operators), use cached SQL template
|
|
223
247
|
if (!args.with && isSimpleWhere) {
|
|
224
248
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
@@ -229,9 +253,7 @@ export class QueryInterface {
|
|
|
229
253
|
const qt = quoteIdent(this.table);
|
|
230
254
|
const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
|
|
231
255
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
232
|
-
const selectExpr = columnsList
|
|
233
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
234
|
-
: `${qt}.*`;
|
|
256
|
+
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
235
257
|
sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
236
258
|
this.sqlCache.set(ck, sql);
|
|
237
259
|
}
|
|
@@ -249,9 +271,7 @@ export class QueryInterface {
|
|
|
249
271
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
250
272
|
if (!args.with) {
|
|
251
273
|
const qt = quoteIdent(this.table);
|
|
252
|
-
const selectExpr = columnsList
|
|
253
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
254
|
-
: `${qt}.*`;
|
|
274
|
+
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
255
275
|
const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
256
276
|
return {
|
|
257
277
|
sql,
|
|
@@ -292,9 +312,7 @@ export class QueryInterface {
|
|
|
292
312
|
});
|
|
293
313
|
}
|
|
294
314
|
buildFindMany(args) {
|
|
295
|
-
const { sql: whereSql, params } = args?.where
|
|
296
|
-
? this.buildWhere(args.where)
|
|
297
|
-
: { sql: '', params: [] };
|
|
315
|
+
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
298
316
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
299
317
|
const qt = quoteIdent(this.table);
|
|
300
318
|
// Distinct support
|
|
@@ -351,13 +369,68 @@ export class QueryInterface {
|
|
|
351
369
|
return {
|
|
352
370
|
sql,
|
|
353
371
|
params,
|
|
354
|
-
transform: (result) => result.rows.map((row) => args?.with
|
|
355
|
-
? this.parseNestedRow(row, this.table)
|
|
356
|
-
: this.parseRow(row, this.table)),
|
|
372
|
+
transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
|
|
357
373
|
tag: `${this.table}.findMany`,
|
|
358
374
|
};
|
|
359
375
|
}
|
|
360
376
|
// -------------------------------------------------------------------------
|
|
377
|
+
// findManyStream — async iterable using PostgreSQL cursors
|
|
378
|
+
// -------------------------------------------------------------------------
|
|
379
|
+
/**
|
|
380
|
+
* Stream rows from a findMany query using PostgreSQL cursors.
|
|
381
|
+
* Returns an AsyncIterable that yields individual rows, fetching in batches internally.
|
|
382
|
+
*
|
|
383
|
+
* Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
|
|
384
|
+
* The cursor is automatically closed and the connection released when iteration
|
|
385
|
+
* completes or is terminated early (e.g. `break` from `for await`).
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* ```ts
|
|
389
|
+
* for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
|
|
390
|
+
* process.stdout.write(`${user.email}\n`);
|
|
391
|
+
* }
|
|
392
|
+
* ```
|
|
393
|
+
*/
|
|
394
|
+
async *findManyStream(args) {
|
|
395
|
+
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
|
|
396
|
+
const deferred = this.buildFindMany(args);
|
|
397
|
+
const hasRelations = !!args?.with;
|
|
398
|
+
// Acquire a dedicated connection — cursors require a single connection in a transaction
|
|
399
|
+
const client = await this.pool.connect();
|
|
400
|
+
const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
401
|
+
const quotedCursor = quoteIdent(cursorName);
|
|
402
|
+
try {
|
|
403
|
+
await client.query('BEGIN');
|
|
404
|
+
await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
|
|
405
|
+
while (true) {
|
|
406
|
+
const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
|
|
407
|
+
if (batch.rows.length === 0)
|
|
408
|
+
break;
|
|
409
|
+
for (const row of batch.rows) {
|
|
410
|
+
yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
|
|
411
|
+
}
|
|
412
|
+
if (batch.rows.length < batchSize)
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
await client.query(`CLOSE ${quotedCursor}`);
|
|
416
|
+
await client.query('COMMIT');
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
// Rollback on error (also closes cursor implicitly)
|
|
420
|
+
try {
|
|
421
|
+
await client.query('ROLLBACK');
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Connection may already be broken — ignore rollback error
|
|
425
|
+
}
|
|
426
|
+
// Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
|
|
427
|
+
throw wrapPgError(err);
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
client.release();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// -------------------------------------------------------------------------
|
|
361
434
|
// findFirst — like findMany but returns a single row or null
|
|
362
435
|
// -------------------------------------------------------------------------
|
|
363
436
|
async findFirst(args) {
|
|
@@ -399,7 +472,11 @@ export class QueryInterface {
|
|
|
399
472
|
transform: (result) => {
|
|
400
473
|
const row = inner.transform(result);
|
|
401
474
|
if (row === null) {
|
|
402
|
-
throw new
|
|
475
|
+
throw new NotFoundError({
|
|
476
|
+
table: this.table,
|
|
477
|
+
where: args?.where,
|
|
478
|
+
operation: 'findFirstOrThrow',
|
|
479
|
+
});
|
|
403
480
|
}
|
|
404
481
|
return row;
|
|
405
482
|
},
|
|
@@ -424,7 +501,11 @@ export class QueryInterface {
|
|
|
424
501
|
transform: (result) => {
|
|
425
502
|
const row = inner.transform(result);
|
|
426
503
|
if (row === null) {
|
|
427
|
-
throw new
|
|
504
|
+
throw new NotFoundError({
|
|
505
|
+
table: this.table,
|
|
506
|
+
where: args.where,
|
|
507
|
+
operation: 'findUniqueOrThrow',
|
|
508
|
+
});
|
|
428
509
|
}
|
|
429
510
|
return row;
|
|
430
511
|
},
|
|
@@ -452,8 +533,13 @@ export class QueryInterface {
|
|
|
452
533
|
params,
|
|
453
534
|
transform: (result) => {
|
|
454
535
|
const row = result.rows[0];
|
|
455
|
-
if (!row)
|
|
456
|
-
throw new
|
|
536
|
+
if (!row) {
|
|
537
|
+
throw new NotFoundError({
|
|
538
|
+
table: this.table,
|
|
539
|
+
operation: 'create',
|
|
540
|
+
message: `[turbine] create on "${this.table}" returned no row from RETURNING * — this should never happen.`,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
457
543
|
return this.parseRow(row, this.table);
|
|
458
544
|
},
|
|
459
545
|
tag: `${this.table}.create`,
|
|
@@ -518,23 +604,26 @@ export class QueryInterface {
|
|
|
518
604
|
}
|
|
519
605
|
buildUpdate(args) {
|
|
520
606
|
const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
521
|
-
// Build SET params first
|
|
607
|
+
// Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
|
|
522
608
|
const params = [];
|
|
523
|
-
const setClauses = setEntries.map(([k, v]) =>
|
|
524
|
-
params.push(v);
|
|
525
|
-
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
526
|
-
});
|
|
609
|
+
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
|
|
527
610
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
528
611
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
529
612
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
613
|
+
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
530
614
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
|
|
531
615
|
return {
|
|
532
616
|
sql,
|
|
533
617
|
params,
|
|
534
618
|
transform: (result) => {
|
|
535
619
|
const row = result.rows[0];
|
|
536
|
-
if (!row)
|
|
537
|
-
throw new
|
|
620
|
+
if (!row) {
|
|
621
|
+
throw new NotFoundError({
|
|
622
|
+
table: this.table,
|
|
623
|
+
where: args.where,
|
|
624
|
+
operation: 'update',
|
|
625
|
+
});
|
|
626
|
+
}
|
|
538
627
|
return this.parseRow(row, this.table);
|
|
539
628
|
},
|
|
540
629
|
tag: `${this.table}.update`,
|
|
@@ -552,14 +641,20 @@ export class QueryInterface {
|
|
|
552
641
|
}
|
|
553
642
|
buildDelete(args) {
|
|
554
643
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
644
|
+
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
555
645
|
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
|
|
556
646
|
return {
|
|
557
647
|
sql,
|
|
558
648
|
params,
|
|
559
649
|
transform: (result) => {
|
|
560
650
|
const row = result.rows[0];
|
|
561
|
-
if (!row)
|
|
562
|
-
throw new
|
|
651
|
+
if (!row) {
|
|
652
|
+
throw new NotFoundError({
|
|
653
|
+
table: this.table,
|
|
654
|
+
where: args.where,
|
|
655
|
+
operation: 'delete',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
563
658
|
return this.parseRow(row, this.table);
|
|
564
659
|
},
|
|
565
660
|
tag: `${this.table}.delete`,
|
|
@@ -602,8 +697,14 @@ export class QueryInterface {
|
|
|
602
697
|
params,
|
|
603
698
|
transform: (result) => {
|
|
604
699
|
const row = result.rows[0];
|
|
605
|
-
if (!row)
|
|
606
|
-
throw new
|
|
700
|
+
if (!row) {
|
|
701
|
+
throw new NotFoundError({
|
|
702
|
+
table: this.table,
|
|
703
|
+
where: args.where,
|
|
704
|
+
operation: 'upsert',
|
|
705
|
+
message: `[turbine] upsert on "${this.table}" returned no row from RETURNING * — this should never happen.`,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
607
708
|
return this.parseRow(row, this.table);
|
|
608
709
|
},
|
|
609
710
|
tag: `${this.table}.upsert`,
|
|
@@ -621,15 +722,13 @@ export class QueryInterface {
|
|
|
621
722
|
}
|
|
622
723
|
buildUpdateMany(args) {
|
|
623
724
|
const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
624
|
-
// Build SET params first
|
|
725
|
+
// Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
|
|
625
726
|
const params = [];
|
|
626
|
-
const setClauses = setEntries.map(([k, v]) =>
|
|
627
|
-
params.push(v);
|
|
628
|
-
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
629
|
-
});
|
|
727
|
+
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
|
|
630
728
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
631
729
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
632
730
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
731
|
+
this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
|
|
633
732
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
634
733
|
return {
|
|
635
734
|
sql,
|
|
@@ -650,6 +749,7 @@ export class QueryInterface {
|
|
|
650
749
|
}
|
|
651
750
|
buildDeleteMany(args) {
|
|
652
751
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
752
|
+
this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
|
|
653
753
|
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
654
754
|
return {
|
|
655
755
|
sql,
|
|
@@ -669,9 +769,7 @@ export class QueryInterface {
|
|
|
669
769
|
});
|
|
670
770
|
}
|
|
671
771
|
buildCount(args) {
|
|
672
|
-
const { sql: whereSql, params } = args?.where
|
|
673
|
-
? this.buildWhere(args.where)
|
|
674
|
-
: { sql: '', params: [] };
|
|
772
|
+
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
675
773
|
const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
676
774
|
return {
|
|
677
775
|
sql,
|
|
@@ -691,11 +789,17 @@ export class QueryInterface {
|
|
|
691
789
|
});
|
|
692
790
|
}
|
|
693
791
|
buildGroupBy(args) {
|
|
792
|
+
const meta = this.schema.tables[this.table];
|
|
793
|
+
if (meta) {
|
|
794
|
+
for (const key of args.by) {
|
|
795
|
+
if (!(key in meta.columnMap)) {
|
|
796
|
+
throw new ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
694
800
|
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
695
801
|
const groupCols = groupColsRaw.map((c) => quoteIdent(c));
|
|
696
|
-
const { sql: whereSql, params } = args.where
|
|
697
|
-
? this.buildWhere(args.where)
|
|
698
|
-
: { sql: '', params: [] };
|
|
802
|
+
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
699
803
|
// Build SELECT expressions: group-by columns + aggregate functions
|
|
700
804
|
const selectExprs = [...groupCols];
|
|
701
805
|
// _count
|
|
@@ -708,7 +812,7 @@ export class QueryInterface {
|
|
|
708
812
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
709
813
|
if (enabled) {
|
|
710
814
|
const col = this.toColumn(field);
|
|
711
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS
|
|
815
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
|
|
712
816
|
}
|
|
713
817
|
}
|
|
714
818
|
}
|
|
@@ -717,7 +821,7 @@ export class QueryInterface {
|
|
|
717
821
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
718
822
|
if (enabled) {
|
|
719
823
|
const col = this.toColumn(field);
|
|
720
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS
|
|
824
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
|
|
721
825
|
}
|
|
722
826
|
}
|
|
723
827
|
}
|
|
@@ -726,7 +830,7 @@ export class QueryInterface {
|
|
|
726
830
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
727
831
|
if (enabled) {
|
|
728
832
|
const col = this.toColumn(field);
|
|
729
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS
|
|
833
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
|
|
730
834
|
}
|
|
731
835
|
}
|
|
732
836
|
}
|
|
@@ -735,7 +839,7 @@ export class QueryInterface {
|
|
|
735
839
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
736
840
|
if (enabled) {
|
|
737
841
|
const col = this.toColumn(field);
|
|
738
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS
|
|
842
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
|
|
739
843
|
}
|
|
740
844
|
}
|
|
741
845
|
}
|
|
@@ -818,9 +922,26 @@ export class QueryInterface {
|
|
|
818
922
|
});
|
|
819
923
|
}
|
|
820
924
|
buildAggregate(args) {
|
|
821
|
-
const { sql: whereSql, params } = args.where
|
|
822
|
-
|
|
823
|
-
|
|
925
|
+
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
926
|
+
const meta = this.schema.tables[this.table];
|
|
927
|
+
if (meta) {
|
|
928
|
+
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
929
|
+
if (group && typeof group === 'object') {
|
|
930
|
+
for (const key of Object.keys(group)) {
|
|
931
|
+
if (!(key in meta.columnMap)) {
|
|
932
|
+
throw new ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (args._count && typeof args._count === 'object') {
|
|
938
|
+
for (const key of Object.keys(args._count)) {
|
|
939
|
+
if (!(key in meta.columnMap)) {
|
|
940
|
+
throw new ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
824
945
|
const selectExprs = [];
|
|
825
946
|
// _count
|
|
826
947
|
if (args._count === true) {
|
|
@@ -830,7 +951,7 @@ export class QueryInterface {
|
|
|
830
951
|
for (const [field, enabled] of Object.entries(args._count)) {
|
|
831
952
|
if (enabled) {
|
|
832
953
|
const col = this.toColumn(field);
|
|
833
|
-
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS
|
|
954
|
+
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent('_count_' + col)}`);
|
|
834
955
|
}
|
|
835
956
|
}
|
|
836
957
|
}
|
|
@@ -839,7 +960,7 @@ export class QueryInterface {
|
|
|
839
960
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
840
961
|
if (enabled) {
|
|
841
962
|
const col = this.toColumn(field);
|
|
842
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS
|
|
963
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
|
|
843
964
|
}
|
|
844
965
|
}
|
|
845
966
|
}
|
|
@@ -848,7 +969,7 @@ export class QueryInterface {
|
|
|
848
969
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
849
970
|
if (enabled) {
|
|
850
971
|
const col = this.toColumn(field);
|
|
851
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS
|
|
972
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
|
|
852
973
|
}
|
|
853
974
|
}
|
|
854
975
|
}
|
|
@@ -857,7 +978,7 @@ export class QueryInterface {
|
|
|
857
978
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
858
979
|
if (enabled) {
|
|
859
980
|
const col = this.toColumn(field);
|
|
860
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS
|
|
981
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
|
|
861
982
|
}
|
|
862
983
|
}
|
|
863
984
|
}
|
|
@@ -866,7 +987,7 @@ export class QueryInterface {
|
|
|
866
987
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
867
988
|
if (enabled) {
|
|
868
989
|
const col = this.toColumn(field);
|
|
869
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS
|
|
990
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
|
|
870
991
|
}
|
|
871
992
|
}
|
|
872
993
|
}
|
|
@@ -978,6 +1099,67 @@ export class QueryInterface {
|
|
|
978
1099
|
toSqlColumn(field) {
|
|
979
1100
|
return quoteIdent(this.toColumn(field));
|
|
980
1101
|
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Build a single SET clause entry for update/updateMany.
|
|
1104
|
+
*
|
|
1105
|
+
* Supports plain values and atomic operator objects ({ set, increment,
|
|
1106
|
+
* decrement, multiply, divide }). An operator object is detected ONLY when
|
|
1107
|
+
* it has EXACTLY one key that is one of the 5 operator keys — this avoids
|
|
1108
|
+
* misinterpreting JSON column values like `{ set: 'x' }` as operators
|
|
1109
|
+
* (real operator objects always have exactly one key, and a plain JSON
|
|
1110
|
+
* payload that happens to have a single `set` key is extremely unusual).
|
|
1111
|
+
* Multi-key objects are always treated as plain (JSON) values.
|
|
1112
|
+
*
|
|
1113
|
+
* Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
|
|
1114
|
+
* pushes any required params onto the shared params array so that WHERE
|
|
1115
|
+
* clause numbering continues correctly afterward.
|
|
1116
|
+
*/
|
|
1117
|
+
buildSetClause(key, value, params) {
|
|
1118
|
+
const col = this.toSqlColumn(key);
|
|
1119
|
+
// Detect atomic-operator object: plain object (not null, not array, not
|
|
1120
|
+
// Date, not Buffer) with EXACTLY one key matching an operator name.
|
|
1121
|
+
if (value !== null &&
|
|
1122
|
+
typeof value === 'object' &&
|
|
1123
|
+
!Array.isArray(value) &&
|
|
1124
|
+
!(value instanceof Date) &&
|
|
1125
|
+
!Buffer.isBuffer(value)) {
|
|
1126
|
+
const v = value;
|
|
1127
|
+
const keys = Object.keys(v);
|
|
1128
|
+
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
1129
|
+
const op = keys[0];
|
|
1130
|
+
const opValue = v[op];
|
|
1131
|
+
if (op === 'set') {
|
|
1132
|
+
params.push(opValue);
|
|
1133
|
+
return `${col} = $${params.length}`;
|
|
1134
|
+
}
|
|
1135
|
+
// Arithmetic operators: must be finite numbers
|
|
1136
|
+
if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
|
|
1137
|
+
throw new ValidationError(`[turbine] update operator "${op}" on "${this.table}.${key}" requires a finite number, got ${typeof opValue}`);
|
|
1138
|
+
}
|
|
1139
|
+
if (op === 'increment') {
|
|
1140
|
+
params.push(opValue);
|
|
1141
|
+
return `${col} = ${col} + $${params.length}`;
|
|
1142
|
+
}
|
|
1143
|
+
if (op === 'decrement') {
|
|
1144
|
+
params.push(opValue);
|
|
1145
|
+
return `${col} = ${col} - $${params.length}`;
|
|
1146
|
+
}
|
|
1147
|
+
if (op === 'multiply') {
|
|
1148
|
+
params.push(opValue);
|
|
1149
|
+
return `${col} = ${col} * $${params.length}`;
|
|
1150
|
+
}
|
|
1151
|
+
if (op === 'divide') {
|
|
1152
|
+
params.push(opValue);
|
|
1153
|
+
return `${col} = ${col} / $${params.length}`;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
// Fall through: multi-key objects or non-operator single-key objects
|
|
1157
|
+
// are treated as plain values (e.g., JSONB column payloads).
|
|
1158
|
+
}
|
|
1159
|
+
// Plain value (including null, Date, Buffer, arrays, JSON objects)
|
|
1160
|
+
params.push(value);
|
|
1161
|
+
return `${col} = $${params.length}`;
|
|
1162
|
+
}
|
|
981
1163
|
/** Build WHERE clause from a where object (supports operators, NULL, OR) */
|
|
982
1164
|
buildWhere(where) {
|
|
983
1165
|
const params = [];
|
|
@@ -986,6 +1168,22 @@ export class QueryInterface {
|
|
|
986
1168
|
return { sql: '', params: [] };
|
|
987
1169
|
return { sql: ` WHERE ${clause}`, params };
|
|
988
1170
|
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Refuse mutations with an empty predicate unless explicitly opted in.
|
|
1173
|
+
*
|
|
1174
|
+
* An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
|
|
1175
|
+
* mutation with no filter — a common footgun when a caller's filter
|
|
1176
|
+
* value accidentally resolves to `undefined`. This guard throws
|
|
1177
|
+
* `ValidationError` in that case unless `allowFullTableScan: true`.
|
|
1178
|
+
*/
|
|
1179
|
+
assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
|
|
1180
|
+
if (whereSql.length > 0)
|
|
1181
|
+
return;
|
|
1182
|
+
if (allowFullTableScan === true)
|
|
1183
|
+
return;
|
|
1184
|
+
throw new ValidationError(`[turbine] ${operation} on "${this.table}" refused: the \`where\` clause is empty. ` +
|
|
1185
|
+
`Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
|
|
1186
|
+
}
|
|
989
1187
|
/**
|
|
990
1188
|
* Build the inner WHERE expression (without the WHERE keyword).
|
|
991
1189
|
* Returns null if no conditions exist.
|
|
@@ -1222,8 +1420,12 @@ export class QueryInterface {
|
|
|
1222
1420
|
}
|
|
1223
1421
|
/** Build ORDER BY clause from an object */
|
|
1224
1422
|
buildOrderBy(orderBy) {
|
|
1423
|
+
const meta = this.schema.tables[this.table];
|
|
1225
1424
|
return Object.entries(orderBy)
|
|
1226
1425
|
.map(([key, dir]) => {
|
|
1426
|
+
if (meta && !(key in meta.columnMap)) {
|
|
1427
|
+
throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
|
|
1428
|
+
}
|
|
1227
1429
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1228
1430
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
1229
1431
|
})
|
|
@@ -1272,6 +1474,7 @@ export class QueryInterface {
|
|
|
1272
1474
|
parsed[relName] = JSON.parse(rawValue);
|
|
1273
1475
|
}
|
|
1274
1476
|
catch {
|
|
1477
|
+
console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
|
|
1275
1478
|
parsed[relName] = rawValue;
|
|
1276
1479
|
}
|
|
1277
1480
|
}
|
|
@@ -1291,52 +1494,173 @@ export class QueryInterface {
|
|
|
1291
1494
|
return parsed;
|
|
1292
1495
|
}
|
|
1293
1496
|
/**
|
|
1294
|
-
* Build a SELECT clause
|
|
1497
|
+
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
1498
|
+
*
|
|
1499
|
+
* For each relation specified in the `with` clause, this method generates a correlated
|
|
1500
|
+
* subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
|
|
1501
|
+
* is a single SQL SELECT clause that resolves the full object tree in one query --
|
|
1502
|
+
* no N+1 problem.
|
|
1295
1503
|
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1504
|
+
* **How it works:**
|
|
1505
|
+
* 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
|
|
1506
|
+
* 2. Iterates over each key in the `with` clause, looking up the relation definition.
|
|
1507
|
+
* 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
|
|
1508
|
+
* correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
|
|
1509
|
+
* 4. Each subquery is aliased as the relation name in the final SELECT.
|
|
1298
1510
|
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1511
|
+
* **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
|
|
1512
|
+
* Each call to `buildRelationSubquery` increments it to produce unique table aliases
|
|
1513
|
+
* (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
|
|
1514
|
+
* collisions in the generated SQL.
|
|
1515
|
+
*
|
|
1516
|
+
* **Example output:**
|
|
1517
|
+
* ```sql
|
|
1518
|
+
* "users"."id", "users"."name", "users"."email",
|
|
1519
|
+
* (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
|
|
1520
|
+
* FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
|
|
1521
|
+
* ```
|
|
1522
|
+
*
|
|
1523
|
+
* @param table - The root table name (e.g. `"users"`).
|
|
1524
|
+
* @param withClause - An object mapping relation names to their include specs
|
|
1525
|
+
* (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
|
|
1526
|
+
* @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
|
|
1527
|
+
* Nested where/limit values are pushed here to prevent SQL injection.
|
|
1528
|
+
* @param columnsList - Optional subset of columns to include in the SELECT. When `null`
|
|
1529
|
+
* or omitted, all columns from the table's schema metadata are used.
|
|
1530
|
+
* @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
|
|
1531
|
+
* for circular-relation detection. Defaults to `0` at the top level.
|
|
1532
|
+
* @param path - Breadcrumb trail of relation names traversed so far, used in error
|
|
1533
|
+
* messages when circular or too-deep nesting is detected.
|
|
1534
|
+
* @returns A complete SELECT clause string (without the `SELECT` keyword) containing
|
|
1535
|
+
* base columns and relation subqueries.
|
|
1301
1536
|
*/
|
|
1302
|
-
buildSelectWithRelations(table, withClause, params, columnsList) {
|
|
1537
|
+
buildSelectWithRelations(table, withClause, params, columnsList, depth, path) {
|
|
1303
1538
|
const meta = this.schema.tables[table];
|
|
1304
1539
|
if (!meta)
|
|
1305
|
-
throw new
|
|
1540
|
+
throw new ValidationError(`[turbine] Unknown table "${table}"`);
|
|
1306
1541
|
const cols = columnsList ?? meta.allColumns;
|
|
1307
1542
|
const qtbl = quoteIdent(table);
|
|
1308
|
-
const baseCols = cols
|
|
1309
|
-
.map((col) => `${qtbl}.${quoteIdent(col)}`)
|
|
1310
|
-
.join(', ');
|
|
1543
|
+
const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
|
|
1311
1544
|
const relationSelects = [];
|
|
1312
1545
|
const aliasCounter = { n: 0 };
|
|
1313
1546
|
for (const [relName, relSpec] of Object.entries(withClause)) {
|
|
1314
1547
|
const relDef = meta.relations[relName];
|
|
1315
1548
|
if (!relDef) {
|
|
1316
|
-
throw new
|
|
1549
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
1317
1550
|
`Available: ${Object.keys(meta.relations).join(', ')}`);
|
|
1318
1551
|
}
|
|
1319
1552
|
// The main table is not aliased, so pass table name as parentRef
|
|
1320
|
-
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
|
|
1553
|
+
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
1321
1554
|
relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
|
|
1322
1555
|
}
|
|
1323
1556
|
return [baseCols, ...relationSelects].join(', ');
|
|
1324
1557
|
}
|
|
1325
1558
|
/**
|
|
1326
|
-
*
|
|
1559
|
+
* Generate a correlated subquery that returns JSON for a single relation.
|
|
1560
|
+
*
|
|
1561
|
+
* This is the core of Turbine's single-query nested relation strategy. For a given
|
|
1562
|
+
* relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
|
|
1563
|
+
* that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
|
|
1564
|
+
* a single JSON object (belongsTo / hasOne).
|
|
1565
|
+
*
|
|
1566
|
+
* ### Algorithm overview
|
|
1327
1567
|
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1568
|
+
* 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
|
|
1569
|
+
* `aliasCounter` so that deeply nested subqueries never collide.
|
|
1330
1570
|
*
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1571
|
+
* 2. **Column resolution:** Honors `select` / `omit` options to control which columns
|
|
1572
|
+
* appear in the output JSON.
|
|
1573
|
+
*
|
|
1574
|
+
* 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
|
|
1575
|
+
* field names to their column values:
|
|
1576
|
+
* ```sql
|
|
1577
|
+
* json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
|
|
1578
|
+
* ```
|
|
1579
|
+
*
|
|
1580
|
+
* 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
|
|
1581
|
+
* `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
|
|
1582
|
+
* into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
|
|
1583
|
+
* For belongsTo / hasOne, no aggregation is used -- just the single JSON object
|
|
1584
|
+
* with `LIMIT 1`.
|
|
1585
|
+
*
|
|
1586
|
+
* 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
|
|
1587
|
+
* - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
|
|
1588
|
+
* (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
|
|
1589
|
+
* - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
|
|
1590
|
+
* (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
|
|
1591
|
+
*
|
|
1592
|
+
* 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
|
|
1593
|
+
* itself recursively for each nested relation, passing the current alias as
|
|
1594
|
+
* `parentRef`. The nested subquery appears as an additional key in the
|
|
1595
|
+
* `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
|
|
1596
|
+
* Depth is incremented and capped at 10 to guard against circular relations.
|
|
1597
|
+
*
|
|
1598
|
+
* 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
|
|
1599
|
+
* the query is restructured into a two-level form:
|
|
1600
|
+
* ```sql
|
|
1601
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
1602
|
+
* FROM (
|
|
1603
|
+
* SELECT t0.* FROM "posts" t0
|
|
1604
|
+
* WHERE t0."user_id" = "users"."id"
|
|
1605
|
+
* ORDER BY t0."created_at" DESC
|
|
1606
|
+
* LIMIT $1
|
|
1607
|
+
* ) t0i
|
|
1608
|
+
* ```
|
|
1609
|
+
* This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
|
|
1610
|
+
* aggregation. Without the inner subquery, LIMIT would be meaningless because
|
|
1611
|
+
* `json_agg` produces a single aggregated row.
|
|
1612
|
+
*
|
|
1613
|
+
* 8. **Parameter threading:** All user-supplied values (where filters, limit) are
|
|
1614
|
+
* pushed to the shared `params` array with `$N` placeholders. No string
|
|
1615
|
+
* interpolation of user data ever occurs -- all identifiers go through
|
|
1616
|
+
* `quoteIdent()` and all values are parameterized.
|
|
1617
|
+
*
|
|
1618
|
+
* ### Example output (hasMany with nested relation)
|
|
1619
|
+
* ```sql
|
|
1620
|
+
* SELECT COALESCE(json_agg(json_build_object(
|
|
1621
|
+
* 'id', t0."id",
|
|
1622
|
+
* 'title', t0."title",
|
|
1623
|
+
* 'comments', COALESCE((
|
|
1624
|
+
* SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
|
|
1625
|
+
* FROM "comments" t1 WHERE t1."post_id" = t0."id"
|
|
1626
|
+
* ), '[]'::json)
|
|
1627
|
+
* )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
|
|
1628
|
+
* ```
|
|
1629
|
+
*
|
|
1630
|
+
* @param relDef - The relation definition from schema metadata (contains `to`, `type`,
|
|
1631
|
+
* `foreignKey`, `referenceKey`).
|
|
1632
|
+
* @param spec - Either `true` (include with defaults) or a `WithOptions` object that
|
|
1633
|
+
* can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
|
|
1634
|
+
* @param params - Shared parameter array. User-supplied values are pushed here and
|
|
1635
|
+
* referenced as `$1`, `$2`, etc. in the generated SQL.
|
|
1636
|
+
* @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
|
|
1637
|
+
* parent query. Used to build the correlated WHERE clause that ties
|
|
1638
|
+
* child rows to their parent row.
|
|
1639
|
+
* @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
|
|
1640
|
+
* table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
|
|
1641
|
+
* Each call increments `n` by 1.
|
|
1642
|
+
* @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
|
|
1643
|
+
* call. If it reaches 10, a {@link CircularRelationError} is thrown.
|
|
1644
|
+
* @param path - Breadcrumb trail of relation/table names traversed so far
|
|
1645
|
+
* (e.g. `["users", "posts", "comments"]`). Used in the error message
|
|
1646
|
+
* when circular or too-deep nesting is detected.
|
|
1647
|
+
* @returns A complete SQL subquery string (without surrounding parentheses) that
|
|
1648
|
+
* evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
|
|
1334
1649
|
*/
|
|
1335
|
-
buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter) {
|
|
1650
|
+
buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter, depth, path) {
|
|
1651
|
+
const currentDepth = depth ?? 0;
|
|
1652
|
+
const currentPath = path ?? [this.table];
|
|
1336
1653
|
const targetTable = relDef.to;
|
|
1654
|
+
// Hard depth cap — the `with` clause is a finite JSON structure so users can't
|
|
1655
|
+
// create true infinite recursion, but extremely deep nesting (10+ levels) produces
|
|
1656
|
+
// unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
|
|
1657
|
+
// since they are legitimate queries (Prisma supports the same pattern).
|
|
1658
|
+
if (currentDepth >= 10) {
|
|
1659
|
+
throw new CircularRelationError([...currentPath, targetTable]);
|
|
1660
|
+
}
|
|
1337
1661
|
const targetMeta = this.schema.tables[targetTable];
|
|
1338
1662
|
if (!targetMeta)
|
|
1339
|
-
throw new
|
|
1663
|
+
throw new RelationError(`[turbine] Unknown relation target "${targetTable}"`);
|
|
1340
1664
|
// Generate a unique alias: t0, t1, t2, ...
|
|
1341
1665
|
const alias = `t${aliasCounter.n++}`;
|
|
1342
1666
|
// Resolve which columns to include based on select/omit
|
|
@@ -1355,17 +1679,23 @@ export class QueryInterface {
|
|
|
1355
1679
|
}
|
|
1356
1680
|
// Build json_build_object pairs for resolved columns
|
|
1357
1681
|
const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${alias}.${quoteIdent(col)}`);
|
|
1358
|
-
//
|
|
1359
|
-
|
|
1682
|
+
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
1683
|
+
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
1684
|
+
// so we must NOT build them here (they would push orphaned params).
|
|
1685
|
+
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
1686
|
+
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
1687
|
+
if (!willWrap && spec !== true && spec.with) {
|
|
1360
1688
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1361
1689
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1362
1690
|
if (!nestedRelDef) {
|
|
1363
|
-
throw new
|
|
1691
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1364
1692
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
1365
1693
|
}
|
|
1366
1694
|
// Recursively build nested subquery, passing THIS alias as the parent reference
|
|
1367
|
-
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter);
|
|
1368
|
-
|
|
1695
|
+
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
1696
|
+
// Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
|
|
1697
|
+
const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
|
|
1698
|
+
jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), ${fallback})`);
|
|
1369
1699
|
}
|
|
1370
1700
|
}
|
|
1371
1701
|
const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
|
|
@@ -1379,7 +1709,7 @@ export class QueryInterface {
|
|
|
1379
1709
|
.map(([k, dir]) => {
|
|
1380
1710
|
const col = camelToSnake(k);
|
|
1381
1711
|
if (!targetMeta.allColumns.includes(col)) {
|
|
1382
|
-
throw new
|
|
1712
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
1383
1713
|
}
|
|
1384
1714
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1385
1715
|
return `${alias}.${quoteIdent(col)} ${safeDir}`;
|
|
@@ -1402,7 +1732,7 @@ export class QueryInterface {
|
|
|
1402
1732
|
for (const [k, v] of Object.entries(spec.where)) {
|
|
1403
1733
|
const col = camelToSnake(k);
|
|
1404
1734
|
if (!targetMeta.allColumns.includes(col)) {
|
|
1405
|
-
throw new
|
|
1735
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
1406
1736
|
}
|
|
1407
1737
|
params.push(v);
|
|
1408
1738
|
whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
|
|
@@ -1424,14 +1754,17 @@ export class QueryInterface {
|
|
|
1424
1754
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1425
1755
|
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
1426
1756
|
const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${innerAlias}.${quoteIdent(col)}`);
|
|
1427
|
-
//
|
|
1757
|
+
// Build nested relation subqueries referencing innerAlias
|
|
1428
1758
|
if (spec !== true && spec.with) {
|
|
1429
|
-
for (const [nestedRelName] of Object.entries(spec.with)) {
|
|
1759
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1430
1760
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1431
|
-
if (nestedRelDef) {
|
|
1432
|
-
|
|
1433
|
-
|
|
1761
|
+
if (!nestedRelDef) {
|
|
1762
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1763
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
1434
1764
|
}
|
|
1765
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
1766
|
+
const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
|
|
1767
|
+
innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), ${fallback})`);
|
|
1435
1768
|
}
|
|
1436
1769
|
}
|
|
1437
1770
|
const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
|