turbine-orm 0.3.0 → 0.3.1

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/dist/query.js CHANGED
@@ -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',
@@ -122,12 +144,13 @@ export class QueryInterface {
122
144
  let sql = this.sqlCache.get(ck);
123
145
  const params = whereKeys.map((k) => whereObj[k]);
124
146
  if (!sql) {
125
- const whereClauses = whereKeys.map((k, i) => `${this.toColumn(k)} = $${i + 1}`);
147
+ const qt = quoteIdent(this.table);
148
+ const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
126
149
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
127
150
  const selectExpr = columnsList
128
- ? columnsList.map((c) => `${this.table}.${c}`).join(', ')
129
- : `${this.table}.*`;
130
- sql = `SELECT ${selectExpr} FROM ${this.table}${whereSql} LIMIT 1`;
151
+ ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
152
+ : `${qt}.*`;
153
+ sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
131
154
  this.sqlCache.set(ck, sql);
132
155
  }
133
156
  return {
@@ -143,10 +166,11 @@ export class QueryInterface {
143
166
  // General path: supports operators, null, OR, nested with
144
167
  const { sql: whereSql, params } = this.buildWhere(args.where);
145
168
  if (!args.with) {
169
+ const qt = quoteIdent(this.table);
146
170
  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`;
171
+ ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
172
+ : `${qt}.*`;
173
+ const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
150
174
  return {
151
175
  sql,
152
176
  params,
@@ -159,7 +183,7 @@ export class QueryInterface {
159
183
  }
160
184
  // Nested queries: build fresh each time (with clause affects params)
161
185
  const selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
162
- const sql = `SELECT ${selectClause} FROM ${this.table}${whereSql} LIMIT 1`;
186
+ const sql = `SELECT ${selectClause} FROM ${quoteIdent(this.table)}${whereSql} LIMIT 1`;
163
187
  return {
164
188
  sql,
165
189
  params,
@@ -185,10 +209,11 @@ export class QueryInterface {
185
209
  ? this.buildWhere(args.where)
186
210
  : { sql: '', params: [] };
187
211
  const columnsList = this.resolveColumns(args?.select, args?.omit);
212
+ const qt = quoteIdent(this.table);
188
213
  // Distinct support
189
214
  let distinctPrefix = '';
190
215
  if (args?.distinct && args.distinct.length > 0) {
191
- const distinctCols = args.distinct.map((k) => this.toColumn(k));
216
+ const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
192
217
  distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
193
218
  }
194
219
  let selectClause;
@@ -196,23 +221,23 @@ export class QueryInterface {
196
221
  selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
197
222
  }
198
223
  else if (columnsList) {
199
- selectClause = columnsList.map((c) => `${this.table}.${c}`).join(', ');
224
+ selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
200
225
  }
201
226
  else {
202
- selectClause = `${this.table}.*`;
227
+ selectClause = `${qt}.*`;
203
228
  }
204
- let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${this.table}${whereSql}`;
229
+ let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${whereSql}`;
205
230
  // Cursor-based pagination: add WHERE condition for cursor
206
231
  if (args?.cursor) {
207
232
  const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
208
233
  if (cursorEntries.length > 0) {
209
234
  // Determine direction from orderBy (default 'asc')
210
235
  const cursorConditions = cursorEntries.map(([k, v]) => {
211
- const col = this.toColumn(k);
236
+ const col = this.toSqlColumn(k);
212
237
  const dir = args.orderBy?.[k] ?? 'asc';
213
238
  const op = dir === 'desc' ? '<' : '>';
214
239
  params.push(v);
215
- return `${this.table}.${col} ${op} $${params.length}`;
240
+ return `${qt}.${col} ${op} $${params.length}`;
216
241
  });
217
242
  // Append to existing WHERE or create new one
218
243
  if (whereSql) {
@@ -229,10 +254,12 @@ export class QueryInterface {
229
254
  // take overrides limit when cursor pagination is used
230
255
  const effectiveLimit = args?.take ?? args?.limit;
231
256
  if (effectiveLimit !== undefined) {
232
- sql += ` LIMIT ${Number(effectiveLimit)}`;
257
+ params.push(Number(effectiveLimit));
258
+ sql += ` LIMIT $${params.length}`;
233
259
  }
234
260
  if (args?.offset !== undefined) {
235
- sql += ` OFFSET ${Number(args.offset)}`;
261
+ params.push(Number(args.offset));
262
+ sql += ` OFFSET $${params.length}`;
236
263
  }
237
264
  return {
238
265
  sql,
@@ -329,14 +356,19 @@ export class QueryInterface {
329
356
  }
330
357
  buildCreate(args) {
331
358
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
332
- const columns = entries.map(([k]) => this.toColumn(k));
359
+ const columns = entries.map(([k]) => this.toSqlColumn(k));
333
360
  const params = entries.map(([, v]) => v);
334
361
  const placeholders = entries.map((_, i) => `$${i + 1}`);
335
- const sql = `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
362
+ const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
336
363
  return {
337
364
  sql,
338
365
  params,
339
- transform: (result) => this.parseRow(result.rows[0], this.table),
366
+ transform: (result) => {
367
+ const row = result.rows[0];
368
+ if (!row)
369
+ throw new Error('[turbine] Expected a row but query returned none');
370
+ return this.parseRow(row, this.table);
371
+ },
340
372
  tag: `${this.table}.create`,
341
373
  };
342
374
  }
@@ -351,9 +383,10 @@ export class QueryInterface {
351
383
  });
352
384
  }
353
385
  buildCreateMany(args) {
386
+ const qt = quoteIdent(this.table);
354
387
  if (args.data.length === 0) {
355
388
  return {
356
- sql: `SELECT * FROM ${this.table} WHERE false`,
389
+ sql: `SELECT * FROM ${qt} WHERE false`,
357
390
  params: [],
358
391
  transform: () => [],
359
392
  tag: `${this.table}.createMany`,
@@ -372,7 +405,8 @@ export class QueryInterface {
372
405
  // Use actual Postgres types for array casts
373
406
  const typeCasts = columns.map((col) => this.getColumnArrayType(col));
374
407
  const unnestArgs = columnArrays.map((_, i) => `$${i + 1}::${typeCasts[i]}`);
375
- let sql = `INSERT INTO ${this.table} (${columns.join(', ')}) SELECT * FROM UNNEST(${unnestArgs.join(', ')})`;
408
+ const quotedColumns = columns.map((c) => quoteIdent(c));
409
+ let sql = `INSERT INTO ${qt} (${quotedColumns.join(', ')}) SELECT * FROM UNNEST(${unnestArgs.join(', ')})`;
376
410
  // skipDuplicates: add ON CONFLICT DO NOTHING
377
411
  if (args.skipDuplicates) {
378
412
  sql += ` ON CONFLICT DO NOTHING`;
@@ -401,16 +435,21 @@ export class QueryInterface {
401
435
  const params = [];
402
436
  const setClauses = setEntries.map(([k, v]) => {
403
437
  params.push(v);
404
- return `${this.toColumn(k)} = $${params.length}`;
438
+ return `${this.toSqlColumn(k)} = $${params.length}`;
405
439
  });
406
440
  // Build WHERE using the shared params array (continues numbering after SET params)
407
441
  const whereClause = this.buildWhereClause(args.where, params);
408
442
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
409
- const sql = `UPDATE ${this.table} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
443
+ const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
410
444
  return {
411
445
  sql,
412
446
  params,
413
- transform: (result) => this.parseRow(result.rows[0], this.table),
447
+ transform: (result) => {
448
+ const row = result.rows[0];
449
+ if (!row)
450
+ throw new Error('[turbine] Expected a row but query returned none');
451
+ return this.parseRow(row, this.table);
452
+ },
414
453
  tag: `${this.table}.update`,
415
454
  };
416
455
  }
@@ -426,11 +465,16 @@ export class QueryInterface {
426
465
  }
427
466
  buildDelete(args) {
428
467
  const { sql: whereSql, params } = this.buildWhere(args.where);
429
- const sql = `DELETE FROM ${this.table}${whereSql} RETURNING *`;
468
+ const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
430
469
  return {
431
470
  sql,
432
471
  params,
433
- transform: (result) => this.parseRow(result.rows[0], this.table),
472
+ transform: (result) => {
473
+ const row = result.rows[0];
474
+ if (!row)
475
+ throw new Error('[turbine] Expected a row but query returned none');
476
+ return this.parseRow(row, this.table);
477
+ },
434
478
  tag: `${this.table}.delete`,
435
479
  };
436
480
  }
@@ -447,29 +491,34 @@ export class QueryInterface {
447
491
  buildUpsert(args) {
448
492
  // Build the INSERT part from create data
449
493
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
450
- const columns = createEntries.map(([k]) => this.toColumn(k));
494
+ const columns = createEntries.map(([k]) => this.toSqlColumn(k));
451
495
  const createParams = createEntries.map(([, v]) => v);
452
496
  const placeholders = createEntries.map((_, i) => `$${i + 1}`);
453
497
  // The conflict target comes from `where` keys — must be unique/PK columns
454
498
  const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
455
- const conflictColumns = conflictKeys.map((k) => this.toColumn(k));
499
+ const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
456
500
  // Build the UPDATE SET part
457
501
  const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
458
502
  let paramIdx = createParams.length + 1;
459
503
  const setClauses = updateEntries.map(([k]) => {
460
- const clause = `${this.toColumn(k)} = $${paramIdx}`;
504
+ const clause = `${this.toSqlColumn(k)} = $${paramIdx}`;
461
505
  paramIdx++;
462
506
  return clause;
463
507
  });
464
508
  const updateParams = updateEntries.map(([, v]) => v);
465
509
  const params = [...createParams, ...updateParams];
466
- const sql = `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})` +
510
+ const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})` +
467
511
  ` ON CONFLICT (${conflictColumns.join(', ')}) DO UPDATE SET ${setClauses.join(', ')}` +
468
512
  ` RETURNING *`;
469
513
  return {
470
514
  sql,
471
515
  params,
472
- transform: (result) => this.parseRow(result.rows[0], this.table),
516
+ transform: (result) => {
517
+ const row = result.rows[0];
518
+ if (!row)
519
+ throw new Error('[turbine] Expected a row but query returned none');
520
+ return this.parseRow(row, this.table);
521
+ },
473
522
  tag: `${this.table}.upsert`,
474
523
  };
475
524
  }
@@ -489,12 +538,12 @@ export class QueryInterface {
489
538
  const params = [];
490
539
  const setClauses = setEntries.map(([k, v]) => {
491
540
  params.push(v);
492
- return `${this.toColumn(k)} = $${params.length}`;
541
+ return `${this.toSqlColumn(k)} = $${params.length}`;
493
542
  });
494
543
  // Build WHERE using the shared params array (continues numbering after SET params)
495
544
  const whereClause = this.buildWhereClause(args.where, params);
496
545
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
497
- const sql = `UPDATE ${this.table} SET ${setClauses.join(', ')}${whereSql}`;
546
+ const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
498
547
  return {
499
548
  sql,
500
549
  params,
@@ -514,7 +563,7 @@ export class QueryInterface {
514
563
  }
515
564
  buildDeleteMany(args) {
516
565
  const { sql: whereSql, params } = this.buildWhere(args.where);
517
- const sql = `DELETE FROM ${this.table}${whereSql}`;
566
+ const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
518
567
  return {
519
568
  sql,
520
569
  params,
@@ -536,7 +585,7 @@ export class QueryInterface {
536
585
  const { sql: whereSql, params } = args?.where
537
586
  ? this.buildWhere(args.where)
538
587
  : { sql: '', params: [] };
539
- const sql = `SELECT COUNT(*)::int AS count FROM ${this.table}${whereSql}`;
588
+ const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
540
589
  return {
541
590
  sql,
542
591
  params,
@@ -555,7 +604,8 @@ export class QueryInterface {
555
604
  });
556
605
  }
557
606
  buildGroupBy(args) {
558
- const groupCols = args.by.map((k) => this.toColumn(k));
607
+ const groupColsRaw = args.by.map((k) => this.toColumn(k));
608
+ const groupCols = groupColsRaw.map((c) => quoteIdent(c));
559
609
  const { sql: whereSql, params } = args.where
560
610
  ? this.buildWhere(args.where)
561
611
  : { sql: '', params: [] };
@@ -571,7 +621,7 @@ export class QueryInterface {
571
621
  for (const [field, enabled] of Object.entries(args._sum)) {
572
622
  if (enabled) {
573
623
  const col = this.toColumn(field);
574
- selectExprs.push(`SUM(${col}) AS _sum_${col}`);
624
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS _sum_${col}`);
575
625
  }
576
626
  }
577
627
  }
@@ -580,7 +630,7 @@ export class QueryInterface {
580
630
  for (const [field, enabled] of Object.entries(args._avg)) {
581
631
  if (enabled) {
582
632
  const col = this.toColumn(field);
583
- selectExprs.push(`AVG(${col})::float AS _avg_${col}`);
633
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS _avg_${col}`);
584
634
  }
585
635
  }
586
636
  }
@@ -589,7 +639,7 @@ export class QueryInterface {
589
639
  for (const [field, enabled] of Object.entries(args._min)) {
590
640
  if (enabled) {
591
641
  const col = this.toColumn(field);
592
- selectExprs.push(`MIN(${col}) AS _min_${col}`);
642
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS _min_${col}`);
593
643
  }
594
644
  }
595
645
  }
@@ -598,11 +648,11 @@ export class QueryInterface {
598
648
  for (const [field, enabled] of Object.entries(args._max)) {
599
649
  if (enabled) {
600
650
  const col = this.toColumn(field);
601
- selectExprs.push(`MAX(${col}) AS _max_${col}`);
651
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS _max_${col}`);
602
652
  }
603
653
  }
604
654
  }
605
- let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.table}${whereSql} GROUP BY ${groupCols.join(', ')}`;
655
+ let sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
606
656
  // ORDER BY
607
657
  if (args.orderBy) {
608
658
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
@@ -693,7 +743,7 @@ export class QueryInterface {
693
743
  for (const [field, enabled] of Object.entries(args._count)) {
694
744
  if (enabled) {
695
745
  const col = this.toColumn(field);
696
- selectExprs.push(`COUNT(${col})::int AS _count_${col}`);
746
+ selectExprs.push(`COUNT(${quoteIdent(col)})::int AS _count_${col}`);
697
747
  }
698
748
  }
699
749
  }
@@ -702,7 +752,7 @@ export class QueryInterface {
702
752
  for (const [field, enabled] of Object.entries(args._sum)) {
703
753
  if (enabled) {
704
754
  const col = this.toColumn(field);
705
- selectExprs.push(`SUM(${col}) AS _sum_${col}`);
755
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS _sum_${col}`);
706
756
  }
707
757
  }
708
758
  }
@@ -711,7 +761,7 @@ export class QueryInterface {
711
761
  for (const [field, enabled] of Object.entries(args._avg)) {
712
762
  if (enabled) {
713
763
  const col = this.toColumn(field);
714
- selectExprs.push(`AVG(${col})::float AS _avg_${col}`);
764
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS _avg_${col}`);
715
765
  }
716
766
  }
717
767
  }
@@ -720,7 +770,7 @@ export class QueryInterface {
720
770
  for (const [field, enabled] of Object.entries(args._min)) {
721
771
  if (enabled) {
722
772
  const col = this.toColumn(field);
723
- selectExprs.push(`MIN(${col}) AS _min_${col}`);
773
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS _min_${col}`);
724
774
  }
725
775
  }
726
776
  }
@@ -729,14 +779,14 @@ export class QueryInterface {
729
779
  for (const [field, enabled] of Object.entries(args._max)) {
730
780
  if (enabled) {
731
781
  const col = this.toColumn(field);
732
- selectExprs.push(`MAX(${col}) AS _max_${col}`);
782
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS _max_${col}`);
733
783
  }
734
784
  }
735
785
  }
736
786
  if (selectExprs.length === 0) {
737
787
  selectExprs.push('COUNT(*)::int AS _count');
738
788
  }
739
- const sql = `SELECT ${selectExprs.join(', ')} FROM ${this.table}${whereSql}`;
789
+ const sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql}`;
740
790
  return {
741
791
  sql,
742
792
  params,
@@ -830,13 +880,17 @@ export class QueryInterface {
830
880
  }
831
881
  return null;
832
882
  }
833
- /** Convert camelCase field name to snake_case column name */
883
+ /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
834
884
  toColumn(field) {
835
885
  const mapped = this.tableMeta.columnMap[field];
836
886
  if (mapped)
837
887
  return mapped;
838
888
  return camelToSnake(field);
839
889
  }
890
+ /** Convert camelCase field name to a double-quoted SQL identifier */
891
+ toSqlColumn(field) {
892
+ return quoteIdent(this.toColumn(field));
893
+ }
840
894
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
841
895
  buildWhere(where) {
842
896
  const params = [];
@@ -907,7 +961,8 @@ export class QueryInterface {
907
961
  continue;
908
962
  }
909
963
  }
910
- const column = this.toColumn(key);
964
+ const rawColumn = this.toColumn(key);
965
+ const column = quoteIdent(rawColumn);
911
966
  // Handle null → IS NULL
912
967
  if (value === null) {
913
968
  andClauses.push(`${column} IS NULL`);
@@ -915,7 +970,7 @@ export class QueryInterface {
915
970
  }
916
971
  // Handle JSONB filter operators (for json/jsonb columns)
917
972
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
918
- const colType = this.getColumnPgType(column);
973
+ const colType = this.getColumnPgType(rawColumn);
919
974
  if (colType === 'json' || colType === 'jsonb') {
920
975
  const jsonClauses = this.buildJsonFilterClauses(column, value, params);
921
976
  andClauses.push(...jsonClauses);
@@ -924,7 +979,7 @@ export class QueryInterface {
924
979
  }
925
980
  // Handle Array filter operators (for array columns)
926
981
  if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
927
- const colType = this.getColumnPgType(column);
982
+ const colType = this.getColumnPgType(rawColumn);
928
983
  if (colType.startsWith('_')) {
929
984
  const arrayClauses = this.buildArrayFilterClauses(column, value, params, colType);
930
985
  andClauses.push(...arrayClauses);
@@ -954,37 +1009,39 @@ export class QueryInterface {
954
1009
  const targetMeta = this.schema.tables[targetTable];
955
1010
  if (!targetMeta)
956
1011
  return null;
1012
+ const qt = quoteIdent(targetTable);
1013
+ const qSelf = quoteIdent(this.table);
957
1014
  const clauses = [];
958
1015
  // Correlation: link child table to parent table
959
1016
  let correlation;
960
1017
  if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
961
1018
  // parent.pk = child.fk
962
- correlation = `${targetTable}.${relDef.foreignKey} = ${this.table}.${relDef.referenceKey}`;
1019
+ correlation = `${qt}.${quoteIdent(relDef.foreignKey)} = ${qSelf}.${quoteIdent(relDef.referenceKey)}`;
963
1020
  }
964
1021
  else {
965
1022
  // belongsTo: parent.fk = child.pk
966
- correlation = `${targetTable}.${relDef.referenceKey} = ${this.table}.${relDef.foreignKey}`;
1023
+ correlation = `${qt}.${quoteIdent(relDef.referenceKey)} = ${qSelf}.${quoteIdent(relDef.foreignKey)}`;
967
1024
  }
968
1025
  // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
969
1026
  if (filterObj.some !== undefined) {
970
1027
  const subWhere = filterObj.some;
971
1028
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
972
1029
  const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
973
- clauses.push(`EXISTS (SELECT 1 FROM ${targetTable} WHERE ${fullWhere})`);
1030
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
974
1031
  }
975
1032
  // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
976
1033
  if (filterObj.none !== undefined) {
977
1034
  const subWhere = filterObj.none;
978
1035
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
979
1036
  const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
980
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${targetTable} WHERE ${fullWhere})`);
1037
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
981
1038
  }
982
1039
  // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
983
1040
  if (filterObj.every !== undefined) {
984
1041
  const subWhere = filterObj.every;
985
1042
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
986
1043
  if (filterClause) {
987
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${targetTable} WHERE ${correlation} AND NOT (${filterClause}))`);
1044
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation} AND NOT (${filterClause}))`);
988
1045
  }
989
1046
  else {
990
1047
  // "every" with empty filter = true (all match trivially)
@@ -1000,22 +1057,24 @@ export class QueryInterface {
1000
1057
  const meta = this.schema.tables[targetTable];
1001
1058
  if (!meta)
1002
1059
  return null;
1060
+ const qt = quoteIdent(targetTable);
1003
1061
  const conditions = [];
1004
1062
  for (const [field, value] of Object.entries(subWhere)) {
1005
1063
  if (value === undefined)
1006
1064
  continue;
1007
1065
  const col = meta.columnMap[field] ?? camelToSnake(field);
1066
+ const qCol = `${qt}.${quoteIdent(col)}`;
1008
1067
  if (value === null) {
1009
- conditions.push(`${targetTable}.${col} IS NULL`);
1068
+ conditions.push(`${qCol} IS NULL`);
1010
1069
  continue;
1011
1070
  }
1012
1071
  if (isWhereOperator(value)) {
1013
- const opClauses = this.buildOperatorClauses(`${targetTable}.${col}`, value, params);
1072
+ const opClauses = this.buildOperatorClauses(qCol, value, params);
1014
1073
  conditions.push(...opClauses);
1015
1074
  continue;
1016
1075
  }
1017
1076
  params.push(value);
1018
- conditions.push(`${targetTable}.${col} = $${params.length}`);
1077
+ conditions.push(`${qCol} = $${params.length}`);
1019
1078
  }
1020
1079
  return conditions.length > 0 ? conditions.join(' AND ') : null;
1021
1080
  }
@@ -1059,23 +1118,26 @@ export class QueryInterface {
1059
1118
  clauses.push(`${column} != ALL($${params.length})`);
1060
1119
  }
1061
1120
  if (op.contains !== undefined) {
1062
- params.push(`%${op.contains}%`);
1063
- clauses.push(`${column} LIKE $${params.length}`);
1121
+ params.push(`%${escapeLike(op.contains)}%`);
1122
+ clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
1064
1123
  }
1065
1124
  if (op.startsWith !== undefined) {
1066
- params.push(`${op.startsWith}%`);
1067
- clauses.push(`${column} LIKE $${params.length}`);
1125
+ params.push(`${escapeLike(op.startsWith)}%`);
1126
+ clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
1068
1127
  }
1069
1128
  if (op.endsWith !== undefined) {
1070
- params.push(`%${op.endsWith}`);
1071
- clauses.push(`${column} LIKE $${params.length}`);
1129
+ params.push(`%${escapeLike(op.endsWith)}`);
1130
+ clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
1072
1131
  }
1073
1132
  return clauses;
1074
1133
  }
1075
1134
  /** Build ORDER BY clause from an object */
1076
1135
  buildOrderBy(orderBy) {
1077
1136
  return Object.entries(orderBy)
1078
- .map(([key, dir]) => `${this.toColumn(key)} ${dir.toUpperCase()}`)
1137
+ .map(([key, dir]) => {
1138
+ const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1139
+ return `${this.toSqlColumn(key)} ${safeDir}`;
1140
+ })
1079
1141
  .join(', ');
1080
1142
  }
1081
1143
  /** Parse a flat row: convert snake_case to camelCase + Date coercion */
@@ -1153,8 +1215,9 @@ export class QueryInterface {
1153
1215
  if (!meta)
1154
1216
  throw new Error(`[turbine] Unknown table "${table}"`);
1155
1217
  const cols = columnsList ?? meta.allColumns;
1218
+ const qtbl = quoteIdent(table);
1156
1219
  const baseCols = cols
1157
- .map((col) => `${table}.${col}`)
1220
+ .map((col) => `${qtbl}.${quoteIdent(col)}`)
1158
1221
  .join(', ');
1159
1222
  const relationSelects = [];
1160
1223
  const aliasCounter = { n: 0 };
@@ -1166,7 +1229,7 @@ export class QueryInterface {
1166
1229
  }
1167
1230
  // The main table is not aliased, so pass table name as parentRef
1168
1231
  const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
1169
- relationSelects.push(`(${subquery}) AS ${relName}`);
1232
+ relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
1170
1233
  }
1171
1234
  return [baseCols, ...relationSelects].join(', ');
1172
1235
  }
@@ -1202,7 +1265,7 @@ export class QueryInterface {
1202
1265
  targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
1203
1266
  }
1204
1267
  // Build json_build_object pairs for resolved columns
1205
- const jsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${alias}.${col}`);
1268
+ const jsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${alias}.${quoteIdent(col)}`);
1206
1269
  // Nested relations?
1207
1270
  if (spec !== true && spec.with) {
1208
1271
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -1217,6 +1280,9 @@ export class QueryInterface {
1217
1280
  }
1218
1281
  }
1219
1282
  const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
1283
+ // Quote parent ref — can be a table name or auto-generated alias
1284
+ const qParent = quoteIdent(parentRef);
1285
+ const qTarget = quoteIdent(targetTable);
1220
1286
  // Build ORDER BY for json_agg
1221
1287
  let orderClause = '';
1222
1288
  if (spec !== true && spec.orderBy) {
@@ -1227,7 +1293,7 @@ export class QueryInterface {
1227
1293
  throw new Error(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
1228
1294
  }
1229
1295
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1230
- return `${alias}.${col} ${safeDir}`;
1296
+ return `${alias}.${quoteIdent(col)} ${safeDir}`;
1231
1297
  })
1232
1298
  .join(', ');
1233
1299
  orderClause = ` ORDER BY ${orders}`;
@@ -1237,10 +1303,10 @@ export class QueryInterface {
1237
1303
  // For belongsTo: source has FK, so alias.pk = parentRef.fk (reversed)
1238
1304
  let whereClause;
1239
1305
  if (relDef.type === 'belongsTo' || relDef.type === 'hasOne') {
1240
- whereClause = `${alias}.${relDef.referenceKey} = ${parentRef}.${relDef.foreignKey}`;
1306
+ whereClause = `${alias}.${quoteIdent(relDef.referenceKey)} = ${qParent}.${quoteIdent(relDef.foreignKey)}`;
1241
1307
  }
1242
1308
  else {
1243
- whereClause = `${alias}.${relDef.foreignKey} = ${parentRef}.${relDef.referenceKey}`;
1309
+ whereClause = `${alias}.${quoteIdent(relDef.foreignKey)} = ${qParent}.${quoteIdent(relDef.referenceKey)}`;
1244
1310
  }
1245
1311
  // Additional filters — properly parameterized
1246
1312
  if (spec !== true && spec.where) {
@@ -1250,13 +1316,14 @@ export class QueryInterface {
1250
1316
  throw new Error(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
1251
1317
  }
1252
1318
  params.push(v);
1253
- whereClause += ` AND ${alias}.${col} = $${params.length}`;
1319
+ whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
1254
1320
  }
1255
1321
  }
1256
1322
  // LIMIT
1257
1323
  let limitClause = '';
1258
1324
  if (spec !== true && spec.limit) {
1259
- limitClause = ` LIMIT ${Number(spec.limit)}`;
1325
+ params.push(Number(spec.limit));
1326
+ limitClause = ` LIMIT $${params.length}`;
1260
1327
  }
1261
1328
  if (relDef.type === 'hasMany') {
1262
1329
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -1265,9 +1332,9 @@ export class QueryInterface {
1265
1332
  const innerAlias = `${alias}i`;
1266
1333
  // Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
1267
1334
  // 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}`;
1335
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
1269
1336
  // 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}`);
1337
+ const innerJsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${innerAlias}.${quoteIdent(col)}`);
1271
1338
  // Re-add nested relation subqueries referencing innerAlias
1272
1339
  if (spec !== true && spec.with) {
1273
1340
  for (const [nestedRelName] of Object.entries(spec.with)) {
@@ -1281,10 +1348,10 @@ export class QueryInterface {
1281
1348
  const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
1282
1349
  return `SELECT COALESCE(json_agg(${innerJsonObj}), '[]'::json) FROM (${innerSql}) ${innerAlias}`;
1283
1350
  }
1284
- return `SELECT COALESCE(json_agg(${jsonObj}${orderClause}), '[]'::json) FROM ${targetTable} ${alias} WHERE ${whereClause}`;
1351
+ return `SELECT COALESCE(json_agg(${jsonObj}${orderClause}), '[]'::json) FROM ${qTarget} ${alias} WHERE ${whereClause}`;
1285
1352
  }
1286
1353
  // belongsTo / hasOne — return single object
1287
- return `SELECT ${jsonObj} FROM ${targetTable} ${alias} WHERE ${whereClause} LIMIT 1`;
1354
+ return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
1288
1355
  }
1289
1356
  /**
1290
1357
  * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "TypeScript ORM with json_agg nested queries — 2-3x faster than Prisma, 1.5x faster than Drizzle",
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,6 +25,8 @@
25
25
  "files": [
26
26
  "dist",
27
27
  "!dist/test",
28
+ "!dist/**/*.js.map",
29
+ "!dist/examples.*",
28
30
  "LICENSE",
29
31
  "README.md"
30
32
  ],
@@ -1 +0,0 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/cli/config.ts"],"names":[],"mappings":"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"}