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.
@@ -66,6 +66,67 @@ function isWhereOperator(value) {
66
66
  const keys = Object.keys(value);
67
67
  return keys.length > 0 && keys.every((k) => utils_js_1.OPERATOR_KEYS.has(k));
68
68
  }
69
+ /**
70
+ * True for a *plain object literal* that reached an equality fallthrough
71
+ * without matching any known filter shape — the misspelled-operator case.
72
+ * Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
73
+ * bind values and return false, as do arrays and Dates.
74
+ */
75
+ function isUnmatchedPlainObject(value) {
76
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
77
+ return false;
78
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
79
+ return false;
80
+ const proto = Object.getPrototypeOf(value);
81
+ return proto === Object.prototype || proto === null;
82
+ }
83
+ /**
84
+ * Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
85
+ * `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
86
+ * param pushed), so null-ness is part of the shape — without it a cache entry
87
+ * warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
88
+ */
89
+ function fingerprintOperatorShape(value) {
90
+ const obj = value;
91
+ const opKeys = Object.keys(obj)
92
+ .filter((k) => k !== 'mode')
93
+ .map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
94
+ .sort();
95
+ const modeStr = value.mode === 'insensitive' ? ':i' : '';
96
+ return `op(${opKeys.join(',')}${modeStr})`;
97
+ }
98
+ /**
99
+ * Guard for the value of an `equals` operator reaching the plain-equality
100
+ * operator path. A plain object literal can only legitimately be an equality
101
+ * value on a json/jsonb column — and those route to the JSONB filter branch
102
+ * BEFORE the operator branch, so any plain object that reaches here is a
103
+ * mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
104
+ * SQL-build path and the cache-hit param-collect path so a warmed cache can
105
+ * never skip the check.
106
+ */
107
+ function assertBindableEqualsOperand(value, column) {
108
+ if (!isUnmatchedPlainObject(value))
109
+ return;
110
+ throw new errors_js_1.ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
111
+ `objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
112
+ `where 'equals' is the JSONB containment filter.`);
113
+ }
114
+ /**
115
+ * Object keys in sorted order, mirroring the canonical order used by every
116
+ * cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
117
+ * enumerate object keys in this exact order: fingerprints sort keys, so two
118
+ * where clauses with the same fields in different insertion order share one
119
+ * cache entry — if build/collect iterated insertion order, the cached SQL's
120
+ * `$N` placeholders would bind the wrong values (cross-tenant-leak class).
121
+ * Array order (OR/AND members) is positional and is never sorted.
122
+ */
123
+ function sortedKeys(obj) {
124
+ return Object.keys(obj).sort();
125
+ }
126
+ /** {@link sortedKeys}, but yielding `[key, value]` pairs. */
127
+ function sortedEntries(obj) {
128
+ return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
129
+ }
69
130
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
70
131
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
71
132
  /** Known JSONB operator keys */
@@ -75,9 +136,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
75
136
  * appear in any other where-filter shape, so the presence of one of these is
76
137
  * an unambiguous signal that the user meant a JSON filter. Used by the
77
138
  * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
78
- * `WhereOperator` for LIKE) is not misclassified.
139
+ * `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
140
+ * set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
141
+ * so it must fall through instead of throwing.
79
142
  */
80
- const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
143
+ const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
81
144
  /** Check if a value is a JSONB filter object */
82
145
  function isJsonFilter(value) {
83
146
  if (value === null ||
@@ -378,10 +441,12 @@ class QueryInterface {
378
441
  * Execute a query through the middleware chain.
379
442
  * If no middlewares are registered, executes directly.
380
443
  *
381
- * Middleware can inspect and log query parameters, modify results after execution,
382
- * and measure timing. Note: query SQL is generated before middleware runs, so
383
- * modifying params.args in middleware will NOT affect the executed SQL.
384
- * To intercept queries before SQL generation, use the raw() method instead.
444
+ * Middleware can inspect and log query parameters, measure timing, and
445
+ * transform the result returned by `next()`. Note: query SQL is generated
446
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
447
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
448
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
449
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
385
450
  */
386
451
  async executeWithMiddleware(action, args, executor) {
387
452
  this.currentAction = action;
@@ -420,8 +485,12 @@ class QueryInterface {
420
485
  const withFp = args.with ? this.withFingerprint(args.with) : '';
421
486
  const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
422
487
  const params = [];
423
- // Check if all where values are simple (plain equality, no operators/null/OR)
424
- const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
488
+ // Check if all where values are simple (plain equality, no operators/null/OR).
489
+ // Keys are sorted to match fingerprintWhere — insertion order here would let
490
+ // permuted where literals share a cache entry with misaligned params.
491
+ const whereKeys = Object.keys(whereObj)
492
+ .filter((k) => whereObj[k] !== undefined)
493
+ .sort();
425
494
  const isSimpleWhere = !whereObj.OR &&
426
495
  !whereObj.AND &&
427
496
  !whereObj.NOT &&
@@ -625,7 +694,8 @@ class QueryInterface {
625
694
  }
626
695
  let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
627
696
  if (args?.cursor) {
628
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
697
+ // Sorted (canonical) order MUST match cursorFp and the cache-hit collect below.
698
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
629
699
  if (cursorEntries.length > 0) {
630
700
  const cursorConditions = cursorEntries.map(([k, v]) => {
631
701
  const col = this.toSqlColumn(k);
@@ -666,9 +736,9 @@ class QueryInterface {
666
736
  if (args?.with) {
667
737
  this.collectWithParams(args.with, params);
668
738
  }
669
- // 3. Cursor params
739
+ // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
670
740
  if (args?.cursor) {
671
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
741
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
672
742
  for (const [, v] of cursorEntries) {
673
743
  params.push(v);
674
744
  }
@@ -1908,12 +1978,7 @@ class QueryInterface {
1908
1978
  }
1909
1979
  // Operator objects
1910
1980
  if (isWhereOperator(value)) {
1911
- const opKeys = Object.keys(value)
1912
- .filter((k) => k !== 'mode')
1913
- .sort();
1914
- const mode = value.mode;
1915
- const modeStr = mode === 'insensitive' ? ':i' : '';
1916
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1981
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
1917
1982
  continue;
1918
1983
  }
1919
1984
  // Vector distance filter — metric (operator) and present comparators
@@ -1944,6 +2009,16 @@ class QueryInterface {
1944
2009
  parts.push(`${key}:fts(${cfg})`);
1945
2010
  continue;
1946
2011
  }
2012
+ // Plain object literal that matched no filter shape — give it a
2013
+ // fingerprint distinct from real equality. The build path throws for
2014
+ // these on non-JSON columns; sharing `key:eq` would let a cache entry
2015
+ // warmed by genuine equality serve the bad filter silently.
2016
+ if (isUnmatchedPlainObject(value)) {
2017
+ parts.push(`${key}:obj(${Object.keys(value)
2018
+ .sort()
2019
+ .join(',')})`);
2020
+ continue;
2021
+ }
1947
2022
  // Plain equality
1948
2023
  parts.push(`${key}:eq`);
1949
2024
  }
@@ -1976,12 +2051,12 @@ class QueryInterface {
1976
2051
  parts.push(`${key}:null`);
1977
2052
  }
1978
2053
  else if (isWhereOperator(value)) {
1979
- const opKeys = Object.keys(value)
1980
- .filter((k) => k !== 'mode')
1981
- .sort();
1982
- const mode = value.mode;
1983
- const modeStr = mode === 'insensitive' ? ':i' : '';
1984
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
2054
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2055
+ }
2056
+ else if (isUnmatchedPlainObject(value)) {
2057
+ parts.push(`${key}:obj(${Object.keys(value)
2058
+ .sort()
2059
+ .join(',')})`);
1985
2060
  }
1986
2061
  else {
1987
2062
  parts.push(`${key}:eq`);
@@ -1997,7 +2072,8 @@ class QueryInterface {
1997
2072
  * @internal Exposed as package-private for testing.
1998
2073
  */
1999
2074
  collectWhereParams(where, params) {
2000
- const keys = Object.keys(where);
2075
+ // Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
2076
+ const keys = sortedKeys(where);
2001
2077
  for (const key of keys) {
2002
2078
  const value = where[key];
2003
2079
  if (value === undefined)
@@ -2082,10 +2158,12 @@ class QueryInterface {
2082
2158
  }
2083
2159
  // Operator objects
2084
2160
  if (isWhereOperator(value)) {
2085
- this.collectOperatorParams(value, params);
2161
+ this.collectOperatorParams(rawColumn, value, params);
2086
2162
  continue;
2087
2163
  }
2088
- // Plain equality
2164
+ // Plain equality — same strict validation as the build path, so a
2165
+ // cache hit can never silently bind a misspelled-operator object.
2166
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2089
2167
  params.push(value);
2090
2168
  }
2091
2169
  }
@@ -2094,20 +2172,28 @@ class QueryInterface {
2094
2172
  const meta = this.schema.tables[targetTable];
2095
2173
  if (!meta)
2096
2174
  return;
2097
- for (const [_field, value] of Object.entries(subWhere)) {
2175
+ // Sorted (canonical) order MUST match fingerprintRelFilter and buildSubWhereForRelation.
2176
+ for (const field of sortedKeys(subWhere)) {
2177
+ const value = subWhere[field];
2098
2178
  if (value === undefined)
2099
2179
  continue;
2100
2180
  if (value === null)
2101
2181
  continue;
2182
+ const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2102
2183
  if (isWhereOperator(value)) {
2103
- this.collectOperatorParams(value, params);
2184
+ this.collectOperatorParams(col, value, params);
2104
2185
  continue;
2105
2186
  }
2187
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2106
2188
  params.push(value);
2107
2189
  }
2108
2190
  }
2109
2191
  /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2110
- collectOperatorParams(op, params) {
2192
+ collectOperatorParams(column, op, params) {
2193
+ if (op.equals !== undefined && op.equals !== null) {
2194
+ assertBindableEqualsOperand(op.equals, `"${column}"`);
2195
+ params.push(op.equals);
2196
+ }
2111
2197
  if (op.gt !== undefined)
2112
2198
  params.push(op.gt);
2113
2199
  if (op.gte !== undefined)
@@ -2230,13 +2316,11 @@ class QueryInterface {
2230
2316
  .sort();
2231
2317
  subParts.push(`om=${omKeys.join(',')}`);
2232
2318
  }
2233
- // where shape (value-invariant)
2319
+ // where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
2320
+ // `{title: {contains: 'x'}}` emit different SQL so they must not share
2321
+ // a fingerprint)
2234
2322
  if (opts.where) {
2235
- // Use a target-table QI if possible, or a simplified fingerprint
2236
- const wKeys = Object.keys(opts.where)
2237
- .filter((k) => opts.where[k] !== undefined)
2238
- .sort();
2239
- subParts.push(`w=${wKeys.join(',')}`);
2323
+ subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
2240
2324
  }
2241
2325
  // orderBy shape
2242
2326
  if (opts.orderBy) {
@@ -2265,7 +2349,7 @@ class QueryInterface {
2265
2349
  const meta = this.schema.tables[table ?? this.table];
2266
2350
  if (!meta)
2267
2351
  return;
2268
- for (const [relName, relSpec] of Object.entries(withClause)) {
2352
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
2269
2353
  const relDef = meta.relations[relName];
2270
2354
  if (!relDef)
2271
2355
  continue;
@@ -2286,15 +2370,13 @@ class QueryInterface {
2286
2370
  // where params → limit param → nested-with params (always, both paths).
2287
2371
  if (relDef.type === 'manyToMany') {
2288
2372
  if (spec.where) {
2289
- for (const [, v] of Object.entries(spec.where)) {
2290
- params.push(v);
2291
- }
2373
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2292
2374
  }
2293
- if (spec.limit) {
2375
+ if (spec.limit !== undefined) {
2294
2376
  params.push(Number(spec.limit));
2295
2377
  }
2296
2378
  if (spec.with) {
2297
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2379
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2298
2380
  const nestedRelDef = targetMeta.relations[nestedRelName];
2299
2381
  if (!nestedRelDef)
2300
2382
  continue;
@@ -2303,31 +2385,32 @@ class QueryInterface {
2303
2385
  }
2304
2386
  return;
2305
2387
  }
2306
- const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2388
+ // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2389
+ const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2390
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2307
2391
  // Non-wrapped path: nested relations BEFORE where/limit
2308
2392
  if (!willWrap && spec.with) {
2309
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2393
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2310
2394
  const nestedRelDef = targetMeta.relations[nestedRelName];
2311
2395
  if (!nestedRelDef)
2312
2396
  continue;
2313
2397
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2314
2398
  }
2315
2399
  }
2316
- // where params
2400
+ // where params — mirrors buildAliasWhere push order
2317
2401
  if (spec.where) {
2318
- for (const [, v] of Object.entries(spec.where)) {
2319
- params.push(v);
2320
- }
2402
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2321
2403
  }
2322
2404
  // limit param — only hasMany parameterizes its limit (mirrors
2323
2405
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2324
2406
  // pushing one here would orphan a param and desync the collect path.
2325
- if (relDef.type === 'hasMany' && spec.limit) {
2407
+ // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2408
+ if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2326
2409
  params.push(Number(spec.limit));
2327
2410
  }
2328
2411
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
2329
2412
  if (willWrap && spec.with) {
2330
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2413
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2331
2414
  const nestedRelDef = targetMeta.relations[nestedRelName];
2332
2415
  if (!nestedRelDef)
2333
2416
  continue;
@@ -2409,7 +2492,8 @@ class QueryInterface {
2409
2492
  * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
2410
2493
  */
2411
2494
  buildWhereClause(where, params) {
2412
- const keys = Object.keys(where);
2495
+ // Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
2496
+ const keys = sortedKeys(where);
2413
2497
  if (keys.length === 0)
2414
2498
  return null;
2415
2499
  const andClauses = [];
@@ -2530,25 +2614,11 @@ class QueryInterface {
2530
2614
  andClauses.push(...opClauses);
2531
2615
  continue;
2532
2616
  }
2533
- // Strict validation: a plain (non-array, non-Date) object on a non-JSON
2534
- // column matched no known filter shape almost always a misspelled
2535
- // operator (`startWith` for `startsWith`) or a stray nested object.
2536
- // Silently treating it as `col = $1` returns wrong rows with no error, so
2537
- // throw with the offending keys and the supported operator list. JSON/JSONB
2538
- // columns legitimately accept object values for equality, so they fall
2539
- // through unchanged.
2540
- if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
2541
- const colType = this.getColumnPgType(rawColumn);
2542
- if (colType !== 'json' && colType !== 'jsonb') {
2543
- const badKeys = Object.keys(value);
2544
- throw new errors_js_1.ValidationError(badKeys.length === 0
2545
- ? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
2546
- `Provide a value or an operator like { gt: 1 }.`
2547
- : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2548
- `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
2549
- `Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
2550
- }
2551
- }
2617
+ // Strict validation: a plain object literal that matched no known filter
2618
+ // shape is almost always a misspelled operator (`startWith` for
2619
+ // `startsWith`). The guard also runs on the cache-hit param-collect path
2620
+ // (collectWhereParams) so a warmed SQL cache can never skip it.
2621
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2552
2622
  // Plain equality
2553
2623
  params.push(value);
2554
2624
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2630,7 +2700,9 @@ class QueryInterface {
2630
2700
  return null;
2631
2701
  const qt = this.q(targetTable);
2632
2702
  const conditions = [];
2633
- for (const [field, value] of Object.entries(subWhere)) {
2703
+ // Sorted (canonical) order MUST match fingerprintRelFilter and collectRelFilterParams.
2704
+ for (const field of sortedKeys(subWhere)) {
2705
+ const value = subWhere[field];
2634
2706
  if (value === undefined)
2635
2707
  continue;
2636
2708
  const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
@@ -2648,17 +2720,184 @@ class QueryInterface {
2648
2720
  conditions.push(...opClauses);
2649
2721
  continue;
2650
2722
  }
2723
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2651
2724
  params.push(value);
2652
2725
  conditions.push(`${qCol} = ${this.p(params.length)}`);
2653
2726
  }
2654
2727
  return conditions.length > 0 ? conditions.join(' AND ') : null;
2655
2728
  }
2729
+ /**
2730
+ * Resolve a column's Postgres type from an arbitrary table's metadata
2731
+ * (relation targets, not just `this.table`).
2732
+ */
2733
+ pgTypeForColumn(meta, column) {
2734
+ return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
2735
+ }
2736
+ /**
2737
+ * Equality-fallthrough guard shared by every SQL-build path AND every
2738
+ * cache-hit param-collect path. A plain object literal that matched no known
2739
+ * filter shape on a non-JSON column is almost always a misspelled operator
2740
+ * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
2741
+ * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
2742
+ * legitimate bind values and pass through, as do objects on json/jsonb
2743
+ * columns (object equality).
2744
+ */
2745
+ assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
2746
+ if (!isUnmatchedPlainObject(value))
2747
+ return;
2748
+ if (columnPgType === 'json' || columnPgType === 'jsonb')
2749
+ return;
2750
+ const badKeys = Object.keys(value);
2751
+ throw new errors_js_1.ValidationError(badKeys.length === 0
2752
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
2753
+ `Provide a value or an operator like { gt: 1 }.`
2754
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2755
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
2756
+ `Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
2757
+ }
2758
+ /**
2759
+ * Build the user-supplied `where` filter of a relation `with` clause against
2760
+ * the relation's table alias. Supports the same scalar surface as the
2761
+ * top-level WHERE builder — equality, IS NULL, operator objects (incl.
2762
+ * `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
2763
+ * objects throw via {@link assertBindableEqualityValue}.
2764
+ *
2765
+ * Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
2766
+ * cache hits and pipeline batching will desync.
2767
+ */
2768
+ buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2769
+ const clauses = [];
2770
+ // Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
2771
+ for (const key of sortedKeys(where)) {
2772
+ const value = where[key];
2773
+ if (value === undefined)
2774
+ continue;
2775
+ if (key === 'OR' || key === 'AND') {
2776
+ const arr = value;
2777
+ if (!Array.isArray(arr) || arr.length === 0)
2778
+ continue;
2779
+ const subs = arr
2780
+ .map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
2781
+ .filter((s) => s !== null)
2782
+ .map((s) => `(${s})`);
2783
+ if (subs.length > 0)
2784
+ clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
2785
+ continue;
2786
+ }
2787
+ if (key === 'NOT') {
2788
+ const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
2789
+ if (sub)
2790
+ clauses.push(`NOT (${sub})`);
2791
+ continue;
2792
+ }
2793
+ const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2794
+ if (!targetMeta.allColumns.includes(col)) {
2795
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
2796
+ }
2797
+ const qCol = `${alias}.${this.q(col)}`;
2798
+ if (value === null) {
2799
+ clauses.push(`${qCol} IS NULL`);
2800
+ continue;
2801
+ }
2802
+ if (isWhereOperator(value)) {
2803
+ clauses.push(...this.buildOperatorClauses(qCol, value, params));
2804
+ continue;
2805
+ }
2806
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2807
+ params.push(value);
2808
+ clauses.push(`${qCol} = ${this.p(params.length)}`);
2809
+ }
2810
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
2811
+ }
2812
+ /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2813
+ collectAliasWhereParams(targetTable, targetMeta, where, params) {
2814
+ // Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
2815
+ for (const key of sortedKeys(where)) {
2816
+ const value = where[key];
2817
+ if (value === undefined)
2818
+ continue;
2819
+ if (key === 'OR' || key === 'AND') {
2820
+ const arr = value;
2821
+ if (!Array.isArray(arr) || arr.length === 0)
2822
+ continue;
2823
+ for (const cond of arr) {
2824
+ this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
2825
+ }
2826
+ continue;
2827
+ }
2828
+ if (key === 'NOT') {
2829
+ this.collectAliasWhereParams(targetTable, targetMeta, value, params);
2830
+ continue;
2831
+ }
2832
+ if (value === null)
2833
+ continue;
2834
+ const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2835
+ if (isWhereOperator(value)) {
2836
+ this.collectOperatorParams(col, value, params);
2837
+ continue;
2838
+ }
2839
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2840
+ params.push(value);
2841
+ }
2842
+ }
2843
+ /**
2844
+ * Value-invariant, shape-aware fingerprint for a relation `with` clause's
2845
+ * `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
2846
+ * can emit — equality vs null vs operator sets vs combinators — or two
2847
+ * differently-shaped wheres would share one cached SQL string.
2848
+ */
2849
+ fingerprintAliasWhere(where) {
2850
+ const keys = Object.keys(where)
2851
+ .filter((k) => where[k] !== undefined)
2852
+ .sort();
2853
+ const parts = [];
2854
+ for (const key of keys) {
2855
+ const value = where[key];
2856
+ if (key === 'OR' || key === 'AND') {
2857
+ const arr = value;
2858
+ if (!Array.isArray(arr) || arr.length === 0)
2859
+ continue;
2860
+ parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
2861
+ continue;
2862
+ }
2863
+ if (key === 'NOT') {
2864
+ parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
2865
+ continue;
2866
+ }
2867
+ if (value === null) {
2868
+ parts.push(`${key}:null`);
2869
+ continue;
2870
+ }
2871
+ if (isWhereOperator(value)) {
2872
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2873
+ continue;
2874
+ }
2875
+ if (isUnmatchedPlainObject(value)) {
2876
+ parts.push(`${key}:obj(${Object.keys(value)
2877
+ .sort()
2878
+ .join(',')})`);
2879
+ continue;
2880
+ }
2881
+ parts.push(`${key}:eq`);
2882
+ }
2883
+ return parts.join('&');
2884
+ }
2656
2885
  /**
2657
2886
  * Build SQL clauses for a single operator object on a column.
2658
2887
  * Each operator key becomes its own clause, all ANDed together.
2659
2888
  */
2660
2889
  buildOperatorClauses(column, op, params) {
2661
2890
  const clauses = [];
2891
+ if (op.equals !== undefined) {
2892
+ if (op.equals === null) {
2893
+ clauses.push(`${column} IS NULL`);
2894
+ }
2895
+ else {
2896
+ assertBindableEqualsOperand(op.equals, column);
2897
+ params.push(op.equals);
2898
+ clauses.push(`${column} = ${this.p(params.length)}`);
2899
+ }
2900
+ }
2662
2901
  if (op.gt !== undefined) {
2663
2902
  params.push(op.gt);
2664
2903
  clauses.push(`${column} > ${this.p(params.length)}`);
@@ -2957,7 +3196,7 @@ class QueryInterface {
2957
3196
  const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
2958
3197
  const relationSelects = [];
2959
3198
  const aliasCounter = { n: 0 };
2960
- for (const [relName, relSpec] of Object.entries(withClause)) {
3199
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
2961
3200
  const relDef = meta.relations[relName];
2962
3201
  if (!relDef) {
2963
3202
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3099,7 +3338,11 @@ class QueryInterface {
3099
3338
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
3100
3339
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
3101
3340
  // so we must NOT build them here (they would push orphaned params).
3102
- const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3341
+ // An orderBy with no defined entries (`orderBy: {}`) is treated as absent
3342
+ // it must neither trigger the wrap (dropping nested relations) nor render a
3343
+ // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
3344
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3345
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
3103
3346
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3104
3347
  // where, orderBy, and select/omit are handled there (the target alias is the
3105
3348
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -3108,7 +3351,7 @@ class QueryInterface {
3108
3351
  }
3109
3352
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
3110
3353
  if (!willWrap && spec !== true && spec.with) {
3111
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3354
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3112
3355
  const nestedRelDef = targetMeta.relations[nestedRelName];
3113
3356
  if (!nestedRelDef) {
3114
3357
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3127,14 +3370,14 @@ class QueryInterface {
3127
3370
  const qTarget = this.q(targetTable);
3128
3371
  // Build ORDER BY for json_agg
3129
3372
  let orderClause = '';
3130
- if (spec !== true && spec.orderBy) {
3131
- const orders = Object.entries(spec.orderBy)
3373
+ if (relOrderEntries.length > 0) {
3374
+ const orders = relOrderEntries
3132
3375
  .map(([k, dir]) => {
3133
3376
  const col = (0, schema_js_1.camelToSnake)(k);
3134
3377
  if (!targetMeta.allColumns.includes(col)) {
3135
3378
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3136
3379
  }
3137
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3380
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3138
3381
  return `${alias}.${this.q(col)} ${safeDir}`;
3139
3382
  })
3140
3383
  .join(', ');
@@ -3151,24 +3394,21 @@ class QueryInterface {
3151
3394
  else {
3152
3395
  whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3153
3396
  }
3154
- // Additional filters — properly parameterized
3397
+ // Additional filters — full scalar where surface (equality, null, operator
3398
+ // objects, OR/AND/NOT), properly parameterized against this alias.
3155
3399
  if (spec !== true && spec.where) {
3156
- for (const [k, v] of Object.entries(spec.where)) {
3157
- const col = (0, schema_js_1.camelToSnake)(k);
3158
- if (!targetMeta.allColumns.includes(col)) {
3159
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3160
- }
3161
- params.push(v);
3162
- whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
3163
- }
3400
+ const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
3401
+ if (extra)
3402
+ whereClause += ` AND ${extra}`;
3164
3403
  }
3165
3404
  // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3166
3405
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3167
3406
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3168
3407
  // which Postgres rejects with "could not determine data type of parameter $N"
3169
3408
  // (and shifts every later placeholder by one). To-one relations ignore limit.
3409
+ // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3170
3410
  let limitClause = '';
3171
- if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
3411
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3172
3412
  params.push(Number(spec.limit));
3173
3413
  limitClause = ` LIMIT ${this.p(params.length)}`;
3174
3414
  }
@@ -3187,7 +3427,7 @@ class QueryInterface {
3187
3427
  ]);
3188
3428
  // Build nested relation subqueries referencing innerAlias
3189
3429
  if (spec !== true && spec.with) {
3190
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3430
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3191
3431
  const nestedRelDef = targetMeta.relations[nestedRelName];
3192
3432
  if (!nestedRelDef) {
3193
3433
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3262,35 +3502,33 @@ class QueryInterface {
3262
3502
  let whereClause = sourceKeys
3263
3503
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3264
3504
  .join(' AND ');
3265
- // ORDER BY on the target rows
3505
+ // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
3506
+ // treated as absent — it must not render a dangling `ORDER BY `.
3507
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3266
3508
  let orderClause = '';
3267
- if (spec !== true && spec.orderBy) {
3268
- const orders = Object.entries(spec.orderBy)
3509
+ if (relOrderEntries.length > 0) {
3510
+ const orders = relOrderEntries
3269
3511
  .map(([k, dir]) => {
3270
3512
  const col = (0, schema_js_1.camelToSnake)(k);
3271
3513
  if (!targetMeta.allColumns.includes(col)) {
3272
3514
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3273
3515
  }
3274
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3516
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3275
3517
  return `${talias}.${this.q(col)} ${safeDir}`;
3276
3518
  })
3277
3519
  .join(', ');
3278
3520
  orderClause = ` ORDER BY ${orders}`;
3279
3521
  }
3280
- // Additional WHERE filters on the target — properly parameterized.
3522
+ // Additional WHERE filters on the target — full scalar where surface,
3523
+ // properly parameterized against the target alias.
3281
3524
  if (spec !== true && spec.where) {
3282
- for (const [k, v] of Object.entries(spec.where)) {
3283
- const col = (0, schema_js_1.camelToSnake)(k);
3284
- if (!targetMeta.allColumns.includes(col)) {
3285
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3286
- }
3287
- params.push(v);
3288
- whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3289
- }
3525
+ const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
3526
+ if (extra)
3527
+ whereClause += ` AND ${extra}`;
3290
3528
  }
3291
- // LIMIT
3529
+ // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3292
3530
  let limitClause = '';
3293
- if (spec !== true && spec.limit) {
3531
+ if (spec !== true && spec.limit !== undefined) {
3294
3532
  params.push(Number(spec.limit));
3295
3533
  limitClause = ` LIMIT ${this.p(params.length)}`;
3296
3534
  }
@@ -3307,7 +3545,7 @@ class QueryInterface {
3307
3545
  ]);
3308
3546
  // Nested relations reference the inner alias.
3309
3547
  if (spec !== true && spec.with) {
3310
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3548
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3311
3549
  const nestedRelDef = targetMeta.relations[nestedRelName];
3312
3550
  if (!nestedRelDef) {
3313
3551
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3330,7 +3568,7 @@ class QueryInterface {
3330
3568
  `${talias}.${this.q(col)}`,
3331
3569
  ]);
3332
3570
  if (spec !== true && spec.with) {
3333
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3571
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3334
3572
  const nestedRelDef = targetMeta.relations[nestedRelName];
3335
3573
  if (!nestedRelDef) {
3336
3574
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +