turbine-orm 0.19.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.js +25 -5
- package/dist/cjs/client.js +8 -0
- package/dist/cjs/query/builder.js +238 -66
- 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.d.ts +9 -4
- package/dist/cli/studio.js +25 -5
- 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 +238 -66
- 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 */
|
|
@@ -1908,6 +1922,16 @@ export class QueryInterface {
|
|
|
1908
1922
|
parts.push(`${key}:fts(${cfg})`);
|
|
1909
1923
|
continue;
|
|
1910
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
|
+
}
|
|
1911
1935
|
// Plain equality
|
|
1912
1936
|
parts.push(`${key}:eq`);
|
|
1913
1937
|
}
|
|
@@ -1947,6 +1971,11 @@ export class QueryInterface {
|
|
|
1947
1971
|
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1948
1972
|
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1949
1973
|
}
|
|
1974
|
+
else if (isUnmatchedPlainObject(value)) {
|
|
1975
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
1976
|
+
.sort()
|
|
1977
|
+
.join(',')})`);
|
|
1978
|
+
}
|
|
1950
1979
|
else {
|
|
1951
1980
|
parts.push(`${key}:eq`);
|
|
1952
1981
|
}
|
|
@@ -2049,7 +2078,9 @@ export class QueryInterface {
|
|
|
2049
2078
|
this.collectOperatorParams(value, params);
|
|
2050
2079
|
continue;
|
|
2051
2080
|
}
|
|
2052
|
-
// 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);
|
|
2053
2084
|
params.push(value);
|
|
2054
2085
|
}
|
|
2055
2086
|
}
|
|
@@ -2058,7 +2089,7 @@ export class QueryInterface {
|
|
|
2058
2089
|
const meta = this.schema.tables[targetTable];
|
|
2059
2090
|
if (!meta)
|
|
2060
2091
|
return;
|
|
2061
|
-
for (const [
|
|
2092
|
+
for (const [field, value] of Object.entries(subWhere)) {
|
|
2062
2093
|
if (value === undefined)
|
|
2063
2094
|
continue;
|
|
2064
2095
|
if (value === null)
|
|
@@ -2067,6 +2098,8 @@ export class QueryInterface {
|
|
|
2067
2098
|
this.collectOperatorParams(value, params);
|
|
2068
2099
|
continue;
|
|
2069
2100
|
}
|
|
2101
|
+
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2102
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2070
2103
|
params.push(value);
|
|
2071
2104
|
}
|
|
2072
2105
|
}
|
|
@@ -2194,13 +2227,11 @@ export class QueryInterface {
|
|
|
2194
2227
|
.sort();
|
|
2195
2228
|
subParts.push(`om=${omKeys.join(',')}`);
|
|
2196
2229
|
}
|
|
2197
|
-
// 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)
|
|
2198
2233
|
if (opts.where) {
|
|
2199
|
-
|
|
2200
|
-
const wKeys = Object.keys(opts.where)
|
|
2201
|
-
.filter((k) => opts.where[k] !== undefined)
|
|
2202
|
-
.sort();
|
|
2203
|
-
subParts.push(`w=${wKeys.join(',')}`);
|
|
2234
|
+
subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
|
|
2204
2235
|
}
|
|
2205
2236
|
// orderBy shape
|
|
2206
2237
|
if (opts.orderBy) {
|
|
@@ -2250,11 +2281,9 @@ export class QueryInterface {
|
|
|
2250
2281
|
// where params → limit param → nested-with params (always, both paths).
|
|
2251
2282
|
if (relDef.type === 'manyToMany') {
|
|
2252
2283
|
if (spec.where) {
|
|
2253
|
-
|
|
2254
|
-
params.push(v);
|
|
2255
|
-
}
|
|
2284
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2256
2285
|
}
|
|
2257
|
-
if (spec.limit) {
|
|
2286
|
+
if (spec.limit !== undefined) {
|
|
2258
2287
|
params.push(Number(spec.limit));
|
|
2259
2288
|
}
|
|
2260
2289
|
if (spec.with) {
|
|
@@ -2267,7 +2296,9 @@ export class QueryInterface {
|
|
|
2267
2296
|
}
|
|
2268
2297
|
return;
|
|
2269
2298
|
}
|
|
2270
|
-
|
|
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);
|
|
2271
2302
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2272
2303
|
if (!willWrap && spec.with) {
|
|
2273
2304
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -2277,16 +2308,15 @@ export class QueryInterface {
|
|
|
2277
2308
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2278
2309
|
}
|
|
2279
2310
|
}
|
|
2280
|
-
// where params
|
|
2311
|
+
// where params — mirrors buildAliasWhere push order
|
|
2281
2312
|
if (spec.where) {
|
|
2282
|
-
|
|
2283
|
-
params.push(v);
|
|
2284
|
-
}
|
|
2313
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2285
2314
|
}
|
|
2286
2315
|
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2287
2316
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2288
2317
|
// pushing one here would orphan a param and desync the collect path.
|
|
2289
|
-
|
|
2318
|
+
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
2319
|
+
if (relDef.type === 'hasMany' && spec.limit !== undefined) {
|
|
2290
2320
|
params.push(Number(spec.limit));
|
|
2291
2321
|
}
|
|
2292
2322
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
@@ -2494,25 +2524,11 @@ export class QueryInterface {
|
|
|
2494
2524
|
andClauses.push(...opClauses);
|
|
2495
2525
|
continue;
|
|
2496
2526
|
}
|
|
2497
|
-
// Strict validation: a plain
|
|
2498
|
-
//
|
|
2499
|
-
//
|
|
2500
|
-
//
|
|
2501
|
-
|
|
2502
|
-
// columns legitimately accept object values for equality, so they fall
|
|
2503
|
-
// through unchanged.
|
|
2504
|
-
if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
2505
|
-
const colType = this.getColumnPgType(rawColumn);
|
|
2506
|
-
if (colType !== 'json' && colType !== 'jsonb') {
|
|
2507
|
-
const badKeys = Object.keys(value);
|
|
2508
|
-
throw new ValidationError(badKeys.length === 0
|
|
2509
|
-
? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
|
|
2510
|
-
`Provide a value or an operator like { gt: 1 }.`
|
|
2511
|
-
: `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
|
|
2512
|
-
`${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
|
|
2513
|
-
`Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
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);
|
|
2516
2532
|
// Plain equality
|
|
2517
2533
|
params.push(value);
|
|
2518
2534
|
andClauses.push(`${column} = ${this.p(params.length)}`);
|
|
@@ -2612,11 +2628,168 @@ export class QueryInterface {
|
|
|
2612
2628
|
conditions.push(...opClauses);
|
|
2613
2629
|
continue;
|
|
2614
2630
|
}
|
|
2631
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2615
2632
|
params.push(value);
|
|
2616
2633
|
conditions.push(`${qCol} = ${this.p(params.length)}`);
|
|
2617
2634
|
}
|
|
2618
2635
|
return conditions.length > 0 ? conditions.join(' AND ') : null;
|
|
2619
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
|
+
}
|
|
2620
2793
|
/**
|
|
2621
2794
|
* Build SQL clauses for a single operator object on a column.
|
|
2622
2795
|
* Each operator key becomes its own clause, all ANDed together.
|
|
@@ -3063,7 +3236,11 @@ export class QueryInterface {
|
|
|
3063
3236
|
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
3064
3237
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
3065
3238
|
// so we must NOT build them here (they would push orphaned params).
|
|
3066
|
-
|
|
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);
|
|
3067
3244
|
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3068
3245
|
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3069
3246
|
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
@@ -3091,14 +3268,14 @@ export class QueryInterface {
|
|
|
3091
3268
|
const qTarget = this.q(targetTable);
|
|
3092
3269
|
// Build ORDER BY for json_agg
|
|
3093
3270
|
let orderClause = '';
|
|
3094
|
-
if (
|
|
3095
|
-
const orders =
|
|
3271
|
+
if (relOrderEntries.length > 0) {
|
|
3272
|
+
const orders = relOrderEntries
|
|
3096
3273
|
.map(([k, dir]) => {
|
|
3097
3274
|
const col = camelToSnake(k);
|
|
3098
3275
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3099
3276
|
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3100
3277
|
}
|
|
3101
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3278
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3102
3279
|
return `${alias}.${this.q(col)} ${safeDir}`;
|
|
3103
3280
|
})
|
|
3104
3281
|
.join(', ');
|
|
@@ -3115,24 +3292,21 @@ export class QueryInterface {
|
|
|
3115
3292
|
else {
|
|
3116
3293
|
whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3117
3294
|
}
|
|
3118
|
-
// Additional filters —
|
|
3295
|
+
// Additional filters — full scalar where surface (equality, null, operator
|
|
3296
|
+
// objects, OR/AND/NOT), properly parameterized against this alias.
|
|
3119
3297
|
if (spec !== true && spec.where) {
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3124
|
-
}
|
|
3125
|
-
params.push(v);
|
|
3126
|
-
whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3127
|
-
}
|
|
3298
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
|
|
3299
|
+
if (extra)
|
|
3300
|
+
whereClause += ` AND ${extra}`;
|
|
3128
3301
|
}
|
|
3129
3302
|
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
3130
3303
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
3131
3304
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
3132
3305
|
// which Postgres rejects with "could not determine data type of parameter $N"
|
|
3133
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.
|
|
3134
3308
|
let limitClause = '';
|
|
3135
|
-
if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
|
|
3309
|
+
if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
|
|
3136
3310
|
params.push(Number(spec.limit));
|
|
3137
3311
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3138
3312
|
}
|
|
@@ -3226,35 +3400,33 @@ export class QueryInterface {
|
|
|
3226
3400
|
let whereClause = sourceKeys
|
|
3227
3401
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3228
3402
|
.join(' AND ');
|
|
3229
|
-
// 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) : [];
|
|
3230
3406
|
let orderClause = '';
|
|
3231
|
-
if (
|
|
3232
|
-
const orders =
|
|
3407
|
+
if (relOrderEntries.length > 0) {
|
|
3408
|
+
const orders = relOrderEntries
|
|
3233
3409
|
.map(([k, dir]) => {
|
|
3234
3410
|
const col = camelToSnake(k);
|
|
3235
3411
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3236
3412
|
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3237
3413
|
}
|
|
3238
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3414
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3239
3415
|
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3240
3416
|
})
|
|
3241
3417
|
.join(', ');
|
|
3242
3418
|
orderClause = ` ORDER BY ${orders}`;
|
|
3243
3419
|
}
|
|
3244
|
-
// Additional WHERE filters on the target —
|
|
3420
|
+
// Additional WHERE filters on the target — full scalar where surface,
|
|
3421
|
+
// properly parameterized against the target alias.
|
|
3245
3422
|
if (spec !== true && spec.where) {
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3250
|
-
}
|
|
3251
|
-
params.push(v);
|
|
3252
|
-
whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3253
|
-
}
|
|
3423
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
|
|
3424
|
+
if (extra)
|
|
3425
|
+
whereClause += ` AND ${extra}`;
|
|
3254
3426
|
}
|
|
3255
|
-
// LIMIT
|
|
3427
|
+
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
3256
3428
|
let limitClause = '';
|
|
3257
|
-
if (spec !== true && spec.limit) {
|
|
3429
|
+
if (spec !== true && spec.limit !== undefined) {
|
|
3258
3430
|
params.push(Number(spec.limit));
|
|
3259
3431
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3260
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.19.
|
|
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/",
|