turbine-orm 0.3.0 → 0.4.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 +5 -5
- package/dist/cli/config.d.ts +1 -1
- package/dist/cli/config.js +3 -3
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +11 -11
- package/dist/cli/migrate.d.ts +1 -1
- package/dist/cli/migrate.js +1 -1
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +2 -2
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -5
- package/dist/generate.d.ts +1 -1
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +27 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +1 -1
- package/dist/introspect.js +1 -1
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +1 -1
- package/dist/query.d.ts +22 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +249 -86
- package/dist/schema-builder.d.ts +2 -2
- package/dist/schema-builder.js +2 -2
- package/dist/schema-sql.d.ts +1 -1
- package/dist/schema-sql.js +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +1 -1
- package/dist/serverless.d.ts +3 -3
- package/dist/serverless.js +4 -4
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/package.json +6 -5
- package/dist/cli/config.js.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/migrate.js.map +0 -1
- package/dist/cli/ui.js.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/generate.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/introspect.js.map +0 -1
- package/dist/pipeline.js.map +0 -1
- package/dist/query.js.map +0 -1
- package/dist/schema-builder.js.map +0 -1
- package/dist/schema-sql.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/serverless.js.map +0 -1
- package/dist/types.js.map +0 -1
package/dist/query.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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.
|
|
@@ -11,6 +11,28 @@
|
|
|
11
11
|
* metadata — nothing is hardcoded.
|
|
12
12
|
*/
|
|
13
13
|
import { snakeToCamel, camelToSnake } from './schema.js';
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Identifier quoting — prevents SQL injection via table/column names
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/**
|
|
18
|
+
* Quote a SQL identifier (table name, column name) using Postgres double-quote
|
|
19
|
+
* rules: wrap in double quotes, escape internal double quotes by doubling them.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* quoteIdent('users') → '"users"'
|
|
23
|
+
* quoteIdent('my"table') → '"my""table"'
|
|
24
|
+
* quoteIdent('user name') → '"user name"'
|
|
25
|
+
*/
|
|
26
|
+
export function quoteIdent(name) {
|
|
27
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Escape LIKE pattern metacharacters: %, _, and \.
|
|
31
|
+
* Must be used with `ESCAPE '\'` in the LIKE clause.
|
|
32
|
+
*/
|
|
33
|
+
function escapeLike(value) {
|
|
34
|
+
return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
35
|
+
}
|
|
14
36
|
/** Known operator keys — used to detect operator objects vs plain values */
|
|
15
37
|
const OPERATOR_KEYS = new Set([
|
|
16
38
|
'gt', 'gte', 'lt', 'lte', 'not', 'in', 'notIn',
|
|
@@ -52,6 +74,9 @@ export class QueryInterface {
|
|
|
52
74
|
/** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
|
|
53
75
|
sqlCache = new Map();
|
|
54
76
|
middlewares;
|
|
77
|
+
// Fast path: pre-computed flag — true when this table has zero date/timestamp columns.
|
|
78
|
+
// When true, parseRow can skip the per-field dateCols.has() check entirely.
|
|
79
|
+
hasNoDateColumns;
|
|
55
80
|
constructor(pool, table, schema, middlewares) {
|
|
56
81
|
this.pool = pool;
|
|
57
82
|
this.table = table;
|
|
@@ -62,6 +87,7 @@ export class QueryInterface {
|
|
|
62
87
|
}
|
|
63
88
|
this.tableMeta = meta;
|
|
64
89
|
this.middlewares = middlewares ?? [];
|
|
90
|
+
this.hasNoDateColumns = meta.dateColumns.size === 0;
|
|
65
91
|
}
|
|
66
92
|
/**
|
|
67
93
|
* Execute a query through the middleware chain.
|
|
@@ -111,42 +137,53 @@ export class QueryInterface {
|
|
|
111
137
|
const whereObj = args.where;
|
|
112
138
|
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
113
139
|
const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
|
|
114
|
-
const isSimpleWhere = !whereObj['OR'] && whereKeys.every((k) => {
|
|
140
|
+
const isSimpleWhere = !whereObj['OR'] && !whereObj['AND'] && !whereObj['NOT'] && whereKeys.every((k) => {
|
|
115
141
|
const v = whereObj[k];
|
|
116
|
-
return v !== null && !isWhereOperator(v);
|
|
142
|
+
return v !== null && !isWhereOperator(v) && !isJsonFilter(v) && !isArrayFilter(v);
|
|
117
143
|
});
|
|
118
|
-
//
|
|
144
|
+
// -----------------------------------------------------------------------
|
|
145
|
+
// Fast path: no relations, simple equality where — cache SQL template.
|
|
146
|
+
// Generates: SELECT col1, col2 FROM "table" WHERE "id" = $1 LIMIT 1
|
|
147
|
+
// No json_build_object, no subqueries, no COALESCE wrappers.
|
|
148
|
+
// -----------------------------------------------------------------------
|
|
119
149
|
if (!args.with && isSimpleWhere) {
|
|
120
150
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
121
151
|
const ck = `fu:${whereKeys.sort().join(',')}:c=${colKey}`;
|
|
122
152
|
let sql = this.sqlCache.get(ck);
|
|
123
153
|
const params = whereKeys.map((k) => whereObj[k]);
|
|
124
154
|
if (!sql) {
|
|
125
|
-
const
|
|
155
|
+
const qt = quoteIdent(this.table);
|
|
156
|
+
const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
|
|
126
157
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
127
158
|
const selectExpr = columnsList
|
|
128
|
-
? columnsList.map((c) => `${
|
|
129
|
-
: `${
|
|
130
|
-
sql = `SELECT ${selectExpr} FROM ${
|
|
159
|
+
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
160
|
+
: `${qt}.*`;
|
|
161
|
+
sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
131
162
|
this.sqlCache.set(ck, sql);
|
|
132
163
|
}
|
|
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);
|
|
133
168
|
return {
|
|
134
169
|
sql,
|
|
135
170
|
params,
|
|
136
171
|
transform: (result) => {
|
|
137
172
|
const row = result.rows[0];
|
|
138
|
-
return row ?
|
|
173
|
+
return row ? transformRow(row) : null;
|
|
139
174
|
},
|
|
140
175
|
tag: `${this.table}.findUnique`,
|
|
141
176
|
};
|
|
142
177
|
}
|
|
143
178
|
// General path: supports operators, null, OR, nested with
|
|
144
179
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
180
|
+
// Fast path: no relations, skip json_agg (but where has operators/null/OR)
|
|
145
181
|
if (!args.with) {
|
|
182
|
+
const qt = quoteIdent(this.table);
|
|
146
183
|
const selectExpr = columnsList
|
|
147
|
-
? columnsList.map((c) => `${
|
|
148
|
-
: `${
|
|
149
|
-
const sql = `SELECT ${selectExpr} FROM ${
|
|
184
|
+
? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
|
|
185
|
+
: `${qt}.*`;
|
|
186
|
+
const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
|
|
150
187
|
return {
|
|
151
188
|
sql,
|
|
152
189
|
params,
|
|
@@ -159,7 +196,7 @@ export class QueryInterface {
|
|
|
159
196
|
}
|
|
160
197
|
// Nested queries: build fresh each time (with clause affects params)
|
|
161
198
|
const selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
|
|
162
|
-
const sql = `SELECT ${selectClause} FROM ${this.table}${whereSql} LIMIT 1`;
|
|
199
|
+
const sql = `SELECT ${selectClause} FROM ${quoteIdent(this.table)}${whereSql} LIMIT 1`;
|
|
163
200
|
return {
|
|
164
201
|
sql,
|
|
165
202
|
params,
|
|
@@ -181,38 +218,106 @@ export class QueryInterface {
|
|
|
181
218
|
});
|
|
182
219
|
}
|
|
183
220
|
buildFindMany(args) {
|
|
221
|
+
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
222
|
+
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
|
+
// -----------------------------------------------------------------------
|
|
184
290
|
const { sql: whereSql, params } = args?.where
|
|
185
291
|
? this.buildWhere(args.where)
|
|
186
292
|
: { sql: '', params: [] };
|
|
187
|
-
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
188
293
|
// Distinct support
|
|
189
294
|
let distinctPrefix = '';
|
|
190
295
|
if (args?.distinct && args.distinct.length > 0) {
|
|
191
|
-
const distinctCols = args.distinct.map((k) => this.
|
|
296
|
+
const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
|
|
192
297
|
distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
|
|
193
298
|
}
|
|
194
299
|
let selectClause;
|
|
195
|
-
if (
|
|
300
|
+
if (hasWith) {
|
|
196
301
|
selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
|
|
197
302
|
}
|
|
198
303
|
else if (columnsList) {
|
|
199
|
-
selectClause = columnsList.map((c) => `${
|
|
304
|
+
selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
|
|
200
305
|
}
|
|
201
306
|
else {
|
|
202
|
-
selectClause = `${
|
|
307
|
+
selectClause = `${qt}.*`;
|
|
203
308
|
}
|
|
204
|
-
let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${
|
|
309
|
+
let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${whereSql}`;
|
|
205
310
|
// Cursor-based pagination: add WHERE condition for cursor
|
|
206
311
|
if (args?.cursor) {
|
|
207
312
|
const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
|
|
208
313
|
if (cursorEntries.length > 0) {
|
|
209
314
|
// Determine direction from orderBy (default 'asc')
|
|
210
315
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
211
|
-
const col = this.
|
|
316
|
+
const col = this.toSqlColumn(k);
|
|
212
317
|
const dir = args.orderBy?.[k] ?? 'asc';
|
|
213
318
|
const op = dir === 'desc' ? '<' : '>';
|
|
214
319
|
params.push(v);
|
|
215
|
-
return `${
|
|
320
|
+
return `${qt}.${col} ${op} $${params.length}`;
|
|
216
321
|
});
|
|
217
322
|
// Append to existing WHERE or create new one
|
|
218
323
|
if (whereSql) {
|
|
@@ -229,15 +334,17 @@ export class QueryInterface {
|
|
|
229
334
|
// take overrides limit when cursor pagination is used
|
|
230
335
|
const effectiveLimit = args?.take ?? args?.limit;
|
|
231
336
|
if (effectiveLimit !== undefined) {
|
|
232
|
-
|
|
337
|
+
params.push(Number(effectiveLimit));
|
|
338
|
+
sql += ` LIMIT $${params.length}`;
|
|
233
339
|
}
|
|
234
340
|
if (args?.offset !== undefined) {
|
|
235
|
-
|
|
341
|
+
params.push(Number(args.offset));
|
|
342
|
+
sql += ` OFFSET $${params.length}`;
|
|
236
343
|
}
|
|
237
344
|
return {
|
|
238
345
|
sql,
|
|
239
346
|
params,
|
|
240
|
-
transform: (result) => result.rows.map((row) =>
|
|
347
|
+
transform: (result) => result.rows.map((row) => hasWith
|
|
241
348
|
? this.parseNestedRow(row, this.table)
|
|
242
349
|
: this.parseRow(row, this.table)),
|
|
243
350
|
tag: `${this.table}.findMany`,
|
|
@@ -329,14 +436,19 @@ export class QueryInterface {
|
|
|
329
436
|
}
|
|
330
437
|
buildCreate(args) {
|
|
331
438
|
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
332
|
-
const columns = entries.map(([k]) => this.
|
|
439
|
+
const columns = entries.map(([k]) => this.toSqlColumn(k));
|
|
333
440
|
const params = entries.map(([, v]) => v);
|
|
334
441
|
const placeholders = entries.map((_, i) => `$${i + 1}`);
|
|
335
|
-
const sql = `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
|
|
442
|
+
const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
|
|
336
443
|
return {
|
|
337
444
|
sql,
|
|
338
445
|
params,
|
|
339
|
-
transform: (result) =>
|
|
446
|
+
transform: (result) => {
|
|
447
|
+
const row = result.rows[0];
|
|
448
|
+
if (!row)
|
|
449
|
+
throw new Error('[turbine] Expected a row but query returned none');
|
|
450
|
+
return this.parseRow(row, this.table);
|
|
451
|
+
},
|
|
340
452
|
tag: `${this.table}.create`,
|
|
341
453
|
};
|
|
342
454
|
}
|
|
@@ -351,9 +463,10 @@ export class QueryInterface {
|
|
|
351
463
|
});
|
|
352
464
|
}
|
|
353
465
|
buildCreateMany(args) {
|
|
466
|
+
const qt = quoteIdent(this.table);
|
|
354
467
|
if (args.data.length === 0) {
|
|
355
468
|
return {
|
|
356
|
-
sql: `SELECT * FROM ${
|
|
469
|
+
sql: `SELECT * FROM ${qt} WHERE false`,
|
|
357
470
|
params: [],
|
|
358
471
|
transform: () => [],
|
|
359
472
|
tag: `${this.table}.createMany`,
|
|
@@ -372,7 +485,8 @@ export class QueryInterface {
|
|
|
372
485
|
// Use actual Postgres types for array casts
|
|
373
486
|
const typeCasts = columns.map((col) => this.getColumnArrayType(col));
|
|
374
487
|
const unnestArgs = columnArrays.map((_, i) => `$${i + 1}::${typeCasts[i]}`);
|
|
375
|
-
|
|
488
|
+
const quotedColumns = columns.map((c) => quoteIdent(c));
|
|
489
|
+
let sql = `INSERT INTO ${qt} (${quotedColumns.join(', ')}) SELECT * FROM UNNEST(${unnestArgs.join(', ')})`;
|
|
376
490
|
// skipDuplicates: add ON CONFLICT DO NOTHING
|
|
377
491
|
if (args.skipDuplicates) {
|
|
378
492
|
sql += ` ON CONFLICT DO NOTHING`;
|
|
@@ -401,16 +515,21 @@ export class QueryInterface {
|
|
|
401
515
|
const params = [];
|
|
402
516
|
const setClauses = setEntries.map(([k, v]) => {
|
|
403
517
|
params.push(v);
|
|
404
|
-
return `${this.
|
|
518
|
+
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
405
519
|
});
|
|
406
520
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
407
521
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
408
522
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
409
|
-
const sql = `UPDATE ${this.table} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
|
|
523
|
+
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
|
|
410
524
|
return {
|
|
411
525
|
sql,
|
|
412
526
|
params,
|
|
413
|
-
transform: (result) =>
|
|
527
|
+
transform: (result) => {
|
|
528
|
+
const row = result.rows[0];
|
|
529
|
+
if (!row)
|
|
530
|
+
throw new Error('[turbine] Expected a row but query returned none');
|
|
531
|
+
return this.parseRow(row, this.table);
|
|
532
|
+
},
|
|
414
533
|
tag: `${this.table}.update`,
|
|
415
534
|
};
|
|
416
535
|
}
|
|
@@ -426,11 +545,16 @@ export class QueryInterface {
|
|
|
426
545
|
}
|
|
427
546
|
buildDelete(args) {
|
|
428
547
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
429
|
-
const sql = `DELETE FROM ${this.table}${whereSql} RETURNING *`;
|
|
548
|
+
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
|
|
430
549
|
return {
|
|
431
550
|
sql,
|
|
432
551
|
params,
|
|
433
|
-
transform: (result) =>
|
|
552
|
+
transform: (result) => {
|
|
553
|
+
const row = result.rows[0];
|
|
554
|
+
if (!row)
|
|
555
|
+
throw new Error('[turbine] Expected a row but query returned none');
|
|
556
|
+
return this.parseRow(row, this.table);
|
|
557
|
+
},
|
|
434
558
|
tag: `${this.table}.delete`,
|
|
435
559
|
};
|
|
436
560
|
}
|
|
@@ -447,29 +571,34 @@ export class QueryInterface {
|
|
|
447
571
|
buildUpsert(args) {
|
|
448
572
|
// Build the INSERT part from create data
|
|
449
573
|
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
450
|
-
const columns = createEntries.map(([k]) => this.
|
|
574
|
+
const columns = createEntries.map(([k]) => this.toSqlColumn(k));
|
|
451
575
|
const createParams = createEntries.map(([, v]) => v);
|
|
452
576
|
const placeholders = createEntries.map((_, i) => `$${i + 1}`);
|
|
453
577
|
// The conflict target comes from `where` keys — must be unique/PK columns
|
|
454
578
|
const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
|
|
455
|
-
const conflictColumns = conflictKeys.map((k) => this.
|
|
579
|
+
const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
|
|
456
580
|
// Build the UPDATE SET part
|
|
457
581
|
const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
|
|
458
582
|
let paramIdx = createParams.length + 1;
|
|
459
583
|
const setClauses = updateEntries.map(([k]) => {
|
|
460
|
-
const clause = `${this.
|
|
584
|
+
const clause = `${this.toSqlColumn(k)} = $${paramIdx}`;
|
|
461
585
|
paramIdx++;
|
|
462
586
|
return clause;
|
|
463
587
|
});
|
|
464
588
|
const updateParams = updateEntries.map(([, v]) => v);
|
|
465
589
|
const params = [...createParams, ...updateParams];
|
|
466
|
-
const sql = `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})` +
|
|
590
|
+
const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})` +
|
|
467
591
|
` ON CONFLICT (${conflictColumns.join(', ')}) DO UPDATE SET ${setClauses.join(', ')}` +
|
|
468
592
|
` RETURNING *`;
|
|
469
593
|
return {
|
|
470
594
|
sql,
|
|
471
595
|
params,
|
|
472
|
-
transform: (result) =>
|
|
596
|
+
transform: (result) => {
|
|
597
|
+
const row = result.rows[0];
|
|
598
|
+
if (!row)
|
|
599
|
+
throw new Error('[turbine] Expected a row but query returned none');
|
|
600
|
+
return this.parseRow(row, this.table);
|
|
601
|
+
},
|
|
473
602
|
tag: `${this.table}.upsert`,
|
|
474
603
|
};
|
|
475
604
|
}
|
|
@@ -489,12 +618,12 @@ export class QueryInterface {
|
|
|
489
618
|
const params = [];
|
|
490
619
|
const setClauses = setEntries.map(([k, v]) => {
|
|
491
620
|
params.push(v);
|
|
492
|
-
return `${this.
|
|
621
|
+
return `${this.toSqlColumn(k)} = $${params.length}`;
|
|
493
622
|
});
|
|
494
623
|
// Build WHERE using the shared params array (continues numbering after SET params)
|
|
495
624
|
const whereClause = this.buildWhereClause(args.where, params);
|
|
496
625
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
497
|
-
const sql = `UPDATE ${this.table} SET ${setClauses.join(', ')}${whereSql}`;
|
|
626
|
+
const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
498
627
|
return {
|
|
499
628
|
sql,
|
|
500
629
|
params,
|
|
@@ -514,7 +643,7 @@ export class QueryInterface {
|
|
|
514
643
|
}
|
|
515
644
|
buildDeleteMany(args) {
|
|
516
645
|
const { sql: whereSql, params } = this.buildWhere(args.where);
|
|
517
|
-
const sql = `DELETE FROM ${this.table}${whereSql}`;
|
|
646
|
+
const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
518
647
|
return {
|
|
519
648
|
sql,
|
|
520
649
|
params,
|
|
@@ -536,7 +665,7 @@ export class QueryInterface {
|
|
|
536
665
|
const { sql: whereSql, params } = args?.where
|
|
537
666
|
? this.buildWhere(args.where)
|
|
538
667
|
: { sql: '', params: [] };
|
|
539
|
-
const sql = `SELECT COUNT(*)::int AS count FROM ${this.table}${whereSql}`;
|
|
668
|
+
const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
540
669
|
return {
|
|
541
670
|
sql,
|
|
542
671
|
params,
|
|
@@ -555,7 +684,8 @@ export class QueryInterface {
|
|
|
555
684
|
});
|
|
556
685
|
}
|
|
557
686
|
buildGroupBy(args) {
|
|
558
|
-
const
|
|
687
|
+
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
688
|
+
const groupCols = groupColsRaw.map((c) => quoteIdent(c));
|
|
559
689
|
const { sql: whereSql, params } = args.where
|
|
560
690
|
? this.buildWhere(args.where)
|
|
561
691
|
: { sql: '', params: [] };
|
|
@@ -571,7 +701,7 @@ export class QueryInterface {
|
|
|
571
701
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
572
702
|
if (enabled) {
|
|
573
703
|
const col = this.toColumn(field);
|
|
574
|
-
selectExprs.push(`SUM(${col}) AS _sum_${col}`);
|
|
704
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS _sum_${col}`);
|
|
575
705
|
}
|
|
576
706
|
}
|
|
577
707
|
}
|
|
@@ -580,7 +710,7 @@ export class QueryInterface {
|
|
|
580
710
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
581
711
|
if (enabled) {
|
|
582
712
|
const col = this.toColumn(field);
|
|
583
|
-
selectExprs.push(`AVG(${col})::float AS _avg_${col}`);
|
|
713
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS _avg_${col}`);
|
|
584
714
|
}
|
|
585
715
|
}
|
|
586
716
|
}
|
|
@@ -589,7 +719,7 @@ export class QueryInterface {
|
|
|
589
719
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
590
720
|
if (enabled) {
|
|
591
721
|
const col = this.toColumn(field);
|
|
592
|
-
selectExprs.push(`MIN(${col}) AS _min_${col}`);
|
|
722
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS _min_${col}`);
|
|
593
723
|
}
|
|
594
724
|
}
|
|
595
725
|
}
|
|
@@ -598,11 +728,11 @@ export class QueryInterface {
|
|
|
598
728
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
599
729
|
if (enabled) {
|
|
600
730
|
const col = this.toColumn(field);
|
|
601
|
-
selectExprs.push(`MAX(${col}) AS _max_${col}`);
|
|
731
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS _max_${col}`);
|
|
602
732
|
}
|
|
603
733
|
}
|
|
604
734
|
}
|
|
605
|
-
let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.table}${whereSql} GROUP BY ${groupCols.join(', ')}`;
|
|
735
|
+
let sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
|
|
606
736
|
// ORDER BY
|
|
607
737
|
if (args.orderBy) {
|
|
608
738
|
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
@@ -693,7 +823,7 @@ export class QueryInterface {
|
|
|
693
823
|
for (const [field, enabled] of Object.entries(args._count)) {
|
|
694
824
|
if (enabled) {
|
|
695
825
|
const col = this.toColumn(field);
|
|
696
|
-
selectExprs.push(`COUNT(${col})::int AS _count_${col}`);
|
|
826
|
+
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS _count_${col}`);
|
|
697
827
|
}
|
|
698
828
|
}
|
|
699
829
|
}
|
|
@@ -702,7 +832,7 @@ export class QueryInterface {
|
|
|
702
832
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
703
833
|
if (enabled) {
|
|
704
834
|
const col = this.toColumn(field);
|
|
705
|
-
selectExprs.push(`SUM(${col}) AS _sum_${col}`);
|
|
835
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS _sum_${col}`);
|
|
706
836
|
}
|
|
707
837
|
}
|
|
708
838
|
}
|
|
@@ -711,7 +841,7 @@ export class QueryInterface {
|
|
|
711
841
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
712
842
|
if (enabled) {
|
|
713
843
|
const col = this.toColumn(field);
|
|
714
|
-
selectExprs.push(`AVG(${col})::float AS _avg_${col}`);
|
|
844
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS _avg_${col}`);
|
|
715
845
|
}
|
|
716
846
|
}
|
|
717
847
|
}
|
|
@@ -720,7 +850,7 @@ export class QueryInterface {
|
|
|
720
850
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
721
851
|
if (enabled) {
|
|
722
852
|
const col = this.toColumn(field);
|
|
723
|
-
selectExprs.push(`MIN(${col}) AS _min_${col}`);
|
|
853
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS _min_${col}`);
|
|
724
854
|
}
|
|
725
855
|
}
|
|
726
856
|
}
|
|
@@ -729,14 +859,14 @@ export class QueryInterface {
|
|
|
729
859
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
730
860
|
if (enabled) {
|
|
731
861
|
const col = this.toColumn(field);
|
|
732
|
-
selectExprs.push(`MAX(${col}) AS _max_${col}`);
|
|
862
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS _max_${col}`);
|
|
733
863
|
}
|
|
734
864
|
}
|
|
735
865
|
}
|
|
736
866
|
if (selectExprs.length === 0) {
|
|
737
867
|
selectExprs.push('COUNT(*)::int AS _count');
|
|
738
868
|
}
|
|
739
|
-
const sql = `SELECT ${selectExprs.join(', ')} FROM ${this.table}${whereSql}`;
|
|
869
|
+
const sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql}`;
|
|
740
870
|
return {
|
|
741
871
|
sql,
|
|
742
872
|
params,
|
|
@@ -830,13 +960,17 @@ export class QueryInterface {
|
|
|
830
960
|
}
|
|
831
961
|
return null;
|
|
832
962
|
}
|
|
833
|
-
/** Convert camelCase field name to snake_case column name */
|
|
963
|
+
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
834
964
|
toColumn(field) {
|
|
835
965
|
const mapped = this.tableMeta.columnMap[field];
|
|
836
966
|
if (mapped)
|
|
837
967
|
return mapped;
|
|
838
968
|
return camelToSnake(field);
|
|
839
969
|
}
|
|
970
|
+
/** Convert camelCase field name to a double-quoted SQL identifier */
|
|
971
|
+
toSqlColumn(field) {
|
|
972
|
+
return quoteIdent(this.toColumn(field));
|
|
973
|
+
}
|
|
840
974
|
/** Build WHERE clause from a where object (supports operators, NULL, OR) */
|
|
841
975
|
buildWhere(where) {
|
|
842
976
|
const params = [];
|
|
@@ -907,7 +1041,8 @@ export class QueryInterface {
|
|
|
907
1041
|
continue;
|
|
908
1042
|
}
|
|
909
1043
|
}
|
|
910
|
-
const
|
|
1044
|
+
const rawColumn = this.toColumn(key);
|
|
1045
|
+
const column = quoteIdent(rawColumn);
|
|
911
1046
|
// Handle null → IS NULL
|
|
912
1047
|
if (value === null) {
|
|
913
1048
|
andClauses.push(`${column} IS NULL`);
|
|
@@ -915,7 +1050,7 @@ export class QueryInterface {
|
|
|
915
1050
|
}
|
|
916
1051
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
917
1052
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
918
|
-
const colType = this.getColumnPgType(
|
|
1053
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
919
1054
|
if (colType === 'json' || colType === 'jsonb') {
|
|
920
1055
|
const jsonClauses = this.buildJsonFilterClauses(column, value, params);
|
|
921
1056
|
andClauses.push(...jsonClauses);
|
|
@@ -924,7 +1059,7 @@ export class QueryInterface {
|
|
|
924
1059
|
}
|
|
925
1060
|
// Handle Array filter operators (for array columns)
|
|
926
1061
|
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
927
|
-
const colType = this.getColumnPgType(
|
|
1062
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
928
1063
|
if (colType.startsWith('_')) {
|
|
929
1064
|
const arrayClauses = this.buildArrayFilterClauses(column, value, params, colType);
|
|
930
1065
|
andClauses.push(...arrayClauses);
|
|
@@ -954,37 +1089,39 @@ export class QueryInterface {
|
|
|
954
1089
|
const targetMeta = this.schema.tables[targetTable];
|
|
955
1090
|
if (!targetMeta)
|
|
956
1091
|
return null;
|
|
1092
|
+
const qt = quoteIdent(targetTable);
|
|
1093
|
+
const qSelf = quoteIdent(this.table);
|
|
957
1094
|
const clauses = [];
|
|
958
1095
|
// Correlation: link child table to parent table
|
|
959
1096
|
let correlation;
|
|
960
1097
|
if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
|
|
961
1098
|
// parent.pk = child.fk
|
|
962
|
-
correlation = `${
|
|
1099
|
+
correlation = `${qt}.${quoteIdent(relDef.foreignKey)} = ${qSelf}.${quoteIdent(relDef.referenceKey)}`;
|
|
963
1100
|
}
|
|
964
1101
|
else {
|
|
965
1102
|
// belongsTo: parent.fk = child.pk
|
|
966
|
-
correlation = `${
|
|
1103
|
+
correlation = `${qt}.${quoteIdent(relDef.referenceKey)} = ${qSelf}.${quoteIdent(relDef.foreignKey)}`;
|
|
967
1104
|
}
|
|
968
1105
|
// "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
|
|
969
1106
|
if (filterObj.some !== undefined) {
|
|
970
1107
|
const subWhere = filterObj.some;
|
|
971
1108
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
972
1109
|
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
973
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${
|
|
1110
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
974
1111
|
}
|
|
975
1112
|
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
|
|
976
1113
|
if (filterObj.none !== undefined) {
|
|
977
1114
|
const subWhere = filterObj.none;
|
|
978
1115
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
979
1116
|
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
980
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${
|
|
1117
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
981
1118
|
}
|
|
982
1119
|
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
|
|
983
1120
|
if (filterObj.every !== undefined) {
|
|
984
1121
|
const subWhere = filterObj.every;
|
|
985
1122
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
986
1123
|
if (filterClause) {
|
|
987
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${
|
|
1124
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation} AND NOT (${filterClause}))`);
|
|
988
1125
|
}
|
|
989
1126
|
else {
|
|
990
1127
|
// "every" with empty filter = true (all match trivially)
|
|
@@ -1000,22 +1137,24 @@ export class QueryInterface {
|
|
|
1000
1137
|
const meta = this.schema.tables[targetTable];
|
|
1001
1138
|
if (!meta)
|
|
1002
1139
|
return null;
|
|
1140
|
+
const qt = quoteIdent(targetTable);
|
|
1003
1141
|
const conditions = [];
|
|
1004
1142
|
for (const [field, value] of Object.entries(subWhere)) {
|
|
1005
1143
|
if (value === undefined)
|
|
1006
1144
|
continue;
|
|
1007
1145
|
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
1146
|
+
const qCol = `${qt}.${quoteIdent(col)}`;
|
|
1008
1147
|
if (value === null) {
|
|
1009
|
-
conditions.push(`${
|
|
1148
|
+
conditions.push(`${qCol} IS NULL`);
|
|
1010
1149
|
continue;
|
|
1011
1150
|
}
|
|
1012
1151
|
if (isWhereOperator(value)) {
|
|
1013
|
-
const opClauses = this.buildOperatorClauses(
|
|
1152
|
+
const opClauses = this.buildOperatorClauses(qCol, value, params);
|
|
1014
1153
|
conditions.push(...opClauses);
|
|
1015
1154
|
continue;
|
|
1016
1155
|
}
|
|
1017
1156
|
params.push(value);
|
|
1018
|
-
conditions.push(`${
|
|
1157
|
+
conditions.push(`${qCol} = $${params.length}`);
|
|
1019
1158
|
}
|
|
1020
1159
|
return conditions.length > 0 ? conditions.join(' AND ') : null;
|
|
1021
1160
|
}
|
|
@@ -1059,23 +1198,26 @@ export class QueryInterface {
|
|
|
1059
1198
|
clauses.push(`${column} != ALL($${params.length})`);
|
|
1060
1199
|
}
|
|
1061
1200
|
if (op.contains !== undefined) {
|
|
1062
|
-
params.push(`%${op.contains}%`);
|
|
1063
|
-
clauses.push(`${column} LIKE $${params.length}`);
|
|
1201
|
+
params.push(`%${escapeLike(op.contains)}%`);
|
|
1202
|
+
clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
|
|
1064
1203
|
}
|
|
1065
1204
|
if (op.startsWith !== undefined) {
|
|
1066
|
-
params.push(`${op.startsWith}%`);
|
|
1067
|
-
clauses.push(`${column} LIKE $${params.length}`);
|
|
1205
|
+
params.push(`${escapeLike(op.startsWith)}%`);
|
|
1206
|
+
clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
|
|
1068
1207
|
}
|
|
1069
1208
|
if (op.endsWith !== undefined) {
|
|
1070
|
-
params.push(`%${op.endsWith}`);
|
|
1071
|
-
clauses.push(`${column} LIKE $${params.length}`);
|
|
1209
|
+
params.push(`%${escapeLike(op.endsWith)}`);
|
|
1210
|
+
clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
|
|
1072
1211
|
}
|
|
1073
1212
|
return clauses;
|
|
1074
1213
|
}
|
|
1075
1214
|
/** Build ORDER BY clause from an object */
|
|
1076
1215
|
buildOrderBy(orderBy) {
|
|
1077
1216
|
return Object.entries(orderBy)
|
|
1078
|
-
.map(([key, dir]) =>
|
|
1217
|
+
.map(([key, dir]) => {
|
|
1218
|
+
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1219
|
+
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
1220
|
+
})
|
|
1079
1221
|
.join(', ');
|
|
1080
1222
|
}
|
|
1081
1223
|
/** Parse a flat row: convert snake_case to camelCase + Date coercion */
|
|
@@ -1107,6 +1249,22 @@ export class QueryInterface {
|
|
|
1107
1249
|
}
|
|
1108
1250
|
return parsed;
|
|
1109
1251
|
}
|
|
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
|
+
}
|
|
1110
1268
|
/** Parse a row that may contain JSON nested relation columns */
|
|
1111
1269
|
parseNestedRow(row, table) {
|
|
1112
1270
|
const parsed = this.parseRow(row, table);
|
|
@@ -1153,8 +1311,9 @@ export class QueryInterface {
|
|
|
1153
1311
|
if (!meta)
|
|
1154
1312
|
throw new Error(`[turbine] Unknown table "${table}"`);
|
|
1155
1313
|
const cols = columnsList ?? meta.allColumns;
|
|
1314
|
+
const qtbl = quoteIdent(table);
|
|
1156
1315
|
const baseCols = cols
|
|
1157
|
-
.map((col) => `${
|
|
1316
|
+
.map((col) => `${qtbl}.${quoteIdent(col)}`)
|
|
1158
1317
|
.join(', ');
|
|
1159
1318
|
const relationSelects = [];
|
|
1160
1319
|
const aliasCounter = { n: 0 };
|
|
@@ -1166,7 +1325,7 @@ export class QueryInterface {
|
|
|
1166
1325
|
}
|
|
1167
1326
|
// The main table is not aliased, so pass table name as parentRef
|
|
1168
1327
|
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
|
|
1169
|
-
relationSelects.push(`(${subquery}) AS ${relName}`);
|
|
1328
|
+
relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
|
|
1170
1329
|
}
|
|
1171
1330
|
return [baseCols, ...relationSelects].join(', ');
|
|
1172
1331
|
}
|
|
@@ -1202,7 +1361,7 @@ export class QueryInterface {
|
|
|
1202
1361
|
targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
1203
1362
|
}
|
|
1204
1363
|
// Build json_build_object pairs for resolved columns
|
|
1205
|
-
const jsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${alias}.${col}`);
|
|
1364
|
+
const jsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${alias}.${quoteIdent(col)}`);
|
|
1206
1365
|
// Nested relations?
|
|
1207
1366
|
if (spec !== true && spec.with) {
|
|
1208
1367
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -1217,6 +1376,9 @@ export class QueryInterface {
|
|
|
1217
1376
|
}
|
|
1218
1377
|
}
|
|
1219
1378
|
const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
|
|
1379
|
+
// Quote parent ref — can be a table name or auto-generated alias
|
|
1380
|
+
const qParent = quoteIdent(parentRef);
|
|
1381
|
+
const qTarget = quoteIdent(targetTable);
|
|
1220
1382
|
// Build ORDER BY for json_agg
|
|
1221
1383
|
let orderClause = '';
|
|
1222
1384
|
if (spec !== true && spec.orderBy) {
|
|
@@ -1227,7 +1389,7 @@ export class QueryInterface {
|
|
|
1227
1389
|
throw new Error(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
1228
1390
|
}
|
|
1229
1391
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1230
|
-
return `${alias}.${col} ${safeDir}`;
|
|
1392
|
+
return `${alias}.${quoteIdent(col)} ${safeDir}`;
|
|
1231
1393
|
})
|
|
1232
1394
|
.join(', ');
|
|
1233
1395
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -1237,10 +1399,10 @@ export class QueryInterface {
|
|
|
1237
1399
|
// For belongsTo: source has FK, so alias.pk = parentRef.fk (reversed)
|
|
1238
1400
|
let whereClause;
|
|
1239
1401
|
if (relDef.type === 'belongsTo' || relDef.type === 'hasOne') {
|
|
1240
|
-
whereClause = `${alias}.${relDef.referenceKey} = ${
|
|
1402
|
+
whereClause = `${alias}.${quoteIdent(relDef.referenceKey)} = ${qParent}.${quoteIdent(relDef.foreignKey)}`;
|
|
1241
1403
|
}
|
|
1242
1404
|
else {
|
|
1243
|
-
whereClause = `${alias}.${relDef.foreignKey} = ${
|
|
1405
|
+
whereClause = `${alias}.${quoteIdent(relDef.foreignKey)} = ${qParent}.${quoteIdent(relDef.referenceKey)}`;
|
|
1244
1406
|
}
|
|
1245
1407
|
// Additional filters — properly parameterized
|
|
1246
1408
|
if (spec !== true && spec.where) {
|
|
@@ -1250,13 +1412,14 @@ export class QueryInterface {
|
|
|
1250
1412
|
throw new Error(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
1251
1413
|
}
|
|
1252
1414
|
params.push(v);
|
|
1253
|
-
whereClause += ` AND ${alias}.${col} = $${params.length}`;
|
|
1415
|
+
whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
|
|
1254
1416
|
}
|
|
1255
1417
|
}
|
|
1256
1418
|
// LIMIT
|
|
1257
1419
|
let limitClause = '';
|
|
1258
1420
|
if (spec !== true && spec.limit) {
|
|
1259
|
-
|
|
1421
|
+
params.push(Number(spec.limit));
|
|
1422
|
+
limitClause = ` LIMIT $${params.length}`;
|
|
1260
1423
|
}
|
|
1261
1424
|
if (relDef.type === 'hasMany') {
|
|
1262
1425
|
// When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
|
|
@@ -1265,9 +1428,9 @@ export class QueryInterface {
|
|
|
1265
1428
|
const innerAlias = `${alias}i`;
|
|
1266
1429
|
// Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
|
|
1267
1430
|
// Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
|
|
1268
|
-
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${c}`).join(', ')} FROM ${
|
|
1431
|
+
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1269
1432
|
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
1270
|
-
const innerJsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${innerAlias}.${col}`);
|
|
1433
|
+
const innerJsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${innerAlias}.${quoteIdent(col)}`);
|
|
1271
1434
|
// Re-add nested relation subqueries referencing innerAlias
|
|
1272
1435
|
if (spec !== true && spec.with) {
|
|
1273
1436
|
for (const [nestedRelName] of Object.entries(spec.with)) {
|
|
@@ -1281,10 +1444,10 @@ export class QueryInterface {
|
|
|
1281
1444
|
const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
|
|
1282
1445
|
return `SELECT COALESCE(json_agg(${innerJsonObj}), '[]'::json) FROM (${innerSql}) ${innerAlias}`;
|
|
1283
1446
|
}
|
|
1284
|
-
return `SELECT COALESCE(json_agg(${jsonObj}${orderClause}), '[]'::json) FROM ${
|
|
1447
|
+
return `SELECT COALESCE(json_agg(${jsonObj}${orderClause}), '[]'::json) FROM ${qTarget} ${alias} WHERE ${whereClause}`;
|
|
1285
1448
|
}
|
|
1286
1449
|
// belongsTo / hasOne — return single object
|
|
1287
|
-
return `SELECT ${jsonObj} FROM ${
|
|
1450
|
+
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
|
|
1288
1451
|
}
|
|
1289
1452
|
/**
|
|
1290
1453
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|