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
package/dist/query/builder.js
CHANGED
|
@@ -30,6 +30,67 @@ function isWhereOperator(value) {
|
|
|
30
30
|
const keys = Object.keys(value);
|
|
31
31
|
return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* True for a *plain object literal* that reached an equality fallthrough
|
|
35
|
+
* without matching any known filter shape — the misspelled-operator case.
|
|
36
|
+
* Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
|
|
37
|
+
* bind values and return false, as do arrays and Dates.
|
|
38
|
+
*/
|
|
39
|
+
function isUnmatchedPlainObject(value) {
|
|
40
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
|
|
41
|
+
return false;
|
|
42
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
|
|
43
|
+
return false;
|
|
44
|
+
const proto = Object.getPrototypeOf(value);
|
|
45
|
+
return proto === Object.prototype || proto === null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
|
|
49
|
+
* `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
|
|
50
|
+
* param pushed), so null-ness is part of the shape — without it a cache entry
|
|
51
|
+
* warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
|
|
52
|
+
*/
|
|
53
|
+
function fingerprintOperatorShape(value) {
|
|
54
|
+
const obj = value;
|
|
55
|
+
const opKeys = Object.keys(obj)
|
|
56
|
+
.filter((k) => k !== 'mode')
|
|
57
|
+
.map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
|
|
58
|
+
.sort();
|
|
59
|
+
const modeStr = value.mode === 'insensitive' ? ':i' : '';
|
|
60
|
+
return `op(${opKeys.join(',')}${modeStr})`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Guard for the value of an `equals` operator reaching the plain-equality
|
|
64
|
+
* operator path. A plain object literal can only legitimately be an equality
|
|
65
|
+
* value on a json/jsonb column — and those route to the JSONB filter branch
|
|
66
|
+
* BEFORE the operator branch, so any plain object that reaches here is a
|
|
67
|
+
* mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
|
|
68
|
+
* SQL-build path and the cache-hit param-collect path so a warmed cache can
|
|
69
|
+
* never skip the check.
|
|
70
|
+
*/
|
|
71
|
+
function assertBindableEqualsOperand(value, column) {
|
|
72
|
+
if (!isUnmatchedPlainObject(value))
|
|
73
|
+
return;
|
|
74
|
+
throw new ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
|
|
75
|
+
`objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
|
|
76
|
+
`where 'equals' is the JSONB containment filter.`);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Object keys in sorted order, mirroring the canonical order used by every
|
|
80
|
+
* cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
|
|
81
|
+
* enumerate object keys in this exact order: fingerprints sort keys, so two
|
|
82
|
+
* where clauses with the same fields in different insertion order share one
|
|
83
|
+
* cache entry — if build/collect iterated insertion order, the cached SQL's
|
|
84
|
+
* `$N` placeholders would bind the wrong values (cross-tenant-leak class).
|
|
85
|
+
* Array order (OR/AND members) is positional and is never sorted.
|
|
86
|
+
*/
|
|
87
|
+
function sortedKeys(obj) {
|
|
88
|
+
return Object.keys(obj).sort();
|
|
89
|
+
}
|
|
90
|
+
/** {@link sortedKeys}, but yielding `[key, value]` pairs. */
|
|
91
|
+
function sortedEntries(obj) {
|
|
92
|
+
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
93
|
+
}
|
|
33
94
|
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
34
95
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
35
96
|
/** Known JSONB operator keys */
|
|
@@ -39,9 +100,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
|
39
100
|
* appear in any other where-filter shape, so the presence of one of these is
|
|
40
101
|
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
41
102
|
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
42
|
-
* `WhereOperator` for LIKE) is not misclassified.
|
|
103
|
+
* `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
|
|
104
|
+
* set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
|
|
105
|
+
* so it must fall through instead of throwing.
|
|
43
106
|
*/
|
|
44
|
-
const JSONB_UNIQUE_KEYS = new Set(['path', '
|
|
107
|
+
const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
|
|
45
108
|
/** Check if a value is a JSONB filter object */
|
|
46
109
|
function isJsonFilter(value) {
|
|
47
110
|
if (value === null ||
|
|
@@ -342,10 +405,12 @@ export class QueryInterface {
|
|
|
342
405
|
* Execute a query through the middleware chain.
|
|
343
406
|
* If no middlewares are registered, executes directly.
|
|
344
407
|
*
|
|
345
|
-
* Middleware can inspect and log query parameters,
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
408
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
409
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
410
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
411
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
412
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
413
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
349
414
|
*/
|
|
350
415
|
async executeWithMiddleware(action, args, executor) {
|
|
351
416
|
this.currentAction = action;
|
|
@@ -384,8 +449,12 @@ export class QueryInterface {
|
|
|
384
449
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
385
450
|
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
386
451
|
const params = [];
|
|
387
|
-
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
388
|
-
|
|
452
|
+
// Check if all where values are simple (plain equality, no operators/null/OR).
|
|
453
|
+
// Keys are sorted to match fingerprintWhere — insertion order here would let
|
|
454
|
+
// permuted where literals share a cache entry with misaligned params.
|
|
455
|
+
const whereKeys = Object.keys(whereObj)
|
|
456
|
+
.filter((k) => whereObj[k] !== undefined)
|
|
457
|
+
.sort();
|
|
389
458
|
const isSimpleWhere = !whereObj.OR &&
|
|
390
459
|
!whereObj.AND &&
|
|
391
460
|
!whereObj.NOT &&
|
|
@@ -589,7 +658,8 @@ export class QueryInterface {
|
|
|
589
658
|
}
|
|
590
659
|
let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
|
|
591
660
|
if (args?.cursor) {
|
|
592
|
-
|
|
661
|
+
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
662
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
593
663
|
if (cursorEntries.length > 0) {
|
|
594
664
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
595
665
|
const col = this.toSqlColumn(k);
|
|
@@ -630,9 +700,9 @@ export class QueryInterface {
|
|
|
630
700
|
if (args?.with) {
|
|
631
701
|
this.collectWithParams(args.with, params);
|
|
632
702
|
}
|
|
633
|
-
// 3. Cursor params
|
|
703
|
+
// 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
|
|
634
704
|
if (args?.cursor) {
|
|
635
|
-
const cursorEntries =
|
|
705
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
636
706
|
for (const [, v] of cursorEntries) {
|
|
637
707
|
params.push(v);
|
|
638
708
|
}
|
|
@@ -1872,12 +1942,7 @@ export class QueryInterface {
|
|
|
1872
1942
|
}
|
|
1873
1943
|
// Operator objects
|
|
1874
1944
|
if (isWhereOperator(value)) {
|
|
1875
|
-
|
|
1876
|
-
.filter((k) => k !== 'mode')
|
|
1877
|
-
.sort();
|
|
1878
|
-
const mode = value.mode;
|
|
1879
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1880
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1945
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
1881
1946
|
continue;
|
|
1882
1947
|
}
|
|
1883
1948
|
// Vector distance filter — metric (operator) and present comparators
|
|
@@ -1908,6 +1973,16 @@ export class QueryInterface {
|
|
|
1908
1973
|
parts.push(`${key}:fts(${cfg})`);
|
|
1909
1974
|
continue;
|
|
1910
1975
|
}
|
|
1976
|
+
// Plain object literal that matched no filter shape — give it a
|
|
1977
|
+
// fingerprint distinct from real equality. The build path throws for
|
|
1978
|
+
// these on non-JSON columns; sharing `key:eq` would let a cache entry
|
|
1979
|
+
// warmed by genuine equality serve the bad filter silently.
|
|
1980
|
+
if (isUnmatchedPlainObject(value)) {
|
|
1981
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
1982
|
+
.sort()
|
|
1983
|
+
.join(',')})`);
|
|
1984
|
+
continue;
|
|
1985
|
+
}
|
|
1911
1986
|
// Plain equality
|
|
1912
1987
|
parts.push(`${key}:eq`);
|
|
1913
1988
|
}
|
|
@@ -1940,12 +2015,12 @@ export class QueryInterface {
|
|
|
1940
2015
|
parts.push(`${key}:null`);
|
|
1941
2016
|
}
|
|
1942
2017
|
else if (isWhereOperator(value)) {
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2018
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2019
|
+
}
|
|
2020
|
+
else if (isUnmatchedPlainObject(value)) {
|
|
2021
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
2022
|
+
.sort()
|
|
2023
|
+
.join(',')})`);
|
|
1949
2024
|
}
|
|
1950
2025
|
else {
|
|
1951
2026
|
parts.push(`${key}:eq`);
|
|
@@ -1961,7 +2036,8 @@ export class QueryInterface {
|
|
|
1961
2036
|
* @internal Exposed as package-private for testing.
|
|
1962
2037
|
*/
|
|
1963
2038
|
collectWhereParams(where, params) {
|
|
1964
|
-
|
|
2039
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
|
|
2040
|
+
const keys = sortedKeys(where);
|
|
1965
2041
|
for (const key of keys) {
|
|
1966
2042
|
const value = where[key];
|
|
1967
2043
|
if (value === undefined)
|
|
@@ -2046,10 +2122,12 @@ export class QueryInterface {
|
|
|
2046
2122
|
}
|
|
2047
2123
|
// Operator objects
|
|
2048
2124
|
if (isWhereOperator(value)) {
|
|
2049
|
-
this.collectOperatorParams(value, params);
|
|
2125
|
+
this.collectOperatorParams(rawColumn, value, params);
|
|
2050
2126
|
continue;
|
|
2051
2127
|
}
|
|
2052
|
-
// Plain equality
|
|
2128
|
+
// Plain equality — same strict validation as the build path, so a
|
|
2129
|
+
// cache hit can never silently bind a misspelled-operator object.
|
|
2130
|
+
this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
|
|
2053
2131
|
params.push(value);
|
|
2054
2132
|
}
|
|
2055
2133
|
}
|
|
@@ -2058,20 +2136,28 @@ export class QueryInterface {
|
|
|
2058
2136
|
const meta = this.schema.tables[targetTable];
|
|
2059
2137
|
if (!meta)
|
|
2060
2138
|
return;
|
|
2061
|
-
|
|
2139
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and buildSubWhereForRelation.
|
|
2140
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2141
|
+
const value = subWhere[field];
|
|
2062
2142
|
if (value === undefined)
|
|
2063
2143
|
continue;
|
|
2064
2144
|
if (value === null)
|
|
2065
2145
|
continue;
|
|
2146
|
+
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2066
2147
|
if (isWhereOperator(value)) {
|
|
2067
|
-
this.collectOperatorParams(value, params);
|
|
2148
|
+
this.collectOperatorParams(col, value, params);
|
|
2068
2149
|
continue;
|
|
2069
2150
|
}
|
|
2151
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2070
2152
|
params.push(value);
|
|
2071
2153
|
}
|
|
2072
2154
|
}
|
|
2073
2155
|
/** Collect params from operator clauses. Mirrors buildOperatorClauses. */
|
|
2074
|
-
collectOperatorParams(op, params) {
|
|
2156
|
+
collectOperatorParams(column, op, params) {
|
|
2157
|
+
if (op.equals !== undefined && op.equals !== null) {
|
|
2158
|
+
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
2159
|
+
params.push(op.equals);
|
|
2160
|
+
}
|
|
2075
2161
|
if (op.gt !== undefined)
|
|
2076
2162
|
params.push(op.gt);
|
|
2077
2163
|
if (op.gte !== undefined)
|
|
@@ -2194,13 +2280,11 @@ export class QueryInterface {
|
|
|
2194
2280
|
.sort();
|
|
2195
2281
|
subParts.push(`om=${omKeys.join(',')}`);
|
|
2196
2282
|
}
|
|
2197
|
-
// where shape (value-invariant
|
|
2283
|
+
// where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
|
|
2284
|
+
// `{title: {contains: 'x'}}` emit different SQL so they must not share
|
|
2285
|
+
// a fingerprint)
|
|
2198
2286
|
if (opts.where) {
|
|
2199
|
-
|
|
2200
|
-
const wKeys = Object.keys(opts.where)
|
|
2201
|
-
.filter((k) => opts.where[k] !== undefined)
|
|
2202
|
-
.sort();
|
|
2203
|
-
subParts.push(`w=${wKeys.join(',')}`);
|
|
2287
|
+
subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
|
|
2204
2288
|
}
|
|
2205
2289
|
// orderBy shape
|
|
2206
2290
|
if (opts.orderBy) {
|
|
@@ -2229,7 +2313,7 @@ export class QueryInterface {
|
|
|
2229
2313
|
const meta = this.schema.tables[table ?? this.table];
|
|
2230
2314
|
if (!meta)
|
|
2231
2315
|
return;
|
|
2232
|
-
for (const [relName, relSpec] of
|
|
2316
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
2233
2317
|
const relDef = meta.relations[relName];
|
|
2234
2318
|
if (!relDef)
|
|
2235
2319
|
continue;
|
|
@@ -2250,15 +2334,13 @@ export class QueryInterface {
|
|
|
2250
2334
|
// where params → limit param → nested-with params (always, both paths).
|
|
2251
2335
|
if (relDef.type === 'manyToMany') {
|
|
2252
2336
|
if (spec.where) {
|
|
2253
|
-
|
|
2254
|
-
params.push(v);
|
|
2255
|
-
}
|
|
2337
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2256
2338
|
}
|
|
2257
|
-
if (spec.limit) {
|
|
2339
|
+
if (spec.limit !== undefined) {
|
|
2258
2340
|
params.push(Number(spec.limit));
|
|
2259
2341
|
}
|
|
2260
2342
|
if (spec.with) {
|
|
2261
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2343
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2262
2344
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2263
2345
|
if (!nestedRelDef)
|
|
2264
2346
|
continue;
|
|
@@ -2267,31 +2349,32 @@ export class QueryInterface {
|
|
|
2267
2349
|
}
|
|
2268
2350
|
return;
|
|
2269
2351
|
}
|
|
2270
|
-
|
|
2352
|
+
// Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
|
|
2353
|
+
const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
|
|
2354
|
+
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2271
2355
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2272
2356
|
if (!willWrap && spec.with) {
|
|
2273
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2357
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2274
2358
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2275
2359
|
if (!nestedRelDef)
|
|
2276
2360
|
continue;
|
|
2277
2361
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2278
2362
|
}
|
|
2279
2363
|
}
|
|
2280
|
-
// where params
|
|
2364
|
+
// where params — mirrors buildAliasWhere push order
|
|
2281
2365
|
if (spec.where) {
|
|
2282
|
-
|
|
2283
|
-
params.push(v);
|
|
2284
|
-
}
|
|
2366
|
+
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2285
2367
|
}
|
|
2286
2368
|
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2287
2369
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2288
2370
|
// pushing one here would orphan a param and desync the collect path.
|
|
2289
|
-
|
|
2371
|
+
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
2372
|
+
if (relDef.type === 'hasMany' && spec.limit !== undefined) {
|
|
2290
2373
|
params.push(Number(spec.limit));
|
|
2291
2374
|
}
|
|
2292
2375
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
2293
2376
|
if (willWrap && spec.with) {
|
|
2294
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2377
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2295
2378
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2296
2379
|
if (!nestedRelDef)
|
|
2297
2380
|
continue;
|
|
@@ -2373,7 +2456,8 @@ export class QueryInterface {
|
|
|
2373
2456
|
* Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
|
|
2374
2457
|
*/
|
|
2375
2458
|
buildWhereClause(where, params) {
|
|
2376
|
-
|
|
2459
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
|
|
2460
|
+
const keys = sortedKeys(where);
|
|
2377
2461
|
if (keys.length === 0)
|
|
2378
2462
|
return null;
|
|
2379
2463
|
const andClauses = [];
|
|
@@ -2494,25 +2578,11 @@ export class QueryInterface {
|
|
|
2494
2578
|
andClauses.push(...opClauses);
|
|
2495
2579
|
continue;
|
|
2496
2580
|
}
|
|
2497
|
-
// Strict validation: a plain
|
|
2498
|
-
//
|
|
2499
|
-
//
|
|
2500
|
-
//
|
|
2501
|
-
|
|
2502
|
-
// columns legitimately accept object values for equality, so they fall
|
|
2503
|
-
// through unchanged.
|
|
2504
|
-
if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
2505
|
-
const colType = this.getColumnPgType(rawColumn);
|
|
2506
|
-
if (colType !== 'json' && colType !== 'jsonb') {
|
|
2507
|
-
const badKeys = Object.keys(value);
|
|
2508
|
-
throw new ValidationError(badKeys.length === 0
|
|
2509
|
-
? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
|
|
2510
|
-
`Provide a value or an operator like { gt: 1 }.`
|
|
2511
|
-
: `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
|
|
2512
|
-
`${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
|
|
2513
|
-
`Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2581
|
+
// Strict validation: a plain object literal that matched no known filter
|
|
2582
|
+
// shape is almost always a misspelled operator (`startWith` for
|
|
2583
|
+
// `startsWith`). The guard also runs on the cache-hit param-collect path
|
|
2584
|
+
// (collectWhereParams) so a warmed SQL cache can never skip it.
|
|
2585
|
+
this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
|
|
2516
2586
|
// Plain equality
|
|
2517
2587
|
params.push(value);
|
|
2518
2588
|
andClauses.push(`${column} = ${this.p(params.length)}`);
|
|
@@ -2594,7 +2664,9 @@ export class QueryInterface {
|
|
|
2594
2664
|
return null;
|
|
2595
2665
|
const qt = this.q(targetTable);
|
|
2596
2666
|
const conditions = [];
|
|
2597
|
-
|
|
2667
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and collectRelFilterParams.
|
|
2668
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2669
|
+
const value = subWhere[field];
|
|
2598
2670
|
if (value === undefined)
|
|
2599
2671
|
continue;
|
|
2600
2672
|
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
@@ -2612,17 +2684,184 @@ export class QueryInterface {
|
|
|
2612
2684
|
conditions.push(...opClauses);
|
|
2613
2685
|
continue;
|
|
2614
2686
|
}
|
|
2687
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2615
2688
|
params.push(value);
|
|
2616
2689
|
conditions.push(`${qCol} = ${this.p(params.length)}`);
|
|
2617
2690
|
}
|
|
2618
2691
|
return conditions.length > 0 ? conditions.join(' AND ') : null;
|
|
2619
2692
|
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Resolve a column's Postgres type from an arbitrary table's metadata
|
|
2695
|
+
* (relation targets, not just `this.table`).
|
|
2696
|
+
*/
|
|
2697
|
+
pgTypeForColumn(meta, column) {
|
|
2698
|
+
return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
|
|
2699
|
+
}
|
|
2700
|
+
/**
|
|
2701
|
+
* Equality-fallthrough guard shared by every SQL-build path AND every
|
|
2702
|
+
* cache-hit param-collect path. A plain object literal that matched no known
|
|
2703
|
+
* filter shape on a non-JSON column is almost always a misspelled operator
|
|
2704
|
+
* (`startWith` for `startsWith`); binding it as `col = $1` silently returns
|
|
2705
|
+
* wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
|
|
2706
|
+
* legitimate bind values and pass through, as do objects on json/jsonb
|
|
2707
|
+
* columns (object equality).
|
|
2708
|
+
*/
|
|
2709
|
+
assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
|
|
2710
|
+
if (!isUnmatchedPlainObject(value))
|
|
2711
|
+
return;
|
|
2712
|
+
if (columnPgType === 'json' || columnPgType === 'jsonb')
|
|
2713
|
+
return;
|
|
2714
|
+
const badKeys = Object.keys(value);
|
|
2715
|
+
throw new ValidationError(badKeys.length === 0
|
|
2716
|
+
? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
|
|
2717
|
+
`Provide a value or an operator like { gt: 1 }.`
|
|
2718
|
+
: `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
|
|
2719
|
+
`${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
|
|
2720
|
+
`Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
|
|
2721
|
+
}
|
|
2722
|
+
/**
|
|
2723
|
+
* Build the user-supplied `where` filter of a relation `with` clause against
|
|
2724
|
+
* the relation's table alias. Supports the same scalar surface as the
|
|
2725
|
+
* top-level WHERE builder — equality, IS NULL, operator objects (incl.
|
|
2726
|
+
* `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
|
|
2727
|
+
* objects throw via {@link assertBindableEqualityValue}.
|
|
2728
|
+
*
|
|
2729
|
+
* Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
|
|
2730
|
+
* cache hits and pipeline batching will desync.
|
|
2731
|
+
*/
|
|
2732
|
+
buildAliasWhere(targetTable, targetMeta, alias, where, params) {
|
|
2733
|
+
const clauses = [];
|
|
2734
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
|
|
2735
|
+
for (const key of sortedKeys(where)) {
|
|
2736
|
+
const value = where[key];
|
|
2737
|
+
if (value === undefined)
|
|
2738
|
+
continue;
|
|
2739
|
+
if (key === 'OR' || key === 'AND') {
|
|
2740
|
+
const arr = value;
|
|
2741
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2742
|
+
continue;
|
|
2743
|
+
const subs = arr
|
|
2744
|
+
.map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
|
|
2745
|
+
.filter((s) => s !== null)
|
|
2746
|
+
.map((s) => `(${s})`);
|
|
2747
|
+
if (subs.length > 0)
|
|
2748
|
+
clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
2751
|
+
if (key === 'NOT') {
|
|
2752
|
+
const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
|
|
2753
|
+
if (sub)
|
|
2754
|
+
clauses.push(`NOT (${sub})`);
|
|
2755
|
+
continue;
|
|
2756
|
+
}
|
|
2757
|
+
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
2758
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
2759
|
+
throw new ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
|
|
2760
|
+
}
|
|
2761
|
+
const qCol = `${alias}.${this.q(col)}`;
|
|
2762
|
+
if (value === null) {
|
|
2763
|
+
clauses.push(`${qCol} IS NULL`);
|
|
2764
|
+
continue;
|
|
2765
|
+
}
|
|
2766
|
+
if (isWhereOperator(value)) {
|
|
2767
|
+
clauses.push(...this.buildOperatorClauses(qCol, value, params));
|
|
2768
|
+
continue;
|
|
2769
|
+
}
|
|
2770
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2771
|
+
params.push(value);
|
|
2772
|
+
clauses.push(`${qCol} = ${this.p(params.length)}`);
|
|
2773
|
+
}
|
|
2774
|
+
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2775
|
+
}
|
|
2776
|
+
/** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
|
|
2777
|
+
collectAliasWhereParams(targetTable, targetMeta, where, params) {
|
|
2778
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
|
|
2779
|
+
for (const key of sortedKeys(where)) {
|
|
2780
|
+
const value = where[key];
|
|
2781
|
+
if (value === undefined)
|
|
2782
|
+
continue;
|
|
2783
|
+
if (key === 'OR' || key === 'AND') {
|
|
2784
|
+
const arr = value;
|
|
2785
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2786
|
+
continue;
|
|
2787
|
+
for (const cond of arr) {
|
|
2788
|
+
this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
|
|
2789
|
+
}
|
|
2790
|
+
continue;
|
|
2791
|
+
}
|
|
2792
|
+
if (key === 'NOT') {
|
|
2793
|
+
this.collectAliasWhereParams(targetTable, targetMeta, value, params);
|
|
2794
|
+
continue;
|
|
2795
|
+
}
|
|
2796
|
+
if (value === null)
|
|
2797
|
+
continue;
|
|
2798
|
+
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
2799
|
+
if (isWhereOperator(value)) {
|
|
2800
|
+
this.collectOperatorParams(col, value, params);
|
|
2801
|
+
continue;
|
|
2802
|
+
}
|
|
2803
|
+
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2804
|
+
params.push(value);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
/**
|
|
2808
|
+
* Value-invariant, shape-aware fingerprint for a relation `with` clause's
|
|
2809
|
+
* `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
|
|
2810
|
+
* can emit — equality vs null vs operator sets vs combinators — or two
|
|
2811
|
+
* differently-shaped wheres would share one cached SQL string.
|
|
2812
|
+
*/
|
|
2813
|
+
fingerprintAliasWhere(where) {
|
|
2814
|
+
const keys = Object.keys(where)
|
|
2815
|
+
.filter((k) => where[k] !== undefined)
|
|
2816
|
+
.sort();
|
|
2817
|
+
const parts = [];
|
|
2818
|
+
for (const key of keys) {
|
|
2819
|
+
const value = where[key];
|
|
2820
|
+
if (key === 'OR' || key === 'AND') {
|
|
2821
|
+
const arr = value;
|
|
2822
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
2823
|
+
continue;
|
|
2824
|
+
parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
if (key === 'NOT') {
|
|
2828
|
+
parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
|
|
2829
|
+
continue;
|
|
2830
|
+
}
|
|
2831
|
+
if (value === null) {
|
|
2832
|
+
parts.push(`${key}:null`);
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
if (isWhereOperator(value)) {
|
|
2836
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2837
|
+
continue;
|
|
2838
|
+
}
|
|
2839
|
+
if (isUnmatchedPlainObject(value)) {
|
|
2840
|
+
parts.push(`${key}:obj(${Object.keys(value)
|
|
2841
|
+
.sort()
|
|
2842
|
+
.join(',')})`);
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2845
|
+
parts.push(`${key}:eq`);
|
|
2846
|
+
}
|
|
2847
|
+
return parts.join('&');
|
|
2848
|
+
}
|
|
2620
2849
|
/**
|
|
2621
2850
|
* Build SQL clauses for a single operator object on a column.
|
|
2622
2851
|
* Each operator key becomes its own clause, all ANDed together.
|
|
2623
2852
|
*/
|
|
2624
2853
|
buildOperatorClauses(column, op, params) {
|
|
2625
2854
|
const clauses = [];
|
|
2855
|
+
if (op.equals !== undefined) {
|
|
2856
|
+
if (op.equals === null) {
|
|
2857
|
+
clauses.push(`${column} IS NULL`);
|
|
2858
|
+
}
|
|
2859
|
+
else {
|
|
2860
|
+
assertBindableEqualsOperand(op.equals, column);
|
|
2861
|
+
params.push(op.equals);
|
|
2862
|
+
clauses.push(`${column} = ${this.p(params.length)}`);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2626
2865
|
if (op.gt !== undefined) {
|
|
2627
2866
|
params.push(op.gt);
|
|
2628
2867
|
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
@@ -2921,7 +3160,7 @@ export class QueryInterface {
|
|
|
2921
3160
|
const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
|
|
2922
3161
|
const relationSelects = [];
|
|
2923
3162
|
const aliasCounter = { n: 0 };
|
|
2924
|
-
for (const [relName, relSpec] of
|
|
3163
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
2925
3164
|
const relDef = meta.relations[relName];
|
|
2926
3165
|
if (!relDef) {
|
|
2927
3166
|
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3063,7 +3302,11 @@ export class QueryInterface {
|
|
|
3063
3302
|
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
3064
3303
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
3065
3304
|
// so we must NOT build them here (they would push orphaned params).
|
|
3066
|
-
|
|
3305
|
+
// An orderBy with no defined entries (`orderBy: {}`) is treated as absent —
|
|
3306
|
+
// it must neither trigger the wrap (dropping nested relations) nor render a
|
|
3307
|
+
// dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
|
|
3308
|
+
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
3309
|
+
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
|
|
3067
3310
|
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3068
3311
|
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3069
3312
|
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
@@ -3072,7 +3315,7 @@ export class QueryInterface {
|
|
|
3072
3315
|
}
|
|
3073
3316
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
3074
3317
|
if (!willWrap && spec !== true && spec.with) {
|
|
3075
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3318
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3076
3319
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3077
3320
|
if (!nestedRelDef) {
|
|
3078
3321
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3091,14 +3334,14 @@ export class QueryInterface {
|
|
|
3091
3334
|
const qTarget = this.q(targetTable);
|
|
3092
3335
|
// Build ORDER BY for json_agg
|
|
3093
3336
|
let orderClause = '';
|
|
3094
|
-
if (
|
|
3095
|
-
const orders =
|
|
3337
|
+
if (relOrderEntries.length > 0) {
|
|
3338
|
+
const orders = relOrderEntries
|
|
3096
3339
|
.map(([k, dir]) => {
|
|
3097
3340
|
const col = camelToSnake(k);
|
|
3098
3341
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3099
3342
|
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3100
3343
|
}
|
|
3101
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3344
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3102
3345
|
return `${alias}.${this.q(col)} ${safeDir}`;
|
|
3103
3346
|
})
|
|
3104
3347
|
.join(', ');
|
|
@@ -3115,24 +3358,21 @@ export class QueryInterface {
|
|
|
3115
3358
|
else {
|
|
3116
3359
|
whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3117
3360
|
}
|
|
3118
|
-
// Additional filters —
|
|
3361
|
+
// Additional filters — full scalar where surface (equality, null, operator
|
|
3362
|
+
// objects, OR/AND/NOT), properly parameterized against this alias.
|
|
3119
3363
|
if (spec !== true && spec.where) {
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3124
|
-
}
|
|
3125
|
-
params.push(v);
|
|
3126
|
-
whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3127
|
-
}
|
|
3364
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
|
|
3365
|
+
if (extra)
|
|
3366
|
+
whereClause += ` AND ${extra}`;
|
|
3128
3367
|
}
|
|
3129
3368
|
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
3130
3369
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
3131
3370
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
3132
3371
|
// which Postgres rejects with "could not determine data type of parameter $N"
|
|
3133
3372
|
// (and shifts every later placeholder by one). To-one relations ignore limit.
|
|
3373
|
+
// `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
|
|
3134
3374
|
let limitClause = '';
|
|
3135
|
-
if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
|
|
3375
|
+
if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
|
|
3136
3376
|
params.push(Number(spec.limit));
|
|
3137
3377
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3138
3378
|
}
|
|
@@ -3151,7 +3391,7 @@ export class QueryInterface {
|
|
|
3151
3391
|
]);
|
|
3152
3392
|
// Build nested relation subqueries referencing innerAlias
|
|
3153
3393
|
if (spec !== true && spec.with) {
|
|
3154
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3394
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3155
3395
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3156
3396
|
if (!nestedRelDef) {
|
|
3157
3397
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3226,35 +3466,33 @@ export class QueryInterface {
|
|
|
3226
3466
|
let whereClause = sourceKeys
|
|
3227
3467
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3228
3468
|
.join(' AND ');
|
|
3229
|
-
// ORDER BY on the target rows
|
|
3469
|
+
// ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
|
|
3470
|
+
// treated as absent — it must not render a dangling `ORDER BY `.
|
|
3471
|
+
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
3230
3472
|
let orderClause = '';
|
|
3231
|
-
if (
|
|
3232
|
-
const orders =
|
|
3473
|
+
if (relOrderEntries.length > 0) {
|
|
3474
|
+
const orders = relOrderEntries
|
|
3233
3475
|
.map(([k, dir]) => {
|
|
3234
3476
|
const col = camelToSnake(k);
|
|
3235
3477
|
if (!targetMeta.allColumns.includes(col)) {
|
|
3236
3478
|
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3237
3479
|
}
|
|
3238
|
-
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3480
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3239
3481
|
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3240
3482
|
})
|
|
3241
3483
|
.join(', ');
|
|
3242
3484
|
orderClause = ` ORDER BY ${orders}`;
|
|
3243
3485
|
}
|
|
3244
|
-
// Additional WHERE filters on the target —
|
|
3486
|
+
// Additional WHERE filters on the target — full scalar where surface,
|
|
3487
|
+
// properly parameterized against the target alias.
|
|
3245
3488
|
if (spec !== true && spec.where) {
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3250
|
-
}
|
|
3251
|
-
params.push(v);
|
|
3252
|
-
whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3253
|
-
}
|
|
3489
|
+
const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
|
|
3490
|
+
if (extra)
|
|
3491
|
+
whereClause += ` AND ${extra}`;
|
|
3254
3492
|
}
|
|
3255
|
-
// LIMIT
|
|
3493
|
+
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
3256
3494
|
let limitClause = '';
|
|
3257
|
-
if (spec !== true && spec.limit) {
|
|
3495
|
+
if (spec !== true && spec.limit !== undefined) {
|
|
3258
3496
|
params.push(Number(spec.limit));
|
|
3259
3497
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3260
3498
|
}
|
|
@@ -3271,7 +3509,7 @@ export class QueryInterface {
|
|
|
3271
3509
|
]);
|
|
3272
3510
|
// Nested relations reference the inner alias.
|
|
3273
3511
|
if (spec !== true && spec.with) {
|
|
3274
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3512
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3275
3513
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3276
3514
|
if (!nestedRelDef) {
|
|
3277
3515
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3294,7 +3532,7 @@ export class QueryInterface {
|
|
|
3294
3532
|
`${talias}.${this.q(col)}`,
|
|
3295
3533
|
]);
|
|
3296
3534
|
if (spec !== true && spec.with) {
|
|
3297
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3535
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3298
3536
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3299
3537
|
if (!nestedRelDef) {
|
|
3300
3538
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|