turbine-orm 0.10.0 → 0.12.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.
@@ -10,9 +10,10 @@
10
10
  * Schema-driven: all column names, types, and relations come from introspected
11
11
  * metadata — nothing is hardcoded.
12
12
  */
13
+ import { postgresDialect } from '../dialect.js';
13
14
  import { CircularRelationError, NotFoundError, RelationError, TimeoutError, ValidationError, wrapPgError, } from '../errors.js';
14
15
  import { camelToSnake, snakeToCamel } from '../schema.js';
15
- import { buildCorrelation, escapeLike, escSingleQuote, LRUCache, OPERATOR_KEYS, quoteIdent, sqlToPreparedName, } from './utils.js';
16
+ import { escapeLike, LRUCache, OPERATOR_KEYS, sqlToPreparedName } from './utils.js';
16
17
  // ---------------------------------------------------------------------------
17
18
  // Internal detection helpers — used by QueryInterface
18
19
  // ---------------------------------------------------------------------------
@@ -109,6 +110,7 @@ export class QueryInterface {
109
110
  warnOnUnlimited;
110
111
  preparedStatementsEnabled;
111
112
  sqlCacheEnabled;
113
+ dialect;
112
114
  /**
113
115
  * Tracks tables that have already triggered an unlimited-query warning so
114
116
  * the user is not spammed once per row. Per-instance state — each
@@ -143,6 +145,7 @@ export class QueryInterface {
143
145
  this.warnOnUnlimited = options?.warnOnUnlimited !== false;
144
146
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
145
147
  this.sqlCacheEnabled = options?.sqlCache !== false;
148
+ this.dialect = options?.dialect ?? postgresDialect;
146
149
  // Pre-compute column type lookup maps (TASK-26)
147
150
  this.columnPgTypeMap = new Map();
148
151
  this.columnArrayTypeMap = new Map();
@@ -151,6 +154,14 @@ export class QueryInterface {
151
154
  this.columnArrayTypeMap.set(col.name, col.pgArrayType);
152
155
  }
153
156
  }
157
+ /** Quote an identifier through the active SQL dialect. */
158
+ q(name) {
159
+ return this.dialect.quoteIdentifier(name);
160
+ }
161
+ /** Return the active dialect's placeholder for a 1-indexed parameter position. */
162
+ p(index) {
163
+ return this.dialect.paramPlaceholder(index);
164
+ }
154
165
  /**
155
166
  * Return cache hit/miss statistics for this QueryInterface instance.
156
167
  * Useful for monitoring and benchmarking.
@@ -288,11 +299,11 @@ export class QueryInterface {
288
299
  // Simple path: plain equality, no operators/null/OR
289
300
  if (!args.with && isSimpleWhere) {
290
301
  const entry = this.acquireSql(ck, () => {
291
- const qt = quoteIdent(this.table);
302
+ const qt = this.q(this.table);
292
303
  const tempParams = whereKeys.map((k) => whereObj[k]);
293
- const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
304
+ const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = ${this.p(i + 1)}`);
294
305
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
295
- const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
306
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
296
307
  void tempParams; // params are positional, SQL is value-invariant
297
308
  return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
298
309
  });
@@ -317,8 +328,8 @@ export class QueryInterface {
317
328
  const freshParams = [];
318
329
  const clause = this.buildWhereClause(whereObj, freshParams);
319
330
  const whereSql = clause ? ` WHERE ${clause}` : '';
320
- const qt = quoteIdent(this.table);
321
- const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
331
+ const qt = this.q(this.table);
332
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
322
333
  return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
323
334
  });
324
335
  // Collect params
@@ -344,7 +355,7 @@ export class QueryInterface {
344
355
  const clause = this.buildWhereClause(whereObj, freshParams);
345
356
  const whereSql = clause ? ` WHERE ${clause}` : '';
346
357
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
347
- return `SELECT ${selectClause} FROM ${quoteIdent(this.table)}${whereSql} LIMIT 1`;
358
+ return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql} LIMIT 1`;
348
359
  });
349
360
  // Collect params in exact build order: where first, then with-clause relations
350
361
  this.collectWhereParams(whereObj, params);
@@ -455,7 +466,7 @@ export class QueryInterface {
455
466
  return { sql: clause ? ` WHERE ${clause}` : '' };
456
467
  })()
457
468
  : { sql: '' };
458
- const qt = quoteIdent(this.table);
469
+ const qt = this.q(this.table);
459
470
  let distinctPrefix = '';
460
471
  if (args?.distinct && args.distinct.length > 0) {
461
472
  const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
@@ -466,7 +477,7 @@ export class QueryInterface {
466
477
  selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
467
478
  }
468
479
  else if (columnsList) {
469
- selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
480
+ selectClause = columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ');
470
481
  }
471
482
  else {
472
483
  selectClause = `${qt}.*`;
@@ -480,7 +491,7 @@ export class QueryInterface {
480
491
  const dir = args.orderBy?.[k] ?? 'asc';
481
492
  const op = dir === 'desc' ? '<' : '>';
482
493
  freshParams.push(v);
483
- return `${qt}.${col} ${op} $${freshParams.length}`;
494
+ return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
484
495
  });
485
496
  if (freshWhereSql) {
486
497
  sql += ` AND ${cursorConditions.join(' AND ')}`;
@@ -495,11 +506,11 @@ export class QueryInterface {
495
506
  }
496
507
  if (effectiveLimit !== undefined) {
497
508
  freshParams.push(Number(effectiveLimit));
498
- sql += ` LIMIT $${freshParams.length}`;
509
+ sql += ` LIMIT ${this.p(freshParams.length)}`;
499
510
  }
500
511
  if (args?.offset !== undefined) {
501
512
  freshParams.push(Number(args.offset));
502
- sql += ` OFFSET $${freshParams.length}`;
513
+ sql += ` OFFSET ${this.p(freshParams.length)}`;
503
514
  }
504
515
  return sql;
505
516
  });
@@ -587,7 +598,7 @@ export class QueryInterface {
587
598
  // Acquire a dedicated connection — cursors require a single connection in a transaction
588
599
  const client = await this.pool.connect();
589
600
  const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
590
- const quotedCursor = quoteIdent(cursorName);
601
+ const quotedCursor = this.q(cursorName);
591
602
  try {
592
603
  await client.query('BEGIN');
593
604
  await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
@@ -721,8 +732,13 @@ export class QueryInterface {
721
732
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
722
733
  const columns = entries.map(([k]) => this.toSqlColumn(k));
723
734
  const params = entries.map(([, v]) => v);
724
- const placeholders = entries.map((_, i) => `$${i + 1}`);
725
- const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
735
+ const placeholders = entries.map((_, i) => `${this.p(i + 1)}`);
736
+ const sql = this.dialect.buildInsertStatement({
737
+ table: this.q(this.table),
738
+ columns,
739
+ valuePlaceholders: placeholders,
740
+ returning: '*',
741
+ });
726
742
  return {
727
743
  sql,
728
744
  params,
@@ -751,7 +767,7 @@ export class QueryInterface {
751
767
  });
752
768
  }
753
769
  buildCreateMany(args) {
754
- const qt = quoteIdent(this.table);
770
+ const qt = this.q(this.table);
755
771
  if (args.data.length === 0) {
756
772
  return {
757
773
  sql: `SELECT * FROM ${qt} WHERE false`,
@@ -762,27 +778,24 @@ export class QueryInterface {
762
778
  }
763
779
  const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
764
780
  const columns = keys.map((k) => this.toColumn(k));
765
- // Build column arrays for UNNEST
766
- const columnArrays = keys.map(() => []);
767
- for (const row of args.data) {
781
+ const rowValues = args.data.map((row) => {
768
782
  const record = row;
769
- keys.forEach((key, i) => {
770
- columnArrays[i].push(record[key]);
771
- });
772
- }
773
- // Use actual Postgres types for array casts
783
+ return keys.map((key) => record[key]);
784
+ });
785
+ // Use actual Postgres types for array casts in the default PostgreSQL dialect.
774
786
  const typeCasts = columns.map((col) => this.getColumnArrayType(col));
775
- const unnestArgs = columnArrays.map((_, i) => `$${i + 1}::${typeCasts[i]}`);
776
- const quotedColumns = columns.map((c) => quoteIdent(c));
777
- let sql = `INSERT INTO ${qt} (${quotedColumns.join(', ')}) SELECT * FROM UNNEST(${unnestArgs.join(', ')})`;
778
- // skipDuplicates: add ON CONFLICT DO NOTHING
779
- if (args.skipDuplicates) {
780
- sql += ` ON CONFLICT DO NOTHING`;
781
- }
782
- sql += ` RETURNING *`;
787
+ const quotedColumns = columns.map((c) => this.q(c));
788
+ const built = this.dialect.buildBulkInsertStatement({
789
+ table: qt,
790
+ columns: quotedColumns,
791
+ rowValues,
792
+ columnArrayTypes: typeCasts,
793
+ skipDuplicates: args.skipDuplicates,
794
+ returning: '*',
795
+ });
783
796
  return {
784
- sql,
785
- params: columnArrays,
797
+ sql: built.sql,
798
+ params: built.params,
786
799
  transform: (result) => result.rows.map((row) => this.parseRow(row, this.table)),
787
800
  tag: `${this.table}.createMany`,
788
801
  };
@@ -811,7 +824,7 @@ export class QueryInterface {
811
824
  const whereClause = this.buildWhereClause(whereObj, freshParams);
812
825
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
813
826
  this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
814
- return `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
827
+ return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
815
828
  });
816
829
  // On cache hit, validate predicate
817
830
  if (whereFp === '') {
@@ -860,7 +873,7 @@ export class QueryInterface {
860
873
  const clause = this.buildWhereClause(whereObj, freshParams);
861
874
  const whereSql = clause ? ` WHERE ${clause}` : '';
862
875
  this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
863
- return `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
876
+ return `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
864
877
  });
865
878
  // On cache hit, still validate the predicate
866
879
  if (whereFp === '') {
@@ -900,7 +913,7 @@ export class QueryInterface {
900
913
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
901
914
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
902
915
  const createParams = createEntries.map(([, v]) => v);
903
- const placeholders = createEntries.map((_, i) => `$${i + 1}`);
916
+ const placeholders = createEntries.map((_, i) => `${this.p(i + 1)}`);
904
917
  // The conflict target comes from `where` keys — must be unique/PK columns
905
918
  const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
906
919
  const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
@@ -908,15 +921,20 @@ export class QueryInterface {
908
921
  const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
909
922
  let paramIdx = createParams.length + 1;
910
923
  const setClauses = updateEntries.map(([k]) => {
911
- const clause = `${this.toSqlColumn(k)} = $${paramIdx}`;
924
+ const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}`;
912
925
  paramIdx++;
913
926
  return clause;
914
927
  });
915
928
  const updateParams = updateEntries.map(([, v]) => v);
916
929
  const params = [...createParams, ...updateParams];
917
- const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})` +
918
- ` ON CONFLICT (${conflictColumns.join(', ')}) DO UPDATE SET ${setClauses.join(', ')}` +
919
- ` RETURNING *`;
930
+ const sql = this.dialect.buildUpsertStatement({
931
+ table: this.q(this.table),
932
+ insertColumns: columns,
933
+ valuePlaceholders: placeholders,
934
+ conflictColumns,
935
+ updateSetClauses: setClauses,
936
+ returning: '*',
937
+ });
920
938
  return {
921
939
  sql,
922
940
  params,
@@ -959,7 +977,7 @@ export class QueryInterface {
959
977
  const whereClause = this.buildWhereClause(whereObj, freshParams);
960
978
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
961
979
  this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
962
- return `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
980
+ return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
963
981
  });
964
982
  if (whereFp === '') {
965
983
  this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
@@ -994,7 +1012,7 @@ export class QueryInterface {
994
1012
  const clause = this.buildWhereClause(whereObj, freshParams);
995
1013
  const whereSql = clause ? ` WHERE ${clause}` : '';
996
1014
  this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
997
- return `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
1015
+ return `DELETE FROM ${this.q(this.table)}${whereSql}`;
998
1016
  });
999
1017
  if (whereFp === '') {
1000
1018
  this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
@@ -1027,7 +1045,7 @@ export class QueryInterface {
1027
1045
  const freshParams = [];
1028
1046
  const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1029
1047
  const whereSql = clause ? ` WHERE ${clause}` : '';
1030
- return `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
1048
+ return `SELECT COUNT(*)::int AS count FROM ${this.q(this.table)}${whereSql}`;
1031
1049
  });
1032
1050
  if (args?.where) {
1033
1051
  this.collectWhereParams(whereObj, params);
@@ -1060,7 +1078,7 @@ export class QueryInterface {
1060
1078
  }
1061
1079
  }
1062
1080
  const groupColsRaw = args.by.map((k) => this.toColumn(k));
1063
- const groupCols = groupColsRaw.map((c) => quoteIdent(c));
1081
+ const groupCols = groupColsRaw.map((c) => this.q(c));
1064
1082
  const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
1065
1083
  // Build SELECT expressions: group-by columns + aggregate functions
1066
1084
  const selectExprs = [...groupCols];
@@ -1074,7 +1092,7 @@ export class QueryInterface {
1074
1092
  for (const [field, enabled] of Object.entries(args._sum)) {
1075
1093
  if (enabled) {
1076
1094
  const col = this.toColumn(field);
1077
- selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(`_sum_${col}`)}`);
1095
+ selectExprs.push(`SUM(${this.q(col)}) AS ${this.q(`_sum_${col}`)}`);
1078
1096
  }
1079
1097
  }
1080
1098
  }
@@ -1083,7 +1101,7 @@ export class QueryInterface {
1083
1101
  for (const [field, enabled] of Object.entries(args._avg)) {
1084
1102
  if (enabled) {
1085
1103
  const col = this.toColumn(field);
1086
- selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(`_avg_${col}`)}`);
1104
+ selectExprs.push(`AVG(${this.q(col)})::float AS ${this.q(`_avg_${col}`)}`);
1087
1105
  }
1088
1106
  }
1089
1107
  }
@@ -1092,7 +1110,7 @@ export class QueryInterface {
1092
1110
  for (const [field, enabled] of Object.entries(args._min)) {
1093
1111
  if (enabled) {
1094
1112
  const col = this.toColumn(field);
1095
- selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(`_min_${col}`)}`);
1113
+ selectExprs.push(`MIN(${this.q(col)}) AS ${this.q(`_min_${col}`)}`);
1096
1114
  }
1097
1115
  }
1098
1116
  }
@@ -1101,11 +1119,11 @@ export class QueryInterface {
1101
1119
  for (const [field, enabled] of Object.entries(args._max)) {
1102
1120
  if (enabled) {
1103
1121
  const col = this.toColumn(field);
1104
- selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(`_max_${col}`)}`);
1122
+ selectExprs.push(`MAX(${this.q(col)}) AS ${this.q(`_max_${col}`)}`);
1105
1123
  }
1106
1124
  }
1107
1125
  }
1108
- let sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1126
+ let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1109
1127
  // ORDER BY
1110
1128
  if (args.orderBy) {
1111
1129
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
@@ -1213,7 +1231,7 @@ export class QueryInterface {
1213
1231
  for (const [field, enabled] of Object.entries(args._count)) {
1214
1232
  if (enabled) {
1215
1233
  const col = this.toColumn(field);
1216
- selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent(`_count_${col}`)}`);
1234
+ selectExprs.push(`COUNT(${this.q(col)})::int AS ${this.q(`_count_${col}`)}`);
1217
1235
  }
1218
1236
  }
1219
1237
  }
@@ -1222,7 +1240,7 @@ export class QueryInterface {
1222
1240
  for (const [field, enabled] of Object.entries(args._sum)) {
1223
1241
  if (enabled) {
1224
1242
  const col = this.toColumn(field);
1225
- selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(`_sum_${col}`)}`);
1243
+ selectExprs.push(`SUM(${this.q(col)}) AS ${this.q(`_sum_${col}`)}`);
1226
1244
  }
1227
1245
  }
1228
1246
  }
@@ -1231,7 +1249,7 @@ export class QueryInterface {
1231
1249
  for (const [field, enabled] of Object.entries(args._avg)) {
1232
1250
  if (enabled) {
1233
1251
  const col = this.toColumn(field);
1234
- selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(`_avg_${col}`)}`);
1252
+ selectExprs.push(`AVG(${this.q(col)})::float AS ${this.q(`_avg_${col}`)}`);
1235
1253
  }
1236
1254
  }
1237
1255
  }
@@ -1240,7 +1258,7 @@ export class QueryInterface {
1240
1258
  for (const [field, enabled] of Object.entries(args._min)) {
1241
1259
  if (enabled) {
1242
1260
  const col = this.toColumn(field);
1243
- selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(`_min_${col}`)}`);
1261
+ selectExprs.push(`MIN(${this.q(col)}) AS ${this.q(`_min_${col}`)}`);
1244
1262
  }
1245
1263
  }
1246
1264
  }
@@ -1249,14 +1267,14 @@ export class QueryInterface {
1249
1267
  for (const [field, enabled] of Object.entries(args._max)) {
1250
1268
  if (enabled) {
1251
1269
  const col = this.toColumn(field);
1252
- selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(`_max_${col}`)}`);
1270
+ selectExprs.push(`MAX(${this.q(col)}) AS ${this.q(`_max_${col}`)}`);
1253
1271
  }
1254
1272
  }
1255
1273
  }
1256
1274
  if (selectExprs.length === 0) {
1257
1275
  selectExprs.push('COUNT(*)::int AS _count');
1258
1276
  }
1259
- const sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql}`;
1277
+ const sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql}`;
1260
1278
  return {
1261
1279
  sql,
1262
1280
  params,
@@ -1373,7 +1391,7 @@ export class QueryInterface {
1373
1391
  }
1374
1392
  /** Convert camelCase field name to a double-quoted SQL identifier */
1375
1393
  toSqlColumn(field) {
1376
- return quoteIdent(this.toColumn(field));
1394
+ return this.q(this.toColumn(field));
1377
1395
  }
1378
1396
  /**
1379
1397
  * Build a single SET clause entry for update/updateMany.
@@ -1406,7 +1424,7 @@ export class QueryInterface {
1406
1424
  const opValue = v[op];
1407
1425
  if (op === 'set') {
1408
1426
  params.push(opValue);
1409
- return `${col} = $${params.length}`;
1427
+ return `${col} = ${this.p(params.length)}`;
1410
1428
  }
1411
1429
  // Arithmetic operators: must be finite numbers
1412
1430
  if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
@@ -1414,19 +1432,19 @@ export class QueryInterface {
1414
1432
  }
1415
1433
  if (op === 'increment') {
1416
1434
  params.push(opValue);
1417
- return `${col} = ${col} + $${params.length}`;
1435
+ return `${col} = ${col} + ${this.p(params.length)}`;
1418
1436
  }
1419
1437
  if (op === 'decrement') {
1420
1438
  params.push(opValue);
1421
- return `${col} = ${col} - $${params.length}`;
1439
+ return `${col} = ${col} - ${this.p(params.length)}`;
1422
1440
  }
1423
1441
  if (op === 'multiply') {
1424
1442
  params.push(opValue);
1425
- return `${col} = ${col} * $${params.length}`;
1443
+ return `${col} = ${col} * ${this.p(params.length)}`;
1426
1444
  }
1427
1445
  if (op === 'divide') {
1428
1446
  params.push(opValue);
1429
- return `${col} = ${col} / $${params.length}`;
1447
+ return `${col} = ${col} / ${this.p(params.length)}`;
1430
1448
  }
1431
1449
  }
1432
1450
  // Fall through: multi-key objects or non-operator single-key objects
@@ -1434,7 +1452,7 @@ export class QueryInterface {
1434
1452
  }
1435
1453
  // Plain value (including null, Date, Buffer, arrays, JSON objects)
1436
1454
  params.push(value);
1437
- return `${col} = $${params.length}`;
1455
+ return `${col} = ${this.p(params.length)}`;
1438
1456
  }
1439
1457
  // =========================================================================
1440
1458
  // Fingerprinting — value-invariant shape keys for SQL cache lookup
@@ -1957,7 +1975,7 @@ export class QueryInterface {
1957
1975
  }
1958
1976
  }
1959
1977
  const rawColumn = this.toColumn(key);
1960
- const column = quoteIdent(rawColumn);
1978
+ const column = this.q(rawColumn);
1961
1979
  // Handle null → IS NULL
1962
1980
  if (value === null) {
1963
1981
  andClauses.push(`${column} IS NULL`);
@@ -2007,7 +2025,7 @@ export class QueryInterface {
2007
2025
  }
2008
2026
  // Plain equality
2009
2027
  params.push(value);
2010
- andClauses.push(`${column} = $${params.length}`);
2028
+ andClauses.push(`${column} = ${this.p(params.length)}`);
2011
2029
  }
2012
2030
  if (andClauses.length === 0)
2013
2031
  return null;
@@ -2022,18 +2040,18 @@ export class QueryInterface {
2022
2040
  const targetMeta = this.schema.tables[targetTable];
2023
2041
  if (!targetMeta)
2024
2042
  return null;
2025
- const qt = quoteIdent(targetTable);
2026
- const qSelf = quoteIdent(this.table);
2043
+ const qt = this.q(targetTable);
2044
+ const qSelf = this.q(this.table);
2027
2045
  const clauses = [];
2028
2046
  // Correlation: link child table to parent table (supports composite FKs)
2029
2047
  let correlation;
2030
2048
  if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
2031
2049
  // parent.pk = child.fk
2032
- correlation = buildCorrelation(qt, relDef.foreignKey, qSelf, relDef.referenceKey);
2050
+ correlation = this.dialect.buildCorrelation(qt, relDef.foreignKey, qSelf, relDef.referenceKey);
2033
2051
  }
2034
2052
  else {
2035
2053
  // belongsTo: parent.fk = child.pk
2036
- correlation = buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
2054
+ correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
2037
2055
  }
2038
2056
  // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
2039
2057
  if (filterObj.some !== undefined) {
@@ -2070,7 +2088,7 @@ export class QueryInterface {
2070
2088
  const meta = this.schema.tables[targetTable];
2071
2089
  if (!meta)
2072
2090
  return null;
2073
- const qt = quoteIdent(targetTable);
2091
+ const qt = this.q(targetTable);
2074
2092
  const conditions = [];
2075
2093
  for (const [field, value] of Object.entries(subWhere)) {
2076
2094
  if (value === undefined)
@@ -2080,7 +2098,7 @@ export class QueryInterface {
2080
2098
  throw new ValidationError(`[turbine] Unknown field "${field}" in relation filter for table "${targetTable}". ` +
2081
2099
  `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
2082
2100
  }
2083
- const qCol = `${qt}.${quoteIdent(col)}`;
2101
+ const qCol = `${qt}.${this.q(col)}`;
2084
2102
  if (value === null) {
2085
2103
  conditions.push(`${qCol} IS NULL`);
2086
2104
  continue;
@@ -2091,7 +2109,7 @@ export class QueryInterface {
2091
2109
  continue;
2092
2110
  }
2093
2111
  params.push(value);
2094
- conditions.push(`${qCol} = $${params.length}`);
2112
+ conditions.push(`${qCol} = ${this.p(params.length)}`);
2095
2113
  }
2096
2114
  return conditions.length > 0 ? conditions.join(' AND ') : null;
2097
2115
  }
@@ -2103,19 +2121,19 @@ export class QueryInterface {
2103
2121
  const clauses = [];
2104
2122
  if (op.gt !== undefined) {
2105
2123
  params.push(op.gt);
2106
- clauses.push(`${column} > $${params.length}`);
2124
+ clauses.push(`${column} > ${this.p(params.length)}`);
2107
2125
  }
2108
2126
  if (op.gte !== undefined) {
2109
2127
  params.push(op.gte);
2110
- clauses.push(`${column} >= $${params.length}`);
2128
+ clauses.push(`${column} >= ${this.p(params.length)}`);
2111
2129
  }
2112
2130
  if (op.lt !== undefined) {
2113
2131
  params.push(op.lt);
2114
- clauses.push(`${column} < $${params.length}`);
2132
+ clauses.push(`${column} < ${this.p(params.length)}`);
2115
2133
  }
2116
2134
  if (op.lte !== undefined) {
2117
2135
  params.push(op.lte);
2118
- clauses.push(`${column} <= $${params.length}`);
2136
+ clauses.push(`${column} <= ${this.p(params.length)}`);
2119
2137
  }
2120
2138
  if (op.not !== undefined) {
2121
2139
  if (op.not === null) {
@@ -2123,30 +2141,29 @@ export class QueryInterface {
2123
2141
  }
2124
2142
  else {
2125
2143
  params.push(op.not);
2126
- clauses.push(`${column} != $${params.length}`);
2144
+ clauses.push(`${column} != ${this.p(params.length)}`);
2127
2145
  }
2128
2146
  }
2129
2147
  if (op.in !== undefined) {
2130
2148
  params.push(op.in);
2131
- clauses.push(`${column} = ANY($${params.length})`);
2149
+ clauses.push(`${column} = ANY(${this.p(params.length)})`);
2132
2150
  }
2133
2151
  if (op.notIn !== undefined) {
2134
2152
  params.push(op.notIn);
2135
- clauses.push(`${column} != ALL($${params.length})`);
2153
+ clauses.push(`${column} != ALL(${this.p(params.length)})`);
2136
2154
  }
2137
- // Use ILIKE for case-insensitive mode, LIKE otherwise
2138
- const likeOp = op.mode === 'insensitive' ? 'ILIKE' : 'LIKE';
2155
+ const buildLikeClause = (paramRef) => op.mode === 'insensitive' ? this.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
2139
2156
  if (op.contains !== undefined) {
2140
2157
  params.push(`%${escapeLike(op.contains)}%`);
2141
- clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
2158
+ clauses.push(`${buildLikeClause(this.p(params.length))} ESCAPE '\\'`);
2142
2159
  }
2143
2160
  if (op.startsWith !== undefined) {
2144
2161
  params.push(`${escapeLike(op.startsWith)}%`);
2145
- clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
2162
+ clauses.push(`${buildLikeClause(this.p(params.length))} ESCAPE '\\'`);
2146
2163
  }
2147
2164
  if (op.endsWith !== undefined) {
2148
2165
  params.push(`%${escapeLike(op.endsWith)}`);
2149
- clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
2166
+ clauses.push(`${buildLikeClause(this.p(params.length))} ESCAPE '\\'`);
2150
2167
  }
2151
2168
  return clauses;
2152
2169
  }
@@ -2306,8 +2323,8 @@ export class QueryInterface {
2306
2323
  if (!meta)
2307
2324
  throw new ValidationError(`[turbine] Unknown table "${table}"`);
2308
2325
  const cols = columnsList ?? meta.allColumns;
2309
- const qtbl = quoteIdent(table);
2310
- const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
2326
+ const qtbl = this.q(table);
2327
+ const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
2311
2328
  const relationSelects = [];
2312
2329
  const aliasCounter = { n: 0 };
2313
2330
  for (const [relName, relSpec] of Object.entries(withClause)) {
@@ -2318,7 +2335,7 @@ export class QueryInterface {
2318
2335
  }
2319
2336
  // The main table is not aliased, so pass table name as parentRef
2320
2337
  const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
2321
- relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
2338
+ relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
2322
2339
  }
2323
2340
  return [baseCols, ...relationSelects].join(', ');
2324
2341
  }
@@ -2380,7 +2397,7 @@ export class QueryInterface {
2380
2397
  * 8. **Parameter threading:** All user-supplied values (where filters, limit) are
2381
2398
  * pushed to the shared `params` array with `$N` placeholders. No string
2382
2399
  * interpolation of user data ever occurs -- all identifiers go through
2383
- * `quoteIdent()` and all values are parameterized.
2400
+ * `this.q()` and all values are parameterized.
2384
2401
  *
2385
2402
  * ### Example output (hasMany with nested relation)
2386
2403
  * ```sql
@@ -2444,8 +2461,11 @@ export class QueryInterface {
2444
2461
  .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
2445
2462
  targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
2446
2463
  }
2447
- // Build json_build_object pairs for resolved columns
2448
- const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${alias}.${quoteIdent(col)}`);
2464
+ // Build JSON object pairs for resolved columns
2465
+ const jsonPairs = targetColumns.map((col) => [
2466
+ targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
2467
+ `${alias}.${this.q(col)}`,
2468
+ ]);
2449
2469
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
2450
2470
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
2451
2471
  // so we must NOT build them here (they would push orphaned params).
@@ -2461,14 +2481,14 @@ export class QueryInterface {
2461
2481
  // Recursively build nested subquery, passing THIS alias as the parent reference
2462
2482
  const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
2463
2483
  // Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
2464
- const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
2465
- jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), ${fallback})`);
2484
+ const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
2485
+ jsonPairs.push([nestedRelName, `COALESCE((${nestedSubquery}), ${fallback})`]);
2466
2486
  }
2467
2487
  }
2468
- const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
2488
+ const jsonObj = this.dialect.buildJsonObject(jsonPairs);
2469
2489
  // Quote parent ref — can be a table name or auto-generated alias
2470
- const qParent = quoteIdent(parentRef);
2471
- const qTarget = quoteIdent(targetTable);
2490
+ const qParent = this.q(parentRef);
2491
+ const qTarget = this.q(targetTable);
2472
2492
  // Build ORDER BY for json_agg
2473
2493
  let orderClause = '';
2474
2494
  if (spec !== true && spec.orderBy) {
@@ -2479,7 +2499,7 @@ export class QueryInterface {
2479
2499
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
2480
2500
  }
2481
2501
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2482
- return `${alias}.${quoteIdent(col)} ${safeDir}`;
2502
+ return `${alias}.${this.q(col)} ${safeDir}`;
2483
2503
  })
2484
2504
  .join(', ');
2485
2505
  orderClause = ` ORDER BY ${orders}`;
@@ -2490,10 +2510,10 @@ export class QueryInterface {
2490
2510
  // Supports composite foreign keys (string[]) via buildCorrelation.
2491
2511
  let whereClause;
2492
2512
  if (relDef.type === 'belongsTo' || relDef.type === 'hasOne') {
2493
- whereClause = buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey);
2513
+ whereClause = this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey);
2494
2514
  }
2495
2515
  else {
2496
- whereClause = buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
2516
+ whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
2497
2517
  }
2498
2518
  // Additional filters — properly parameterized
2499
2519
  if (spec !== true && spec.where) {
@@ -2503,14 +2523,14 @@ export class QueryInterface {
2503
2523
  throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
2504
2524
  }
2505
2525
  params.push(v);
2506
- whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
2526
+ whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
2507
2527
  }
2508
2528
  }
2509
2529
  // LIMIT
2510
2530
  let limitClause = '';
2511
2531
  if (spec !== true && spec.limit) {
2512
2532
  params.push(Number(spec.limit));
2513
- limitClause = ` LIMIT $${params.length}`;
2533
+ limitClause = ` LIMIT ${this.p(params.length)}`;
2514
2534
  }
2515
2535
  if (relDef.type === 'hasMany') {
2516
2536
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -2519,9 +2539,12 @@ export class QueryInterface {
2519
2539
  const innerAlias = `${alias}i`;
2520
2540
  // Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
2521
2541
  // Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
2522
- const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
2542
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${this.q(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
2523
2543
  // For the json_build_object, reference the inner alias — only include resolved columns
2524
- const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${innerAlias}.${quoteIdent(col)}`);
2544
+ const innerJsonPairs = targetColumns.map((col) => [
2545
+ targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
2546
+ `${innerAlias}.${this.q(col)}`,
2547
+ ]);
2525
2548
  // Build nested relation subqueries referencing innerAlias
2526
2549
  if (spec !== true && spec.with) {
2527
2550
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2531,14 +2554,14 @@ export class QueryInterface {
2531
2554
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
2532
2555
  }
2533
2556
  const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
2534
- const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
2535
- innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), ${fallback})`);
2557
+ const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
2558
+ innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
2536
2559
  }
2537
2560
  }
2538
- const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
2539
- return `SELECT COALESCE(json_agg(${innerJsonObj}), '[]'::json) FROM (${innerSql}) ${innerAlias}`;
2561
+ const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
2562
+ return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
2540
2563
  }
2541
- return `SELECT COALESCE(json_agg(${jsonObj}${orderClause}), '[]'::json) FROM ${qTarget} ${alias} WHERE ${whereClause}`;
2564
+ return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, orderClause.trim() || undefined)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
2542
2565
  }
2543
2566
  // belongsTo / hasOne — return single object
2544
2567
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
@@ -2585,22 +2608,22 @@ export class QueryInterface {
2585
2608
  params.push(filter.path);
2586
2609
  const pathParam = params.length;
2587
2610
  params.push(String(filter.equals));
2588
- clauses.push(`${column} #>> $${pathParam}::text[] = $${params.length}`);
2611
+ clauses.push(`${this.dialect.buildJsonPathExtract(column, this.p(pathParam))} = ${this.p(params.length)}`);
2589
2612
  }
2590
2613
  else if (filter.equals !== undefined) {
2591
2614
  // Containment equality: column @> $N::jsonb
2592
2615
  params.push(JSON.stringify(filter.equals));
2593
- clauses.push(`${column} @> $${params.length}::jsonb`);
2616
+ clauses.push(this.dialect.buildJsonContains(column, this.p(params.length)));
2594
2617
  }
2595
2618
  if (filter.contains !== undefined) {
2596
2619
  // Containment: column @> $N::jsonb
2597
2620
  params.push(JSON.stringify(filter.contains));
2598
- clauses.push(`${column} @> $${params.length}::jsonb`);
2621
+ clauses.push(this.dialect.buildJsonContains(column, this.p(params.length)));
2599
2622
  }
2600
2623
  if (filter.hasKey !== undefined) {
2601
2624
  // Key existence: column ? $N
2602
2625
  params.push(filter.hasKey);
2603
- clauses.push(`${column} ? $${params.length}`);
2626
+ clauses.push(`${column} ? ${this.p(params.length)}`);
2604
2627
  }
2605
2628
  return clauses;
2606
2629
  }
@@ -2614,17 +2637,17 @@ export class QueryInterface {
2614
2637
  if (filter.has !== undefined) {
2615
2638
  // value = ANY(column)
2616
2639
  params.push(filter.has);
2617
- clauses.push(`$${params.length} = ANY(${column})`);
2640
+ clauses.push(`${this.p(params.length)} = ANY(${column})`);
2618
2641
  }
2619
2642
  if (filter.hasEvery !== undefined) {
2620
2643
  // column @> ARRAY[...]::type[]
2621
2644
  params.push(filter.hasEvery);
2622
- clauses.push(`${column} @> $${params.length}::${elementType}[]`);
2645
+ clauses.push(`${column} @> ${this.p(params.length)}::${elementType}[]`);
2623
2646
  }
2624
2647
  if (filter.hasSome !== undefined) {
2625
2648
  // column && ARRAY[...]::type[]
2626
2649
  params.push(filter.hasSome);
2627
- clauses.push(`${column} && $${params.length}::${elementType}[]`);
2650
+ clauses.push(`${column} && ${this.p(params.length)}::${elementType}[]`);
2628
2651
  }
2629
2652
  if (filter.isEmpty === true) {
2630
2653
  // array_length(column, 1) IS NULL