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
|
@@ -66,6 +66,20 @@ 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
|
+
}
|
|
69
83
|
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
70
84
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
71
85
|
/** Known JSONB operator keys */
|
|
@@ -1944,6 +1958,16 @@ class QueryInterface {
|
|
|
1944
1958
|
parts.push(`${key}:fts(${cfg})`);
|
|
1945
1959
|
continue;
|
|
1946
1960
|
}
|
|
1961
|
+
// Plain object literal that matched no filter shape — give it a
|
|
1962
|
+
// fingerprint distinct from real equality. The build path throws for
|
|
1963
|
+
// these on non-JSON columns; sharing `key:eq` would let a cache entry
|
|
1964
|
+
// warmed by genuine equality serve the bad filter silently.
|
|
1965
|
+
if (isUnmatchedPlainObject(value)) {
|
|
1966
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
1967
|
+
.sort()
|
|
1968
|
+
.join(',')})`);
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1947
1971
|
// Plain equality
|
|
1948
1972
|
parts.push(`${key}:eq`);
|
|
1949
1973
|
}
|
|
@@ -1983,6 +2007,11 @@ class QueryInterface {
|
|
|
1983
2007
|
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1984
2008
|
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1985
2009
|
}
|
|
2010
|
+
else if (isUnmatchedPlainObject(value)) {
|
|
2011
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
2012
|
+
.sort()
|
|
2013
|
+
.join(',')})`);
|
|
2014
|
+
}
|
|
1986
2015
|
else {
|
|
1987
2016
|
parts.push(`${key}:eq`);
|
|
1988
2017
|
}
|
|
@@ -2085,7 +2114,9 @@ class QueryInterface {
|
|
|
2085
2114
|
this.collectOperatorParams(value, params);
|
|
2086
2115
|
continue;
|
|
2087
2116
|
}
|
|
2088
|
-
// Plain equality
|
|
2117
|
+
// Plain equality — same strict validation as the build path, so a
|
|
2118
|
+
// cache hit can never silently bind a misspelled-operator object.
|
|
2119
|
+
this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
|
|
2089
2120
|
params.push(value);
|
|
2090
2121
|
}
|
|
2091
2122
|
}
|
|
@@ -2094,7 +2125,7 @@ class QueryInterface {
|
|
|
2094
2125
|
const meta = this.schema.tables[targetTable];
|
|
2095
2126
|
if (!meta)
|
|
2096
2127
|
return;
|
|
2097
|
-
for (const [
|
|
2128
|
+
for (const [field, value] of Object.entries(subWhere)) {
|
|
2098
2129
|
if (value === undefined)
|
|
2099
2130
|
continue;
|
|
2100
2131
|
if (value === null)
|
|
@@ -2103,6 +2134,8 @@ class QueryInterface {
|
|
|
2103
2134
|
this.collectOperatorParams(value, params);
|
|
2104
2135
|
continue;
|
|
2105
2136
|
}
|
|
2137
|
+
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2138
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2106
2139
|
params.push(value);
|
|
2107
2140
|
}
|
|
2108
2141
|
}
|
|
@@ -2230,13 +2263,11 @@ class QueryInterface {
|
|
|
2230
2263
|
.sort();
|
|
2231
2264
|
subParts.push(`om=${omKeys.join(',')}`);
|
|
2232
2265
|
}
|
|
2233
|
-
// where shape (value-invariant
|
|
2266
|
+
// where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
|
|
2267
|
+
// `{title: {contains: 'x'}}` emit different SQL so they must not share
|
|
2268
|
+
// a fingerprint)
|
|
2234
2269
|
if (opts.where) {
|
|
2235
|
-
|
|
2236
|
-
const wKeys = Object.keys(opts.where)
|
|
2237
|
-
.filter((k) => opts.where[k] !== undefined)
|
|
2238
|
-
.sort();
|
|
2239
|
-
subParts.push(`w=${wKeys.join(',')}`);
|
|
2270
|
+
subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
|
|
2240
2271
|
}
|
|
2241
2272
|
// orderBy shape
|
|
2242
2273
|
if (opts.orderBy) {
|
|
@@ -2286,11 +2317,9 @@ class QueryInterface {
|
|
|
2286
2317
|
// where params → limit param → nested-with params (always, both paths).
|
|
2287
2318
|
if (relDef.type === 'manyToMany') {
|
|
2288
2319
|
if (spec.where) {
|
|
2289
|
-
|
|
2290
|
-
params.push(v);
|
|
2291
|
-
}
|
|
2320
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2292
2321
|
}
|
|
2293
|
-
if (spec.limit) {
|
|
2322
|
+
if (spec.limit !== undefined) {
|
|
2294
2323
|
params.push(Number(spec.limit));
|
|
2295
2324
|
}
|
|
2296
2325
|
if (spec.with) {
|
|
@@ -2303,7 +2332,9 @@ class QueryInterface {
|
|
|
2303
2332
|
}
|
|
2304
2333
|
return;
|
|
2305
2334
|
}
|
|
2306
|
-
|
|
2335
|
+
// Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
|
|
2336
|
+
const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
|
|
2337
|
+
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2307
2338
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2308
2339
|
if (!willWrap && spec.with) {
|
|
2309
2340
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -2313,16 +2344,15 @@ class QueryInterface {
|
|
|
2313
2344
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2314
2345
|
}
|
|
2315
2346
|
}
|
|
2316
|
-
// where params
|
|
2347
|
+
// where params — mirrors buildAliasWhere push order
|
|
2317
2348
|
if (spec.where) {
|
|
2318
|
-
|
|
2319
|
-
params.push(v);
|
|
2320
|
-
}
|
|
2349
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2321
2350
|
}
|
|
2322
2351
|
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2323
2352
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2324
2353
|
// pushing one here would orphan a param and desync the collect path.
|
|
2325
|
-
|
|
2354
|
+
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
2355
|
+
if (relDef.type === 'hasMany' && spec.limit !== undefined) {
|
|
2326
2356
|
params.push(Number(spec.limit));
|
|
2327
2357
|
}
|
|
2328
2358
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
@@ -2530,25 +2560,11 @@ class QueryInterface {
|
|
|
2530
2560
|
andClauses.push(...opClauses);
|
|
2531
2561
|
continue;
|
|
2532
2562
|
}
|
|
2533
|
-
// Strict validation: a plain
|
|
2534
|
-
//
|
|
2535
|
-
//
|
|
2536
|
-
//
|
|
2537
|
-
|
|
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
|
-
}
|
|
2563
|
+
// Strict validation: a plain object literal that matched no known filter
|
|
2564
|
+
// shape is almost always a misspelled operator (`startWith` for
|
|
2565
|
+
// `startsWith`). The guard also runs on the cache-hit param-collect path
|
|
2566
|
+
// (collectWhereParams) so a warmed SQL cache can never skip it.
|
|
2567
|
+
this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
|
|
2552
2568
|
// Plain equality
|
|
2553
2569
|
params.push(value);
|
|
2554
2570
|
andClauses.push(`${column} = ${this.p(params.length)}`);
|
|
@@ -2648,11 +2664,168 @@ class QueryInterface {
|
|
|
2648
2664
|
conditions.push(...opClauses);
|
|
2649
2665
|
continue;
|
|
2650
2666
|
}
|
|
2667
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2651
2668
|
params.push(value);
|
|
2652
2669
|
conditions.push(`${qCol} = ${this.p(params.length)}`);
|
|
2653
2670
|
}
|
|
2654
2671
|
return conditions.length > 0 ? conditions.join(' AND ') : null;
|
|
2655
2672
|
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Resolve a column's Postgres type from an arbitrary table's metadata
|
|
2675
|
+
* (relation targets, not just `this.table`).
|
|
2676
|
+
*/
|
|
2677
|
+
pgTypeForColumn(meta, column) {
|
|
2678
|
+
return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* Equality-fallthrough guard shared by every SQL-build path AND every
|
|
2682
|
+
* cache-hit param-collect path. A plain object literal that matched no known
|
|
2683
|
+
* filter shape on a non-JSON column is almost always a misspelled operator
|
|
2684
|
+
* (`startWith` for `startsWith`); binding it as `col = $1` silently returns
|
|
2685
|
+
* wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
|
|
2686
|
+
* legitimate bind values and pass through, as do objects on json/jsonb
|
|
2687
|
+
* columns (object equality).
|
|
2688
|
+
*/
|
|
2689
|
+
assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
|
|
2690
|
+
if (!isUnmatchedPlainObject(value))
|
|
2691
|
+
return;
|
|
2692
|
+
if (columnPgType === 'json' || columnPgType === 'jsonb')
|
|
2693
|
+
return;
|
|
2694
|
+
const badKeys = Object.keys(value);
|
|
2695
|
+
throw new errors_js_1.ValidationError(badKeys.length === 0
|
|
2696
|
+
? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
|
|
2697
|
+
`Provide a value or an operator like { gt: 1 }.`
|
|
2698
|
+
: `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
|
|
2699
|
+
`${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
|
|
2700
|
+
`Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* Build the user-supplied `where` filter of a relation `with` clause against
|
|
2704
|
+
* the relation's table alias. Supports the same scalar surface as the
|
|
2705
|
+
* top-level WHERE builder — equality, IS NULL, operator objects (incl.
|
|
2706
|
+
* `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
|
|
2707
|
+
* objects throw via {@link assertBindableEqualityValue}.
|
|
2708
|
+
*
|
|
2709
|
+
* Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
|
|
2710
|
+
* cache hits and pipeline batching will desync.
|
|
2711
|
+
*/
|
|
2712
|
+
buildAliasWhere(targetTable, targetMeta, alias, where, params) {
|
|
2713
|
+
const clauses = [];
|
|
2714
|
+
for (const [key, value] of Object.entries(where)) {
|
|
2715
|
+
if (value === undefined)
|
|
2716
|
+
continue;
|
|
2717
|
+
if (key === 'OR' || key === 'AND') {
|
|
2718
|
+
const arr = value;
|
|
2719
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2720
|
+
continue;
|
|
2721
|
+
const subs = arr
|
|
2722
|
+
.map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
|
|
2723
|
+
.filter((s) => s !== null)
|
|
2724
|
+
.map((s) => `(${s})`);
|
|
2725
|
+
if (subs.length > 0)
|
|
2726
|
+
clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
|
|
2727
|
+
continue;
|
|
2728
|
+
}
|
|
2729
|
+
if (key === 'NOT') {
|
|
2730
|
+
const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
|
|
2731
|
+
if (sub)
|
|
2732
|
+
clauses.push(`NOT (${sub})`);
|
|
2733
|
+
continue;
|
|
2734
|
+
}
|
|
2735
|
+
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
2736
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
2737
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
|
|
2738
|
+
}
|
|
2739
|
+
const qCol = `${alias}.${this.q(col)}`;
|
|
2740
|
+
if (value === null) {
|
|
2741
|
+
clauses.push(`${qCol} IS NULL`);
|
|
2742
|
+
continue;
|
|
2743
|
+
}
|
|
2744
|
+
if (isWhereOperator(value)) {
|
|
2745
|
+
clauses.push(...this.buildOperatorClauses(qCol, value, params));
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2749
|
+
params.push(value);
|
|
2750
|
+
clauses.push(`${qCol} = ${this.p(params.length)}`);
|
|
2751
|
+
}
|
|
2752
|
+
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2753
|
+
}
|
|
2754
|
+
/** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
|
|
2755
|
+
collectAliasWhereParams(targetTable, targetMeta, where, params) {
|
|
2756
|
+
for (const [key, value] of Object.entries(where)) {
|
|
2757
|
+
if (value === undefined)
|
|
2758
|
+
continue;
|
|
2759
|
+
if (key === 'OR' || key === 'AND') {
|
|
2760
|
+
const arr = value;
|
|
2761
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2762
|
+
continue;
|
|
2763
|
+
for (const cond of arr) {
|
|
2764
|
+
this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
|
|
2765
|
+
}
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2768
|
+
if (key === 'NOT') {
|
|
2769
|
+
this.collectAliasWhereParams(targetTable, targetMeta, value, params);
|
|
2770
|
+
continue;
|
|
2771
|
+
}
|
|
2772
|
+
if (value === null)
|
|
2773
|
+
continue;
|
|
2774
|
+
if (isWhereOperator(value)) {
|
|
2775
|
+
this.collectOperatorParams(value, params);
|
|
2776
|
+
continue;
|
|
2777
|
+
}
|
|
2778
|
+
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
2779
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2780
|
+
params.push(value);
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* Value-invariant, shape-aware fingerprint for a relation `with` clause's
|
|
2785
|
+
* `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
|
|
2786
|
+
* can emit — equality vs null vs operator sets vs combinators — or two
|
|
2787
|
+
* differently-shaped wheres would share one cached SQL string.
|
|
2788
|
+
*/
|
|
2789
|
+
fingerprintAliasWhere(where) {
|
|
2790
|
+
const keys = Object.keys(where)
|
|
2791
|
+
.filter((k) => where[k] !== undefined)
|
|
2792
|
+
.sort();
|
|
2793
|
+
const parts = [];
|
|
2794
|
+
for (const key of keys) {
|
|
2795
|
+
const value = where[key];
|
|
2796
|
+
if (key === 'OR' || key === 'AND') {
|
|
2797
|
+
const arr = value;
|
|
2798
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2799
|
+
continue;
|
|
2800
|
+
parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
|
|
2801
|
+
continue;
|
|
2802
|
+
}
|
|
2803
|
+
if (key === 'NOT') {
|
|
2804
|
+
parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
|
|
2805
|
+
continue;
|
|
2806
|
+
}
|
|
2807
|
+
if (value === null) {
|
|
2808
|
+
parts.push(`${key}:null`);
|
|
2809
|
+
continue;
|
|
2810
|
+
}
|
|
2811
|
+
if (isWhereOperator(value)) {
|
|
2812
|
+
const opKeys = Object.keys(value)
|
|
2813
|
+
.filter((k) => k !== 'mode')
|
|
2814
|
+
.sort();
|
|
2815
|
+
const mode = value.mode;
|
|
2816
|
+
parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
|
|
2817
|
+
continue;
|
|
2818
|
+
}
|
|
2819
|
+
if (isUnmatchedPlainObject(value)) {
|
|
2820
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
2821
|
+
.sort()
|
|
2822
|
+
.join(',')})`);
|
|
2823
|
+
continue;
|
|
2824
|
+
}
|
|
2825
|
+
parts.push(`${key}:eq`);
|
|
2826
|
+
}
|
|
2827
|
+
return parts.join('&');
|
|
2828
|
+
}
|
|
2656
2829
|
/**
|
|
2657
2830
|
* Build SQL clauses for a single operator object on a column.
|
|
2658
2831
|
* Each operator key becomes its own clause, all ANDed together.
|
|
@@ -3099,7 +3272,11 @@ class QueryInterface {
|
|
|
3099
3272
|
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
3100
3273
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
3101
3274
|
// so we must NOT build them here (they would push orphaned params).
|
|
3102
|
-
|
|
3275
|
+
// An orderBy with no defined entries (`orderBy: {}`) is treated as absent —
|
|
3276
|
+
// it must neither trigger the wrap (dropping nested relations) nor render a
|
|
3277
|
+
// dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
|
|
3278
|
+
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
3279
|
+
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
|
|
3103
3280
|
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3104
3281
|
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3105
3282
|
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
@@ -3127,14 +3304,14 @@ class QueryInterface {
|
|
|
3127
3304
|
const qTarget = this.q(targetTable);
|
|
3128
3305
|
// Build ORDER BY for json_agg
|
|
3129
3306
|
let orderClause = '';
|
|
3130
|
-
if (
|
|
3131
|
-
const orders =
|
|
3307
|
+
if (relOrderEntries.length > 0) {
|
|
3308
|
+
const orders = relOrderEntries
|
|
3132
3309
|
.map(([k, dir]) => {
|
|
3133
3310
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3134
3311
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3135
3312
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3136
3313
|
}
|
|
3137
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3314
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3138
3315
|
return `${alias}.${this.q(col)} ${safeDir}`;
|
|
3139
3316
|
})
|
|
3140
3317
|
.join(', ');
|
|
@@ -3151,24 +3328,21 @@ class QueryInterface {
|
|
|
3151
3328
|
else {
|
|
3152
3329
|
whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3153
3330
|
}
|
|
3154
|
-
// Additional filters —
|
|
3331
|
+
// Additional filters — full scalar where surface (equality, null, operator
|
|
3332
|
+
// objects, OR/AND/NOT), properly parameterized against this alias.
|
|
3155
3333
|
if (spec !== true && spec.where) {
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
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
|
-
}
|
|
3334
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
|
|
3335
|
+
if (extra)
|
|
3336
|
+
whereClause += ` AND ${extra}`;
|
|
3164
3337
|
}
|
|
3165
3338
|
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
3166
3339
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
3167
3340
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
3168
3341
|
// which Postgres rejects with "could not determine data type of parameter $N"
|
|
3169
3342
|
// (and shifts every later placeholder by one). To-one relations ignore limit.
|
|
3343
|
+
// `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
|
|
3170
3344
|
let limitClause = '';
|
|
3171
|
-
if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
|
|
3345
|
+
if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
|
|
3172
3346
|
params.push(Number(spec.limit));
|
|
3173
3347
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3174
3348
|
}
|
|
@@ -3262,35 +3436,33 @@ class QueryInterface {
|
|
|
3262
3436
|
let whereClause = sourceKeys
|
|
3263
3437
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3264
3438
|
.join(' AND ');
|
|
3265
|
-
// ORDER BY on the target rows
|
|
3439
|
+
// ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
|
|
3440
|
+
// treated as absent — it must not render a dangling `ORDER BY `.
|
|
3441
|
+
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
3266
3442
|
let orderClause = '';
|
|
3267
|
-
if (
|
|
3268
|
-
const orders =
|
|
3443
|
+
if (relOrderEntries.length > 0) {
|
|
3444
|
+
const orders = relOrderEntries
|
|
3269
3445
|
.map(([k, dir]) => {
|
|
3270
3446
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3271
3447
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3272
3448
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3273
3449
|
}
|
|
3274
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3450
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3275
3451
|
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3276
3452
|
})
|
|
3277
3453
|
.join(', ');
|
|
3278
3454
|
orderClause = ` ORDER BY ${orders}`;
|
|
3279
3455
|
}
|
|
3280
|
-
// Additional WHERE filters on the target —
|
|
3456
|
+
// Additional WHERE filters on the target — full scalar where surface,
|
|
3457
|
+
// properly parameterized against the target alias.
|
|
3281
3458
|
if (spec !== true && spec.where) {
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
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
|
-
}
|
|
3459
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
|
|
3460
|
+
if (extra)
|
|
3461
|
+
whereClause += ` AND ${extra}`;
|
|
3290
3462
|
}
|
|
3291
|
-
// LIMIT
|
|
3463
|
+
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
3292
3464
|
let limitClause = '';
|
|
3293
|
-
if (spec !== true && spec.limit) {
|
|
3465
|
+
if (spec !== true && spec.limit !== undefined) {
|
|
3294
3466
|
params.push(Number(spec.limit));
|
|
3295
3467
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3296
3468
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
* npx turbine init --url postgres://...
|
|
21
21
|
* npx turbine migrate create add_users_table
|
|
22
22
|
*/
|
|
23
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
24
24
|
import { dirname, relative, resolve } from 'node:path';
|
|
25
25
|
import { pathToFileURL } from 'node:url';
|
|
26
26
|
import { generate } from '../generate.js';
|
|
27
27
|
import { introspect } from '../introspect.js';
|
|
28
28
|
import { schemaDiff, schemaPush } from '../schema-sql.js';
|
|
29
29
|
import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
|
|
30
|
-
import { needsTsLoader, registerTsLoader } from './loader.js';
|
|
30
|
+
import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
|
|
31
31
|
import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
|
|
32
32
|
import { startObserve } from './observe.js';
|
|
33
33
|
import { startStudio } from './studio.js';
|
|
@@ -132,6 +132,15 @@ function failMissingTsLoader(filePath, reason) {
|
|
|
132
132
|
console.log(` ${dim('Your Node.js version does not support')} ${cyan('module.register()')}.`);
|
|
133
133
|
console.log(` ${dim('Upgrade to Node.js')} ${cyan('20.6+')} ${dim('or use a')} ${cyan('.js')} ${dim('/')} ${cyan('.mjs')} ${dim('config file.')}`);
|
|
134
134
|
}
|
|
135
|
+
else if (reason === 'failed') {
|
|
136
|
+
// tsx IS installed but registering its loader threw. Report the real
|
|
137
|
+
// cause — telling the user to install tsx here would be a misdiagnosis.
|
|
138
|
+
console.log(` ${dim('tsx is installed, but registering its TypeScript loader failed:')}`);
|
|
139
|
+
newline();
|
|
140
|
+
console.log(` ${getTsLoaderError() ?? '(unknown error)'}`);
|
|
141
|
+
newline();
|
|
142
|
+
console.log(` ${dim('Try upgrading tsx:')} ${cyan('npm install --save-dev tsx@latest')}${dim(', or rename your file to')} ${cyan('.mjs')}.`);
|
|
143
|
+
}
|
|
135
144
|
else {
|
|
136
145
|
console.log(` ${dim('Loading .ts config / schema files requires')} ${cyan('tsx')} ${dim('to be installed.')}`);
|
|
137
146
|
newline();
|
|
@@ -175,7 +184,7 @@ async function loadSchemaFile(schemaFile) {
|
|
|
175
184
|
// ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
|
|
176
185
|
if (needsTsLoader(absPath)) {
|
|
177
186
|
const status = await registerTsLoader();
|
|
178
|
-
if (status === 'missing' || status === 'unsupported') {
|
|
187
|
+
if (status === 'missing' || status === 'unsupported' || status === 'failed') {
|
|
179
188
|
failMissingTsLoader(schemaFile, status);
|
|
180
189
|
}
|
|
181
190
|
}
|
|
@@ -311,7 +320,7 @@ export default defineSchema({
|
|
|
311
320
|
// id: { type: 'serial', primaryKey: true },
|
|
312
321
|
// email: { type: 'text', notNull: true, unique: true },
|
|
313
322
|
// name: { type: 'text', notNull: true },
|
|
314
|
-
// created_at: { type: '
|
|
323
|
+
// created_at: { type: 'timestamp', default: 'NOW()' },
|
|
315
324
|
// },
|
|
316
325
|
});
|
|
317
326
|
`, 'utf-8');
|
|
@@ -374,6 +383,9 @@ export default defineSchema({
|
|
|
374
383
|
console.log(` ${dim('or create a')} ${cyan('.env')} ${dim('file with')} ${cyan('DATABASE_URL=postgres://...')}`);
|
|
375
384
|
}
|
|
376
385
|
console.log(` ${dim('2.')} Run ${cyan('npx turbine generate')} to introspect your DB`);
|
|
386
|
+
if (!canResolveTsx()) {
|
|
387
|
+
console.log(` ${dim('Note: the TypeScript config requires')} ${cyan('tsx')} ${dim('—')} ${cyan('npm install --save-dev tsx')}`);
|
|
388
|
+
}
|
|
377
389
|
}
|
|
378
390
|
else {
|
|
379
391
|
console.log(` ${dim('1.')} Import the generated client:`);
|
|
@@ -1214,7 +1226,17 @@ function showVersion() {
|
|
|
1214
1226
|
// Using process.argv[1] instead of import.meta.url so the same code compiles
|
|
1215
1227
|
// cleanly for both the ESM and CJS builds.
|
|
1216
1228
|
try {
|
|
1217
|
-
|
|
1229
|
+
// Resolve symlinks first: `npx turbine` runs via node_modules/.bin/turbine,
|
|
1230
|
+
// a symlink whose dirname would walk the CONSUMER's tree and never find
|
|
1231
|
+
// turbine-orm's package.json (printing no version number at all).
|
|
1232
|
+
let entry = process.argv[1] ?? '';
|
|
1233
|
+
try {
|
|
1234
|
+
entry = realpathSync(entry);
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
// keep the raw path if realpath fails (e.g. deleted cwd)
|
|
1238
|
+
}
|
|
1239
|
+
let dir = dirname(entry);
|
|
1218
1240
|
for (let i = 0; i < 6; i++) {
|
|
1219
1241
|
const candidate = resolve(dir, 'package.json');
|
|
1220
1242
|
if (existsSync(candidate)) {
|
|
@@ -1262,7 +1284,7 @@ async function main() {
|
|
|
1262
1284
|
const configPath = findConfigFile();
|
|
1263
1285
|
if (needsTsLoader(configPath)) {
|
|
1264
1286
|
const status = await registerTsLoader();
|
|
1265
|
-
if (status === 'missing' || status === 'unsupported') {
|
|
1287
|
+
if (status === 'missing' || status === 'unsupported' || status === 'failed') {
|
|
1266
1288
|
failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
|
|
1267
1289
|
}
|
|
1268
1290
|
}
|
package/dist/cli/loader.d.ts
CHANGED
|
@@ -8,9 +8,18 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Strategy:
|
|
10
10
|
* 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
|
|
11
|
-
* probe whether `tsx
|
|
12
|
-
* 2.
|
|
13
|
-
*
|
|
11
|
+
* probe whether `tsx` is resolvable from the user's CWD.
|
|
12
|
+
* 2. Prefer tsx's supported programmatic API, `tsx/esm/api`'s `register()`.
|
|
13
|
+
* Calling Node's `module.register('tsx/esm', ...)` directly throws
|
|
14
|
+
* "tsx must be loaded with --import instead of --loader" on every Node
|
|
15
|
+
* version that has `module.register()` (>= 20.6) — tsx's hook file
|
|
16
|
+
* guards against being loaded that way. The `tsx/esm/api` entry point
|
|
17
|
+
* is the documented path and works everywhere `module.register()` does.
|
|
18
|
+
* 3. Fall back to `module.register('tsx/esm', ...)` only for very old tsx
|
|
19
|
+
* versions (< 4.0) that predate `tsx/esm/api`.
|
|
20
|
+
* 4. If tsx isn't installed, or registration genuinely fails, surface an
|
|
21
|
+
* actionable error — including the REAL underlying error message, never
|
|
22
|
+
* a misdiagnosed "tsx is not installed".
|
|
14
23
|
*
|
|
15
24
|
* `tsx` is intentionally NOT a runtime dependency — many projects already
|
|
16
25
|
* have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
|
|
@@ -28,7 +37,12 @@ export declare function needsTsLoader(filePath: string | null | undefined): bool
|
|
|
28
37
|
* Accepts an injected `resolver` so unit tests don't need a real filesystem.
|
|
29
38
|
*/
|
|
30
39
|
export declare function canResolveTsx(resolver?: (id: string) => string): boolean;
|
|
31
|
-
export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing';
|
|
40
|
+
export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing' | 'failed';
|
|
41
|
+
/**
|
|
42
|
+
* The underlying error message from the last failed registration attempt,
|
|
43
|
+
* or null. Lets the CLI report the REAL cause instead of guessing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getTsLoaderError(): string | null;
|
|
32
46
|
/**
|
|
33
47
|
* Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
|
|
34
48
|
* work. Safe to call multiple times — internal flag prevents double registration.
|
|
@@ -36,8 +50,11 @@ export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing
|
|
|
36
50
|
* Returns:
|
|
37
51
|
* - 'registered' loader was successfully registered this call
|
|
38
52
|
* - 'already' a loader was previously registered (idempotent)
|
|
39
|
-
* - 'unsupported' Node lacks `module.register()` (Node < 20.6)
|
|
53
|
+
* - 'unsupported' Node lacks `module.register()` (Node < 20.6) and tsx has
|
|
54
|
+
* no programmatic API to fall back to
|
|
40
55
|
* - 'missing' `tsx` is not installed in the user's project
|
|
56
|
+
* - 'failed' tsx IS installed but registration threw — see
|
|
57
|
+
* {@link getTsLoaderError} for the underlying message
|
|
41
58
|
*/
|
|
42
59
|
export declare function registerTsLoader(): Promise<TsLoaderStatus>;
|
|
43
60
|
/** Reset the loader state — used by unit tests only. */
|