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
|
@@ -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 */
|
|
@@ -1723,12 +1737,24 @@ class QueryInterface {
|
|
|
1723
1737
|
*/
|
|
1724
1738
|
resolveColumns(select, omit) {
|
|
1725
1739
|
if (select) {
|
|
1740
|
+
// An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
|
|
1741
|
+
// style) instead of the object shape. Object.entries() would iterate the
|
|
1742
|
+
// numeric indices and throw a cryptic `Unknown field "0"` — catch it early
|
|
1743
|
+
// with an actionable message.
|
|
1744
|
+
if (Array.isArray(select)) {
|
|
1745
|
+
throw new errors_js_1.ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
|
|
1746
|
+
`(e.g. { id: true, name: true }), not an array.`);
|
|
1747
|
+
}
|
|
1726
1748
|
// Only include columns where value is true
|
|
1727
1749
|
return Object.entries(select)
|
|
1728
1750
|
.filter(([, v]) => v)
|
|
1729
1751
|
.map(([k]) => this.toColumn(k));
|
|
1730
1752
|
}
|
|
1731
1753
|
if (omit) {
|
|
1754
|
+
if (Array.isArray(omit)) {
|
|
1755
|
+
throw new errors_js_1.ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
|
|
1756
|
+
`(e.g. { createdAt: true }), not an array.`);
|
|
1757
|
+
}
|
|
1732
1758
|
// Include all columns except those where value is true
|
|
1733
1759
|
const omitCols = new Set(Object.entries(omit)
|
|
1734
1760
|
.filter(([, v]) => v)
|
|
@@ -1932,6 +1958,16 @@ class QueryInterface {
|
|
|
1932
1958
|
parts.push(`${key}:fts(${cfg})`);
|
|
1933
1959
|
continue;
|
|
1934
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
|
+
}
|
|
1935
1971
|
// Plain equality
|
|
1936
1972
|
parts.push(`${key}:eq`);
|
|
1937
1973
|
}
|
|
@@ -1971,6 +2007,11 @@ class QueryInterface {
|
|
|
1971
2007
|
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1972
2008
|
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1973
2009
|
}
|
|
2010
|
+
else if (isUnmatchedPlainObject(value)) {
|
|
2011
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
2012
|
+
.sort()
|
|
2013
|
+
.join(',')})`);
|
|
2014
|
+
}
|
|
1974
2015
|
else {
|
|
1975
2016
|
parts.push(`${key}:eq`);
|
|
1976
2017
|
}
|
|
@@ -2073,7 +2114,9 @@ class QueryInterface {
|
|
|
2073
2114
|
this.collectOperatorParams(value, params);
|
|
2074
2115
|
continue;
|
|
2075
2116
|
}
|
|
2076
|
-
// 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);
|
|
2077
2120
|
params.push(value);
|
|
2078
2121
|
}
|
|
2079
2122
|
}
|
|
@@ -2082,7 +2125,7 @@ class QueryInterface {
|
|
|
2082
2125
|
const meta = this.schema.tables[targetTable];
|
|
2083
2126
|
if (!meta)
|
|
2084
2127
|
return;
|
|
2085
|
-
for (const [
|
|
2128
|
+
for (const [field, value] of Object.entries(subWhere)) {
|
|
2086
2129
|
if (value === undefined)
|
|
2087
2130
|
continue;
|
|
2088
2131
|
if (value === null)
|
|
@@ -2091,6 +2134,8 @@ class QueryInterface {
|
|
|
2091
2134
|
this.collectOperatorParams(value, params);
|
|
2092
2135
|
continue;
|
|
2093
2136
|
}
|
|
2137
|
+
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2138
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2094
2139
|
params.push(value);
|
|
2095
2140
|
}
|
|
2096
2141
|
}
|
|
@@ -2218,13 +2263,11 @@ class QueryInterface {
|
|
|
2218
2263
|
.sort();
|
|
2219
2264
|
subParts.push(`om=${omKeys.join(',')}`);
|
|
2220
2265
|
}
|
|
2221
|
-
// 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)
|
|
2222
2269
|
if (opts.where) {
|
|
2223
|
-
|
|
2224
|
-
const wKeys = Object.keys(opts.where)
|
|
2225
|
-
.filter((k) => opts.where[k] !== undefined)
|
|
2226
|
-
.sort();
|
|
2227
|
-
subParts.push(`w=${wKeys.join(',')}`);
|
|
2270
|
+
subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
|
|
2228
2271
|
}
|
|
2229
2272
|
// orderBy shape
|
|
2230
2273
|
if (opts.orderBy) {
|
|
@@ -2274,11 +2317,9 @@ class QueryInterface {
|
|
|
2274
2317
|
// where params → limit param → nested-with params (always, both paths).
|
|
2275
2318
|
if (relDef.type === 'manyToMany') {
|
|
2276
2319
|
if (spec.where) {
|
|
2277
|
-
|
|
2278
|
-
params.push(v);
|
|
2279
|
-
}
|
|
2320
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2280
2321
|
}
|
|
2281
|
-
if (spec.limit) {
|
|
2322
|
+
if (spec.limit !== undefined) {
|
|
2282
2323
|
params.push(Number(spec.limit));
|
|
2283
2324
|
}
|
|
2284
2325
|
if (spec.with) {
|
|
@@ -2291,7 +2332,9 @@ class QueryInterface {
|
|
|
2291
2332
|
}
|
|
2292
2333
|
return;
|
|
2293
2334
|
}
|
|
2294
|
-
|
|
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);
|
|
2295
2338
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2296
2339
|
if (!willWrap && spec.with) {
|
|
2297
2340
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -2301,14 +2344,15 @@ class QueryInterface {
|
|
|
2301
2344
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2302
2345
|
}
|
|
2303
2346
|
}
|
|
2304
|
-
// where params
|
|
2347
|
+
// where params — mirrors buildAliasWhere push order
|
|
2305
2348
|
if (spec.where) {
|
|
2306
|
-
|
|
2307
|
-
params.push(v);
|
|
2308
|
-
}
|
|
2349
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2309
2350
|
}
|
|
2310
|
-
// limit param
|
|
2311
|
-
|
|
2351
|
+
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2352
|
+
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2353
|
+
// pushing one here would orphan a param and desync the collect path.
|
|
2354
|
+
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
2355
|
+
if (relDef.type === 'hasMany' && spec.limit !== undefined) {
|
|
2312
2356
|
params.push(Number(spec.limit));
|
|
2313
2357
|
}
|
|
2314
2358
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
@@ -2516,6 +2560,11 @@ class QueryInterface {
|
|
|
2516
2560
|
andClauses.push(...opClauses);
|
|
2517
2561
|
continue;
|
|
2518
2562
|
}
|
|
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);
|
|
2519
2568
|
// Plain equality
|
|
2520
2569
|
params.push(value);
|
|
2521
2570
|
andClauses.push(`${column} = ${this.p(params.length)}`);
|
|
@@ -2615,11 +2664,168 @@ class QueryInterface {
|
|
|
2615
2664
|
conditions.push(...opClauses);
|
|
2616
2665
|
continue;
|
|
2617
2666
|
}
|
|
2667
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2618
2668
|
params.push(value);
|
|
2619
2669
|
conditions.push(`${qCol} = ${this.p(params.length)}`);
|
|
2620
2670
|
}
|
|
2621
2671
|
return conditions.length > 0 ? conditions.join(' AND ') : null;
|
|
2622
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
|
+
}
|
|
2623
2829
|
/**
|
|
2624
2830
|
* Build SQL clauses for a single operator object on a column.
|
|
2625
2831
|
* Each operator key becomes its own clause, all ANDed together.
|
|
@@ -2699,7 +2905,8 @@ class QueryInterface {
|
|
|
2699
2905
|
return Object.entries(orderBy)
|
|
2700
2906
|
.map(([key, dir]) => {
|
|
2701
2907
|
if (meta && !(key in meta.columnMap)) {
|
|
2702
|
-
throw new errors_js_1.ValidationError(`Unknown
|
|
2908
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
2909
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
2703
2910
|
}
|
|
2704
2911
|
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
2705
2912
|
if (isVectorOrderBy(dir)) {
|
|
@@ -3065,7 +3272,11 @@ class QueryInterface {
|
|
|
3065
3272
|
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
3066
3273
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
3067
3274
|
// so we must NOT build them here (they would push orphaned params).
|
|
3068
|
-
|
|
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);
|
|
3069
3280
|
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3070
3281
|
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3071
3282
|
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
@@ -3093,14 +3304,14 @@ class QueryInterface {
|
|
|
3093
3304
|
const qTarget = this.q(targetTable);
|
|
3094
3305
|
// Build ORDER BY for json_agg
|
|
3095
3306
|
let orderClause = '';
|
|
3096
|
-
if (
|
|
3097
|
-
const orders =
|
|
3307
|
+
if (relOrderEntries.length > 0) {
|
|
3308
|
+
const orders = relOrderEntries
|
|
3098
3309
|
.map(([k, dir]) => {
|
|
3099
3310
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3100
3311
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3101
3312
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3102
3313
|
}
|
|
3103
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3314
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3104
3315
|
return `${alias}.${this.q(col)} ${safeDir}`;
|
|
3105
3316
|
})
|
|
3106
3317
|
.join(', ');
|
|
@@ -3117,20 +3328,21 @@ class QueryInterface {
|
|
|
3117
3328
|
else {
|
|
3118
3329
|
whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3119
3330
|
}
|
|
3120
|
-
// Additional filters —
|
|
3331
|
+
// Additional filters — full scalar where surface (equality, null, operator
|
|
3332
|
+
// objects, OR/AND/NOT), properly parameterized against this alias.
|
|
3121
3333
|
if (spec !== true && spec.where) {
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
// LIMIT
|
|
3334
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
|
|
3335
|
+
if (extra)
|
|
3336
|
+
whereClause += ` AND ${extra}`;
|
|
3337
|
+
}
|
|
3338
|
+
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
3339
|
+
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
3340
|
+
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
3341
|
+
// which Postgres rejects with "could not determine data type of parameter $N"
|
|
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.
|
|
3132
3344
|
let limitClause = '';
|
|
3133
|
-
if (spec !== true && spec.limit) {
|
|
3345
|
+
if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
|
|
3134
3346
|
params.push(Number(spec.limit));
|
|
3135
3347
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3136
3348
|
}
|
|
@@ -3224,35 +3436,33 @@ class QueryInterface {
|
|
|
3224
3436
|
let whereClause = sourceKeys
|
|
3225
3437
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3226
3438
|
.join(' AND ');
|
|
3227
|
-
// 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) : [];
|
|
3228
3442
|
let orderClause = '';
|
|
3229
|
-
if (
|
|
3230
|
-
const orders =
|
|
3443
|
+
if (relOrderEntries.length > 0) {
|
|
3444
|
+
const orders = relOrderEntries
|
|
3231
3445
|
.map(([k, dir]) => {
|
|
3232
3446
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3233
3447
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3234
3448
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3235
3449
|
}
|
|
3236
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3450
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3237
3451
|
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3238
3452
|
})
|
|
3239
3453
|
.join(', ');
|
|
3240
3454
|
orderClause = ` ORDER BY ${orders}`;
|
|
3241
3455
|
}
|
|
3242
|
-
// Additional WHERE filters on the target —
|
|
3456
|
+
// Additional WHERE filters on the target — full scalar where surface,
|
|
3457
|
+
// properly parameterized against the target alias.
|
|
3243
3458
|
if (spec !== true && spec.where) {
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3248
|
-
}
|
|
3249
|
-
params.push(v);
|
|
3250
|
-
whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3251
|
-
}
|
|
3459
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
|
|
3460
|
+
if (extra)
|
|
3461
|
+
whereClause += ` AND ${extra}`;
|
|
3252
3462
|
}
|
|
3253
|
-
// LIMIT
|
|
3463
|
+
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
3254
3464
|
let limitClause = '';
|
|
3255
|
-
if (spec !== true && spec.limit) {
|
|
3465
|
+
if (spec !== true && spec.limit !== undefined) {
|
|
3256
3466
|
params.push(Number(spec.limit));
|
|
3257
3467
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3258
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. */
|