turbine-orm 0.18.0 → 0.19.1

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,20 @@ 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
+ }
33
47
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
34
48
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
35
49
  /** Known JSONB operator keys */
@@ -1687,12 +1701,24 @@ export class QueryInterface {
1687
1701
  */
1688
1702
  resolveColumns(select, omit) {
1689
1703
  if (select) {
1704
+ // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
1705
+ // style) instead of the object shape. Object.entries() would iterate the
1706
+ // numeric indices and throw a cryptic `Unknown field "0"` — catch it early
1707
+ // with an actionable message.
1708
+ if (Array.isArray(select)) {
1709
+ throw new ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
1710
+ `(e.g. { id: true, name: true }), not an array.`);
1711
+ }
1690
1712
  // Only include columns where value is true
1691
1713
  return Object.entries(select)
1692
1714
  .filter(([, v]) => v)
1693
1715
  .map(([k]) => this.toColumn(k));
1694
1716
  }
1695
1717
  if (omit) {
1718
+ if (Array.isArray(omit)) {
1719
+ throw new ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
1720
+ `(e.g. { createdAt: true }), not an array.`);
1721
+ }
1696
1722
  // Include all columns except those where value is true
1697
1723
  const omitCols = new Set(Object.entries(omit)
1698
1724
  .filter(([, v]) => v)
@@ -1896,6 +1922,16 @@ export class QueryInterface {
1896
1922
  parts.push(`${key}:fts(${cfg})`);
1897
1923
  continue;
1898
1924
  }
1925
+ // Plain object literal that matched no filter shape — give it a
1926
+ // fingerprint distinct from real equality. The build path throws for
1927
+ // these on non-JSON columns; sharing `key:eq` would let a cache entry
1928
+ // warmed by genuine equality serve the bad filter silently.
1929
+ if (isUnmatchedPlainObject(value)) {
1930
+ parts.push(`${key}:obj(${Object.keys(value)
1931
+ .sort()
1932
+ .join(',')})`);
1933
+ continue;
1934
+ }
1899
1935
  // Plain equality
1900
1936
  parts.push(`${key}:eq`);
1901
1937
  }
@@ -1935,6 +1971,11 @@ export class QueryInterface {
1935
1971
  const modeStr = mode === 'insensitive' ? ':i' : '';
1936
1972
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1937
1973
  }
1974
+ else if (isUnmatchedPlainObject(value)) {
1975
+ parts.push(`${key}:obj(${Object.keys(value)
1976
+ .sort()
1977
+ .join(',')})`);
1978
+ }
1938
1979
  else {
1939
1980
  parts.push(`${key}:eq`);
1940
1981
  }
@@ -2037,7 +2078,9 @@ export class QueryInterface {
2037
2078
  this.collectOperatorParams(value, params);
2038
2079
  continue;
2039
2080
  }
2040
- // Plain equality
2081
+ // Plain equality — same strict validation as the build path, so a
2082
+ // cache hit can never silently bind a misspelled-operator object.
2083
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2041
2084
  params.push(value);
2042
2085
  }
2043
2086
  }
@@ -2046,7 +2089,7 @@ export class QueryInterface {
2046
2089
  const meta = this.schema.tables[targetTable];
2047
2090
  if (!meta)
2048
2091
  return;
2049
- for (const [_field, value] of Object.entries(subWhere)) {
2092
+ for (const [field, value] of Object.entries(subWhere)) {
2050
2093
  if (value === undefined)
2051
2094
  continue;
2052
2095
  if (value === null)
@@ -2055,6 +2098,8 @@ export class QueryInterface {
2055
2098
  this.collectOperatorParams(value, params);
2056
2099
  continue;
2057
2100
  }
2101
+ const col = meta.columnMap[field] ?? camelToSnake(field);
2102
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2058
2103
  params.push(value);
2059
2104
  }
2060
2105
  }
@@ -2182,13 +2227,11 @@ export class QueryInterface {
2182
2227
  .sort();
2183
2228
  subParts.push(`om=${omKeys.join(',')}`);
2184
2229
  }
2185
- // where shape (value-invariant)
2230
+ // where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
2231
+ // `{title: {contains: 'x'}}` emit different SQL so they must not share
2232
+ // a fingerprint)
2186
2233
  if (opts.where) {
2187
- // Use a target-table QI if possible, or a simplified fingerprint
2188
- const wKeys = Object.keys(opts.where)
2189
- .filter((k) => opts.where[k] !== undefined)
2190
- .sort();
2191
- subParts.push(`w=${wKeys.join(',')}`);
2234
+ subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
2192
2235
  }
2193
2236
  // orderBy shape
2194
2237
  if (opts.orderBy) {
@@ -2238,11 +2281,9 @@ export class QueryInterface {
2238
2281
  // where params → limit param → nested-with params (always, both paths).
2239
2282
  if (relDef.type === 'manyToMany') {
2240
2283
  if (spec.where) {
2241
- for (const [, v] of Object.entries(spec.where)) {
2242
- params.push(v);
2243
- }
2284
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2244
2285
  }
2245
- if (spec.limit) {
2286
+ if (spec.limit !== undefined) {
2246
2287
  params.push(Number(spec.limit));
2247
2288
  }
2248
2289
  if (spec.with) {
@@ -2255,7 +2296,9 @@ export class QueryInterface {
2255
2296
  }
2256
2297
  return;
2257
2298
  }
2258
- const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2299
+ // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2300
+ const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2301
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2259
2302
  // Non-wrapped path: nested relations BEFORE where/limit
2260
2303
  if (!willWrap && spec.with) {
2261
2304
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2265,14 +2308,15 @@ export class QueryInterface {
2265
2308
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2266
2309
  }
2267
2310
  }
2268
- // where params
2311
+ // where params — mirrors buildAliasWhere push order
2269
2312
  if (spec.where) {
2270
- for (const [, v] of Object.entries(spec.where)) {
2271
- params.push(v);
2272
- }
2313
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2273
2314
  }
2274
- // limit param
2275
- if (spec.limit) {
2315
+ // limit param — only hasMany parameterizes its limit (mirrors
2316
+ // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2317
+ // pushing one here would orphan a param and desync the collect path.
2318
+ // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2319
+ if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2276
2320
  params.push(Number(spec.limit));
2277
2321
  }
2278
2322
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2480,6 +2524,11 @@ export class QueryInterface {
2480
2524
  andClauses.push(...opClauses);
2481
2525
  continue;
2482
2526
  }
2527
+ // Strict validation: a plain object literal that matched no known filter
2528
+ // shape is almost always a misspelled operator (`startWith` for
2529
+ // `startsWith`). The guard also runs on the cache-hit param-collect path
2530
+ // (collectWhereParams) so a warmed SQL cache can never skip it.
2531
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2483
2532
  // Plain equality
2484
2533
  params.push(value);
2485
2534
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2579,11 +2628,168 @@ export class QueryInterface {
2579
2628
  conditions.push(...opClauses);
2580
2629
  continue;
2581
2630
  }
2631
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2582
2632
  params.push(value);
2583
2633
  conditions.push(`${qCol} = ${this.p(params.length)}`);
2584
2634
  }
2585
2635
  return conditions.length > 0 ? conditions.join(' AND ') : null;
2586
2636
  }
2637
+ /**
2638
+ * Resolve a column's Postgres type from an arbitrary table's metadata
2639
+ * (relation targets, not just `this.table`).
2640
+ */
2641
+ pgTypeForColumn(meta, column) {
2642
+ return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
2643
+ }
2644
+ /**
2645
+ * Equality-fallthrough guard shared by every SQL-build path AND every
2646
+ * cache-hit param-collect path. A plain object literal that matched no known
2647
+ * filter shape on a non-JSON column is almost always a misspelled operator
2648
+ * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
2649
+ * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
2650
+ * legitimate bind values and pass through, as do objects on json/jsonb
2651
+ * columns (object equality).
2652
+ */
2653
+ assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
2654
+ if (!isUnmatchedPlainObject(value))
2655
+ return;
2656
+ if (columnPgType === 'json' || columnPgType === 'jsonb')
2657
+ return;
2658
+ const badKeys = Object.keys(value);
2659
+ throw new ValidationError(badKeys.length === 0
2660
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
2661
+ `Provide a value or an operator like { gt: 1 }.`
2662
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2663
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
2664
+ `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
2665
+ }
2666
+ /**
2667
+ * Build the user-supplied `where` filter of a relation `with` clause against
2668
+ * the relation's table alias. Supports the same scalar surface as the
2669
+ * top-level WHERE builder — equality, IS NULL, operator objects (incl.
2670
+ * `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
2671
+ * objects throw via {@link assertBindableEqualityValue}.
2672
+ *
2673
+ * Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
2674
+ * cache hits and pipeline batching will desync.
2675
+ */
2676
+ buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2677
+ const clauses = [];
2678
+ for (const [key, value] of Object.entries(where)) {
2679
+ if (value === undefined)
2680
+ continue;
2681
+ if (key === 'OR' || key === 'AND') {
2682
+ const arr = value;
2683
+ if (!Array.isArray(arr) || arr.length === 0)
2684
+ continue;
2685
+ const subs = arr
2686
+ .map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
2687
+ .filter((s) => s !== null)
2688
+ .map((s) => `(${s})`);
2689
+ if (subs.length > 0)
2690
+ clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
2691
+ continue;
2692
+ }
2693
+ if (key === 'NOT') {
2694
+ const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
2695
+ if (sub)
2696
+ clauses.push(`NOT (${sub})`);
2697
+ continue;
2698
+ }
2699
+ const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2700
+ if (!targetMeta.allColumns.includes(col)) {
2701
+ throw new ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
2702
+ }
2703
+ const qCol = `${alias}.${this.q(col)}`;
2704
+ if (value === null) {
2705
+ clauses.push(`${qCol} IS NULL`);
2706
+ continue;
2707
+ }
2708
+ if (isWhereOperator(value)) {
2709
+ clauses.push(...this.buildOperatorClauses(qCol, value, params));
2710
+ continue;
2711
+ }
2712
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2713
+ params.push(value);
2714
+ clauses.push(`${qCol} = ${this.p(params.length)}`);
2715
+ }
2716
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
2717
+ }
2718
+ /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2719
+ collectAliasWhereParams(targetTable, targetMeta, where, params) {
2720
+ for (const [key, value] of Object.entries(where)) {
2721
+ if (value === undefined)
2722
+ continue;
2723
+ if (key === 'OR' || key === 'AND') {
2724
+ const arr = value;
2725
+ if (!Array.isArray(arr) || arr.length === 0)
2726
+ continue;
2727
+ for (const cond of arr) {
2728
+ this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
2729
+ }
2730
+ continue;
2731
+ }
2732
+ if (key === 'NOT') {
2733
+ this.collectAliasWhereParams(targetTable, targetMeta, value, params);
2734
+ continue;
2735
+ }
2736
+ if (value === null)
2737
+ continue;
2738
+ if (isWhereOperator(value)) {
2739
+ this.collectOperatorParams(value, params);
2740
+ continue;
2741
+ }
2742
+ const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2743
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2744
+ params.push(value);
2745
+ }
2746
+ }
2747
+ /**
2748
+ * Value-invariant, shape-aware fingerprint for a relation `with` clause's
2749
+ * `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
2750
+ * can emit — equality vs null vs operator sets vs combinators — or two
2751
+ * differently-shaped wheres would share one cached SQL string.
2752
+ */
2753
+ fingerprintAliasWhere(where) {
2754
+ const keys = Object.keys(where)
2755
+ .filter((k) => where[k] !== undefined)
2756
+ .sort();
2757
+ const parts = [];
2758
+ for (const key of keys) {
2759
+ const value = where[key];
2760
+ if (key === 'OR' || key === 'AND') {
2761
+ const arr = value;
2762
+ if (!Array.isArray(arr) || arr.length === 0)
2763
+ continue;
2764
+ parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
2765
+ continue;
2766
+ }
2767
+ if (key === 'NOT') {
2768
+ parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
2769
+ continue;
2770
+ }
2771
+ if (value === null) {
2772
+ parts.push(`${key}:null`);
2773
+ continue;
2774
+ }
2775
+ 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' : ''})`);
2781
+ continue;
2782
+ }
2783
+ if (isUnmatchedPlainObject(value)) {
2784
+ parts.push(`${key}:obj(${Object.keys(value)
2785
+ .sort()
2786
+ .join(',')})`);
2787
+ continue;
2788
+ }
2789
+ parts.push(`${key}:eq`);
2790
+ }
2791
+ return parts.join('&');
2792
+ }
2587
2793
  /**
2588
2794
  * Build SQL clauses for a single operator object on a column.
2589
2795
  * Each operator key becomes its own clause, all ANDed together.
@@ -2663,7 +2869,8 @@ export class QueryInterface {
2663
2869
  return Object.entries(orderBy)
2664
2870
  .map(([key, dir]) => {
2665
2871
  if (meta && !(key in meta.columnMap)) {
2666
- throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2872
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
2873
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
2667
2874
  }
2668
2875
  // Vector KNN ordering: { distance: { to, metric, direction? } }
2669
2876
  if (isVectorOrderBy(dir)) {
@@ -3029,7 +3236,11 @@ export class QueryInterface {
3029
3236
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
3030
3237
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
3031
3238
  // so we must NOT build them here (they would push orphaned params).
3032
- const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3239
+ // An orderBy with no defined entries (`orderBy: {}`) is treated as absent
3240
+ // it must neither trigger the wrap (dropping nested relations) nor render a
3241
+ // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
3242
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3243
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
3033
3244
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3034
3245
  // where, orderBy, and select/omit are handled there (the target alias is the
3035
3246
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -3057,14 +3268,14 @@ export class QueryInterface {
3057
3268
  const qTarget = this.q(targetTable);
3058
3269
  // Build ORDER BY for json_agg
3059
3270
  let orderClause = '';
3060
- if (spec !== true && spec.orderBy) {
3061
- const orders = Object.entries(spec.orderBy)
3271
+ if (relOrderEntries.length > 0) {
3272
+ const orders = relOrderEntries
3062
3273
  .map(([k, dir]) => {
3063
3274
  const col = camelToSnake(k);
3064
3275
  if (!targetMeta.allColumns.includes(col)) {
3065
3276
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3066
3277
  }
3067
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3278
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3068
3279
  return `${alias}.${this.q(col)} ${safeDir}`;
3069
3280
  })
3070
3281
  .join(', ');
@@ -3081,20 +3292,21 @@ export class QueryInterface {
3081
3292
  else {
3082
3293
  whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3083
3294
  }
3084
- // Additional filters — properly parameterized
3295
+ // Additional filters — full scalar where surface (equality, null, operator
3296
+ // objects, OR/AND/NOT), properly parameterized against this alias.
3085
3297
  if (spec !== true && spec.where) {
3086
- for (const [k, v] of Object.entries(spec.where)) {
3087
- const col = camelToSnake(k);
3088
- if (!targetMeta.allColumns.includes(col)) {
3089
- throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3090
- }
3091
- params.push(v);
3092
- whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
3093
- }
3094
- }
3095
- // LIMIT
3298
+ const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
3299
+ if (extra)
3300
+ whereClause += ` AND ${extra}`;
3301
+ }
3302
+ // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3303
+ // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3304
+ // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3305
+ // which Postgres rejects with "could not determine data type of parameter $N"
3306
+ // (and shifts every later placeholder by one). To-one relations ignore limit.
3307
+ // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3096
3308
  let limitClause = '';
3097
- if (spec !== true && spec.limit) {
3309
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3098
3310
  params.push(Number(spec.limit));
3099
3311
  limitClause = ` LIMIT ${this.p(params.length)}`;
3100
3312
  }
@@ -3188,35 +3400,33 @@ export class QueryInterface {
3188
3400
  let whereClause = sourceKeys
3189
3401
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3190
3402
  .join(' AND ');
3191
- // ORDER BY on the target rows
3403
+ // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
3404
+ // treated as absent — it must not render a dangling `ORDER BY `.
3405
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3192
3406
  let orderClause = '';
3193
- if (spec !== true && spec.orderBy) {
3194
- const orders = Object.entries(spec.orderBy)
3407
+ if (relOrderEntries.length > 0) {
3408
+ const orders = relOrderEntries
3195
3409
  .map(([k, dir]) => {
3196
3410
  const col = camelToSnake(k);
3197
3411
  if (!targetMeta.allColumns.includes(col)) {
3198
3412
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3199
3413
  }
3200
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3414
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3201
3415
  return `${talias}.${this.q(col)} ${safeDir}`;
3202
3416
  })
3203
3417
  .join(', ');
3204
3418
  orderClause = ` ORDER BY ${orders}`;
3205
3419
  }
3206
- // Additional WHERE filters on the target — properly parameterized.
3420
+ // Additional WHERE filters on the target — full scalar where surface,
3421
+ // properly parameterized against the target alias.
3207
3422
  if (spec !== true && spec.where) {
3208
- for (const [k, v] of Object.entries(spec.where)) {
3209
- const col = camelToSnake(k);
3210
- if (!targetMeta.allColumns.includes(col)) {
3211
- throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3212
- }
3213
- params.push(v);
3214
- whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3215
- }
3423
+ const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
3424
+ if (extra)
3425
+ whereClause += ` AND ${extra}`;
3216
3426
  }
3217
- // LIMIT
3427
+ // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3218
3428
  let limitClause = '';
3219
- if (spec !== true && spec.limit) {
3429
+ if (spec !== true && spec.limit !== undefined) {
3220
3430
  params.push(Number(spec.limit));
3221
3431
  limitClause = ` LIMIT ${this.p(params.length)}`;
3222
3432
  }
@@ -5,7 +5,7 @@
5
5
  * `import { … } from './query/index.js'` is a drop-in replacement for the
6
6
  * former monolithic `import { … } from './query.js'`.
7
7
  */
8
- export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, HavingClause, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
9
9
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { SqlCacheEntry } from './utils.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {
@@ -42,7 +42,7 @@
42
42
  "sideEffects": false,
43
43
  "scripts": {
44
44
  "prebuild": "npm run gen:studio",
45
- "build": "tsc && tsc --project tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
45
+ "build": "rm -rf dist && tsc && tsc --project tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
46
46
  "dev": "tsc --watch",
47
47
  "typecheck": "tsc --noEmit --project tsconfig.test.json",
48
48
  "generate": "tsx src/cli/index.ts generate",
@@ -50,7 +50,7 @@
50
50
  "examples": "tsx examples/examples.ts",
51
51
  "dogfood": "tsx examples/dogfood.ts",
52
52
  "test": "tsx --test --test-concurrency=1 src/test/*.test.ts",
53
- "test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts src/test/cockroachdb-adapter.test.ts src/test/yugabytedb-adapter.test.ts src/test/pg-compat.test.ts src/test/relation-filter-validation.test.ts src/test/client-coverage.test.ts src/test/schema-diff.test.ts src/test/composite-fk.test.ts src/test/retry.test.ts src/test/text-search.test.ts src/test/optimistic-lock.test.ts src/test/sql-safety-property.test.ts src/test/nested-write.test.ts src/test/nested-write-update-upsert.test.ts src/test/cursor-pagination.test.ts src/test/client-branches.test.ts src/test/is-isNot-filter.test.ts src/test/event-emitter.test.ts src/test/observe.test.ts",
53
+ "test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts src/test/cockroachdb-adapter.test.ts src/test/yugabytedb-adapter.test.ts src/test/pg-compat.test.ts src/test/relation-filter-validation.test.ts src/test/client-coverage.test.ts src/test/schema-diff.test.ts src/test/composite-fk.test.ts src/test/retry.test.ts src/test/text-search.test.ts src/test/optimistic-lock.test.ts src/test/sql-safety-property.test.ts src/test/nested-write.test.ts src/test/nested-write-update-upsert.test.ts src/test/cursor-pagination.test.ts src/test/client-branches.test.ts src/test/is-isNot-filter.test.ts src/test/event-emitter.test.ts src/test/observe.test.ts src/test/relation-limit-param.test.ts src/test/where-guard-cache-and-relation-where.test.ts",
54
54
  "test:coverage": "c8 tsx --test --test-concurrency=1 src/test/*.test.ts",
55
55
  "lint": "biome check src/",
56
56
  "lint:fix": "biome check --write src/",