turbine-orm 0.19.1 → 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 (53) hide show
  1. package/README.md +156 -24
  2. package/dist/adapters/cockroachdb.js +4 -9
  3. package/dist/cjs/adapters/cockroachdb.js +4 -9
  4. package/dist/cjs/cli/index.js +23 -12
  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-ui.generated.js +1 -1
  8. package/dist/cjs/cli/studio.js +6 -37
  9. package/dist/cjs/client.js +45 -33
  10. package/dist/cjs/dialect.js +116 -0
  11. package/dist/cjs/errors.js +19 -1
  12. package/dist/cjs/generate.js +4 -0
  13. package/dist/cjs/index.js +3 -2
  14. package/dist/cjs/introspect.js +20 -0
  15. package/dist/cjs/mssql.js +1338 -0
  16. package/dist/cjs/mysql.js +1052 -0
  17. package/dist/cjs/query/builder.js +408 -122
  18. package/dist/cjs/query/utils.js +1 -0
  19. package/dist/cjs/sqlite.js +849 -0
  20. package/dist/cjs/typed-sql.js +9 -7
  21. package/dist/cli/index.js +23 -12
  22. package/dist/cli/migrate.d.ts +2 -2
  23. package/dist/cli/observe-ui.d.ts +1 -1
  24. package/dist/cli/observe-ui.js +12 -2
  25. package/dist/cli/observe.js +7 -8
  26. package/dist/cli/studio-ui.generated.js +1 -1
  27. package/dist/cli/studio.d.ts +0 -10
  28. package/dist/cli/studio.js +7 -37
  29. package/dist/client.d.ts +35 -14
  30. package/dist/client.js +46 -34
  31. package/dist/dialect.d.ts +258 -1
  32. package/dist/dialect.js +83 -0
  33. package/dist/errors.d.ts +12 -0
  34. package/dist/errors.js +17 -0
  35. package/dist/generate.js +4 -0
  36. package/dist/index.d.ts +4 -4
  37. package/dist/index.js +1 -1
  38. package/dist/introspect.d.ts +18 -0
  39. package/dist/introspect.js +19 -0
  40. package/dist/mssql.d.ts +233 -0
  41. package/dist/mssql.js +1298 -0
  42. package/dist/mysql.d.ts +174 -0
  43. package/dist/mysql.js +1012 -0
  44. package/dist/query/builder.d.ts +105 -6
  45. package/dist/query/builder.js +409 -123
  46. package/dist/query/index.d.ts +2 -2
  47. package/dist/query/types.d.ts +62 -12
  48. package/dist/query/utils.js +1 -0
  49. package/dist/sqlite.d.ts +144 -0
  50. package/dist/sqlite.js +842 -0
  51. package/dist/typed-sql.d.ts +7 -5
  52. package/dist/typed-sql.js +9 -7
  53. package/package.json +32 -3
@@ -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';
@@ -44,6 +44,53 @@ function isUnmatchedPlainObject(value) {
44
44
  const proto = Object.getPrototypeOf(value);
45
45
  return proto === Object.prototype || proto === null;
46
46
  }
47
+ /**
48
+ * Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
49
+ * `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
50
+ * param pushed), so null-ness is part of the shape — without it a cache entry
51
+ * warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
52
+ */
53
+ function fingerprintOperatorShape(value) {
54
+ const obj = value;
55
+ const opKeys = Object.keys(obj)
56
+ .filter((k) => k !== 'mode')
57
+ .map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
58
+ .sort();
59
+ const modeStr = value.mode === 'insensitive' ? ':i' : '';
60
+ return `op(${opKeys.join(',')}${modeStr})`;
61
+ }
62
+ /**
63
+ * Guard for the value of an `equals` operator reaching the plain-equality
64
+ * operator path. A plain object literal can only legitimately be an equality
65
+ * value on a json/jsonb column — and those route to the JSONB filter branch
66
+ * BEFORE the operator branch, so any plain object that reaches here is a
67
+ * mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
68
+ * SQL-build path and the cache-hit param-collect path so a warmed cache can
69
+ * never skip the check.
70
+ */
71
+ function assertBindableEqualsOperand(value, column) {
72
+ if (!isUnmatchedPlainObject(value))
73
+ return;
74
+ throw new ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
75
+ `objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
76
+ `where 'equals' is the JSONB containment filter.`);
77
+ }
78
+ /**
79
+ * Object keys in sorted order, mirroring the canonical order used by every
80
+ * cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
81
+ * enumerate object keys in this exact order: fingerprints sort keys, so two
82
+ * where clauses with the same fields in different insertion order share one
83
+ * cache entry — if build/collect iterated insertion order, the cached SQL's
84
+ * `$N` placeholders would bind the wrong values (cross-tenant-leak class).
85
+ * Array order (OR/AND members) is positional and is never sorted.
86
+ */
87
+ function sortedKeys(obj) {
88
+ return Object.keys(obj).sort();
89
+ }
90
+ /** {@link sortedKeys}, but yielding `[key, value]` pairs. */
91
+ function sortedEntries(obj) {
92
+ return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
93
+ }
47
94
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
48
95
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
49
96
  /** Known JSONB operator keys */
@@ -53,9 +100,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
53
100
  * appear in any other where-filter shape, so the presence of one of these is
54
101
  * an unambiguous signal that the user meant a JSON filter. Used by the
55
102
  * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
56
- * `WhereOperator` for LIKE) is not misclassified.
103
+ * `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
104
+ * set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
105
+ * so it must fall through instead of throwing.
57
106
  */
58
- const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
107
+ const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
59
108
  /** Check if a value is a JSONB filter object */
60
109
  function isJsonFilter(value) {
61
110
  if (value === null ||
@@ -252,6 +301,89 @@ export class QueryInterface {
252
301
  p(index) {
253
302
  return this.dialect.paramPlaceholder(index);
254
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
+ }
255
387
  /**
256
388
  * Return cache hit/miss statistics for this QueryInterface instance.
257
389
  * Useful for monitoring and benchmarking.
@@ -352,14 +484,59 @@ export class QueryInterface {
352
484
  clearTimeout(timer);
353
485
  }
354
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
+ }
355
530
  /**
356
531
  * Execute a query through the middleware chain.
357
532
  * If no middlewares are registered, executes directly.
358
533
  *
359
- * Middleware can inspect and log query parameters, modify results after execution,
360
- * and measure timing. Note: query SQL is generated before middleware runs, so
361
- * modifying params.args in middleware will NOT affect the executed SQL.
362
- * To intercept queries before SQL generation, use the raw() method instead.
534
+ * Middleware can inspect and log query parameters, measure timing, and
535
+ * transform the result returned by `next()`. Note: query SQL is generated
536
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
537
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
538
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
539
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
363
540
  */
364
541
  async executeWithMiddleware(action, args, executor) {
365
542
  this.currentAction = action;
@@ -398,8 +575,12 @@ export class QueryInterface {
398
575
  const withFp = args.with ? this.withFingerprint(args.with) : '';
399
576
  const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
400
577
  const params = [];
401
- // Check if all where values are simple (plain equality, no operators/null/OR)
402
- const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
578
+ // Check if all where values are simple (plain equality, no operators/null/OR).
579
+ // Keys are sorted to match fingerprintWhere — insertion order here would let
580
+ // permuted where literals share a cache entry with misaligned params.
581
+ const whereKeys = Object.keys(whereObj)
582
+ .filter((k) => whereObj[k] !== undefined)
583
+ .sort();
403
584
  const isSimpleWhere = !whereObj.OR &&
404
585
  !whereObj.AND &&
405
586
  !whereObj.NOT &&
@@ -416,7 +597,7 @@ export class QueryInterface {
416
597
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
417
598
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
418
599
  void tempParams; // params are positional, SQL is value-invariant
419
- return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
600
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
420
601
  });
421
602
  // Collect params (same order as build)
422
603
  for (const k of whereKeys) {
@@ -441,7 +622,7 @@ export class QueryInterface {
441
622
  const whereSql = clause ? ` WHERE ${clause}` : '';
442
623
  const qt = this.q(this.table);
443
624
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
444
- return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
625
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
445
626
  });
446
627
  // Collect params
447
628
  this.collectWhereParams(whereObj, params);
@@ -466,7 +647,7 @@ export class QueryInterface {
466
647
  const clause = this.buildWhereClause(whereObj, freshParams);
467
648
  const whereSql = clause ? ` WHERE ${clause}` : '';
468
649
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
469
- return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql} LIMIT 1`;
650
+ return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
470
651
  });
471
652
  // Collect params in exact build order: where first, then with-clause relations
472
653
  this.collectWhereParams(whereObj, params);
@@ -603,7 +784,8 @@ export class QueryInterface {
603
784
  }
604
785
  let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
605
786
  if (args?.cursor) {
606
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
787
+ // Sorted (canonical) order MUST match cursorFp and the cache-hit collect below.
788
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
607
789
  if (cursorEntries.length > 0) {
608
790
  const cursorConditions = cursorEntries.map(([k, v]) => {
609
791
  const col = this.toSqlColumn(k);
@@ -625,14 +807,18 @@ export class QueryInterface {
625
807
  // vector at the correct position (after cursor params, before LIMIT).
626
808
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
627
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;
628
815
  if (effectiveLimit !== undefined) {
629
- freshParams.push(Number(effectiveLimit));
630
- sql += ` LIMIT ${this.p(freshParams.length)}`;
816
+ limitPh = this.paginationRef(effectiveLimit, freshParams);
631
817
  }
632
818
  if (args?.offset !== undefined) {
633
- freshParams.push(Number(args.offset));
634
- sql += ` OFFSET ${this.p(freshParams.length)}`;
819
+ offsetPh = this.paginationRef(args.offset, freshParams);
635
820
  }
821
+ sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
636
822
  return sql;
637
823
  });
638
824
  // Collect params in exact build order:
@@ -644,9 +830,9 @@ export class QueryInterface {
644
830
  if (args?.with) {
645
831
  this.collectWithParams(args.with, params);
646
832
  }
647
- // 3. Cursor params
833
+ // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
648
834
  if (args?.cursor) {
649
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
835
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
650
836
  for (const [, v] of cursorEntries) {
651
837
  params.push(v);
652
838
  }
@@ -656,12 +842,13 @@ export class QueryInterface {
656
842
  if (args?.orderBy) {
657
843
  this.collectOrderByParams(args.orderBy, params);
658
844
  }
659
- // 5. LIMIT param
660
- 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) {
661
848
  params.push(Number(effectiveLimit));
662
849
  }
663
- // 6. OFFSET param
664
- if (args?.offset !== undefined) {
850
+ // 6. OFFSET param — same inline gate as LIMIT above.
851
+ if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
665
852
  params.push(Number(args.offset));
666
853
  }
667
854
  return {
@@ -721,34 +908,19 @@ export class QueryInterface {
721
908
  }
722
909
  // --- Overflow: fall back to cursor path from scratch ---
723
910
  const deferred = this.buildFindMany(args);
724
- // 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.
725
915
  const client = await this.pool.connect();
726
- const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
727
- const quotedCursor = this.q(cursorName);
728
916
  try {
729
- await client.query('BEGIN');
730
- await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
731
- while (true) {
732
- const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
733
- if (batch.rows.length === 0)
734
- break;
735
- 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) {
736
919
  yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
737
920
  }
738
- if (batch.rows.length < batchSize)
739
- break;
740
921
  }
741
- await client.query(`CLOSE ${quotedCursor}`);
742
- await client.query('COMMIT');
743
922
  }
744
923
  catch (err) {
745
- // Rollback on error (also closes cursor implicitly)
746
- try {
747
- await client.query('ROLLBACK');
748
- }
749
- catch {
750
- // Connection may already be broken — ignore rollback error
751
- }
752
924
  // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
753
925
  throw wrapPgError(err);
754
926
  }
@@ -850,8 +1022,7 @@ export class QueryInterface {
850
1022
  return this.nestedCreate(args);
851
1023
  }
852
1024
  const deferred = this.buildCreate(args);
853
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
854
- return deferred.transform(result);
1025
+ return this.executeMutation(deferred, args.timeout);
855
1026
  });
856
1027
  }
857
1028
  buildCreate(args) {
@@ -880,6 +1051,33 @@ export class QueryInterface {
880
1051
  return this.parseRow(row, this.table);
881
1052
  },
882
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);
883
1081
  };
884
1082
  }
885
1083
  // -------------------------------------------------------------------------
@@ -935,8 +1133,7 @@ export class QueryInterface {
935
1133
  return this.nestedUpdate(args);
936
1134
  }
937
1135
  const deferred = this.buildUpdate(args);
938
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
939
- return deferred.transform(result);
1136
+ return this.executeMutation(deferred, args.timeout);
940
1137
  });
941
1138
  }
942
1139
  buildUpdate(args) {
@@ -964,7 +1161,12 @@ export class QueryInterface {
964
1161
  whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
965
1162
  }
966
1163
  this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
967
- 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('*')}`;
968
1170
  };
969
1171
  let sql;
970
1172
  let preparedName;
@@ -1008,6 +1210,26 @@ export class QueryInterface {
1008
1210
  },
1009
1211
  tag: `${this.table}.update`,
1010
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,
1011
1233
  };
1012
1234
  }
1013
1235
  // -------------------------------------------------------------------------
@@ -1039,19 +1261,19 @@ export class QueryInterface {
1039
1261
  async runInImplicitTx(fn) {
1040
1262
  const client = await this.pool.connect();
1041
1263
  try {
1042
- await client.query('BEGIN');
1264
+ await client.query(this.dialect.beginStatement());
1043
1265
  const { TransactionClient } = await import('../client.js');
1044
1266
  // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
1045
1267
  const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
1046
1268
  // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
1047
1269
  const ctx = { schema: this.schema, tx: tx };
1048
1270
  const result = await fn(ctx);
1049
- await client.query('COMMIT');
1271
+ await client.query(this.dialect.commitStatement());
1050
1272
  return result;
1051
1273
  }
1052
1274
  catch (err) {
1053
1275
  try {
1054
- await client.query('ROLLBACK');
1276
+ await client.query(this.dialect.rollbackStatement());
1055
1277
  }
1056
1278
  catch {
1057
1279
  // Best-effort rollback — connection may have died.
@@ -1084,8 +1306,7 @@ export class QueryInterface {
1084
1306
  async delete(args) {
1085
1307
  return this.executeWithMiddleware('delete', args, async () => {
1086
1308
  const deferred = this.buildDelete(args);
1087
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1088
- return deferred.transform(result);
1309
+ return this.executeMutation(deferred, args.timeout);
1089
1310
  });
1090
1311
  }
1091
1312
  buildDelete(args) {
@@ -1100,7 +1321,11 @@ export class QueryInterface {
1100
1321
  const clause = this.buildWhereClause(whereObj, freshParams);
1101
1322
  const whereSql = clause ? ` WHERE ${clause}` : '';
1102
1323
  this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
1103
- 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('*')}`;
1104
1329
  });
1105
1330
  // On cache hit, still validate the predicate
1106
1331
  if (whereFp === '') {
@@ -1123,6 +1348,16 @@ export class QueryInterface {
1123
1348
  },
1124
1349
  tag: `${this.table}.delete`,
1125
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,
1126
1361
  };
1127
1362
  }
1128
1363
  // -------------------------------------------------------------------------
@@ -1131,8 +1366,7 @@ export class QueryInterface {
1131
1366
  async upsert(args) {
1132
1367
  return this.executeWithMiddleware('upsert', args, async () => {
1133
1368
  const deferred = this.buildUpsert(args);
1134
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1135
- return deferred.transform(result);
1369
+ return this.executeMutation(deferred, args.timeout);
1136
1370
  });
1137
1371
  }
1138
1372
  buildUpsert(args) {
@@ -1178,6 +1412,14 @@ export class QueryInterface {
1178
1412
  return this.parseRow(row, this.table);
1179
1413
  },
1180
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,
1181
1423
  };
1182
1424
  }
1183
1425
  // -------------------------------------------------------------------------
@@ -1272,7 +1514,7 @@ export class QueryInterface {
1272
1514
  const freshParams = [];
1273
1515
  const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1274
1516
  const whereSql = clause ? ` WHERE ${clause}` : '';
1275
- 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}`;
1276
1518
  });
1277
1519
  if (args?.where) {
1278
1520
  this.collectWhereParams(whereObj, params);
@@ -1312,7 +1554,7 @@ export class QueryInterface {
1312
1554
  // _count
1313
1555
  if (args._count === true || args._count === undefined) {
1314
1556
  // default: always include count
1315
- selectExprs.push('COUNT(*)::int AS _count');
1557
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1316
1558
  }
1317
1559
  // _sum
1318
1560
  if (args._sum) {
@@ -1328,7 +1570,7 @@ export class QueryInterface {
1328
1570
  for (const [field, enabled] of Object.entries(args._avg)) {
1329
1571
  if (enabled) {
1330
1572
  const col = this.toColumn(field);
1331
- 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}`)}`);
1332
1574
  }
1333
1575
  }
1334
1576
  }
@@ -1529,12 +1771,12 @@ export class QueryInterface {
1529
1771
  clauses.push(`${expr} <= ${this.p(params.length)}`);
1530
1772
  }
1531
1773
  if (op.in !== undefined) {
1532
- params.push(op.in);
1533
- 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));
1534
1776
  }
1535
1777
  if (op.notIn !== undefined) {
1536
- params.push(op.notIn);
1537
- 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));
1538
1780
  }
1539
1781
  return clauses;
1540
1782
  }
@@ -1572,13 +1814,13 @@ export class QueryInterface {
1572
1814
  const selectExprs = [];
1573
1815
  // _count
1574
1816
  if (args._count === true) {
1575
- selectExprs.push('COUNT(*)::int AS _count');
1817
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1576
1818
  }
1577
1819
  else if (args._count && typeof args._count === 'object') {
1578
1820
  for (const [field, enabled] of Object.entries(args._count)) {
1579
1821
  if (enabled) {
1580
1822
  const col = this.toColumn(field);
1581
- 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}`)}`);
1582
1824
  }
1583
1825
  }
1584
1826
  }
@@ -1596,7 +1838,7 @@ export class QueryInterface {
1596
1838
  for (const [field, enabled] of Object.entries(args._avg)) {
1597
1839
  if (enabled) {
1598
1840
  const col = this.toColumn(field);
1599
- 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}`)}`);
1600
1842
  }
1601
1843
  }
1602
1844
  }
@@ -1619,7 +1861,7 @@ export class QueryInterface {
1619
1861
  }
1620
1862
  }
1621
1863
  if (selectExprs.length === 0) {
1622
- selectExprs.push('COUNT(*)::int AS _count');
1864
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1623
1865
  }
1624
1866
  const sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql}`;
1625
1867
  return {
@@ -1886,12 +2128,7 @@ export class QueryInterface {
1886
2128
  }
1887
2129
  // Operator objects
1888
2130
  if (isWhereOperator(value)) {
1889
- const opKeys = Object.keys(value)
1890
- .filter((k) => k !== 'mode')
1891
- .sort();
1892
- const mode = value.mode;
1893
- const modeStr = mode === 'insensitive' ? ':i' : '';
1894
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
2131
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
1895
2132
  continue;
1896
2133
  }
1897
2134
  // Vector distance filter — metric (operator) and present comparators
@@ -1964,12 +2201,7 @@ export class QueryInterface {
1964
2201
  parts.push(`${key}:null`);
1965
2202
  }
1966
2203
  else if (isWhereOperator(value)) {
1967
- const opKeys = Object.keys(value)
1968
- .filter((k) => k !== 'mode')
1969
- .sort();
1970
- const mode = value.mode;
1971
- const modeStr = mode === 'insensitive' ? ':i' : '';
1972
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
2204
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
1973
2205
  }
1974
2206
  else if (isUnmatchedPlainObject(value)) {
1975
2207
  parts.push(`${key}:obj(${Object.keys(value)
@@ -1990,7 +2222,8 @@ export class QueryInterface {
1990
2222
  * @internal Exposed as package-private for testing.
1991
2223
  */
1992
2224
  collectWhereParams(where, params) {
1993
- const keys = Object.keys(where);
2225
+ // Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
2226
+ const keys = sortedKeys(where);
1994
2227
  for (const key of keys) {
1995
2228
  const value = where[key];
1996
2229
  if (value === undefined)
@@ -2075,7 +2308,7 @@ export class QueryInterface {
2075
2308
  }
2076
2309
  // Operator objects
2077
2310
  if (isWhereOperator(value)) {
2078
- this.collectOperatorParams(value, params);
2311
+ this.collectOperatorParams(rawColumn, value, params);
2079
2312
  continue;
2080
2313
  }
2081
2314
  // Plain equality — same strict validation as the build path, so a
@@ -2089,22 +2322,28 @@ export class QueryInterface {
2089
2322
  const meta = this.schema.tables[targetTable];
2090
2323
  if (!meta)
2091
2324
  return;
2092
- for (const [field, value] of Object.entries(subWhere)) {
2325
+ // Sorted (canonical) order MUST match fingerprintRelFilter and buildSubWhereForRelation.
2326
+ for (const field of sortedKeys(subWhere)) {
2327
+ const value = subWhere[field];
2093
2328
  if (value === undefined)
2094
2329
  continue;
2095
2330
  if (value === null)
2096
2331
  continue;
2332
+ const col = meta.columnMap[field] ?? camelToSnake(field);
2097
2333
  if (isWhereOperator(value)) {
2098
- this.collectOperatorParams(value, params);
2334
+ this.collectOperatorParams(col, value, params);
2099
2335
  continue;
2100
2336
  }
2101
- const col = meta.columnMap[field] ?? camelToSnake(field);
2102
2337
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2103
2338
  params.push(value);
2104
2339
  }
2105
2340
  }
2106
2341
  /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2107
- collectOperatorParams(op, params) {
2342
+ collectOperatorParams(column, op, params) {
2343
+ if (op.equals !== undefined && op.equals !== null) {
2344
+ assertBindableEqualsOperand(op.equals, `"${column}"`);
2345
+ params.push(op.equals);
2346
+ }
2108
2347
  if (op.gt !== undefined)
2109
2348
  params.push(op.gt);
2110
2349
  if (op.gte !== undefined)
@@ -2116,9 +2355,9 @@ export class QueryInterface {
2116
2355
  if (op.not !== undefined && op.not !== null)
2117
2356
  params.push(op.not);
2118
2357
  if (op.in !== undefined)
2119
- params.push(op.in);
2358
+ params.push(this.inParam(op.in));
2120
2359
  if (op.notIn !== undefined)
2121
- params.push(op.notIn);
2360
+ params.push(this.inParam(op.notIn));
2122
2361
  if (op.contains !== undefined)
2123
2362
  params.push(`%${escapeLike(op.contains)}%`);
2124
2363
  if (op.startsWith !== undefined)
@@ -2260,7 +2499,7 @@ export class QueryInterface {
2260
2499
  const meta = this.schema.tables[table ?? this.table];
2261
2500
  if (!meta)
2262
2501
  return;
2263
- for (const [relName, relSpec] of Object.entries(withClause)) {
2502
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
2264
2503
  const relDef = meta.relations[relName];
2265
2504
  if (!relDef)
2266
2505
  continue;
@@ -2283,11 +2522,11 @@ export class QueryInterface {
2283
2522
  if (spec.where) {
2284
2523
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2285
2524
  }
2286
- if (spec.limit !== undefined) {
2525
+ if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2287
2526
  params.push(Number(spec.limit));
2288
2527
  }
2289
2528
  if (spec.with) {
2290
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2529
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2291
2530
  const nestedRelDef = targetMeta.relations[nestedRelName];
2292
2531
  if (!nestedRelDef)
2293
2532
  continue;
@@ -2301,7 +2540,7 @@ export class QueryInterface {
2301
2540
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2302
2541
  // Non-wrapped path: nested relations BEFORE where/limit
2303
2542
  if (!willWrap && spec.with) {
2304
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2543
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2305
2544
  const nestedRelDef = targetMeta.relations[nestedRelName];
2306
2545
  if (!nestedRelDef)
2307
2546
  continue;
@@ -2316,12 +2555,12 @@ export class QueryInterface {
2316
2555
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2317
2556
  // pushing one here would orphan a param and desync the collect path.
2318
2557
  // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2319
- if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2558
+ if (relDef.type === 'hasMany' && spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2320
2559
  params.push(Number(spec.limit));
2321
2560
  }
2322
2561
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
2323
2562
  if (willWrap && spec.with) {
2324
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2563
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2325
2564
  const nestedRelDef = targetMeta.relations[nestedRelName];
2326
2565
  if (!nestedRelDef)
2327
2566
  continue;
@@ -2403,7 +2642,8 @@ export class QueryInterface {
2403
2642
  * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
2404
2643
  */
2405
2644
  buildWhereClause(where, params) {
2406
- const keys = Object.keys(where);
2645
+ // Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
2646
+ const keys = sortedKeys(where);
2407
2647
  if (keys.length === 0)
2408
2648
  return null;
2409
2649
  const andClauses = [];
@@ -2610,7 +2850,9 @@ export class QueryInterface {
2610
2850
  return null;
2611
2851
  const qt = this.q(targetTable);
2612
2852
  const conditions = [];
2613
- for (const [field, value] of Object.entries(subWhere)) {
2853
+ // Sorted (canonical) order MUST match fingerprintRelFilter and collectRelFilterParams.
2854
+ for (const field of sortedKeys(subWhere)) {
2855
+ const value = subWhere[field];
2614
2856
  if (value === undefined)
2615
2857
  continue;
2616
2858
  const col = meta.columnMap[field] ?? camelToSnake(field);
@@ -2675,7 +2917,9 @@ export class QueryInterface {
2675
2917
  */
2676
2918
  buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2677
2919
  const clauses = [];
2678
- for (const [key, value] of Object.entries(where)) {
2920
+ // Sorted (canonical) order MUST match fingerprintAliasWhere and collectAliasWhereParams.
2921
+ for (const key of sortedKeys(where)) {
2922
+ const value = where[key];
2679
2923
  if (value === undefined)
2680
2924
  continue;
2681
2925
  if (key === 'OR' || key === 'AND') {
@@ -2717,7 +2961,9 @@ export class QueryInterface {
2717
2961
  }
2718
2962
  /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2719
2963
  collectAliasWhereParams(targetTable, targetMeta, where, params) {
2720
- for (const [key, value] of Object.entries(where)) {
2964
+ // Sorted (canonical) order MUST match fingerprintAliasWhere and buildAliasWhere.
2965
+ for (const key of sortedKeys(where)) {
2966
+ const value = where[key];
2721
2967
  if (value === undefined)
2722
2968
  continue;
2723
2969
  if (key === 'OR' || key === 'AND') {
@@ -2735,11 +2981,11 @@ export class QueryInterface {
2735
2981
  }
2736
2982
  if (value === null)
2737
2983
  continue;
2984
+ const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2738
2985
  if (isWhereOperator(value)) {
2739
- this.collectOperatorParams(value, params);
2986
+ this.collectOperatorParams(col, value, params);
2740
2987
  continue;
2741
2988
  }
2742
- const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2743
2989
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2744
2990
  params.push(value);
2745
2991
  }
@@ -2773,11 +3019,7 @@ export class QueryInterface {
2773
3019
  continue;
2774
3020
  }
2775
3021
  if (isWhereOperator(value)) {
2776
- const opKeys = Object.keys(value)
2777
- .filter((k) => k !== 'mode')
2778
- .sort();
2779
- const mode = value.mode;
2780
- parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
3022
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2781
3023
  continue;
2782
3024
  }
2783
3025
  if (isUnmatchedPlainObject(value)) {
@@ -2796,6 +3038,16 @@ export class QueryInterface {
2796
3038
  */
2797
3039
  buildOperatorClauses(column, op, params) {
2798
3040
  const clauses = [];
3041
+ if (op.equals !== undefined) {
3042
+ if (op.equals === null) {
3043
+ clauses.push(`${column} IS NULL`);
3044
+ }
3045
+ else {
3046
+ assertBindableEqualsOperand(op.equals, column);
3047
+ params.push(op.equals);
3048
+ clauses.push(`${column} = ${this.p(params.length)}`);
3049
+ }
3050
+ }
2799
3051
  if (op.gt !== undefined) {
2800
3052
  params.push(op.gt);
2801
3053
  clauses.push(`${column} > ${this.p(params.length)}`);
@@ -2822,12 +3074,12 @@ export class QueryInterface {
2822
3074
  }
2823
3075
  }
2824
3076
  if (op.in !== undefined) {
2825
- params.push(op.in);
2826
- 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));
2827
3079
  }
2828
3080
  if (op.notIn !== undefined) {
2829
- params.push(op.notIn);
2830
- 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));
2831
3083
  }
2832
3084
  const buildLikeClause = (paramRef) => op.mode === 'insensitive' ? this.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
2833
3085
  if (op.contains !== undefined) {
@@ -2898,6 +3150,9 @@ export class QueryInterface {
2898
3150
  * non-vector column — a user-supplied string can never become a SQL operator.
2899
3151
  */
2900
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
+ }
2901
3156
  const colType = this.getColumnPgType(rawColumn);
2902
3157
  if (colType !== 'vector') {
2903
3158
  throw new ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
@@ -2918,6 +3173,9 @@ export class QueryInterface {
2918
3173
  * placeholder string.
2919
3174
  */
2920
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
+ }
2921
3179
  if (!Array.isArray(to) || to.length === 0) {
2922
3180
  throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
2923
3181
  }
@@ -3094,7 +3352,7 @@ export class QueryInterface {
3094
3352
  const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
3095
3353
  const relationSelects = [];
3096
3354
  const aliasCounter = { n: 0 };
3097
- for (const [relName, relSpec] of Object.entries(withClause)) {
3355
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
3098
3356
  const relDef = meta.relations[relName];
3099
3357
  if (!relDef) {
3100
3358
  throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3228,6 +3486,32 @@ export class QueryInterface {
3228
3486
  .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
3229
3487
  targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
3230
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
+ }
3231
3515
  // Build JSON object pairs for resolved columns
3232
3516
  const jsonPairs = targetColumns.map((col) => [
3233
3517
  targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
@@ -3249,7 +3533,7 @@ export class QueryInterface {
3249
3533
  }
3250
3534
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
3251
3535
  if (!willWrap && spec !== true && spec.with) {
3252
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3536
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3253
3537
  const nestedRelDef = targetMeta.relations[nestedRelName];
3254
3538
  if (!nestedRelDef) {
3255
3539
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3259,7 +3543,7 @@ export class QueryInterface {
3259
3543
  const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3260
3544
  // Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
3261
3545
  const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
3262
- jsonPairs.push([nestedRelName, `COALESCE((${nestedSubquery}), ${fallback})`]);
3546
+ jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSubquery, fallback)]);
3263
3547
  }
3264
3548
  }
3265
3549
  const jsonObj = this.dialect.buildJsonObject(jsonPairs);
@@ -3307,8 +3591,7 @@ export class QueryInterface {
3307
3591
  // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3308
3592
  let limitClause = '';
3309
3593
  if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3310
- params.push(Number(spec.limit));
3311
- limitClause = ` LIMIT ${this.p(params.length)}`;
3594
+ limitClause = ` LIMIT ${this.paginationRef(spec.limit, params)}`;
3312
3595
  }
3313
3596
  if (relDef.type === 'hasMany') {
3314
3597
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -3325,7 +3608,7 @@ export class QueryInterface {
3325
3608
  ]);
3326
3609
  // Build nested relation subqueries referencing innerAlias
3327
3610
  if (spec !== true && spec.with) {
3328
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3611
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3329
3612
  const nestedRelDef = targetMeta.relations[nestedRelName];
3330
3613
  if (!nestedRelDef) {
3331
3614
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3333,13 +3616,17 @@ export class QueryInterface {
3333
3616
  }
3334
3617
  const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3335
3618
  const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
3336
- innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3619
+ innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3337
3620
  }
3338
3621
  }
3339
3622
  const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
3340
3623
  return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
3341
3624
  }
3342
- 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}`;
3343
3630
  }
3344
3631
  // belongsTo / hasOne — return single object
3345
3632
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
@@ -3427,8 +3714,7 @@ export class QueryInterface {
3427
3714
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3428
3715
  let limitClause = '';
3429
3716
  if (spec !== true && spec.limit !== undefined) {
3430
- params.push(Number(spec.limit));
3431
- limitClause = ` LIMIT ${this.p(params.length)}`;
3717
+ limitClause = ` LIMIT ${this.paginationRef(spec.limit, params)}`;
3432
3718
  }
3433
3719
  const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
3434
3720
  // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
@@ -3443,7 +3729,7 @@ export class QueryInterface {
3443
3729
  ]);
3444
3730
  // Nested relations reference the inner alias.
3445
3731
  if (spec !== true && spec.with) {
3446
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3732
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3447
3733
  const nestedRelDef = targetMeta.relations[nestedRelName];
3448
3734
  if (!nestedRelDef) {
3449
3735
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3453,7 +3739,7 @@ export class QueryInterface {
3453
3739
  const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3454
3740
  ? this.dialect.nullJsonLiteral
3455
3741
  : this.dialect.emptyJsonArrayLiteral;
3456
- innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3742
+ innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3457
3743
  }
3458
3744
  }
3459
3745
  const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
@@ -3466,7 +3752,7 @@ export class QueryInterface {
3466
3752
  `${talias}.${this.q(col)}`,
3467
3753
  ]);
3468
3754
  if (spec !== true && spec.with) {
3469
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3755
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3470
3756
  const nestedRelDef = targetMeta.relations[nestedRelName];
3471
3757
  if (!nestedRelDef) {
3472
3758
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3476,7 +3762,7 @@ export class QueryInterface {
3476
3762
  const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3477
3763
  ? this.dialect.nullJsonLiteral
3478
3764
  : this.dialect.emptyJsonArrayLiteral;
3479
- jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3765
+ jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3480
3766
  }
3481
3767
  }
3482
3768
  const jsonObj = this.dialect.buildJsonObject(jsonPairs);