turbine-orm 0.4.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 +243 -26
- package/dist/cjs/cli/config.js +151 -0
- package/dist/cjs/cli/index.js +1176 -0
- package/dist/cjs/cli/migrate.js +446 -0
- package/dist/cjs/cli/ui.js +233 -0
- package/dist/cjs/client.js +512 -0
- package/dist/cjs/errors.js +293 -0
- package/dist/cjs/generate.js +321 -0
- package/dist/cjs/index.js +94 -0
- package/dist/cjs/introspect.js +287 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pipeline.js +78 -0
- package/dist/cjs/query.js +1891 -0
- package/dist/cjs/schema-builder.js +238 -0
- package/dist/cjs/schema-sql.js +509 -0
- package/dist/cjs/schema.js +140 -0
- package/dist/cjs/serverless.js +110 -0
- package/dist/cli/config.js +6 -16
- package/dist/cli/index.js +256 -49
- package/dist/cli/migrate.d.ts +35 -6
- package/dist/cli/migrate.js +124 -76
- package/dist/cli/ui.js +5 -9
- package/dist/client.d.ts +87 -3
- package/dist/client.js +122 -46
- package/dist/errors.d.ts +138 -0
- package/dist/errors.js +278 -0
- package/dist/generate.js +37 -11
- package/dist/index.d.ts +10 -8
- package/dist/index.js +15 -11
- package/dist/introspect.js +3 -5
- package/dist/pipeline.js +8 -1
- package/dist/query.d.ts +310 -45
- package/dist/query.js +565 -237
- package/dist/schema-builder.js +91 -23
- package/dist/schema-sql.d.ts +6 -2
- package/dist/schema-sql.js +180 -26
- package/dist/schema.js +4 -1
- package/dist/serverless.d.ts +91 -139
- package/dist/serverless.js +86 -173
- package/package.json +44 -21
- package/dist/cli/config.d.ts.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/migrate.d.ts.map +0 -1
- package/dist/cli/ui.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/generate.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/introspect.d.ts.map +0 -1
- package/dist/pipeline.d.ts.map +0 -1
- package/dist/query.d.ts.map +0 -1
- package/dist/schema-builder.d.ts.map +0 -1
- package/dist/schema-sql.d.ts.map +0 -1
- package/dist/schema.d.ts.map +0 -1
- package/dist/serverless.d.ts.map +0 -1
- package/dist/types.d.ts +0 -93
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -126
package/dist/query.js
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -26,6 +27,13 @@ import { snakeToCamel, camelToSnake } from './schema.js';
|
|
|
26
27
|
export function quoteIdent(name) {
|
|
27
28
|
return `"${name.replace(/"/g, '""')}"`;
|
|
28
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Escape single quotes for use as string keys in json_build_object().
|
|
32
|
+
* Doubles single quotes per SQL quoting rules.
|
|
33
|
+
*/
|
|
34
|
+
function escSingleQuote(s) {
|
|
35
|
+
return s.replace(/'/g, "''");
|
|
36
|
+
}
|
|
29
37
|
/**
|
|
30
38
|
* Escape LIKE pattern metacharacters: %, _, and \.
|
|
31
39
|
* Must be used with `ESCAPE '\'` in the LIKE clause.
|
|
@@ -35,22 +43,41 @@ function escapeLike(value) {
|
|
|
35
43
|
}
|
|
36
44
|
/** Known operator keys — used to detect operator objects vs plain values */
|
|
37
45
|
const OPERATOR_KEYS = new Set([
|
|
38
|
-
'gt',
|
|
39
|
-
'
|
|
46
|
+
'gt',
|
|
47
|
+
'gte',
|
|
48
|
+
'lt',
|
|
49
|
+
'lte',
|
|
50
|
+
'not',
|
|
51
|
+
'in',
|
|
52
|
+
'notIn',
|
|
53
|
+
'contains',
|
|
54
|
+
'startsWith',
|
|
55
|
+
'endsWith',
|
|
56
|
+
'mode',
|
|
40
57
|
]);
|
|
41
58
|
/** Check if a value is a where operator object (has at least one known operator key) */
|
|
42
59
|
function isWhereOperator(value) {
|
|
43
|
-
if (value === null ||
|
|
60
|
+
if (value === null ||
|
|
61
|
+
value === undefined ||
|
|
62
|
+
typeof value !== 'object' ||
|
|
63
|
+
Array.isArray(value) ||
|
|
64
|
+
value instanceof Date) {
|
|
44
65
|
return false;
|
|
45
66
|
}
|
|
46
67
|
const keys = Object.keys(value);
|
|
47
68
|
return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
|
|
48
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']);
|
|
49
72
|
/** Known JSONB operator keys */
|
|
50
73
|
const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
51
74
|
/** Check if a value is a JSONB filter object */
|
|
52
75
|
function isJsonFilter(value) {
|
|
53
|
-
if (value === null ||
|
|
76
|
+
if (value === null ||
|
|
77
|
+
value === undefined ||
|
|
78
|
+
typeof value !== 'object' ||
|
|
79
|
+
Array.isArray(value) ||
|
|
80
|
+
value instanceof Date) {
|
|
54
81
|
return false;
|
|
55
82
|
}
|
|
56
83
|
const keys = Object.keys(value);
|
|
@@ -60,38 +87,124 @@ function isJsonFilter(value) {
|
|
|
60
87
|
const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
61
88
|
/** Check if a value is an Array filter object */
|
|
62
89
|
function isArrayFilter(value) {
|
|
63
|
-
if (value === null ||
|
|
90
|
+
if (value === null ||
|
|
91
|
+
value === undefined ||
|
|
92
|
+
typeof value !== 'object' ||
|
|
93
|
+
Array.isArray(value) ||
|
|
94
|
+
value instanceof Date) {
|
|
64
95
|
return false;
|
|
65
96
|
}
|
|
66
97
|
const keys = Object.keys(value);
|
|
67
98
|
return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
|
|
68
99
|
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// LRU cache — bounded SQL template cache to prevent memory leaks
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/**
|
|
104
|
+
* Simple LRU (Least Recently Used) cache with a fixed maximum size.
|
|
105
|
+
* When the cache exceeds maxSize, the oldest (least recently used) entry is evicted.
|
|
106
|
+
* Uses Map insertion order for O(1) eviction.
|
|
107
|
+
*/
|
|
108
|
+
class LRUCache {
|
|
109
|
+
maxSize;
|
|
110
|
+
cache = new Map();
|
|
111
|
+
constructor(maxSize) {
|
|
112
|
+
this.maxSize = maxSize;
|
|
113
|
+
}
|
|
114
|
+
get(key) {
|
|
115
|
+
const value = this.cache.get(key);
|
|
116
|
+
if (value !== undefined) {
|
|
117
|
+
// Move to end (most recently used)
|
|
118
|
+
this.cache.delete(key);
|
|
119
|
+
this.cache.set(key, value);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
set(key, value) {
|
|
124
|
+
if (this.cache.has(key)) {
|
|
125
|
+
this.cache.delete(key);
|
|
126
|
+
}
|
|
127
|
+
else if (this.cache.size >= this.maxSize) {
|
|
128
|
+
// Delete oldest (first) entry
|
|
129
|
+
const firstKey = this.cache.keys().next().value;
|
|
130
|
+
if (firstKey !== undefined)
|
|
131
|
+
this.cache.delete(firstKey);
|
|
132
|
+
}
|
|
133
|
+
this.cache.set(key, value);
|
|
134
|
+
}
|
|
135
|
+
get size() {
|
|
136
|
+
return this.cache.size;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
69
139
|
export class QueryInterface {
|
|
70
140
|
pool;
|
|
71
141
|
table;
|
|
72
142
|
schema;
|
|
73
143
|
tableMeta;
|
|
74
144
|
/** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
|
|
75
|
-
sqlCache = new
|
|
145
|
+
sqlCache = new LRUCache(1000);
|
|
76
146
|
middlewares;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
147
|
+
defaultLimit;
|
|
148
|
+
warnOnUnlimited;
|
|
149
|
+
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
150
|
+
columnPgTypeMap;
|
|
151
|
+
columnArrayTypeMap;
|
|
152
|
+
constructor(pool, table, schema, middlewares, options) {
|
|
81
153
|
this.pool = pool;
|
|
82
154
|
this.table = table;
|
|
83
155
|
this.schema = schema;
|
|
84
156
|
const meta = schema.tables[table];
|
|
85
157
|
if (!meta) {
|
|
86
|
-
throw new
|
|
158
|
+
throw new ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
|
|
87
159
|
}
|
|
88
160
|
this.tableMeta = meta;
|
|
89
161
|
this.middlewares = middlewares ?? [];
|
|
90
|
-
this.
|
|
162
|
+
this.defaultLimit = options?.defaultLimit;
|
|
163
|
+
this.warnOnUnlimited = options?.warnOnUnlimited ?? false;
|
|
164
|
+
// Pre-compute column type lookup maps (TASK-26)
|
|
165
|
+
this.columnPgTypeMap = new Map();
|
|
166
|
+
this.columnArrayTypeMap = new Map();
|
|
167
|
+
for (const col of this.tableMeta.columns) {
|
|
168
|
+
this.columnPgTypeMap.set(col.name, col.pgType);
|
|
169
|
+
this.columnArrayTypeMap.set(col.name, col.pgArrayType);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Execute a pool.query with an optional timeout.
|
|
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.
|
|
176
|
+
*/
|
|
177
|
+
async queryWithTimeout(sql, params, timeout) {
|
|
178
|
+
if (!timeout) {
|
|
179
|
+
try {
|
|
180
|
+
return await this.pool.query(sql, params);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
throw wrapPgError(err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let timer;
|
|
187
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
188
|
+
timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
|
|
189
|
+
});
|
|
190
|
+
try {
|
|
191
|
+
return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
throw wrapPgError(err);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
91
199
|
}
|
|
92
200
|
/**
|
|
93
201
|
* Execute a query through the middleware chain.
|
|
94
202
|
* If no middlewares are registered, executes directly.
|
|
203
|
+
*
|
|
204
|
+
* Middleware can inspect and log query parameters, modify results after execution,
|
|
205
|
+
* and measure timing. Note: query SQL is generated before middleware runs, so
|
|
206
|
+
* modifying params.args in middleware will NOT affect the executed SQL.
|
|
207
|
+
* To intercept queries before SQL generation, use the raw() method instead.
|
|
95
208
|
*/
|
|
96
209
|
async executeWithMiddleware(action, args, executor) {
|
|
97
210
|
if (this.middlewares.length === 0) {
|
|
@@ -110,25 +223,13 @@ export class QueryInterface {
|
|
|
110
223
|
};
|
|
111
224
|
return next(params);
|
|
112
225
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Generate a cache key for a query shape.
|
|
115
|
-
* Same where-keys + same with-clause = same SQL template.
|
|
116
|
-
*/
|
|
117
|
-
cacheKey(op, whereKeys, withClause, extra) {
|
|
118
|
-
let key = `${op}:${whereKeys.sort().join(',')}`;
|
|
119
|
-
if (withClause)
|
|
120
|
-
key += `:w=${JSON.stringify(Object.keys(withClause).sort())}`;
|
|
121
|
-
if (extra)
|
|
122
|
-
key += `:${extra}`;
|
|
123
|
-
return key;
|
|
124
|
-
}
|
|
125
226
|
// -------------------------------------------------------------------------
|
|
126
227
|
// findUnique
|
|
127
228
|
// -------------------------------------------------------------------------
|
|
128
229
|
async findUnique(args) {
|
|
129
230
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
130
231
|
const deferred = this.buildFindUnique(args);
|
|
131
|
-
const result = await this.
|
|
232
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
132
233
|
return deferred.transform(result);
|
|
133
234
|
});
|
|
134
235
|
}
|
|
@@ -137,15 +238,12 @@ export class QueryInterface {
|
|
|
137
238
|
const whereObj = args.where;
|
|
138
239
|
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
139
240
|
const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
|
|
140
|
-
const isSimpleWhere = !whereObj
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
// Generates: SELECT col1, col2 FROM "table" WHERE "id" = $1 LIMIT 1
|
|
147
|
-
// No json_build_object, no subqueries, no COALESCE wrappers.
|
|
148
|
-
// -----------------------------------------------------------------------
|
|
241
|
+
const isSimpleWhere = !whereObj.OR &&
|
|
242
|
+
whereKeys.every((k) => {
|
|
243
|
+
const v = whereObj[k];
|
|
244
|
+
return v !== null && !isWhereOperator(v);
|
|
245
|
+
});
|
|
246
|
+
// For simple queries (no nested with, no operators), use cached SQL template
|
|
149
247
|
if (!args.with && isSimpleWhere) {
|
|
150
248
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
151
249
|
const ck = `fu:${whereKeys.sort().join(',')}:c=${colKey}`;
|
|
@@ -155,34 +253,25 @@ export class QueryInterface {
|
|
|
155
253
|
const qt = quoteIdent(this.table);
|
|
156
254
|
const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
|
|
157
255
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
158
|
-
const selectExpr = columnsList
|
|
159
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
160
|
-
: `${qt}.*`;
|
|
256
|
+
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
161
257
|
sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
162
258
|
this.sqlCache.set(ck, sql);
|
|
163
259
|
}
|
|
164
|
-
// Fast path: skip date coercion when table has no date columns
|
|
165
|
-
const transformRow = this.hasNoDateColumns
|
|
166
|
-
? (row) => this.parseRowFast(row)
|
|
167
|
-
: (row) => this.parseRow(row, this.table);
|
|
168
260
|
return {
|
|
169
261
|
sql,
|
|
170
262
|
params,
|
|
171
263
|
transform: (result) => {
|
|
172
264
|
const row = result.rows[0];
|
|
173
|
-
return row ?
|
|
265
|
+
return row ? this.parseRow(row, this.table) : null;
|
|
174
266
|
},
|
|
175
267
|
tag: `${this.table}.findUnique`,
|
|
176
268
|
};
|
|
177
269
|
}
|
|
178
270
|
// General path: supports operators, null, OR, nested with
|
|
179
271
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
180
|
-
// Fast path: no relations, skip json_agg (but where has operators/null/OR)
|
|
181
272
|
if (!args.with) {
|
|
182
273
|
const qt = quoteIdent(this.table);
|
|
183
|
-
const selectExpr = columnsList
|
|
184
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
185
|
-
: `${qt}.*`;
|
|
274
|
+
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
|
|
186
275
|
const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
187
276
|
return {
|
|
188
277
|
sql,
|
|
@@ -211,85 +300,21 @@ export class QueryInterface {
|
|
|
211
300
|
// findMany
|
|
212
301
|
// -------------------------------------------------------------------------
|
|
213
302
|
async findMany(args) {
|
|
303
|
+
// Warn if no limit specified and warnOnUnlimited is enabled
|
|
304
|
+
const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
|
|
305
|
+
if (this.warnOnUnlimited && !hasExplicitLimit) {
|
|
306
|
+
console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
|
|
307
|
+
}
|
|
214
308
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
215
309
|
const deferred = this.buildFindMany(args);
|
|
216
|
-
const result = await this.
|
|
310
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
217
311
|
return deferred.transform(result);
|
|
218
312
|
});
|
|
219
313
|
}
|
|
220
314
|
buildFindMany(args) {
|
|
315
|
+
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
221
316
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
222
317
|
const qt = quoteIdent(this.table);
|
|
223
|
-
const hasWith = !!(args?.with);
|
|
224
|
-
// -----------------------------------------------------------------------
|
|
225
|
-
// Fast path: no relations, no cursor, no distinct — cache SQL template
|
|
226
|
-
// Skip json_agg subquery machinery entirely for simple table scans.
|
|
227
|
-
// -----------------------------------------------------------------------
|
|
228
|
-
if (!hasWith && !args?.cursor && !args?.distinct) {
|
|
229
|
-
const whereObj = args?.where;
|
|
230
|
-
const whereKeys = whereObj
|
|
231
|
-
? Object.keys(whereObj).filter((k) => whereObj[k] !== undefined)
|
|
232
|
-
: [];
|
|
233
|
-
// Check if all where values are simple equality (no operators, null, OR/AND/NOT)
|
|
234
|
-
const isSimpleWhere = !whereObj || (!whereObj['OR'] && !whereObj['AND'] && !whereObj['NOT'] && whereKeys.every((k) => {
|
|
235
|
-
const v = whereObj[k];
|
|
236
|
-
return v !== null && !isWhereOperator(v) && !isJsonFilter(v) && !isArrayFilter(v);
|
|
237
|
-
}));
|
|
238
|
-
if (isSimpleWhere) {
|
|
239
|
-
// Build cache key: operation + where keys + columns + orderBy + hasLimit + hasOffset
|
|
240
|
-
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
241
|
-
const orderKey = args?.orderBy ? Object.entries(args.orderBy).map(([k, d]) => `${k}:${d}`).join(',') : '';
|
|
242
|
-
const hasLimit = args?.limit !== undefined || args?.take !== undefined;
|
|
243
|
-
const hasOffset = args?.offset !== undefined;
|
|
244
|
-
const ck = `fm:${whereKeys.sort().join(',')}:c=${colKey}:o=${orderKey}:l=${hasLimit ? '1' : '0'}:off=${hasOffset ? '1' : '0'}`;
|
|
245
|
-
let sql = this.sqlCache.get(ck);
|
|
246
|
-
const params = whereKeys.map((k) => whereObj[k]);
|
|
247
|
-
if (!sql) {
|
|
248
|
-
// Build SQL template once and cache it
|
|
249
|
-
const selectExpr = columnsList
|
|
250
|
-
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
251
|
-
: `${qt}.*`;
|
|
252
|
-
const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
|
|
253
|
-
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
254
|
-
sql = `SELECT ${selectExpr} FROM ${qt}${whereSql}`;
|
|
255
|
-
if (args?.orderBy) {
|
|
256
|
-
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
257
|
-
}
|
|
258
|
-
// Placeholders for LIMIT/OFFSET — positions are stable since where params come first
|
|
259
|
-
if (hasLimit) {
|
|
260
|
-
sql += ` LIMIT $${params.length + 1}`;
|
|
261
|
-
}
|
|
262
|
-
if (hasOffset) {
|
|
263
|
-
sql += ` OFFSET $${params.length + (hasLimit ? 2 : 1)}`;
|
|
264
|
-
}
|
|
265
|
-
this.sqlCache.set(ck, sql);
|
|
266
|
-
}
|
|
267
|
-
// Append runtime param values for LIMIT and OFFSET
|
|
268
|
-
const effectiveLimit = args?.take ?? args?.limit;
|
|
269
|
-
if (effectiveLimit !== undefined) {
|
|
270
|
-
params.push(Number(effectiveLimit));
|
|
271
|
-
}
|
|
272
|
-
if (args?.offset !== undefined) {
|
|
273
|
-
params.push(Number(args.offset));
|
|
274
|
-
}
|
|
275
|
-
// Fast path: no relations, use parseRow (or parseRowFast when no date columns)
|
|
276
|
-
const parseRow = this.hasNoDateColumns
|
|
277
|
-
? (row) => this.parseRowFast(row)
|
|
278
|
-
: (row) => this.parseRow(row, this.table);
|
|
279
|
-
return {
|
|
280
|
-
sql,
|
|
281
|
-
params,
|
|
282
|
-
transform: (result) => result.rows.map(parseRow),
|
|
283
|
-
tag: `${this.table}.findMany`,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
// -----------------------------------------------------------------------
|
|
288
|
-
// General path: supports operators, null, OR, cursor, distinct, nested with
|
|
289
|
-
// -----------------------------------------------------------------------
|
|
290
|
-
const { sql: whereSql, params } = args?.where
|
|
291
|
-
? this.buildWhere(args.where)
|
|
292
|
-
: { sql: '', params: [] };
|
|
293
318
|
// Distinct support
|
|
294
319
|
let distinctPrefix = '';
|
|
295
320
|
if (args?.distinct && args.distinct.length > 0) {
|
|
@@ -297,7 +322,7 @@ export class QueryInterface {
|
|
|
297
322
|
distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
|
|
298
323
|
}
|
|
299
324
|
let selectClause;
|
|
300
|
-
if (
|
|
325
|
+
if (args?.with) {
|
|
301
326
|
selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
|
|
302
327
|
}
|
|
303
328
|
else if (columnsList) {
|
|
@@ -331,8 +356,8 @@ export class QueryInterface {
|
|
|
331
356
|
if (args?.orderBy) {
|
|
332
357
|
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
333
358
|
}
|
|
334
|
-
// take overrides limit when cursor pagination is used
|
|
335
|
-
const effectiveLimit = args?.take ?? args?.limit;
|
|
359
|
+
// take overrides limit when cursor pagination is used; fall back to defaultLimit
|
|
360
|
+
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
336
361
|
if (effectiveLimit !== undefined) {
|
|
337
362
|
params.push(Number(effectiveLimit));
|
|
338
363
|
sql += ` LIMIT $${params.length}`;
|
|
@@ -344,19 +369,74 @@ export class QueryInterface {
|
|
|
344
369
|
return {
|
|
345
370
|
sql,
|
|
346
371
|
params,
|
|
347
|
-
transform: (result) => result.rows.map((row) =>
|
|
348
|
-
? this.parseNestedRow(row, this.table)
|
|
349
|
-
: this.parseRow(row, this.table)),
|
|
372
|
+
transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
|
|
350
373
|
tag: `${this.table}.findMany`,
|
|
351
374
|
};
|
|
352
375
|
}
|
|
353
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
|
+
// -------------------------------------------------------------------------
|
|
354
434
|
// findFirst — like findMany but returns a single row or null
|
|
355
435
|
// -------------------------------------------------------------------------
|
|
356
436
|
async findFirst(args) {
|
|
357
437
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
358
438
|
const deferred = this.buildFindFirst(args);
|
|
359
|
-
const result = await this.
|
|
439
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
360
440
|
return deferred.transform(result);
|
|
361
441
|
});
|
|
362
442
|
}
|
|
@@ -380,7 +460,7 @@ export class QueryInterface {
|
|
|
380
460
|
async findFirstOrThrow(args) {
|
|
381
461
|
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
382
462
|
const deferred = this.buildFindFirstOrThrow(args);
|
|
383
|
-
const result = await this.
|
|
463
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
384
464
|
return deferred.transform(result);
|
|
385
465
|
});
|
|
386
466
|
}
|
|
@@ -392,7 +472,11 @@ export class QueryInterface {
|
|
|
392
472
|
transform: (result) => {
|
|
393
473
|
const row = inner.transform(result);
|
|
394
474
|
if (row === null) {
|
|
395
|
-
throw new
|
|
475
|
+
throw new NotFoundError({
|
|
476
|
+
table: this.table,
|
|
477
|
+
where: args?.where,
|
|
478
|
+
operation: 'findFirstOrThrow',
|
|
479
|
+
});
|
|
396
480
|
}
|
|
397
481
|
return row;
|
|
398
482
|
},
|
|
@@ -405,7 +489,7 @@ export class QueryInterface {
|
|
|
405
489
|
async findUniqueOrThrow(args) {
|
|
406
490
|
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
407
491
|
const deferred = this.buildFindUniqueOrThrow(args);
|
|
408
|
-
const result = await this.
|
|
492
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
409
493
|
return deferred.transform(result);
|
|
410
494
|
});
|
|
411
495
|
}
|
|
@@ -417,7 +501,11 @@ export class QueryInterface {
|
|
|
417
501
|
transform: (result) => {
|
|
418
502
|
const row = inner.transform(result);
|
|
419
503
|
if (row === null) {
|
|
420
|
-
throw new
|
|
504
|
+
throw new NotFoundError({
|
|
505
|
+
table: this.table,
|
|
506
|
+
where: args.where,
|
|
507
|
+
operation: 'findUniqueOrThrow',
|
|
508
|
+
});
|
|
421
509
|
}
|
|
422
510
|
return row;
|
|
423
511
|
},
|
|
@@ -430,7 +518,7 @@ export class QueryInterface {
|
|
|
430
518
|
async create(args) {
|
|
431
519
|
return this.executeWithMiddleware('create', args, async () => {
|
|
432
520
|
const deferred = this.buildCreate(args);
|
|
433
|
-
const result = await this.
|
|
521
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
434
522
|
return deferred.transform(result);
|
|
435
523
|
});
|
|
436
524
|
}
|
|
@@ -445,8 +533,13 @@ export class QueryInterface {
|
|
|
445
533
|
params,
|
|
446
534
|
transform: (result) => {
|
|
447
535
|
const row = result.rows[0];
|
|
448
|
-
if (!row)
|
|
449
|
-
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
|
+
}
|
|
450
543
|
return this.parseRow(row, this.table);
|
|
451
544
|
},
|
|
452
545
|
tag: `${this.table}.create`,
|
|
@@ -458,7 +551,7 @@ export class QueryInterface {
|
|
|
458
551
|
async createMany(args) {
|
|
459
552
|
return this.executeWithMiddleware('createMany', args, async () => {
|
|
460
553
|
const deferred = this.buildCreateMany(args);
|
|
461
|
-
const result = await this.
|
|
554
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
462
555
|
return deferred.transform(result);
|
|
463
556
|
});
|
|
464
557
|
}
|
|
@@ -505,29 +598,32 @@ export class QueryInterface {
|
|
|
505
598
|
async update(args) {
|
|
506
599
|
return this.executeWithMiddleware('update', args, async () => {
|
|
507
600
|
const deferred = this.buildUpdate(args);
|
|
508
|
-
const result = await this.
|
|
601
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
509
602
|
return deferred.transform(result);
|
|
510
603
|
});
|
|
511
604
|
}
|
|
512
605
|
buildUpdate(args) {
|
|
513
606
|
const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
514
|
-
// Build SET params first
|
|
607
|
+
// Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
|
|
515
608
|
const params = [];
|
|
516
|
-
const setClauses = setEntries.map(([k, v]) =>
|
|
517
|
-
params.push(v);
|
|
518
|
-
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
519
|
-
});
|
|
609
|
+
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
|
|
520
610
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
521
611
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
522
612
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
613
|
+
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
523
614
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
|
|
524
615
|
return {
|
|
525
616
|
sql,
|
|
526
617
|
params,
|
|
527
618
|
transform: (result) => {
|
|
528
619
|
const row = result.rows[0];
|
|
529
|
-
if (!row)
|
|
530
|
-
throw new
|
|
620
|
+
if (!row) {
|
|
621
|
+
throw new NotFoundError({
|
|
622
|
+
table: this.table,
|
|
623
|
+
where: args.where,
|
|
624
|
+
operation: 'update',
|
|
625
|
+
});
|
|
626
|
+
}
|
|
531
627
|
return this.parseRow(row, this.table);
|
|
532
628
|
},
|
|
533
629
|
tag: `${this.table}.update`,
|
|
@@ -539,20 +635,26 @@ export class QueryInterface {
|
|
|
539
635
|
async delete(args) {
|
|
540
636
|
return this.executeWithMiddleware('delete', args, async () => {
|
|
541
637
|
const deferred = this.buildDelete(args);
|
|
542
|
-
const result = await this.
|
|
638
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
543
639
|
return deferred.transform(result);
|
|
544
640
|
});
|
|
545
641
|
}
|
|
546
642
|
buildDelete(args) {
|
|
547
643
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
644
|
+
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
548
645
|
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
|
|
549
646
|
return {
|
|
550
647
|
sql,
|
|
551
648
|
params,
|
|
552
649
|
transform: (result) => {
|
|
553
650
|
const row = result.rows[0];
|
|
554
|
-
if (!row)
|
|
555
|
-
throw new
|
|
651
|
+
if (!row) {
|
|
652
|
+
throw new NotFoundError({
|
|
653
|
+
table: this.table,
|
|
654
|
+
where: args.where,
|
|
655
|
+
operation: 'delete',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
556
658
|
return this.parseRow(row, this.table);
|
|
557
659
|
},
|
|
558
660
|
tag: `${this.table}.delete`,
|
|
@@ -564,7 +666,7 @@ export class QueryInterface {
|
|
|
564
666
|
async upsert(args) {
|
|
565
667
|
return this.executeWithMiddleware('upsert', args, async () => {
|
|
566
668
|
const deferred = this.buildUpsert(args);
|
|
567
|
-
const result = await this.
|
|
669
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
568
670
|
return deferred.transform(result);
|
|
569
671
|
});
|
|
570
672
|
}
|
|
@@ -595,8 +697,14 @@ export class QueryInterface {
|
|
|
595
697
|
params,
|
|
596
698
|
transform: (result) => {
|
|
597
699
|
const row = result.rows[0];
|
|
598
|
-
if (!row)
|
|
599
|
-
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
|
+
}
|
|
600
708
|
return this.parseRow(row, this.table);
|
|
601
709
|
},
|
|
602
710
|
tag: `${this.table}.upsert`,
|
|
@@ -608,21 +716,19 @@ export class QueryInterface {
|
|
|
608
716
|
async updateMany(args) {
|
|
609
717
|
return this.executeWithMiddleware('updateMany', args, async () => {
|
|
610
718
|
const deferred = this.buildUpdateMany(args);
|
|
611
|
-
const result = await this.
|
|
719
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
612
720
|
return deferred.transform(result);
|
|
613
721
|
});
|
|
614
722
|
}
|
|
615
723
|
buildUpdateMany(args) {
|
|
616
724
|
const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
617
|
-
// Build SET params first
|
|
725
|
+
// Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
|
|
618
726
|
const params = [];
|
|
619
|
-
const setClauses = setEntries.map(([k, v]) =>
|
|
620
|
-
params.push(v);
|
|
621
|
-
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
622
|
-
});
|
|
727
|
+
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
|
|
623
728
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
624
729
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
625
730
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
731
|
+
this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
|
|
626
732
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
627
733
|
return {
|
|
628
734
|
sql,
|
|
@@ -637,12 +743,13 @@ export class QueryInterface {
|
|
|
637
743
|
async deleteMany(args) {
|
|
638
744
|
return this.executeWithMiddleware('deleteMany', args, async () => {
|
|
639
745
|
const deferred = this.buildDeleteMany(args);
|
|
640
|
-
const result = await this.
|
|
746
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
641
747
|
return deferred.transform(result);
|
|
642
748
|
});
|
|
643
749
|
}
|
|
644
750
|
buildDeleteMany(args) {
|
|
645
751
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
752
|
+
this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
|
|
646
753
|
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
647
754
|
return {
|
|
648
755
|
sql,
|
|
@@ -657,14 +764,12 @@ export class QueryInterface {
|
|
|
657
764
|
async count(args) {
|
|
658
765
|
return this.executeWithMiddleware('count', (args ?? {}), async () => {
|
|
659
766
|
const deferred = this.buildCount(args);
|
|
660
|
-
const result = await this.
|
|
767
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
661
768
|
return deferred.transform(result);
|
|
662
769
|
});
|
|
663
770
|
}
|
|
664
771
|
buildCount(args) {
|
|
665
|
-
const { sql: whereSql, params } = args?.where
|
|
666
|
-
? this.buildWhere(args.where)
|
|
667
|
-
: { sql: '', params: [] };
|
|
772
|
+
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
668
773
|
const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
669
774
|
return {
|
|
670
775
|
sql,
|
|
@@ -679,16 +784,22 @@ export class QueryInterface {
|
|
|
679
784
|
async groupBy(args) {
|
|
680
785
|
return this.executeWithMiddleware('groupBy', args, async () => {
|
|
681
786
|
const deferred = this.buildGroupBy(args);
|
|
682
|
-
const result = await this.
|
|
787
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
683
788
|
return deferred.transform(result);
|
|
684
789
|
});
|
|
685
790
|
}
|
|
686
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
|
+
}
|
|
687
800
|
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
688
801
|
const groupCols = groupColsRaw.map((c) => quoteIdent(c));
|
|
689
|
-
const { sql: whereSql, params } = args.where
|
|
690
|
-
? this.buildWhere(args.where)
|
|
691
|
-
: { sql: '', params: [] };
|
|
802
|
+
const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
692
803
|
// Build SELECT expressions: group-by columns + aggregate functions
|
|
693
804
|
const selectExprs = [...groupCols];
|
|
694
805
|
// _count
|
|
@@ -701,7 +812,7 @@ export class QueryInterface {
|
|
|
701
812
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
702
813
|
if (enabled) {
|
|
703
814
|
const col = this.toColumn(field);
|
|
704
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS
|
|
815
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
|
|
705
816
|
}
|
|
706
817
|
}
|
|
707
818
|
}
|
|
@@ -710,7 +821,7 @@ export class QueryInterface {
|
|
|
710
821
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
711
822
|
if (enabled) {
|
|
712
823
|
const col = this.toColumn(field);
|
|
713
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS
|
|
824
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
|
|
714
825
|
}
|
|
715
826
|
}
|
|
716
827
|
}
|
|
@@ -719,7 +830,7 @@ export class QueryInterface {
|
|
|
719
830
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
720
831
|
if (enabled) {
|
|
721
832
|
const col = this.toColumn(field);
|
|
722
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS
|
|
833
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
|
|
723
834
|
}
|
|
724
835
|
}
|
|
725
836
|
}
|
|
@@ -728,7 +839,7 @@ export class QueryInterface {
|
|
|
728
839
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
729
840
|
if (enabled) {
|
|
730
841
|
const col = this.toColumn(field);
|
|
731
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS
|
|
842
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
|
|
732
843
|
}
|
|
733
844
|
}
|
|
734
845
|
}
|
|
@@ -806,14 +917,31 @@ export class QueryInterface {
|
|
|
806
917
|
async aggregate(args) {
|
|
807
918
|
return this.executeWithMiddleware('aggregate', args, async () => {
|
|
808
919
|
const deferred = this.buildAggregate(args);
|
|
809
|
-
const result = await this.
|
|
920
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
810
921
|
return deferred.transform(result);
|
|
811
922
|
});
|
|
812
923
|
}
|
|
813
924
|
buildAggregate(args) {
|
|
814
|
-
const { sql: whereSql, params } = args.where
|
|
815
|
-
|
|
816
|
-
|
|
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
|
+
}
|
|
817
945
|
const selectExprs = [];
|
|
818
946
|
// _count
|
|
819
947
|
if (args._count === true) {
|
|
@@ -823,7 +951,7 @@ export class QueryInterface {
|
|
|
823
951
|
for (const [field, enabled] of Object.entries(args._count)) {
|
|
824
952
|
if (enabled) {
|
|
825
953
|
const col = this.toColumn(field);
|
|
826
|
-
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS
|
|
954
|
+
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent('_count_' + col)}`);
|
|
827
955
|
}
|
|
828
956
|
}
|
|
829
957
|
}
|
|
@@ -832,7 +960,7 @@ export class QueryInterface {
|
|
|
832
960
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
833
961
|
if (enabled) {
|
|
834
962
|
const col = this.toColumn(field);
|
|
835
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS
|
|
963
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
|
|
836
964
|
}
|
|
837
965
|
}
|
|
838
966
|
}
|
|
@@ -841,7 +969,7 @@ export class QueryInterface {
|
|
|
841
969
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
842
970
|
if (enabled) {
|
|
843
971
|
const col = this.toColumn(field);
|
|
844
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS
|
|
972
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
|
|
845
973
|
}
|
|
846
974
|
}
|
|
847
975
|
}
|
|
@@ -850,7 +978,7 @@ export class QueryInterface {
|
|
|
850
978
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
851
979
|
if (enabled) {
|
|
852
980
|
const col = this.toColumn(field);
|
|
853
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS
|
|
981
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
|
|
854
982
|
}
|
|
855
983
|
}
|
|
856
984
|
}
|
|
@@ -859,7 +987,7 @@ export class QueryInterface {
|
|
|
859
987
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
860
988
|
if (enabled) {
|
|
861
989
|
const col = this.toColumn(field);
|
|
862
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS
|
|
990
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
|
|
863
991
|
}
|
|
864
992
|
}
|
|
865
993
|
}
|
|
@@ -971,6 +1099,67 @@ export class QueryInterface {
|
|
|
971
1099
|
toSqlColumn(field) {
|
|
972
1100
|
return quoteIdent(this.toColumn(field));
|
|
973
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
|
+
}
|
|
974
1163
|
/** Build WHERE clause from a where object (supports operators, NULL, OR) */
|
|
975
1164
|
buildWhere(where) {
|
|
976
1165
|
const params = [];
|
|
@@ -979,6 +1168,22 @@ export class QueryInterface {
|
|
|
979
1168
|
return { sql: '', params: [] };
|
|
980
1169
|
return { sql: ` WHERE ${clause}`, params };
|
|
981
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
|
+
}
|
|
982
1187
|
/**
|
|
983
1188
|
* Build the inner WHERE expression (without the WHERE keyword).
|
|
984
1189
|
* Returns null if no conditions exist.
|
|
@@ -1197,24 +1402,30 @@ export class QueryInterface {
|
|
|
1197
1402
|
params.push(op.notIn);
|
|
1198
1403
|
clauses.push(`${column} != ALL($${params.length})`);
|
|
1199
1404
|
}
|
|
1405
|
+
// Use ILIKE for case-insensitive mode, LIKE otherwise
|
|
1406
|
+
const likeOp = op.mode === 'insensitive' ? 'ILIKE' : 'LIKE';
|
|
1200
1407
|
if (op.contains !== undefined) {
|
|
1201
1408
|
params.push(`%${escapeLike(op.contains)}%`);
|
|
1202
|
-
clauses.push(`${column}
|
|
1409
|
+
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
1203
1410
|
}
|
|
1204
1411
|
if (op.startsWith !== undefined) {
|
|
1205
1412
|
params.push(`${escapeLike(op.startsWith)}%`);
|
|
1206
|
-
clauses.push(`${column}
|
|
1413
|
+
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
1207
1414
|
}
|
|
1208
1415
|
if (op.endsWith !== undefined) {
|
|
1209
1416
|
params.push(`%${escapeLike(op.endsWith)}`);
|
|
1210
|
-
clauses.push(`${column}
|
|
1417
|
+
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
1211
1418
|
}
|
|
1212
1419
|
return clauses;
|
|
1213
1420
|
}
|
|
1214
1421
|
/** Build ORDER BY clause from an object */
|
|
1215
1422
|
buildOrderBy(orderBy) {
|
|
1423
|
+
const meta = this.schema.tables[this.table];
|
|
1216
1424
|
return Object.entries(orderBy)
|
|
1217
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
|
+
}
|
|
1218
1429
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1219
1430
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
1220
1431
|
})
|
|
@@ -1249,22 +1460,6 @@ export class QueryInterface {
|
|
|
1249
1460
|
}
|
|
1250
1461
|
return parsed;
|
|
1251
1462
|
}
|
|
1252
|
-
/**
|
|
1253
|
-
* Fast path: parse a flat row when the table has NO date columns.
|
|
1254
|
-
* Only renames snake_case -> camelCase via the pre-computed reverseMap.
|
|
1255
|
-
* Skips all date coercion checks — avoids Set.has() per field per row.
|
|
1256
|
-
* Used by findUnique/findMany fast paths when hasNoDateColumns is true.
|
|
1257
|
-
*/
|
|
1258
|
-
parseRowFast(row) {
|
|
1259
|
-
const parsed = {};
|
|
1260
|
-
const reverseMap = this.tableMeta.reverseColumnMap;
|
|
1261
|
-
const keys = Object.keys(row);
|
|
1262
|
-
for (let i = 0; i < keys.length; i++) {
|
|
1263
|
-
const col = keys[i];
|
|
1264
|
-
parsed[reverseMap[col] ?? col] = row[col];
|
|
1265
|
-
}
|
|
1266
|
-
return parsed;
|
|
1267
|
-
}
|
|
1268
1463
|
/** Parse a row that may contain JSON nested relation columns */
|
|
1269
1464
|
parseNestedRow(row, table) {
|
|
1270
1465
|
const parsed = this.parseRow(row, table);
|
|
@@ -1279,6 +1474,7 @@ export class QueryInterface {
|
|
|
1279
1474
|
parsed[relName] = JSON.parse(rawValue);
|
|
1280
1475
|
}
|
|
1281
1476
|
catch {
|
|
1477
|
+
console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
|
|
1282
1478
|
parsed[relName] = rawValue;
|
|
1283
1479
|
}
|
|
1284
1480
|
}
|
|
@@ -1298,52 +1494,173 @@ export class QueryInterface {
|
|
|
1298
1494
|
return parsed;
|
|
1299
1495
|
}
|
|
1300
1496
|
/**
|
|
1301
|
-
* 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.
|
|
1503
|
+
*
|
|
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.
|
|
1510
|
+
*
|
|
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.
|
|
1302
1515
|
*
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
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
|
+
* ```
|
|
1305
1522
|
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
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.
|
|
1308
1536
|
*/
|
|
1309
|
-
buildSelectWithRelations(table, withClause, params, columnsList) {
|
|
1537
|
+
buildSelectWithRelations(table, withClause, params, columnsList, depth, path) {
|
|
1310
1538
|
const meta = this.schema.tables[table];
|
|
1311
1539
|
if (!meta)
|
|
1312
|
-
throw new
|
|
1540
|
+
throw new ValidationError(`[turbine] Unknown table "${table}"`);
|
|
1313
1541
|
const cols = columnsList ?? meta.allColumns;
|
|
1314
1542
|
const qtbl = quoteIdent(table);
|
|
1315
|
-
const baseCols = cols
|
|
1316
|
-
.map((col) => `${qtbl}.${quoteIdent(col)}`)
|
|
1317
|
-
.join(', ');
|
|
1543
|
+
const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
|
|
1318
1544
|
const relationSelects = [];
|
|
1319
1545
|
const aliasCounter = { n: 0 };
|
|
1320
1546
|
for (const [relName, relSpec] of Object.entries(withClause)) {
|
|
1321
1547
|
const relDef = meta.relations[relName];
|
|
1322
1548
|
if (!relDef) {
|
|
1323
|
-
throw new
|
|
1549
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
1324
1550
|
`Available: ${Object.keys(meta.relations).join(', ')}`);
|
|
1325
1551
|
}
|
|
1326
1552
|
// The main table is not aliased, so pass table name as parentRef
|
|
1327
|
-
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
|
|
1553
|
+
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
1328
1554
|
relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
|
|
1329
1555
|
}
|
|
1330
1556
|
return [baseCols, ...relationSelects].join(', ');
|
|
1331
1557
|
}
|
|
1332
1558
|
/**
|
|
1333
|
-
*
|
|
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
|
|
1567
|
+
*
|
|
1568
|
+
* 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
|
|
1569
|
+
* `aliasCounter` so that deeply nested subqueries never collide.
|
|
1570
|
+
*
|
|
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`.
|
|
1334
1585
|
*
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
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)
|
|
1337
1591
|
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
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).
|
|
1341
1649
|
*/
|
|
1342
|
-
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];
|
|
1343
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
|
+
}
|
|
1344
1661
|
const targetMeta = this.schema.tables[targetTable];
|
|
1345
1662
|
if (!targetMeta)
|
|
1346
|
-
throw new
|
|
1663
|
+
throw new RelationError(`[turbine] Unknown relation target "${targetTable}"`);
|
|
1347
1664
|
// Generate a unique alias: t0, t1, t2, ...
|
|
1348
1665
|
const alias = `t${aliasCounter.n++}`;
|
|
1349
1666
|
// Resolve which columns to include based on select/omit
|
|
@@ -1361,18 +1678,24 @@ export class QueryInterface {
|
|
|
1361
1678
|
targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
1362
1679
|
}
|
|
1363
1680
|
// Build json_build_object pairs for resolved columns
|
|
1364
|
-
const jsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${alias}.${quoteIdent(col)}`);
|
|
1365
|
-
//
|
|
1366
|
-
|
|
1681
|
+
const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${alias}.${quoteIdent(col)}`);
|
|
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) {
|
|
1367
1688
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1368
1689
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1369
1690
|
if (!nestedRelDef) {
|
|
1370
|
-
throw new
|
|
1691
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1371
1692
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
1372
1693
|
}
|
|
1373
1694
|
// Recursively build nested subquery, passing THIS alias as the parent reference
|
|
1374
|
-
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter);
|
|
1375
|
-
|
|
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})`);
|
|
1376
1699
|
}
|
|
1377
1700
|
}
|
|
1378
1701
|
const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
|
|
@@ -1386,7 +1709,7 @@ export class QueryInterface {
|
|
|
1386
1709
|
.map(([k, dir]) => {
|
|
1387
1710
|
const col = camelToSnake(k);
|
|
1388
1711
|
if (!targetMeta.allColumns.includes(col)) {
|
|
1389
|
-
throw new
|
|
1712
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
1390
1713
|
}
|
|
1391
1714
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1392
1715
|
return `${alias}.${quoteIdent(col)} ${safeDir}`;
|
|
@@ -1409,7 +1732,7 @@ export class QueryInterface {
|
|
|
1409
1732
|
for (const [k, v] of Object.entries(spec.where)) {
|
|
1410
1733
|
const col = camelToSnake(k);
|
|
1411
1734
|
if (!targetMeta.allColumns.includes(col)) {
|
|
1412
|
-
throw new
|
|
1735
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
1413
1736
|
}
|
|
1414
1737
|
params.push(v);
|
|
1415
1738
|
whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
|
|
@@ -1430,15 +1753,18 @@ export class QueryInterface {
|
|
|
1430
1753
|
// Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
|
|
1431
1754
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1432
1755
|
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
1433
|
-
const innerJsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${innerAlias}.${quoteIdent(col)}`);
|
|
1434
|
-
//
|
|
1756
|
+
const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${innerAlias}.${quoteIdent(col)}`);
|
|
1757
|
+
// Build nested relation subqueries referencing innerAlias
|
|
1435
1758
|
if (spec !== true && spec.with) {
|
|
1436
|
-
for (const [nestedRelName] of Object.entries(spec.with)) {
|
|
1759
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
1437
1760
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1438
|
-
if (nestedRelDef) {
|
|
1439
|
-
|
|
1440
|
-
|
|
1761
|
+
if (!nestedRelDef) {
|
|
1762
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1763
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
1441
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})`);
|
|
1442
1768
|
}
|
|
1443
1769
|
}
|
|
1444
1770
|
const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
|
|
@@ -1452,10 +1778,10 @@ export class QueryInterface {
|
|
|
1452
1778
|
/**
|
|
1453
1779
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
1454
1780
|
* Used to detect JSONB/array columns for specialized operators.
|
|
1781
|
+
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
1455
1782
|
*/
|
|
1456
1783
|
getColumnPgType(column) {
|
|
1457
|
-
|
|
1458
|
-
return col?.pgType ?? 'text';
|
|
1784
|
+
return this.columnPgTypeMap.get(column) ?? 'text';
|
|
1459
1785
|
}
|
|
1460
1786
|
/**
|
|
1461
1787
|
* Get the Postgres base element type for an array column.
|
|
@@ -1542,13 +1868,15 @@ export class QueryInterface {
|
|
|
1542
1868
|
}
|
|
1543
1869
|
return clauses;
|
|
1544
1870
|
}
|
|
1545
|
-
/**
|
|
1871
|
+
/**
|
|
1872
|
+
* Get the Postgres array type for a column (used by UNNEST in createMany).
|
|
1873
|
+
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
1874
|
+
*/
|
|
1546
1875
|
getColumnArrayType(column) {
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
// Fallback heuristic
|
|
1876
|
+
const arrayType = this.columnArrayTypeMap.get(column);
|
|
1877
|
+
if (arrayType)
|
|
1878
|
+
return arrayType;
|
|
1879
|
+
// Fallback heuristic for unknown columns
|
|
1552
1880
|
if (column === 'id' || column.endsWith('_id'))
|
|
1553
1881
|
return 'bigint[]';
|
|
1554
1882
|
if (column.endsWith('_at'))
|