turbine-orm 0.19.0 → 0.19.2
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 +83 -15
- package/dist/adapters/index.d.ts +3 -2
- package/dist/cjs/cli/index.js +43 -13
- package/dist/cjs/cli/loader.js +62 -7
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +25 -35
- package/dist/cjs/client.js +20 -13
- package/dist/cjs/query/builder.js +342 -104
- package/dist/cjs/query/utils.js +1 -0
- package/dist/cli/index.js +45 -15
- package/dist/cli/loader.d.ts +22 -5
- package/dist/cli/loader.js +61 -7
- package/dist/cli/migrate.d.ts +2 -2
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +9 -14
- package/dist/cli/studio.js +25 -34
- package/dist/client.d.ts +12 -13
- package/dist/client.js +20 -13
- package/dist/index.d.ts +1 -1
- package/dist/query/builder.d.ts +43 -6
- package/dist/query/builder.js +342 -104
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +62 -12
- package/dist/query/utils.js +1 -0
- package/package.json +4 -4
- package/dist/cjs/query.js +0 -2711
- package/dist/query.d.ts +0 -878
- package/dist/query.js +0 -2705
|
@@ -66,6 +66,67 @@ 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
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
|
|
85
|
+
* `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
|
|
86
|
+
* param pushed), so null-ness is part of the shape — without it a cache entry
|
|
87
|
+
* warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
|
|
88
|
+
*/
|
|
89
|
+
function fingerprintOperatorShape(value) {
|
|
90
|
+
const obj = value;
|
|
91
|
+
const opKeys = Object.keys(obj)
|
|
92
|
+
.filter((k) => k !== 'mode')
|
|
93
|
+
.map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
|
|
94
|
+
.sort();
|
|
95
|
+
const modeStr = value.mode === 'insensitive' ? ':i' : '';
|
|
96
|
+
return `op(${opKeys.join(',')}${modeStr})`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Guard for the value of an `equals` operator reaching the plain-equality
|
|
100
|
+
* operator path. A plain object literal can only legitimately be an equality
|
|
101
|
+
* value on a json/jsonb column — and those route to the JSONB filter branch
|
|
102
|
+
* BEFORE the operator branch, so any plain object that reaches here is a
|
|
103
|
+
* mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
|
|
104
|
+
* SQL-build path and the cache-hit param-collect path so a warmed cache can
|
|
105
|
+
* never skip the check.
|
|
106
|
+
*/
|
|
107
|
+
function assertBindableEqualsOperand(value, column) {
|
|
108
|
+
if (!isUnmatchedPlainObject(value))
|
|
109
|
+
return;
|
|
110
|
+
throw new errors_js_1.ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
|
|
111
|
+
`objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
|
|
112
|
+
`where 'equals' is the JSONB containment filter.`);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Object keys in sorted order, mirroring the canonical order used by every
|
|
116
|
+
* cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
|
|
117
|
+
* enumerate object keys in this exact order: fingerprints sort keys, so two
|
|
118
|
+
* where clauses with the same fields in different insertion order share one
|
|
119
|
+
* cache entry — if build/collect iterated insertion order, the cached SQL's
|
|
120
|
+
* `$N` placeholders would bind the wrong values (cross-tenant-leak class).
|
|
121
|
+
* Array order (OR/AND members) is positional and is never sorted.
|
|
122
|
+
*/
|
|
123
|
+
function sortedKeys(obj) {
|
|
124
|
+
return Object.keys(obj).sort();
|
|
125
|
+
}
|
|
126
|
+
/** {@link sortedKeys}, but yielding `[key, value]` pairs. */
|
|
127
|
+
function sortedEntries(obj) {
|
|
128
|
+
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
129
|
+
}
|
|
69
130
|
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
70
131
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
71
132
|
/** Known JSONB operator keys */
|
|
@@ -75,9 +136,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
|
75
136
|
* appear in any other where-filter shape, so the presence of one of these is
|
|
76
137
|
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
77
138
|
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
78
|
-
* `WhereOperator` for LIKE) is not misclassified.
|
|
139
|
+
* `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
|
|
140
|
+
* set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
|
|
141
|
+
* so it must fall through instead of throwing.
|
|
79
142
|
*/
|
|
80
|
-
const JSONB_UNIQUE_KEYS = new Set(['path', '
|
|
143
|
+
const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
|
|
81
144
|
/** Check if a value is a JSONB filter object */
|
|
82
145
|
function isJsonFilter(value) {
|
|
83
146
|
if (value === null ||
|
|
@@ -378,10 +441,12 @@ class QueryInterface {
|
|
|
378
441
|
* Execute a query through the middleware chain.
|
|
379
442
|
* If no middlewares are registered, executes directly.
|
|
380
443
|
*
|
|
381
|
-
* Middleware can inspect and log query parameters,
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
444
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
445
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
446
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
447
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
448
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
449
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
385
450
|
*/
|
|
386
451
|
async executeWithMiddleware(action, args, executor) {
|
|
387
452
|
this.currentAction = action;
|
|
@@ -420,8 +485,12 @@ class QueryInterface {
|
|
|
420
485
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
421
486
|
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
422
487
|
const params = [];
|
|
423
|
-
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
424
|
-
|
|
488
|
+
// Check if all where values are simple (plain equality, no operators/null/OR).
|
|
489
|
+
// Keys are sorted to match fingerprintWhere — insertion order here would let
|
|
490
|
+
// permuted where literals share a cache entry with misaligned params.
|
|
491
|
+
const whereKeys = Object.keys(whereObj)
|
|
492
|
+
.filter((k) => whereObj[k] !== undefined)
|
|
493
|
+
.sort();
|
|
425
494
|
const isSimpleWhere = !whereObj.OR &&
|
|
426
495
|
!whereObj.AND &&
|
|
427
496
|
!whereObj.NOT &&
|
|
@@ -625,7 +694,8 @@ class QueryInterface {
|
|
|
625
694
|
}
|
|
626
695
|
let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
|
|
627
696
|
if (args?.cursor) {
|
|
628
|
-
|
|
697
|
+
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
698
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
629
699
|
if (cursorEntries.length > 0) {
|
|
630
700
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
631
701
|
const col = this.toSqlColumn(k);
|
|
@@ -666,9 +736,9 @@ class QueryInterface {
|
|
|
666
736
|
if (args?.with) {
|
|
667
737
|
this.collectWithParams(args.with, params);
|
|
668
738
|
}
|
|
669
|
-
// 3. Cursor params
|
|
739
|
+
// 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
|
|
670
740
|
if (args?.cursor) {
|
|
671
|
-
const cursorEntries =
|
|
741
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
672
742
|
for (const [, v] of cursorEntries) {
|
|
673
743
|
params.push(v);
|
|
674
744
|
}
|
|
@@ -1908,12 +1978,7 @@ class QueryInterface {
|
|
|
1908
1978
|
}
|
|
1909
1979
|
// Operator objects
|
|
1910
1980
|
if (isWhereOperator(value)) {
|
|
1911
|
-
|
|
1912
|
-
.filter((k) => k !== 'mode')
|
|
1913
|
-
.sort();
|
|
1914
|
-
const mode = value.mode;
|
|
1915
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1916
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1981
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
1917
1982
|
continue;
|
|
1918
1983
|
}
|
|
1919
1984
|
// Vector distance filter — metric (operator) and present comparators
|
|
@@ -1944,6 +2009,16 @@ class QueryInterface {
|
|
|
1944
2009
|
parts.push(`${key}:fts(${cfg})`);
|
|
1945
2010
|
continue;
|
|
1946
2011
|
}
|
|
2012
|
+
// Plain object literal that matched no filter shape — give it a
|
|
2013
|
+
// fingerprint distinct from real equality. The build path throws for
|
|
2014
|
+
// these on non-JSON columns; sharing `key:eq` would let a cache entry
|
|
2015
|
+
// warmed by genuine equality serve the bad filter silently.
|
|
2016
|
+
if (isUnmatchedPlainObject(value)) {
|
|
2017
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
2018
|
+
.sort()
|
|
2019
|
+
.join(',')})`);
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
1947
2022
|
// Plain equality
|
|
1948
2023
|
parts.push(`${key}:eq`);
|
|
1949
2024
|
}
|
|
@@ -1976,12 +2051,12 @@ class QueryInterface {
|
|
|
1976
2051
|
parts.push(`${key}:null`);
|
|
1977
2052
|
}
|
|
1978
2053
|
else if (isWhereOperator(value)) {
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2054
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2055
|
+
}
|
|
2056
|
+
else if (isUnmatchedPlainObject(value)) {
|
|
2057
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
2058
|
+
.sort()
|
|
2059
|
+
.join(',')})`);
|
|
1985
2060
|
}
|
|
1986
2061
|
else {
|
|
1987
2062
|
parts.push(`${key}:eq`);
|
|
@@ -1997,7 +2072,8 @@ class QueryInterface {
|
|
|
1997
2072
|
* @internal Exposed as package-private for testing.
|
|
1998
2073
|
*/
|
|
1999
2074
|
collectWhereParams(where, params) {
|
|
2000
|
-
|
|
2075
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
|
|
2076
|
+
const keys = sortedKeys(where);
|
|
2001
2077
|
for (const key of keys) {
|
|
2002
2078
|
const value = where[key];
|
|
2003
2079
|
if (value === undefined)
|
|
@@ -2082,10 +2158,12 @@ class QueryInterface {
|
|
|
2082
2158
|
}
|
|
2083
2159
|
// Operator objects
|
|
2084
2160
|
if (isWhereOperator(value)) {
|
|
2085
|
-
this.collectOperatorParams(value, params);
|
|
2161
|
+
this.collectOperatorParams(rawColumn, value, params);
|
|
2086
2162
|
continue;
|
|
2087
2163
|
}
|
|
2088
|
-
// Plain equality
|
|
2164
|
+
// Plain equality — same strict validation as the build path, so a
|
|
2165
|
+
// cache hit can never silently bind a misspelled-operator object.
|
|
2166
|
+
this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
|
|
2089
2167
|
params.push(value);
|
|
2090
2168
|
}
|
|
2091
2169
|
}
|
|
@@ -2094,20 +2172,28 @@ class QueryInterface {
|
|
|
2094
2172
|
const meta = this.schema.tables[targetTable];
|
|
2095
2173
|
if (!meta)
|
|
2096
2174
|
return;
|
|
2097
|
-
|
|
2175
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and buildSubWhereForRelation.
|
|
2176
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2177
|
+
const value = subWhere[field];
|
|
2098
2178
|
if (value === undefined)
|
|
2099
2179
|
continue;
|
|
2100
2180
|
if (value === null)
|
|
2101
2181
|
continue;
|
|
2182
|
+
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2102
2183
|
if (isWhereOperator(value)) {
|
|
2103
|
-
this.collectOperatorParams(value, params);
|
|
2184
|
+
this.collectOperatorParams(col, value, params);
|
|
2104
2185
|
continue;
|
|
2105
2186
|
}
|
|
2187
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2106
2188
|
params.push(value);
|
|
2107
2189
|
}
|
|
2108
2190
|
}
|
|
2109
2191
|
/** Collect params from operator clauses. Mirrors buildOperatorClauses. */
|
|
2110
|
-
collectOperatorParams(op, params) {
|
|
2192
|
+
collectOperatorParams(column, op, params) {
|
|
2193
|
+
if (op.equals !== undefined && op.equals !== null) {
|
|
2194
|
+
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
2195
|
+
params.push(op.equals);
|
|
2196
|
+
}
|
|
2111
2197
|
if (op.gt !== undefined)
|
|
2112
2198
|
params.push(op.gt);
|
|
2113
2199
|
if (op.gte !== undefined)
|
|
@@ -2230,13 +2316,11 @@ class QueryInterface {
|
|
|
2230
2316
|
.sort();
|
|
2231
2317
|
subParts.push(`om=${omKeys.join(',')}`);
|
|
2232
2318
|
}
|
|
2233
|
-
// where shape (value-invariant
|
|
2319
|
+
// where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
|
|
2320
|
+
// `{title: {contains: 'x'}}` emit different SQL so they must not share
|
|
2321
|
+
// a fingerprint)
|
|
2234
2322
|
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(',')}`);
|
|
2323
|
+
subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
|
|
2240
2324
|
}
|
|
2241
2325
|
// orderBy shape
|
|
2242
2326
|
if (opts.orderBy) {
|
|
@@ -2265,7 +2349,7 @@ class QueryInterface {
|
|
|
2265
2349
|
const meta = this.schema.tables[table ?? this.table];
|
|
2266
2350
|
if (!meta)
|
|
2267
2351
|
return;
|
|
2268
|
-
for (const [relName, relSpec] of
|
|
2352
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
2269
2353
|
const relDef = meta.relations[relName];
|
|
2270
2354
|
if (!relDef)
|
|
2271
2355
|
continue;
|
|
@@ -2286,15 +2370,13 @@ class QueryInterface {
|
|
|
2286
2370
|
// where params → limit param → nested-with params (always, both paths).
|
|
2287
2371
|
if (relDef.type === 'manyToMany') {
|
|
2288
2372
|
if (spec.where) {
|
|
2289
|
-
|
|
2290
|
-
params.push(v);
|
|
2291
|
-
}
|
|
2373
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2292
2374
|
}
|
|
2293
|
-
if (spec.limit) {
|
|
2375
|
+
if (spec.limit !== undefined) {
|
|
2294
2376
|
params.push(Number(spec.limit));
|
|
2295
2377
|
}
|
|
2296
2378
|
if (spec.with) {
|
|
2297
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2379
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2298
2380
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2299
2381
|
if (!nestedRelDef)
|
|
2300
2382
|
continue;
|
|
@@ -2303,31 +2385,32 @@ class QueryInterface {
|
|
|
2303
2385
|
}
|
|
2304
2386
|
return;
|
|
2305
2387
|
}
|
|
2306
|
-
|
|
2388
|
+
// Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
|
|
2389
|
+
const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
|
|
2390
|
+
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2307
2391
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2308
2392
|
if (!willWrap && spec.with) {
|
|
2309
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2393
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2310
2394
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2311
2395
|
if (!nestedRelDef)
|
|
2312
2396
|
continue;
|
|
2313
2397
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2314
2398
|
}
|
|
2315
2399
|
}
|
|
2316
|
-
// where params
|
|
2400
|
+
// where params — mirrors buildAliasWhere push order
|
|
2317
2401
|
if (spec.where) {
|
|
2318
|
-
|
|
2319
|
-
params.push(v);
|
|
2320
|
-
}
|
|
2402
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2321
2403
|
}
|
|
2322
2404
|
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2323
2405
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2324
2406
|
// pushing one here would orphan a param and desync the collect path.
|
|
2325
|
-
|
|
2407
|
+
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
2408
|
+
if (relDef.type === 'hasMany' && spec.limit !== undefined) {
|
|
2326
2409
|
params.push(Number(spec.limit));
|
|
2327
2410
|
}
|
|
2328
2411
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
2329
2412
|
if (willWrap && spec.with) {
|
|
2330
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2413
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2331
2414
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2332
2415
|
if (!nestedRelDef)
|
|
2333
2416
|
continue;
|
|
@@ -2409,7 +2492,8 @@ class QueryInterface {
|
|
|
2409
2492
|
* Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
|
|
2410
2493
|
*/
|
|
2411
2494
|
buildWhereClause(where, params) {
|
|
2412
|
-
|
|
2495
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
|
|
2496
|
+
const keys = sortedKeys(where);
|
|
2413
2497
|
if (keys.length === 0)
|
|
2414
2498
|
return null;
|
|
2415
2499
|
const andClauses = [];
|
|
@@ -2530,25 +2614,11 @@ class QueryInterface {
|
|
|
2530
2614
|
andClauses.push(...opClauses);
|
|
2531
2615
|
continue;
|
|
2532
2616
|
}
|
|
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
|
-
}
|
|
2617
|
+
// Strict validation: a plain object literal that matched no known filter
|
|
2618
|
+
// shape is almost always a misspelled operator (`startWith` for
|
|
2619
|
+
// `startsWith`). The guard also runs on the cache-hit param-collect path
|
|
2620
|
+
// (collectWhereParams) so a warmed SQL cache can never skip it.
|
|
2621
|
+
this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
|
|
2552
2622
|
// Plain equality
|
|
2553
2623
|
params.push(value);
|
|
2554
2624
|
andClauses.push(`${column} = ${this.p(params.length)}`);
|
|
@@ -2630,7 +2700,9 @@ class QueryInterface {
|
|
|
2630
2700
|
return null;
|
|
2631
2701
|
const qt = this.q(targetTable);
|
|
2632
2702
|
const conditions = [];
|
|
2633
|
-
|
|
2703
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and collectRelFilterParams.
|
|
2704
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2705
|
+
const value = subWhere[field];
|
|
2634
2706
|
if (value === undefined)
|
|
2635
2707
|
continue;
|
|
2636
2708
|
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
@@ -2648,17 +2720,184 @@ class QueryInterface {
|
|
|
2648
2720
|
conditions.push(...opClauses);
|
|
2649
2721
|
continue;
|
|
2650
2722
|
}
|
|
2723
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2651
2724
|
params.push(value);
|
|
2652
2725
|
conditions.push(`${qCol} = ${this.p(params.length)}`);
|
|
2653
2726
|
}
|
|
2654
2727
|
return conditions.length > 0 ? conditions.join(' AND ') : null;
|
|
2655
2728
|
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Resolve a column's Postgres type from an arbitrary table's metadata
|
|
2731
|
+
* (relation targets, not just `this.table`).
|
|
2732
|
+
*/
|
|
2733
|
+
pgTypeForColumn(meta, column) {
|
|
2734
|
+
return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
|
|
2735
|
+
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Equality-fallthrough guard shared by every SQL-build path AND every
|
|
2738
|
+
* cache-hit param-collect path. A plain object literal that matched no known
|
|
2739
|
+
* filter shape on a non-JSON column is almost always a misspelled operator
|
|
2740
|
+
* (`startWith` for `startsWith`); binding it as `col = $1` silently returns
|
|
2741
|
+
* wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
|
|
2742
|
+
* legitimate bind values and pass through, as do objects on json/jsonb
|
|
2743
|
+
* columns (object equality).
|
|
2744
|
+
*/
|
|
2745
|
+
assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
|
|
2746
|
+
if (!isUnmatchedPlainObject(value))
|
|
2747
|
+
return;
|
|
2748
|
+
if (columnPgType === 'json' || columnPgType === 'jsonb')
|
|
2749
|
+
return;
|
|
2750
|
+
const badKeys = Object.keys(value);
|
|
2751
|
+
throw new errors_js_1.ValidationError(badKeys.length === 0
|
|
2752
|
+
? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
|
|
2753
|
+
`Provide a value or an operator like { gt: 1 }.`
|
|
2754
|
+
: `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
|
|
2755
|
+
`${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
|
|
2756
|
+
`Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* Build the user-supplied `where` filter of a relation `with` clause against
|
|
2760
|
+
* the relation's table alias. Supports the same scalar surface as the
|
|
2761
|
+
* top-level WHERE builder — equality, IS NULL, operator objects (incl.
|
|
2762
|
+
* `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
|
|
2763
|
+
* objects throw via {@link assertBindableEqualityValue}.
|
|
2764
|
+
*
|
|
2765
|
+
* Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
|
|
2766
|
+
* cache hits and pipeline batching will desync.
|
|
2767
|
+
*/
|
|
2768
|
+
buildAliasWhere(targetTable, targetMeta, alias, where, params) {
|
|
2769
|
+
const clauses = [];
|
|
2770
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
|
|
2771
|
+
for (const key of sortedKeys(where)) {
|
|
2772
|
+
const value = where[key];
|
|
2773
|
+
if (value === undefined)
|
|
2774
|
+
continue;
|
|
2775
|
+
if (key === 'OR' || key === 'AND') {
|
|
2776
|
+
const arr = value;
|
|
2777
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2778
|
+
continue;
|
|
2779
|
+
const subs = arr
|
|
2780
|
+
.map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
|
|
2781
|
+
.filter((s) => s !== null)
|
|
2782
|
+
.map((s) => `(${s})`);
|
|
2783
|
+
if (subs.length > 0)
|
|
2784
|
+
clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
|
|
2785
|
+
continue;
|
|
2786
|
+
}
|
|
2787
|
+
if (key === 'NOT') {
|
|
2788
|
+
const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
|
|
2789
|
+
if (sub)
|
|
2790
|
+
clauses.push(`NOT (${sub})`);
|
|
2791
|
+
continue;
|
|
2792
|
+
}
|
|
2793
|
+
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
2794
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
2795
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
|
|
2796
|
+
}
|
|
2797
|
+
const qCol = `${alias}.${this.q(col)}`;
|
|
2798
|
+
if (value === null) {
|
|
2799
|
+
clauses.push(`${qCol} IS NULL`);
|
|
2800
|
+
continue;
|
|
2801
|
+
}
|
|
2802
|
+
if (isWhereOperator(value)) {
|
|
2803
|
+
clauses.push(...this.buildOperatorClauses(qCol, value, params));
|
|
2804
|
+
continue;
|
|
2805
|
+
}
|
|
2806
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2807
|
+
params.push(value);
|
|
2808
|
+
clauses.push(`${qCol} = ${this.p(params.length)}`);
|
|
2809
|
+
}
|
|
2810
|
+
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2811
|
+
}
|
|
2812
|
+
/** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
|
|
2813
|
+
collectAliasWhereParams(targetTable, targetMeta, where, params) {
|
|
2814
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
|
|
2815
|
+
for (const key of sortedKeys(where)) {
|
|
2816
|
+
const value = where[key];
|
|
2817
|
+
if (value === undefined)
|
|
2818
|
+
continue;
|
|
2819
|
+
if (key === 'OR' || key === 'AND') {
|
|
2820
|
+
const arr = value;
|
|
2821
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2822
|
+
continue;
|
|
2823
|
+
for (const cond of arr) {
|
|
2824
|
+
this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
|
|
2825
|
+
}
|
|
2826
|
+
continue;
|
|
2827
|
+
}
|
|
2828
|
+
if (key === 'NOT') {
|
|
2829
|
+
this.collectAliasWhereParams(targetTable, targetMeta, value, params);
|
|
2830
|
+
continue;
|
|
2831
|
+
}
|
|
2832
|
+
if (value === null)
|
|
2833
|
+
continue;
|
|
2834
|
+
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
2835
|
+
if (isWhereOperator(value)) {
|
|
2836
|
+
this.collectOperatorParams(col, value, params);
|
|
2837
|
+
continue;
|
|
2838
|
+
}
|
|
2839
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2840
|
+
params.push(value);
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Value-invariant, shape-aware fingerprint for a relation `with` clause's
|
|
2845
|
+
* `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
|
|
2846
|
+
* can emit — equality vs null vs operator sets vs combinators — or two
|
|
2847
|
+
* differently-shaped wheres would share one cached SQL string.
|
|
2848
|
+
*/
|
|
2849
|
+
fingerprintAliasWhere(where) {
|
|
2850
|
+
const keys = Object.keys(where)
|
|
2851
|
+
.filter((k) => where[k] !== undefined)
|
|
2852
|
+
.sort();
|
|
2853
|
+
const parts = [];
|
|
2854
|
+
for (const key of keys) {
|
|
2855
|
+
const value = where[key];
|
|
2856
|
+
if (key === 'OR' || key === 'AND') {
|
|
2857
|
+
const arr = value;
|
|
2858
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2859
|
+
continue;
|
|
2860
|
+
parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
|
|
2861
|
+
continue;
|
|
2862
|
+
}
|
|
2863
|
+
if (key === 'NOT') {
|
|
2864
|
+
parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
|
|
2865
|
+
continue;
|
|
2866
|
+
}
|
|
2867
|
+
if (value === null) {
|
|
2868
|
+
parts.push(`${key}:null`);
|
|
2869
|
+
continue;
|
|
2870
|
+
}
|
|
2871
|
+
if (isWhereOperator(value)) {
|
|
2872
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2873
|
+
continue;
|
|
2874
|
+
}
|
|
2875
|
+
if (isUnmatchedPlainObject(value)) {
|
|
2876
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
2877
|
+
.sort()
|
|
2878
|
+
.join(',')})`);
|
|
2879
|
+
continue;
|
|
2880
|
+
}
|
|
2881
|
+
parts.push(`${key}:eq`);
|
|
2882
|
+
}
|
|
2883
|
+
return parts.join('&');
|
|
2884
|
+
}
|
|
2656
2885
|
/**
|
|
2657
2886
|
* Build SQL clauses for a single operator object on a column.
|
|
2658
2887
|
* Each operator key becomes its own clause, all ANDed together.
|
|
2659
2888
|
*/
|
|
2660
2889
|
buildOperatorClauses(column, op, params) {
|
|
2661
2890
|
const clauses = [];
|
|
2891
|
+
if (op.equals !== undefined) {
|
|
2892
|
+
if (op.equals === null) {
|
|
2893
|
+
clauses.push(`${column} IS NULL`);
|
|
2894
|
+
}
|
|
2895
|
+
else {
|
|
2896
|
+
assertBindableEqualsOperand(op.equals, column);
|
|
2897
|
+
params.push(op.equals);
|
|
2898
|
+
clauses.push(`${column} = ${this.p(params.length)}`);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2662
2901
|
if (op.gt !== undefined) {
|
|
2663
2902
|
params.push(op.gt);
|
|
2664
2903
|
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
@@ -2957,7 +3196,7 @@ class QueryInterface {
|
|
|
2957
3196
|
const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
|
|
2958
3197
|
const relationSelects = [];
|
|
2959
3198
|
const aliasCounter = { n: 0 };
|
|
2960
|
-
for (const [relName, relSpec] of
|
|
3199
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
2961
3200
|
const relDef = meta.relations[relName];
|
|
2962
3201
|
if (!relDef) {
|
|
2963
3202
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3099,7 +3338,11 @@ class QueryInterface {
|
|
|
3099
3338
|
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
3100
3339
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
3101
3340
|
// so we must NOT build them here (they would push orphaned params).
|
|
3102
|
-
|
|
3341
|
+
// An orderBy with no defined entries (`orderBy: {}`) is treated as absent —
|
|
3342
|
+
// it must neither trigger the wrap (dropping nested relations) nor render a
|
|
3343
|
+
// dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
|
|
3344
|
+
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
3345
|
+
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
|
|
3103
3346
|
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3104
3347
|
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3105
3348
|
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
@@ -3108,7 +3351,7 @@ class QueryInterface {
|
|
|
3108
3351
|
}
|
|
3109
3352
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
3110
3353
|
if (!willWrap && spec !== true && spec.with) {
|
|
3111
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3354
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3112
3355
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3113
3356
|
if (!nestedRelDef) {
|
|
3114
3357
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3127,14 +3370,14 @@ class QueryInterface {
|
|
|
3127
3370
|
const qTarget = this.q(targetTable);
|
|
3128
3371
|
// Build ORDER BY for json_agg
|
|
3129
3372
|
let orderClause = '';
|
|
3130
|
-
if (
|
|
3131
|
-
const orders =
|
|
3373
|
+
if (relOrderEntries.length > 0) {
|
|
3374
|
+
const orders = relOrderEntries
|
|
3132
3375
|
.map(([k, dir]) => {
|
|
3133
3376
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3134
3377
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3135
3378
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3136
3379
|
}
|
|
3137
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3380
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3138
3381
|
return `${alias}.${this.q(col)} ${safeDir}`;
|
|
3139
3382
|
})
|
|
3140
3383
|
.join(', ');
|
|
@@ -3151,24 +3394,21 @@ class QueryInterface {
|
|
|
3151
3394
|
else {
|
|
3152
3395
|
whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3153
3396
|
}
|
|
3154
|
-
// Additional filters —
|
|
3397
|
+
// Additional filters — full scalar where surface (equality, null, operator
|
|
3398
|
+
// objects, OR/AND/NOT), properly parameterized against this alias.
|
|
3155
3399
|
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
|
-
}
|
|
3400
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
|
|
3401
|
+
if (extra)
|
|
3402
|
+
whereClause += ` AND ${extra}`;
|
|
3164
3403
|
}
|
|
3165
3404
|
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
3166
3405
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
3167
3406
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
3168
3407
|
// which Postgres rejects with "could not determine data type of parameter $N"
|
|
3169
3408
|
// (and shifts every later placeholder by one). To-one relations ignore limit.
|
|
3409
|
+
// `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
|
|
3170
3410
|
let limitClause = '';
|
|
3171
|
-
if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
|
|
3411
|
+
if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
|
|
3172
3412
|
params.push(Number(spec.limit));
|
|
3173
3413
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3174
3414
|
}
|
|
@@ -3187,7 +3427,7 @@ class QueryInterface {
|
|
|
3187
3427
|
]);
|
|
3188
3428
|
// Build nested relation subqueries referencing innerAlias
|
|
3189
3429
|
if (spec !== true && spec.with) {
|
|
3190
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3430
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3191
3431
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3192
3432
|
if (!nestedRelDef) {
|
|
3193
3433
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3262,35 +3502,33 @@ class QueryInterface {
|
|
|
3262
3502
|
let whereClause = sourceKeys
|
|
3263
3503
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3264
3504
|
.join(' AND ');
|
|
3265
|
-
// ORDER BY on the target rows
|
|
3505
|
+
// ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
|
|
3506
|
+
// treated as absent — it must not render a dangling `ORDER BY `.
|
|
3507
|
+
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
3266
3508
|
let orderClause = '';
|
|
3267
|
-
if (
|
|
3268
|
-
const orders =
|
|
3509
|
+
if (relOrderEntries.length > 0) {
|
|
3510
|
+
const orders = relOrderEntries
|
|
3269
3511
|
.map(([k, dir]) => {
|
|
3270
3512
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3271
3513
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3272
3514
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3273
3515
|
}
|
|
3274
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3516
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3275
3517
|
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3276
3518
|
})
|
|
3277
3519
|
.join(', ');
|
|
3278
3520
|
orderClause = ` ORDER BY ${orders}`;
|
|
3279
3521
|
}
|
|
3280
|
-
// Additional WHERE filters on the target —
|
|
3522
|
+
// Additional WHERE filters on the target — full scalar where surface,
|
|
3523
|
+
// properly parameterized against the target alias.
|
|
3281
3524
|
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
|
-
}
|
|
3525
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
|
|
3526
|
+
if (extra)
|
|
3527
|
+
whereClause += ` AND ${extra}`;
|
|
3290
3528
|
}
|
|
3291
|
-
// LIMIT
|
|
3529
|
+
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
3292
3530
|
let limitClause = '';
|
|
3293
|
-
if (spec !== true && spec.limit) {
|
|
3531
|
+
if (spec !== true && spec.limit !== undefined) {
|
|
3294
3532
|
params.push(Number(spec.limit));
|
|
3295
3533
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3296
3534
|
}
|
|
@@ -3307,7 +3545,7 @@ class QueryInterface {
|
|
|
3307
3545
|
]);
|
|
3308
3546
|
// Nested relations reference the inner alias.
|
|
3309
3547
|
if (spec !== true && spec.with) {
|
|
3310
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3548
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3311
3549
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3312
3550
|
if (!nestedRelDef) {
|
|
3313
3551
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3330,7 +3568,7 @@ class QueryInterface {
|
|
|
3330
3568
|
`${talias}.${this.q(col)}`,
|
|
3331
3569
|
]);
|
|
3332
3570
|
if (spec !== true && spec.with) {
|
|
3333
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3571
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3334
3572
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3335
3573
|
if (!nestedRelDef) {
|
|
3336
3574
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|