turbine-orm 0.19.2 → 0.21.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 (46) hide show
  1. package/README.md +82 -18
  2. package/dist/adapters/cockroachdb.js +4 -9
  3. package/dist/cjs/adapters/cockroachdb.js +4 -9
  4. package/dist/cjs/cli/index.js +6 -3
  5. package/dist/cjs/cli/observe-ui.js +12 -2
  6. package/dist/cjs/cli/observe.js +6 -7
  7. package/dist/cjs/cli/studio.js +6 -7
  8. package/dist/cjs/client.js +33 -20
  9. package/dist/cjs/dialect.js +116 -0
  10. package/dist/cjs/errors.js +19 -1
  11. package/dist/cjs/generate.js +4 -0
  12. package/dist/cjs/index.js +3 -2
  13. package/dist/cjs/introspect.js +20 -0
  14. package/dist/cjs/mssql.js +1338 -0
  15. package/dist/cjs/mysql.js +1052 -0
  16. package/dist/cjs/query/builder.js +293 -73
  17. package/dist/cjs/sqlite.js +849 -0
  18. package/dist/cjs/typed-sql.js +9 -7
  19. package/dist/cli/index.js +6 -3
  20. package/dist/cli/observe-ui.d.ts +1 -1
  21. package/dist/cli/observe-ui.js +12 -2
  22. package/dist/cli/observe.js +7 -8
  23. package/dist/cli/studio.js +7 -8
  24. package/dist/client.d.ts +23 -1
  25. package/dist/client.js +34 -21
  26. package/dist/dialect.d.ts +258 -1
  27. package/dist/dialect.js +83 -0
  28. package/dist/errors.d.ts +12 -0
  29. package/dist/errors.js +17 -0
  30. package/dist/generate.js +4 -0
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.js +1 -1
  33. package/dist/introspect.d.ts +18 -0
  34. package/dist/introspect.js +19 -0
  35. package/dist/mssql.d.ts +233 -0
  36. package/dist/mssql.js +1298 -0
  37. package/dist/mysql.d.ts +174 -0
  38. package/dist/mysql.js +1012 -0
  39. package/dist/query/builder.d.ts +97 -0
  40. package/dist/query/builder.js +294 -74
  41. package/dist/query/index.d.ts +1 -1
  42. package/dist/sqlite.d.ts +144 -0
  43. package/dist/sqlite.js +842 -0
  44. package/dist/typed-sql.d.ts +7 -5
  45. package/dist/typed-sql.js +9 -7
  46. package/package.json +30 -1
@@ -11,7 +11,7 @@
11
11
  * metadata — nothing is hardcoded.
12
12
  */
13
13
  import { postgresDialect } from '../dialect.js';
14
- import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, ValidationError, wrapPgError, } from '../errors.js';
14
+ import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from '../errors.js';
15
15
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
16
16
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
17
17
  import { escapeLike, LRUCache, OPERATOR_KEYS, sqlToPreparedName } from './utils.js';
@@ -301,6 +301,89 @@ export class QueryInterface {
301
301
  p(index) {
302
302
  return this.dialect.paramPlaceholder(index);
303
303
  }
304
+ /**
305
+ * Cast an aggregate expression to an integer/float result type through the
306
+ * active dialect. PostgreSQL keeps the historical postfix cast (`expr::int` /
307
+ * `expr::float`); SQLite (no `::` operator) maps to `CAST(expr AS INTEGER/REAL)`.
308
+ * Falls back to the Postgres postfix cast for dialects without the hook.
309
+ */
310
+ castAgg(expr, target) {
311
+ return this.dialect.castAggregate?.(expr, target) ?? `${expr}::${target}`;
312
+ }
313
+ /**
314
+ * Build the trailing pagination clause for an OUTER SELECT. PostgreSQL/MySQL/
315
+ * SQLite use ` LIMIT <ph>` and/or ` OFFSET <ph>`. SQL Server has no `LIMIT`, so
316
+ * its dialect implements {@link Dialect.buildLimitOffset} to emit
317
+ * `[ORDER BY (SELECT NULL)] OFFSET <off> ROWS [FETCH NEXT <lim> ROWS ONLY]`.
318
+ * Param-push order (limit before offset) is owned by the caller and unchanged —
319
+ * this only varies the SQL text, so PG output stays byte-identical.
320
+ */
321
+ buildPagination(limitPh, offsetPh, hasOrderBy) {
322
+ if (this.dialect.buildLimitOffset) {
323
+ return this.dialect.buildLimitOffset({ limitPlaceholder: limitPh, offsetPlaceholder: offsetPh, hasOrderBy });
324
+ }
325
+ let s = '';
326
+ if (limitPh !== undefined)
327
+ s += ` LIMIT ${limitPh}`;
328
+ if (offsetPh !== undefined)
329
+ s += ` OFFSET ${offsetPh}`;
330
+ return s;
331
+ }
332
+ /**
333
+ * The single-row limit appended to findUnique / findFirst-style lookups. ` LIMIT 1`
334
+ * for PG/MySQL/SQLite; SQL Server routes through {@link Dialect.buildLimitOffset}
335
+ * (literal `1`, no params) → ` ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1
336
+ * ROWS ONLY`. No params are pushed, so the collect path is unaffected.
337
+ */
338
+ limitOneClause() {
339
+ if (this.dialect.buildLimitOffset) {
340
+ return this.dialect.buildLimitOffset({ limitPlaceholder: '1', offsetPlaceholder: undefined, hasOrderBy: false });
341
+ }
342
+ return ' LIMIT 1';
343
+ }
344
+ /**
345
+ * Validate a LIMIT/OFFSET value as a non-negative integer and return it as an
346
+ * inline SQL literal. Used only on `dialect.inlineLimitOffset` engines (MySQL).
347
+ * The input is always a Turbine-controlled pagination value, never a raw user
348
+ * string — and this guard guarantees the output is `String` of a validated
349
+ * integer, so inlining cannot inject SQL.
350
+ */
351
+ limitOffsetLiteral(value) {
352
+ const n = Number(value);
353
+ if (!Number.isInteger(n) || n < 0) {
354
+ throw new ValidationError(`LIMIT/OFFSET must be a non-negative integer, received: ${String(value)}`);
355
+ }
356
+ return String(n);
357
+ }
358
+ /**
359
+ * Resolve a LIMIT/OFFSET value to either an inline literal (no param pushed, on
360
+ * `dialect.inlineLimitOffset` engines) or a bound placeholder (the value is
361
+ * pushed to `params`). Build and collect paths both gate on the same flag so
362
+ * the param order stays mirrored; PG/SQLite/SQL Server keep parameterizing and
363
+ * stay byte-identical.
364
+ */
365
+ paginationRef(value, params) {
366
+ if (this.dialect.inlineLimitOffset) {
367
+ return this.limitOffsetLiteral(value);
368
+ }
369
+ params.push(Number(value));
370
+ return this.p(params.length);
371
+ }
372
+ /**
373
+ * Build an `IN` / `NOT IN` predicate through the active dialect. PostgreSQL
374
+ * keeps the array-param form (`expr = ANY($n)` / `expr != ALL($n)`); other
375
+ * engines (SQLite) use a length-independent single-placeholder form. Paired
376
+ * with {@link inParam}, which supplies the single bound value.
377
+ */
378
+ inClause(expr, paramRef, negated) {
379
+ if (this.dialect.buildInClause)
380
+ return this.dialect.buildInClause(expr, paramRef, negated);
381
+ return negated ? `${expr} != ALL(${paramRef})` : `${expr} = ANY(${paramRef})`;
382
+ }
383
+ /** The single bound parameter for an `IN` list (PG: the array; SQLite: a JSON string). */
384
+ inParam(values) {
385
+ return this.dialect.inClauseParam ? this.dialect.inClauseParam(values) : values;
386
+ }
304
387
  /**
305
388
  * Return cache hit/miss statistics for this QueryInterface instance.
306
389
  * Useful for monitoring and benchmarking.
@@ -401,6 +484,49 @@ export class QueryInterface {
401
484
  clearTimeout(timer);
402
485
  }
403
486
  }
487
+ /**
488
+ * Execute a write `DeferredQuery` (create/update/delete/upsert) according to
489
+ * the active dialect's {@link Dialect.resultStrategy}, then apply its
490
+ * transform.
491
+ *
492
+ * - `'returning'` / `'output'`: the statement returns its own affected rows
493
+ * (`RETURNING *` / `OUTPUT INSERTED.*`). Byte-identical to the historical
494
+ * single `queryWithTimeout` + `transform(result)` path — the PostgreSQL
495
+ * route is unchanged.
496
+ * - `'reselect'`: the engine cannot return rows from a write, so the build
497
+ * method attached a {@link DeferredQuery.reselect} plan that runs the
498
+ * write and a follow-up SELECT; the SELECT's rows feed the transform.
499
+ */
500
+ async executeMutation(deferred, timeout) {
501
+ if (this.dialect.resultStrategy === 'reselect' && deferred.reselect) {
502
+ const exec = (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, preparedName);
503
+ const result = await deferred.reselect(exec);
504
+ return deferred.transform(result);
505
+ }
506
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, timeout, deferred.preparedName);
507
+ return deferred.transform(result);
508
+ }
509
+ /**
510
+ * Build a `SELECT * ... WHERE <predicate>` that re-fetches the row(s) matched
511
+ * by a write's `where` clause. Used by the `'reselect'` result strategy to
512
+ * return rows from non-RETURNING engines. Reuses the same parameterized WHERE
513
+ * builder as reads, so no user value is interpolated.
514
+ */
515
+ buildReselectByWhere(whereObj) {
516
+ const params = [];
517
+ const clause = this.buildWhereClause(whereObj, params);
518
+ const where = clause ? ` WHERE ${clause}` : '';
519
+ return { sql: `SELECT * FROM ${this.q(this.table)}${where}`, params };
520
+ }
521
+ /**
522
+ * Best-effort extraction of an auto-generated primary key from a write
523
+ * result for `'reselect'` engines (e.g. mysql2's `insertId`). Returns
524
+ * `undefined` when the driver exposes no such field.
525
+ */
526
+ mutationInsertId(result) {
527
+ const r = result;
528
+ return r.insertId ?? r.lastID;
529
+ }
404
530
  /**
405
531
  * Execute a query through the middleware chain.
406
532
  * If no middlewares are registered, executes directly.
@@ -471,7 +597,7 @@ export class QueryInterface {
471
597
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
472
598
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
473
599
  void tempParams; // params are positional, SQL is value-invariant
474
- return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
600
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
475
601
  });
476
602
  // Collect params (same order as build)
477
603
  for (const k of whereKeys) {
@@ -496,7 +622,7 @@ export class QueryInterface {
496
622
  const whereSql = clause ? ` WHERE ${clause}` : '';
497
623
  const qt = this.q(this.table);
498
624
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
499
- return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
625
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
500
626
  });
501
627
  // Collect params
502
628
  this.collectWhereParams(whereObj, params);
@@ -521,7 +647,7 @@ export class QueryInterface {
521
647
  const clause = this.buildWhereClause(whereObj, freshParams);
522
648
  const whereSql = clause ? ` WHERE ${clause}` : '';
523
649
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
524
- return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql} LIMIT 1`;
650
+ return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
525
651
  });
526
652
  // Collect params in exact build order: where first, then with-clause relations
527
653
  this.collectWhereParams(whereObj, params);
@@ -681,14 +807,18 @@ export class QueryInterface {
681
807
  // vector at the correct position (after cursor params, before LIMIT).
682
808
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
683
809
  }
810
+ // Pagination — push params in the same order the collect path mirrors
811
+ // (limit before offset); the SQL TEXT shape is dialect-owned via
812
+ // buildPagination (PG: ` LIMIT $n`/` OFFSET $n`; SQL Server: OFFSET/FETCH).
813
+ let limitPh;
814
+ let offsetPh;
684
815
  if (effectiveLimit !== undefined) {
685
- freshParams.push(Number(effectiveLimit));
686
- sql += ` LIMIT ${this.p(freshParams.length)}`;
816
+ limitPh = this.paginationRef(effectiveLimit, freshParams);
687
817
  }
688
818
  if (args?.offset !== undefined) {
689
- freshParams.push(Number(args.offset));
690
- sql += ` OFFSET ${this.p(freshParams.length)}`;
819
+ offsetPh = this.paginationRef(args.offset, freshParams);
691
820
  }
821
+ sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
692
822
  return sql;
693
823
  });
694
824
  // Collect params in exact build order:
@@ -712,12 +842,13 @@ export class QueryInterface {
712
842
  if (args?.orderBy) {
713
843
  this.collectOrderByParams(args.orderBy, params);
714
844
  }
715
- // 5. LIMIT param
716
- if (effectiveLimit !== undefined) {
845
+ // 5. LIMIT param — skipped when the dialect inlines pagination (build path
846
+ // mirrors via paginationRef → no placeholder, no param).
847
+ if (effectiveLimit !== undefined && !this.dialect.inlineLimitOffset) {
717
848
  params.push(Number(effectiveLimit));
718
849
  }
719
- // 6. OFFSET param
720
- if (args?.offset !== undefined) {
850
+ // 6. OFFSET param — same inline gate as LIMIT above.
851
+ if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
721
852
  params.push(Number(args.offset));
722
853
  }
723
854
  return {
@@ -777,34 +908,19 @@ export class QueryInterface {
777
908
  }
778
909
  // --- Overflow: fall back to cursor path from scratch ---
779
910
  const deferred = this.buildFindMany(args);
780
- // Acquire a dedicated connection — cursors require a single connection in a transaction
911
+ // Acquire a dedicated connection — cursors require a single connection in a
912
+ // transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
913
+ // … NO SCROLL CURSOR FOR → FETCH n → CLOSE → COMMIT, ROLLBACK on error); we
914
+ // just parse + yield the row batches it produces.
781
915
  const client = await this.pool.connect();
782
- const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
783
- const quotedCursor = this.q(cursorName);
784
916
  try {
785
- await client.query('BEGIN');
786
- await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
787
- while (true) {
788
- const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
789
- if (batch.rows.length === 0)
790
- break;
791
- for (const row of batch.rows) {
917
+ for await (const batch of this.dialect.openStream(client, deferred.sql, deferred.params, batchSize)) {
918
+ for (const row of batch) {
792
919
  yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
793
920
  }
794
- if (batch.rows.length < batchSize)
795
- break;
796
921
  }
797
- await client.query(`CLOSE ${quotedCursor}`);
798
- await client.query('COMMIT');
799
922
  }
800
923
  catch (err) {
801
- // Rollback on error (also closes cursor implicitly)
802
- try {
803
- await client.query('ROLLBACK');
804
- }
805
- catch {
806
- // Connection may already be broken — ignore rollback error
807
- }
808
924
  // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
809
925
  throw wrapPgError(err);
810
926
  }
@@ -906,8 +1022,7 @@ export class QueryInterface {
906
1022
  return this.nestedCreate(args);
907
1023
  }
908
1024
  const deferred = this.buildCreate(args);
909
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
910
- return deferred.transform(result);
1025
+ return this.executeMutation(deferred, args.timeout);
911
1026
  });
912
1027
  }
913
1028
  buildCreate(args) {
@@ -936,6 +1051,33 @@ export class QueryInterface {
936
1051
  return this.parseRow(row, this.table);
937
1052
  },
938
1053
  tag: `${this.table}.create`,
1054
+ // Non-RETURNING engines: INSERT, then re-fetch the new row by primary key
1055
+ // (provided value, else the driver's generated insert id).
1056
+ reselect: this.makeCreateReselect(sql, params, args.data),
1057
+ };
1058
+ }
1059
+ /**
1060
+ * Build the `'reselect'` plan for {@link buildCreate}: run the INSERT, then
1061
+ * `SELECT * WHERE pk = ?`. Returns `undefined` (skipped) unless the active
1062
+ * dialect's result strategy is `'reselect'`, so the PostgreSQL/RETURNING path
1063
+ * pays nothing. Not yet wired to a real non-RETURNING engine.
1064
+ */
1065
+ makeCreateReselect(insertSql, insertParams, data) {
1066
+ if (this.dialect.resultStrategy !== 'reselect')
1067
+ return undefined;
1068
+ return async (exec) => {
1069
+ const writeResult = await exec(insertSql, insertParams);
1070
+ const insertId = this.mutationInsertId(writeResult);
1071
+ const conds = [];
1072
+ const selParams = [];
1073
+ let idx = 1;
1074
+ for (const pk of this.tableMeta.primaryKey) {
1075
+ const field = this.tableMeta.reverseColumnMap[pk] ?? snakeToCamel(pk);
1076
+ selParams.push(data[field] ?? data[pk] ?? insertId);
1077
+ conds.push(`${this.q(pk)} = ${this.p(idx++)}`);
1078
+ }
1079
+ const where = conds.length > 0 ? ` WHERE ${conds.join(' AND ')}` : '';
1080
+ return exec(`SELECT * FROM ${this.q(this.table)}${where}`, selParams);
939
1081
  };
940
1082
  }
941
1083
  // -------------------------------------------------------------------------
@@ -991,8 +1133,7 @@ export class QueryInterface {
991
1133
  return this.nestedUpdate(args);
992
1134
  }
993
1135
  const deferred = this.buildUpdate(args);
994
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
995
- return deferred.transform(result);
1136
+ return this.executeMutation(deferred, args.timeout);
996
1137
  });
997
1138
  }
998
1139
  buildUpdate(args) {
@@ -1020,7 +1161,12 @@ export class QueryInterface {
1020
1161
  whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
1021
1162
  }
1022
1163
  this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
1023
- return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
1164
+ // Engines that inject their returning shape MID-statement (SQL Server
1165
+ // `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
1166
+ // absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
1167
+ return this.dialect.buildUpdateStatement
1168
+ ? this.dialect.buildUpdateStatement({ table: this.q(this.table), setClauses, whereSql, returning: '*' })
1169
+ : `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
1024
1170
  };
1025
1171
  let sql;
1026
1172
  let preparedName;
@@ -1064,6 +1210,26 @@ export class QueryInterface {
1064
1210
  },
1065
1211
  tag: `${this.table}.update`,
1066
1212
  preparedName,
1213
+ // Non-RETURNING engines: UPDATE, then re-fetch the row by the same where.
1214
+ reselect: this.dialect.resultStrategy === 'reselect'
1215
+ ? async (exec) => {
1216
+ const writeResult = await exec(sql, params, preparedName);
1217
+ // Optimistic-lock conflict: the version-checked UPDATE matched no
1218
+ // row. The re-fetch below uses `where` WITHOUT the version
1219
+ // predicate, so it would return the stale row and silently mask
1220
+ // the conflict — detect it from affected-rows here instead, to
1221
+ // match the OptimisticLockError thrown on RETURNING/OUTPUT engines.
1222
+ if (lock && (writeResult.rowCount ?? 0) === 0) {
1223
+ throw new OptimisticLockError({
1224
+ table: this.table,
1225
+ versionField: lock.field,
1226
+ expectedVersion: lock.expected,
1227
+ });
1228
+ }
1229
+ const sel = this.buildReselectByWhere(whereObj);
1230
+ return exec(sel.sql, sel.params);
1231
+ }
1232
+ : undefined,
1067
1233
  };
1068
1234
  }
1069
1235
  // -------------------------------------------------------------------------
@@ -1095,19 +1261,19 @@ export class QueryInterface {
1095
1261
  async runInImplicitTx(fn) {
1096
1262
  const client = await this.pool.connect();
1097
1263
  try {
1098
- await client.query('BEGIN');
1264
+ await client.query(this.dialect.beginStatement());
1099
1265
  const { TransactionClient } = await import('../client.js');
1100
1266
  // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
1101
1267
  const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
1102
1268
  // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
1103
1269
  const ctx = { schema: this.schema, tx: tx };
1104
1270
  const result = await fn(ctx);
1105
- await client.query('COMMIT');
1271
+ await client.query(this.dialect.commitStatement());
1106
1272
  return result;
1107
1273
  }
1108
1274
  catch (err) {
1109
1275
  try {
1110
- await client.query('ROLLBACK');
1276
+ await client.query(this.dialect.rollbackStatement());
1111
1277
  }
1112
1278
  catch {
1113
1279
  // Best-effort rollback — connection may have died.
@@ -1140,8 +1306,7 @@ export class QueryInterface {
1140
1306
  async delete(args) {
1141
1307
  return this.executeWithMiddleware('delete', args, async () => {
1142
1308
  const deferred = this.buildDelete(args);
1143
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1144
- return deferred.transform(result);
1309
+ return this.executeMutation(deferred, args.timeout);
1145
1310
  });
1146
1311
  }
1147
1312
  buildDelete(args) {
@@ -1156,7 +1321,11 @@ export class QueryInterface {
1156
1321
  const clause = this.buildWhereClause(whereObj, freshParams);
1157
1322
  const whereSql = clause ? ` WHERE ${clause}` : '';
1158
1323
  this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
1159
- return `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1324
+ // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
1325
+ // absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
1326
+ return this.dialect.buildDeleteStatement
1327
+ ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1328
+ : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1160
1329
  });
1161
1330
  // On cache hit, still validate the predicate
1162
1331
  if (whereFp === '') {
@@ -1179,6 +1348,16 @@ export class QueryInterface {
1179
1348
  },
1180
1349
  tag: `${this.table}.delete`,
1181
1350
  preparedName: entry.name,
1351
+ // Non-RETURNING engines: the row is gone after DELETE, so pre-SELECT it
1352
+ // by the same where, then run the DELETE, returning the captured row.
1353
+ reselect: this.dialect.resultStrategy === 'reselect'
1354
+ ? async (exec) => {
1355
+ const sel = this.buildReselectByWhere(whereObj);
1356
+ const pre = await exec(sel.sql, sel.params);
1357
+ await exec(entry.sql, params, entry.name);
1358
+ return pre;
1359
+ }
1360
+ : undefined,
1182
1361
  };
1183
1362
  }
1184
1363
  // -------------------------------------------------------------------------
@@ -1187,8 +1366,7 @@ export class QueryInterface {
1187
1366
  async upsert(args) {
1188
1367
  return this.executeWithMiddleware('upsert', args, async () => {
1189
1368
  const deferred = this.buildUpsert(args);
1190
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1191
- return deferred.transform(result);
1369
+ return this.executeMutation(deferred, args.timeout);
1192
1370
  });
1193
1371
  }
1194
1372
  buildUpsert(args) {
@@ -1234,6 +1412,14 @@ export class QueryInterface {
1234
1412
  return this.parseRow(row, this.table);
1235
1413
  },
1236
1414
  tag: `${this.table}.upsert`,
1415
+ // Non-RETURNING engines: run the upsert, then re-fetch by the where keys.
1416
+ reselect: this.dialect.resultStrategy === 'reselect'
1417
+ ? async (exec) => {
1418
+ await exec(sql, params);
1419
+ const sel = this.buildReselectByWhere(args.where);
1420
+ return exec(sel.sql, sel.params);
1421
+ }
1422
+ : undefined,
1237
1423
  };
1238
1424
  }
1239
1425
  // -------------------------------------------------------------------------
@@ -1328,7 +1514,7 @@ export class QueryInterface {
1328
1514
  const freshParams = [];
1329
1515
  const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1330
1516
  const whereSql = clause ? ` WHERE ${clause}` : '';
1331
- return `SELECT COUNT(*)::int AS count FROM ${this.q(this.table)}${whereSql}`;
1517
+ return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1332
1518
  });
1333
1519
  if (args?.where) {
1334
1520
  this.collectWhereParams(whereObj, params);
@@ -1368,7 +1554,7 @@ export class QueryInterface {
1368
1554
  // _count
1369
1555
  if (args._count === true || args._count === undefined) {
1370
1556
  // default: always include count
1371
- selectExprs.push('COUNT(*)::int AS _count');
1557
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1372
1558
  }
1373
1559
  // _sum
1374
1560
  if (args._sum) {
@@ -1384,7 +1570,7 @@ export class QueryInterface {
1384
1570
  for (const [field, enabled] of Object.entries(args._avg)) {
1385
1571
  if (enabled) {
1386
1572
  const col = this.toColumn(field);
1387
- selectExprs.push(`AVG(${this.q(col)})::float AS ${this.q(`_avg_${col}`)}`);
1573
+ selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
1388
1574
  }
1389
1575
  }
1390
1576
  }
@@ -1585,12 +1771,12 @@ export class QueryInterface {
1585
1771
  clauses.push(`${expr} <= ${this.p(params.length)}`);
1586
1772
  }
1587
1773
  if (op.in !== undefined) {
1588
- params.push(op.in);
1589
- clauses.push(`${expr} = ANY(${this.p(params.length)})`);
1774
+ params.push(this.inParam(op.in));
1775
+ clauses.push(this.inClause(expr, this.p(params.length), false));
1590
1776
  }
1591
1777
  if (op.notIn !== undefined) {
1592
- params.push(op.notIn);
1593
- clauses.push(`${expr} != ALL(${this.p(params.length)})`);
1778
+ params.push(this.inParam(op.notIn));
1779
+ clauses.push(this.inClause(expr, this.p(params.length), true));
1594
1780
  }
1595
1781
  return clauses;
1596
1782
  }
@@ -1628,13 +1814,13 @@ export class QueryInterface {
1628
1814
  const selectExprs = [];
1629
1815
  // _count
1630
1816
  if (args._count === true) {
1631
- selectExprs.push('COUNT(*)::int AS _count');
1817
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1632
1818
  }
1633
1819
  else if (args._count && typeof args._count === 'object') {
1634
1820
  for (const [field, enabled] of Object.entries(args._count)) {
1635
1821
  if (enabled) {
1636
1822
  const col = this.toColumn(field);
1637
- selectExprs.push(`COUNT(${this.q(col)})::int AS ${this.q(`_count_${col}`)}`);
1823
+ selectExprs.push(`${this.castAgg(`COUNT(${this.q(col)})`, 'int')} AS ${this.q(`_count_${col}`)}`);
1638
1824
  }
1639
1825
  }
1640
1826
  }
@@ -1652,7 +1838,7 @@ export class QueryInterface {
1652
1838
  for (const [field, enabled] of Object.entries(args._avg)) {
1653
1839
  if (enabled) {
1654
1840
  const col = this.toColumn(field);
1655
- selectExprs.push(`AVG(${this.q(col)})::float AS ${this.q(`_avg_${col}`)}`);
1841
+ selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
1656
1842
  }
1657
1843
  }
1658
1844
  }
@@ -1675,7 +1861,7 @@ export class QueryInterface {
1675
1861
  }
1676
1862
  }
1677
1863
  if (selectExprs.length === 0) {
1678
- selectExprs.push('COUNT(*)::int AS _count');
1864
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1679
1865
  }
1680
1866
  const sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql}`;
1681
1867
  return {
@@ -2169,9 +2355,9 @@ export class QueryInterface {
2169
2355
  if (op.not !== undefined && op.not !== null)
2170
2356
  params.push(op.not);
2171
2357
  if (op.in !== undefined)
2172
- params.push(op.in);
2358
+ params.push(this.inParam(op.in));
2173
2359
  if (op.notIn !== undefined)
2174
- params.push(op.notIn);
2360
+ params.push(this.inParam(op.notIn));
2175
2361
  if (op.contains !== undefined)
2176
2362
  params.push(`%${escapeLike(op.contains)}%`);
2177
2363
  if (op.startsWith !== undefined)
@@ -2336,7 +2522,7 @@ export class QueryInterface {
2336
2522
  if (spec.where) {
2337
2523
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2338
2524
  }
2339
- if (spec.limit !== undefined) {
2525
+ if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2340
2526
  params.push(Number(spec.limit));
2341
2527
  }
2342
2528
  if (spec.with) {
@@ -2369,7 +2555,7 @@ export class QueryInterface {
2369
2555
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2370
2556
  // pushing one here would orphan a param and desync the collect path.
2371
2557
  // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2372
- if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2558
+ if (relDef.type === 'hasMany' && spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2373
2559
  params.push(Number(spec.limit));
2374
2560
  }
2375
2561
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2888,12 +3074,12 @@ export class QueryInterface {
2888
3074
  }
2889
3075
  }
2890
3076
  if (op.in !== undefined) {
2891
- params.push(op.in);
2892
- clauses.push(`${column} = ANY(${this.p(params.length)})`);
3077
+ params.push(this.inParam(op.in));
3078
+ clauses.push(this.inClause(column, this.p(params.length), false));
2893
3079
  }
2894
3080
  if (op.notIn !== undefined) {
2895
- params.push(op.notIn);
2896
- clauses.push(`${column} != ALL(${this.p(params.length)})`);
3081
+ params.push(this.inParam(op.notIn));
3082
+ clauses.push(this.inClause(column, this.p(params.length), true));
2897
3083
  }
2898
3084
  const buildLikeClause = (paramRef) => op.mode === 'insensitive' ? this.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
2899
3085
  if (op.contains !== undefined) {
@@ -2964,6 +3150,9 @@ export class QueryInterface {
2964
3150
  * non-vector column — a user-supplied string can never become a SQL operator.
2965
3151
  */
2966
3152
  vectorOperator(field, rawColumn, metric) {
3153
+ if (!this.dialect.supportsVector) {
3154
+ throw new UnsupportedFeatureError('pgvector distance operations', this.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
3155
+ }
2967
3156
  const colType = this.getColumnPgType(rawColumn);
2968
3157
  if (colType !== 'vector') {
2969
3158
  throw new ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
@@ -2984,6 +3173,9 @@ export class QueryInterface {
2984
3173
  * placeholder string.
2985
3174
  */
2986
3175
  pushVectorParam(field, _rawColumn, to, params) {
3176
+ if (!this.dialect.supportsVector) {
3177
+ throw new UnsupportedFeatureError('pgvector distance operations', this.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
3178
+ }
2987
3179
  if (!Array.isArray(to) || to.length === 0) {
2988
3180
  throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
2989
3181
  }
@@ -3294,6 +3486,32 @@ export class QueryInterface {
3294
3486
  .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
3295
3487
  targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
3296
3488
  }
3489
+ // Engine override seam (additive): a dialect whose JSON-aggregation shape does
3490
+ // not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
3491
+ // the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
3492
+ // unchanged (byte-identical output, all their tests stay green). The override
3493
+ // pushes params per the documented RelationSubqueryContext ordering contract,
3494
+ // which mirrors collectRelationSubqueryParams so the SQL cache / pipeline stay
3495
+ // in sync.
3496
+ if (this.dialect.buildRelationSubquery) {
3497
+ return this.dialect.buildRelationSubquery({
3498
+ relDef,
3499
+ spec,
3500
+ params,
3501
+ parentRef,
3502
+ alias,
3503
+ targetTable,
3504
+ targetMeta,
3505
+ targetColumns,
3506
+ depth: currentDepth,
3507
+ path: currentPath,
3508
+ quote: (name) => this.q(name),
3509
+ buildWhere: (whereAlias) => (spec !== true && spec.where
3510
+ ? this.buildAliasWhere(targetTable, targetMeta, whereAlias, spec.where, params)
3511
+ : '') ?? '',
3512
+ recurse: (nRelDef, nSpec, nParent, nDepth, nPath) => this.buildRelationSubquery(nRelDef, nSpec, params, nParent, aliasCounter, nDepth, nPath),
3513
+ });
3514
+ }
3297
3515
  // Build JSON object pairs for resolved columns
3298
3516
  const jsonPairs = targetColumns.map((col) => [
3299
3517
  targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
@@ -3325,7 +3543,7 @@ export class QueryInterface {
3325
3543
  const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3326
3544
  // Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
3327
3545
  const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
3328
- jsonPairs.push([nestedRelName, `COALESCE((${nestedSubquery}), ${fallback})`]);
3546
+ jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSubquery, fallback)]);
3329
3547
  }
3330
3548
  }
3331
3549
  const jsonObj = this.dialect.buildJsonObject(jsonPairs);
@@ -3373,8 +3591,7 @@ export class QueryInterface {
3373
3591
  // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3374
3592
  let limitClause = '';
3375
3593
  if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3376
- params.push(Number(spec.limit));
3377
- limitClause = ` LIMIT ${this.p(params.length)}`;
3594
+ limitClause = ` LIMIT ${this.paginationRef(spec.limit, params)}`;
3378
3595
  }
3379
3596
  if (relDef.type === 'hasMany') {
3380
3597
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -3399,13 +3616,17 @@ export class QueryInterface {
3399
3616
  }
3400
3617
  const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3401
3618
  const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
3402
- innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3619
+ innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3403
3620
  }
3404
3621
  }
3405
3622
  const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
3406
3623
  return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
3407
3624
  }
3408
- return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, orderClause.trim() || undefined)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
3625
+ // Inline ORDER BY only when the dialect's array-agg supports it (PG). For
3626
+ // hasMany this path is reached only when there is no orderClause, so the
3627
+ // argument is `undefined` either way — keeping PG output byte-identical.
3628
+ const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
3629
+ return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
3409
3630
  }
3410
3631
  // belongsTo / hasOne — return single object
3411
3632
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
@@ -3493,8 +3714,7 @@ export class QueryInterface {
3493
3714
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3494
3715
  let limitClause = '';
3495
3716
  if (spec !== true && spec.limit !== undefined) {
3496
- params.push(Number(spec.limit));
3497
- limitClause = ` LIMIT ${this.p(params.length)}`;
3717
+ limitClause = ` LIMIT ${this.paginationRef(spec.limit, params)}`;
3498
3718
  }
3499
3719
  const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
3500
3720
  // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
@@ -3519,7 +3739,7 @@ export class QueryInterface {
3519
3739
  const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3520
3740
  ? this.dialect.nullJsonLiteral
3521
3741
  : this.dialect.emptyJsonArrayLiteral;
3522
- innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3742
+ innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3523
3743
  }
3524
3744
  }
3525
3745
  const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
@@ -3542,7 +3762,7 @@ export class QueryInterface {
3542
3762
  const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3543
3763
  ? this.dialect.nullJsonLiteral
3544
3764
  : this.dialect.emptyJsonArrayLiteral;
3545
- jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3765
+ jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3546
3766
  }
3547
3767
  }
3548
3768
  const jsonObj = this.dialect.buildJsonObject(jsonPairs);