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.
- package/README.md +8 -8
- package/dist/adapters/index.d.ts +3 -2
- package/dist/cjs/cli/index.js +26 -4
- package/dist/cjs/cli/loader.js +62 -7
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +54 -76
- package/dist/cjs/client.js +8 -0
- package/dist/cjs/query/builder.js +261 -51
- package/dist/cli/index.js +28 -6
- package/dist/cli/loader.d.ts +22 -5
- package/dist/cli/loader.js +61 -7
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +9 -4
- package/dist/cli/studio.js +54 -76
- package/dist/client.js +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/query/builder.d.ts +35 -0
- package/dist/query/builder.js +261 -51
- package/dist/query/index.d.ts +1 -1
- package/package.json +3 -3
- package/dist/cjs/query.js +0 -2711
- package/dist/query.d.ts +0 -878
- package/dist/query.js +0 -2705
package/dist/query/builder.js
CHANGED
|
@@ -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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2271
|
-
params.push(v);
|
|
2272
|
-
}
|
|
2313
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2273
2314
|
}
|
|
2274
|
-
// limit param
|
|
2275
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
3061
|
-
const orders =
|
|
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 —
|
|
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
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
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 (
|
|
3194
|
-
const orders =
|
|
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 —
|
|
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
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
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
|
}
|
package/dist/query/index.d.ts
CHANGED
|
@@ -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.
|
|
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/",
|