turbine-orm 0.19.0 → 0.19.2

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.
@@ -30,6 +30,67 @@ function isWhereOperator(value) {
30
30
  const keys = Object.keys(value);
31
31
  return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
32
32
  }
33
+ /**
34
+ * True for a *plain object literal* that reached an equality fallthrough
35
+ * without matching any known filter shape — the misspelled-operator case.
36
+ * Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
37
+ * bind values and return false, as do arrays and Dates.
38
+ */
39
+ function isUnmatchedPlainObject(value) {
40
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
41
+ return false;
42
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
43
+ return false;
44
+ const proto = Object.getPrototypeOf(value);
45
+ return proto === Object.prototype || proto === null;
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
+ }
33
94
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
34
95
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
35
96
  /** Known JSONB operator keys */
@@ -39,9 +100,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
39
100
  * appear in any other where-filter shape, so the presence of one of these is
40
101
  * an unambiguous signal that the user meant a JSON filter. Used by the
41
102
  * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
42
- * `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.
43
106
  */
44
- const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
107
+ const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
45
108
  /** Check if a value is a JSONB filter object */
46
109
  function isJsonFilter(value) {
47
110
  if (value === null ||
@@ -342,10 +405,12 @@ export class QueryInterface {
342
405
  * Execute a query through the middleware chain.
343
406
  * If no middlewares are registered, executes directly.
344
407
  *
345
- * Middleware can inspect and log query parameters, modify results after execution,
346
- * and measure timing. Note: query SQL is generated before middleware runs, so
347
- * modifying params.args in middleware will NOT affect the executed SQL.
348
- * To intercept queries before SQL generation, use the raw() method instead.
408
+ * Middleware can inspect and log query parameters, measure timing, and
409
+ * transform the result returned by `next()`. Note: query SQL is generated
410
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
411
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
412
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
413
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
349
414
  */
350
415
  async executeWithMiddleware(action, args, executor) {
351
416
  this.currentAction = action;
@@ -384,8 +449,12 @@ export class QueryInterface {
384
449
  const withFp = args.with ? this.withFingerprint(args.with) : '';
385
450
  const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
386
451
  const params = [];
387
- // Check if all where values are simple (plain equality, no operators/null/OR)
388
- const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
452
+ // Check if all where values are simple (plain equality, no operators/null/OR).
453
+ // Keys are sorted to match fingerprintWhere — insertion order here would let
454
+ // permuted where literals share a cache entry with misaligned params.
455
+ const whereKeys = Object.keys(whereObj)
456
+ .filter((k) => whereObj[k] !== undefined)
457
+ .sort();
389
458
  const isSimpleWhere = !whereObj.OR &&
390
459
  !whereObj.AND &&
391
460
  !whereObj.NOT &&
@@ -589,7 +658,8 @@ export class QueryInterface {
589
658
  }
590
659
  let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
591
660
  if (args?.cursor) {
592
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
661
+ // Sorted (canonical) order MUST match cursorFp and the cache-hit collect below.
662
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
593
663
  if (cursorEntries.length > 0) {
594
664
  const cursorConditions = cursorEntries.map(([k, v]) => {
595
665
  const col = this.toSqlColumn(k);
@@ -630,9 +700,9 @@ export class QueryInterface {
630
700
  if (args?.with) {
631
701
  this.collectWithParams(args.with, params);
632
702
  }
633
- // 3. Cursor params
703
+ // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
634
704
  if (args?.cursor) {
635
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
705
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
636
706
  for (const [, v] of cursorEntries) {
637
707
  params.push(v);
638
708
  }
@@ -1872,12 +1942,7 @@ export class QueryInterface {
1872
1942
  }
1873
1943
  // Operator objects
1874
1944
  if (isWhereOperator(value)) {
1875
- const opKeys = Object.keys(value)
1876
- .filter((k) => k !== 'mode')
1877
- .sort();
1878
- const mode = value.mode;
1879
- const modeStr = mode === 'insensitive' ? ':i' : '';
1880
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1945
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
1881
1946
  continue;
1882
1947
  }
1883
1948
  // Vector distance filter — metric (operator) and present comparators
@@ -1908,6 +1973,16 @@ export class QueryInterface {
1908
1973
  parts.push(`${key}:fts(${cfg})`);
1909
1974
  continue;
1910
1975
  }
1976
+ // Plain object literal that matched no filter shape — give it a
1977
+ // fingerprint distinct from real equality. The build path throws for
1978
+ // these on non-JSON columns; sharing `key:eq` would let a cache entry
1979
+ // warmed by genuine equality serve the bad filter silently.
1980
+ if (isUnmatchedPlainObject(value)) {
1981
+ parts.push(`${key}:obj(${Object.keys(value)
1982
+ .sort()
1983
+ .join(',')})`);
1984
+ continue;
1985
+ }
1911
1986
  // Plain equality
1912
1987
  parts.push(`${key}:eq`);
1913
1988
  }
@@ -1940,12 +2015,12 @@ export class QueryInterface {
1940
2015
  parts.push(`${key}:null`);
1941
2016
  }
1942
2017
  else if (isWhereOperator(value)) {
1943
- const opKeys = Object.keys(value)
1944
- .filter((k) => k !== 'mode')
1945
- .sort();
1946
- const mode = value.mode;
1947
- const modeStr = mode === 'insensitive' ? ':i' : '';
1948
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
2018
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2019
+ }
2020
+ else if (isUnmatchedPlainObject(value)) {
2021
+ parts.push(`${key}:obj(${Object.keys(value)
2022
+ .sort()
2023
+ .join(',')})`);
1949
2024
  }
1950
2025
  else {
1951
2026
  parts.push(`${key}:eq`);
@@ -1961,7 +2036,8 @@ export class QueryInterface {
1961
2036
  * @internal Exposed as package-private for testing.
1962
2037
  */
1963
2038
  collectWhereParams(where, params) {
1964
- const keys = Object.keys(where);
2039
+ // Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
2040
+ const keys = sortedKeys(where);
1965
2041
  for (const key of keys) {
1966
2042
  const value = where[key];
1967
2043
  if (value === undefined)
@@ -2046,10 +2122,12 @@ export class QueryInterface {
2046
2122
  }
2047
2123
  // Operator objects
2048
2124
  if (isWhereOperator(value)) {
2049
- this.collectOperatorParams(value, params);
2125
+ this.collectOperatorParams(rawColumn, value, params);
2050
2126
  continue;
2051
2127
  }
2052
- // Plain equality
2128
+ // Plain equality — same strict validation as the build path, so a
2129
+ // cache hit can never silently bind a misspelled-operator object.
2130
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2053
2131
  params.push(value);
2054
2132
  }
2055
2133
  }
@@ -2058,20 +2136,28 @@ export class QueryInterface {
2058
2136
  const meta = this.schema.tables[targetTable];
2059
2137
  if (!meta)
2060
2138
  return;
2061
- for (const [_field, value] of Object.entries(subWhere)) {
2139
+ // Sorted (canonical) order MUST match fingerprintRelFilter and buildSubWhereForRelation.
2140
+ for (const field of sortedKeys(subWhere)) {
2141
+ const value = subWhere[field];
2062
2142
  if (value === undefined)
2063
2143
  continue;
2064
2144
  if (value === null)
2065
2145
  continue;
2146
+ const col = meta.columnMap[field] ?? camelToSnake(field);
2066
2147
  if (isWhereOperator(value)) {
2067
- this.collectOperatorParams(value, params);
2148
+ this.collectOperatorParams(col, value, params);
2068
2149
  continue;
2069
2150
  }
2151
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2070
2152
  params.push(value);
2071
2153
  }
2072
2154
  }
2073
2155
  /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2074
- collectOperatorParams(op, params) {
2156
+ collectOperatorParams(column, op, params) {
2157
+ if (op.equals !== undefined && op.equals !== null) {
2158
+ assertBindableEqualsOperand(op.equals, `"${column}"`);
2159
+ params.push(op.equals);
2160
+ }
2075
2161
  if (op.gt !== undefined)
2076
2162
  params.push(op.gt);
2077
2163
  if (op.gte !== undefined)
@@ -2194,13 +2280,11 @@ export class QueryInterface {
2194
2280
  .sort();
2195
2281
  subParts.push(`om=${omKeys.join(',')}`);
2196
2282
  }
2197
- // where shape (value-invariant)
2283
+ // where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
2284
+ // `{title: {contains: 'x'}}` emit different SQL so they must not share
2285
+ // a fingerprint)
2198
2286
  if (opts.where) {
2199
- // Use a target-table QI if possible, or a simplified fingerprint
2200
- const wKeys = Object.keys(opts.where)
2201
- .filter((k) => opts.where[k] !== undefined)
2202
- .sort();
2203
- subParts.push(`w=${wKeys.join(',')}`);
2287
+ subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
2204
2288
  }
2205
2289
  // orderBy shape
2206
2290
  if (opts.orderBy) {
@@ -2229,7 +2313,7 @@ export class QueryInterface {
2229
2313
  const meta = this.schema.tables[table ?? this.table];
2230
2314
  if (!meta)
2231
2315
  return;
2232
- for (const [relName, relSpec] of Object.entries(withClause)) {
2316
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
2233
2317
  const relDef = meta.relations[relName];
2234
2318
  if (!relDef)
2235
2319
  continue;
@@ -2250,15 +2334,13 @@ export class QueryInterface {
2250
2334
  // where params → limit param → nested-with params (always, both paths).
2251
2335
  if (relDef.type === 'manyToMany') {
2252
2336
  if (spec.where) {
2253
- for (const [, v] of Object.entries(spec.where)) {
2254
- params.push(v);
2255
- }
2337
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2256
2338
  }
2257
- if (spec.limit) {
2339
+ if (spec.limit !== undefined) {
2258
2340
  params.push(Number(spec.limit));
2259
2341
  }
2260
2342
  if (spec.with) {
2261
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2343
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2262
2344
  const nestedRelDef = targetMeta.relations[nestedRelName];
2263
2345
  if (!nestedRelDef)
2264
2346
  continue;
@@ -2267,31 +2349,32 @@ export class QueryInterface {
2267
2349
  }
2268
2350
  return;
2269
2351
  }
2270
- const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2352
+ // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2353
+ const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2354
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2271
2355
  // Non-wrapped path: nested relations BEFORE where/limit
2272
2356
  if (!willWrap && spec.with) {
2273
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2357
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2274
2358
  const nestedRelDef = targetMeta.relations[nestedRelName];
2275
2359
  if (!nestedRelDef)
2276
2360
  continue;
2277
2361
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2278
2362
  }
2279
2363
  }
2280
- // where params
2364
+ // where params — mirrors buildAliasWhere push order
2281
2365
  if (spec.where) {
2282
- for (const [, v] of Object.entries(spec.where)) {
2283
- params.push(v);
2284
- }
2366
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2285
2367
  }
2286
2368
  // limit param — only hasMany parameterizes its limit (mirrors
2287
2369
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2288
2370
  // pushing one here would orphan a param and desync the collect path.
2289
- if (relDef.type === 'hasMany' && spec.limit) {
2371
+ // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2372
+ if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2290
2373
  params.push(Number(spec.limit));
2291
2374
  }
2292
2375
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
2293
2376
  if (willWrap && spec.with) {
2294
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2377
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2295
2378
  const nestedRelDef = targetMeta.relations[nestedRelName];
2296
2379
  if (!nestedRelDef)
2297
2380
  continue;
@@ -2373,7 +2456,8 @@ export class QueryInterface {
2373
2456
  * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
2374
2457
  */
2375
2458
  buildWhereClause(where, params) {
2376
- const keys = Object.keys(where);
2459
+ // Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
2460
+ const keys = sortedKeys(where);
2377
2461
  if (keys.length === 0)
2378
2462
  return null;
2379
2463
  const andClauses = [];
@@ -2494,25 +2578,11 @@ export class QueryInterface {
2494
2578
  andClauses.push(...opClauses);
2495
2579
  continue;
2496
2580
  }
2497
- // Strict validation: a plain (non-array, non-Date) object on a non-JSON
2498
- // column matched no known filter shape almost always a misspelled
2499
- // operator (`startWith` for `startsWith`) or a stray nested object.
2500
- // Silently treating it as `col = $1` returns wrong rows with no error, so
2501
- // throw with the offending keys and the supported operator list. JSON/JSONB
2502
- // columns legitimately accept object values for equality, so they fall
2503
- // through unchanged.
2504
- if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
2505
- const colType = this.getColumnPgType(rawColumn);
2506
- if (colType !== 'json' && colType !== 'jsonb') {
2507
- const badKeys = Object.keys(value);
2508
- throw new ValidationError(badKeys.length === 0
2509
- ? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
2510
- `Provide a value or an operator like { gt: 1 }.`
2511
- : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2512
- `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
2513
- `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
2514
- }
2515
- }
2581
+ // Strict validation: a plain object literal that matched no known filter
2582
+ // shape is almost always a misspelled operator (`startWith` for
2583
+ // `startsWith`). The guard also runs on the cache-hit param-collect path
2584
+ // (collectWhereParams) so a warmed SQL cache can never skip it.
2585
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2516
2586
  // Plain equality
2517
2587
  params.push(value);
2518
2588
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2594,7 +2664,9 @@ export class QueryInterface {
2594
2664
  return null;
2595
2665
  const qt = this.q(targetTable);
2596
2666
  const conditions = [];
2597
- for (const [field, value] of Object.entries(subWhere)) {
2667
+ // Sorted (canonical) order MUST match fingerprintRelFilter and collectRelFilterParams.
2668
+ for (const field of sortedKeys(subWhere)) {
2669
+ const value = subWhere[field];
2598
2670
  if (value === undefined)
2599
2671
  continue;
2600
2672
  const col = meta.columnMap[field] ?? camelToSnake(field);
@@ -2612,17 +2684,184 @@ export class QueryInterface {
2612
2684
  conditions.push(...opClauses);
2613
2685
  continue;
2614
2686
  }
2687
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2615
2688
  params.push(value);
2616
2689
  conditions.push(`${qCol} = ${this.p(params.length)}`);
2617
2690
  }
2618
2691
  return conditions.length > 0 ? conditions.join(' AND ') : null;
2619
2692
  }
2693
+ /**
2694
+ * Resolve a column's Postgres type from an arbitrary table's metadata
2695
+ * (relation targets, not just `this.table`).
2696
+ */
2697
+ pgTypeForColumn(meta, column) {
2698
+ return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
2699
+ }
2700
+ /**
2701
+ * Equality-fallthrough guard shared by every SQL-build path AND every
2702
+ * cache-hit param-collect path. A plain object literal that matched no known
2703
+ * filter shape on a non-JSON column is almost always a misspelled operator
2704
+ * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
2705
+ * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
2706
+ * legitimate bind values and pass through, as do objects on json/jsonb
2707
+ * columns (object equality).
2708
+ */
2709
+ assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
2710
+ if (!isUnmatchedPlainObject(value))
2711
+ return;
2712
+ if (columnPgType === 'json' || columnPgType === 'jsonb')
2713
+ return;
2714
+ const badKeys = Object.keys(value);
2715
+ throw new ValidationError(badKeys.length === 0
2716
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
2717
+ `Provide a value or an operator like { gt: 1 }.`
2718
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2719
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
2720
+ `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
2721
+ }
2722
+ /**
2723
+ * Build the user-supplied `where` filter of a relation `with` clause against
2724
+ * the relation's table alias. Supports the same scalar surface as the
2725
+ * top-level WHERE builder — equality, IS NULL, operator objects (incl.
2726
+ * `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
2727
+ * objects throw via {@link assertBindableEqualityValue}.
2728
+ *
2729
+ * Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
2730
+ * cache hits and pipeline batching will desync.
2731
+ */
2732
+ buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2733
+ const clauses = [];
2734
+ // Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
2735
+ for (const key of sortedKeys(where)) {
2736
+ const value = where[key];
2737
+ if (value === undefined)
2738
+ continue;
2739
+ if (key === 'OR' || key === 'AND') {
2740
+ const arr = value;
2741
+ if (!Array.isArray(arr) || arr.length === 0)
2742
+ continue;
2743
+ const subs = arr
2744
+ .map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
2745
+ .filter((s) => s !== null)
2746
+ .map((s) => `(${s})`);
2747
+ if (subs.length > 0)
2748
+ clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
2749
+ continue;
2750
+ }
2751
+ if (key === 'NOT') {
2752
+ const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
2753
+ if (sub)
2754
+ clauses.push(`NOT (${sub})`);
2755
+ continue;
2756
+ }
2757
+ const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2758
+ if (!targetMeta.allColumns.includes(col)) {
2759
+ throw new ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
2760
+ }
2761
+ const qCol = `${alias}.${this.q(col)}`;
2762
+ if (value === null) {
2763
+ clauses.push(`${qCol} IS NULL`);
2764
+ continue;
2765
+ }
2766
+ if (isWhereOperator(value)) {
2767
+ clauses.push(...this.buildOperatorClauses(qCol, value, params));
2768
+ continue;
2769
+ }
2770
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2771
+ params.push(value);
2772
+ clauses.push(`${qCol} = ${this.p(params.length)}`);
2773
+ }
2774
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
2775
+ }
2776
+ /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2777
+ collectAliasWhereParams(targetTable, targetMeta, where, params) {
2778
+ // Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
2779
+ for (const key of sortedKeys(where)) {
2780
+ const value = where[key];
2781
+ if (value === undefined)
2782
+ continue;
2783
+ if (key === 'OR' || key === 'AND') {
2784
+ const arr = value;
2785
+ if (!Array.isArray(arr) || arr.length === 0)
2786
+ continue;
2787
+ for (const cond of arr) {
2788
+ this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
2789
+ }
2790
+ continue;
2791
+ }
2792
+ if (key === 'NOT') {
2793
+ this.collectAliasWhereParams(targetTable, targetMeta, value, params);
2794
+ continue;
2795
+ }
2796
+ if (value === null)
2797
+ continue;
2798
+ const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2799
+ if (isWhereOperator(value)) {
2800
+ this.collectOperatorParams(col, value, params);
2801
+ continue;
2802
+ }
2803
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2804
+ params.push(value);
2805
+ }
2806
+ }
2807
+ /**
2808
+ * Value-invariant, shape-aware fingerprint for a relation `with` clause's
2809
+ * `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
2810
+ * can emit — equality vs null vs operator sets vs combinators — or two
2811
+ * differently-shaped wheres would share one cached SQL string.
2812
+ */
2813
+ fingerprintAliasWhere(where) {
2814
+ const keys = Object.keys(where)
2815
+ .filter((k) => where[k] !== undefined)
2816
+ .sort();
2817
+ const parts = [];
2818
+ for (const key of keys) {
2819
+ const value = where[key];
2820
+ if (key === 'OR' || key === 'AND') {
2821
+ const arr = value;
2822
+ if (!Array.isArray(arr) || arr.length === 0)
2823
+ continue;
2824
+ parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
2825
+ continue;
2826
+ }
2827
+ if (key === 'NOT') {
2828
+ parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
2829
+ continue;
2830
+ }
2831
+ if (value === null) {
2832
+ parts.push(`${key}:null`);
2833
+ continue;
2834
+ }
2835
+ if (isWhereOperator(value)) {
2836
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2837
+ continue;
2838
+ }
2839
+ if (isUnmatchedPlainObject(value)) {
2840
+ parts.push(`${key}:obj(${Object.keys(value)
2841
+ .sort()
2842
+ .join(',')})`);
2843
+ continue;
2844
+ }
2845
+ parts.push(`${key}:eq`);
2846
+ }
2847
+ return parts.join('&');
2848
+ }
2620
2849
  /**
2621
2850
  * Build SQL clauses for a single operator object on a column.
2622
2851
  * Each operator key becomes its own clause, all ANDed together.
2623
2852
  */
2624
2853
  buildOperatorClauses(column, op, params) {
2625
2854
  const clauses = [];
2855
+ if (op.equals !== undefined) {
2856
+ if (op.equals === null) {
2857
+ clauses.push(`${column} IS NULL`);
2858
+ }
2859
+ else {
2860
+ assertBindableEqualsOperand(op.equals, column);
2861
+ params.push(op.equals);
2862
+ clauses.push(`${column} = ${this.p(params.length)}`);
2863
+ }
2864
+ }
2626
2865
  if (op.gt !== undefined) {
2627
2866
  params.push(op.gt);
2628
2867
  clauses.push(`${column} > ${this.p(params.length)}`);
@@ -2921,7 +3160,7 @@ export class QueryInterface {
2921
3160
  const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
2922
3161
  const relationSelects = [];
2923
3162
  const aliasCounter = { n: 0 };
2924
- for (const [relName, relSpec] of Object.entries(withClause)) {
3163
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
2925
3164
  const relDef = meta.relations[relName];
2926
3165
  if (!relDef) {
2927
3166
  throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3063,7 +3302,11 @@ export class QueryInterface {
3063
3302
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
3064
3303
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
3065
3304
  // so we must NOT build them here (they would push orphaned params).
3066
- const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3305
+ // An orderBy with no defined entries (`orderBy: {}`) is treated as absent
3306
+ // it must neither trigger the wrap (dropping nested relations) nor render a
3307
+ // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
3308
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3309
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
3067
3310
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3068
3311
  // where, orderBy, and select/omit are handled there (the target alias is the
3069
3312
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -3072,7 +3315,7 @@ export class QueryInterface {
3072
3315
  }
3073
3316
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
3074
3317
  if (!willWrap && spec !== true && spec.with) {
3075
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3318
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3076
3319
  const nestedRelDef = targetMeta.relations[nestedRelName];
3077
3320
  if (!nestedRelDef) {
3078
3321
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3091,14 +3334,14 @@ export class QueryInterface {
3091
3334
  const qTarget = this.q(targetTable);
3092
3335
  // Build ORDER BY for json_agg
3093
3336
  let orderClause = '';
3094
- if (spec !== true && spec.orderBy) {
3095
- const orders = Object.entries(spec.orderBy)
3337
+ if (relOrderEntries.length > 0) {
3338
+ const orders = relOrderEntries
3096
3339
  .map(([k, dir]) => {
3097
3340
  const col = camelToSnake(k);
3098
3341
  if (!targetMeta.allColumns.includes(col)) {
3099
3342
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3100
3343
  }
3101
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3344
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3102
3345
  return `${alias}.${this.q(col)} ${safeDir}`;
3103
3346
  })
3104
3347
  .join(', ');
@@ -3115,24 +3358,21 @@ export class QueryInterface {
3115
3358
  else {
3116
3359
  whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3117
3360
  }
3118
- // Additional filters — properly parameterized
3361
+ // Additional filters — full scalar where surface (equality, null, operator
3362
+ // objects, OR/AND/NOT), properly parameterized against this alias.
3119
3363
  if (spec !== true && spec.where) {
3120
- for (const [k, v] of Object.entries(spec.where)) {
3121
- const col = camelToSnake(k);
3122
- if (!targetMeta.allColumns.includes(col)) {
3123
- throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3124
- }
3125
- params.push(v);
3126
- whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
3127
- }
3364
+ const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
3365
+ if (extra)
3366
+ whereClause += ` AND ${extra}`;
3128
3367
  }
3129
3368
  // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3130
3369
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3131
3370
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3132
3371
  // which Postgres rejects with "could not determine data type of parameter $N"
3133
3372
  // (and shifts every later placeholder by one). To-one relations ignore limit.
3373
+ // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3134
3374
  let limitClause = '';
3135
- if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
3375
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3136
3376
  params.push(Number(spec.limit));
3137
3377
  limitClause = ` LIMIT ${this.p(params.length)}`;
3138
3378
  }
@@ -3151,7 +3391,7 @@ export class QueryInterface {
3151
3391
  ]);
3152
3392
  // Build nested relation subqueries referencing innerAlias
3153
3393
  if (spec !== true && spec.with) {
3154
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3394
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3155
3395
  const nestedRelDef = targetMeta.relations[nestedRelName];
3156
3396
  if (!nestedRelDef) {
3157
3397
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3226,35 +3466,33 @@ export class QueryInterface {
3226
3466
  let whereClause = sourceKeys
3227
3467
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3228
3468
  .join(' AND ');
3229
- // ORDER BY on the target rows
3469
+ // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
3470
+ // treated as absent — it must not render a dangling `ORDER BY `.
3471
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3230
3472
  let orderClause = '';
3231
- if (spec !== true && spec.orderBy) {
3232
- const orders = Object.entries(spec.orderBy)
3473
+ if (relOrderEntries.length > 0) {
3474
+ const orders = relOrderEntries
3233
3475
  .map(([k, dir]) => {
3234
3476
  const col = camelToSnake(k);
3235
3477
  if (!targetMeta.allColumns.includes(col)) {
3236
3478
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3237
3479
  }
3238
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3480
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3239
3481
  return `${talias}.${this.q(col)} ${safeDir}`;
3240
3482
  })
3241
3483
  .join(', ');
3242
3484
  orderClause = ` ORDER BY ${orders}`;
3243
3485
  }
3244
- // Additional WHERE filters on the target — properly parameterized.
3486
+ // Additional WHERE filters on the target — full scalar where surface,
3487
+ // properly parameterized against the target alias.
3245
3488
  if (spec !== true && spec.where) {
3246
- for (const [k, v] of Object.entries(spec.where)) {
3247
- const col = camelToSnake(k);
3248
- if (!targetMeta.allColumns.includes(col)) {
3249
- throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3250
- }
3251
- params.push(v);
3252
- whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3253
- }
3489
+ const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
3490
+ if (extra)
3491
+ whereClause += ` AND ${extra}`;
3254
3492
  }
3255
- // LIMIT
3493
+ // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3256
3494
  let limitClause = '';
3257
- if (spec !== true && spec.limit) {
3495
+ if (spec !== true && spec.limit !== undefined) {
3258
3496
  params.push(Number(spec.limit));
3259
3497
  limitClause = ` LIMIT ${this.p(params.length)}`;
3260
3498
  }
@@ -3271,7 +3509,7 @@ export class QueryInterface {
3271
3509
  ]);
3272
3510
  // Nested relations reference the inner alias.
3273
3511
  if (spec !== true && spec.with) {
3274
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3512
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3275
3513
  const nestedRelDef = targetMeta.relations[nestedRelName];
3276
3514
  if (!nestedRelDef) {
3277
3515
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3294,7 +3532,7 @@ export class QueryInterface {
3294
3532
  `${talias}.${this.q(col)}`,
3295
3533
  ]);
3296
3534
  if (spec !== true && spec.with) {
3297
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3535
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3298
3536
  const nestedRelDef = targetMeta.relations[nestedRelName];
3299
3537
  if (!nestedRelDef) {
3300
3538
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +