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.
Files changed (50) hide show
  1. package/README.md +5 -5
  2. package/dist/cli/config.d.ts +1 -1
  3. package/dist/cli/config.js +3 -3
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/index.js +11 -11
  6. package/dist/cli/migrate.d.ts +1 -1
  7. package/dist/cli/migrate.js +1 -1
  8. package/dist/cli/ui.d.ts +1 -1
  9. package/dist/cli/ui.js +2 -2
  10. package/dist/client.d.ts +2 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +11 -5
  13. package/dist/generate.d.ts +1 -1
  14. package/dist/generate.d.ts.map +1 -1
  15. package/dist/generate.js +27 -23
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/introspect.d.ts +1 -1
  19. package/dist/introspect.js +1 -1
  20. package/dist/pipeline.d.ts +1 -1
  21. package/dist/pipeline.js +1 -1
  22. package/dist/query.d.ts +22 -2
  23. package/dist/query.d.ts.map +1 -1
  24. package/dist/query.js +249 -86
  25. package/dist/schema-builder.d.ts +2 -2
  26. package/dist/schema-builder.js +2 -2
  27. package/dist/schema-sql.d.ts +1 -1
  28. package/dist/schema-sql.js +1 -1
  29. package/dist/schema.d.ts +1 -1
  30. package/dist/schema.js +1 -1
  31. package/dist/serverless.d.ts +3 -3
  32. package/dist/serverless.js +4 -4
  33. package/dist/types.d.ts +1 -1
  34. package/dist/types.js +1 -1
  35. package/package.json +6 -5
  36. package/dist/cli/config.js.map +0 -1
  37. package/dist/cli/index.js.map +0 -1
  38. package/dist/cli/migrate.js.map +0 -1
  39. package/dist/cli/ui.js.map +0 -1
  40. package/dist/client.js.map +0 -1
  41. package/dist/generate.js.map +0 -1
  42. package/dist/index.js.map +0 -1
  43. package/dist/introspect.js.map +0 -1
  44. package/dist/pipeline.js.map +0 -1
  45. package/dist/query.js.map +0 -1
  46. package/dist/schema-builder.js.map +0 -1
  47. package/dist/schema-sql.js.map +0 -1
  48. package/dist/schema.js.map +0 -1
  49. package/dist/serverless.js.map +0 -1
  50. package/dist/types.js.map +0 -1
package/dist/query.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @batadata/turbine — Query builder
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
- // For simple queries (no nested with, no operators), use cached SQL template
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 whereClauses = whereKeys.map((k, i) => `${this.toColumn(k)} = $${i + 1}`);
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) => `${this.table}.${c}`).join(', ')
129
- : `${this.table}.*`;
130
- sql = `SELECT ${selectExpr} FROM ${this.table}${whereSql} LIMIT 1`;
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 ? this.parseRow(row, this.table) : null;
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) => `${this.table}.${c}`).join(', ')
148
- : `${this.table}.*`;
149
- const sql = `SELECT ${selectExpr} FROM ${this.table}${whereSql} LIMIT 1`;
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.toColumn(k));
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 (args?.with) {
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) => `${this.table}.${c}`).join(', ');
304
+ selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
200
305
  }
201
306
  else {
202
- selectClause = `${this.table}.*`;
307
+ selectClause = `${qt}.*`;
203
308
  }
204
- let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${this.table}${whereSql}`;
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.toColumn(k);
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 `${this.table}.${col} ${op} $${params.length}`;
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
- sql += ` LIMIT ${Number(effectiveLimit)}`;
337
+ params.push(Number(effectiveLimit));
338
+ sql += ` LIMIT $${params.length}`;
233
339
  }
234
340
  if (args?.offset !== undefined) {
235
- sql += ` OFFSET ${Number(args.offset)}`;
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) => args?.with
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.toColumn(k));
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) => this.parseRow(result.rows[0], this.table),
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 ${this.table} WHERE false`,
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
- let sql = `INSERT INTO ${this.table} (${columns.join(', ')}) SELECT * FROM UNNEST(${unnestArgs.join(', ')})`;
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.toColumn(k)} = $${params.length}`;
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) => this.parseRow(result.rows[0], this.table),
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) => this.parseRow(result.rows[0], this.table),
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.toColumn(k));
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.toColumn(k));
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.toColumn(k)} = $${paramIdx}`;
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) => this.parseRow(result.rows[0], this.table),
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.toColumn(k)} = $${params.length}`;
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 groupCols = args.by.map((k) => this.toColumn(k));
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 column = this.toColumn(key);
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(column);
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(column);
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 = `${targetTable}.${relDef.foreignKey} = ${this.table}.${relDef.referenceKey}`;
1099
+ correlation = `${qt}.${quoteIdent(relDef.foreignKey)} = ${qSelf}.${quoteIdent(relDef.referenceKey)}`;
963
1100
  }
964
1101
  else {
965
1102
  // belongsTo: parent.fk = child.pk
966
- correlation = `${targetTable}.${relDef.referenceKey} = ${this.table}.${relDef.foreignKey}`;
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 ${targetTable} WHERE ${fullWhere})`);
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 ${targetTable} WHERE ${fullWhere})`);
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 ${targetTable} WHERE ${correlation} AND NOT (${filterClause}))`);
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(`${targetTable}.${col} IS NULL`);
1148
+ conditions.push(`${qCol} IS NULL`);
1010
1149
  continue;
1011
1150
  }
1012
1151
  if (isWhereOperator(value)) {
1013
- const opClauses = this.buildOperatorClauses(`${targetTable}.${col}`, value, params);
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(`${targetTable}.${col} = $${params.length}`);
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]) => `${this.toColumn(key)} ${dir.toUpperCase()}`)
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) => `${table}.${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} = ${parentRef}.${relDef.foreignKey}`;
1402
+ whereClause = `${alias}.${quoteIdent(relDef.referenceKey)} = ${qParent}.${quoteIdent(relDef.foreignKey)}`;
1241
1403
  }
1242
1404
  else {
1243
- whereClause = `${alias}.${relDef.foreignKey} = ${parentRef}.${relDef.referenceKey}`;
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
- limitClause = ` LIMIT ${Number(spec.limit)}`;
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 ${targetTable} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
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 ${targetTable} ${alias} WHERE ${whereClause}`;
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 ${targetTable} ${alias} WHERE ${whereClause} LIMIT 1`;
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').