turbine-orm 0.19.1 → 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 +75 -7
- package/dist/cjs/cli/index.js +17 -9
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +0 -30
- package/dist/cjs/client.js +12 -13
- package/dist/cjs/query/builder.js +115 -49
- package/dist/cjs/query/utils.js +1 -0
- package/dist/cli/index.js +17 -9
- package/dist/cli/migrate.d.ts +2 -2
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +0 -10
- package/dist/cli/studio.js +0 -29
- package/dist/client.d.ts +12 -13
- package/dist/client.js +12 -13
- package/dist/index.d.ts +1 -1
- package/dist/query/builder.d.ts +8 -6
- package/dist/query/builder.js +115 -49
- 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 +3 -3
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -34,7 +34,6 @@ exports.apiBuilder = apiBuilder;
|
|
|
34
34
|
exports.apiListSavedQueries = apiListSavedQueries;
|
|
35
35
|
exports.apiCreateSavedQuery = apiCreateSavedQuery;
|
|
36
36
|
exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
|
|
37
|
-
exports.isReadOnlyStatement = isReadOnlyStatement;
|
|
38
37
|
const node_child_process_1 = require("node:child_process");
|
|
39
38
|
const node_crypto_1 = require("node:crypto");
|
|
40
39
|
const node_fs_1 = require("node:fs");
|
|
@@ -550,35 +549,6 @@ function clampInt(value, fallback, min, max) {
|
|
|
550
549
|
return fallback;
|
|
551
550
|
return Math.min(Math.max(n, min), max);
|
|
552
551
|
}
|
|
553
|
-
/**
|
|
554
|
-
* Accept only SELECT or WITH (CTE) statements. Reject any statement that
|
|
555
|
-
* contains a semicolon followed by non-whitespace (prevents statement
|
|
556
|
-
* stacking), and require the first non-comment keyword to be SELECT or WITH.
|
|
557
|
-
*
|
|
558
|
-
* This is a first-line filter — the transaction's READ ONLY mode is the
|
|
559
|
-
* second line of defense. Both must fail before a destructive statement
|
|
560
|
-
* could run.
|
|
561
|
-
*/
|
|
562
|
-
function isReadOnlyStatement(sql) {
|
|
563
|
-
const stripped = stripSqlComments(sql).trim();
|
|
564
|
-
if (!stripped)
|
|
565
|
-
return false;
|
|
566
|
-
// Disallow statement stacking. A single trailing `;` is fine.
|
|
567
|
-
const withoutTrailingSemi = stripped.replace(/;+\s*$/, '');
|
|
568
|
-
if (withoutTrailingSemi.includes(';'))
|
|
569
|
-
return false;
|
|
570
|
-
const firstWord = withoutTrailingSemi.slice(0, 6).toUpperCase();
|
|
571
|
-
if (firstWord.startsWith('SELECT'))
|
|
572
|
-
return true;
|
|
573
|
-
if (firstWord.startsWith('WITH'))
|
|
574
|
-
return true;
|
|
575
|
-
return false;
|
|
576
|
-
}
|
|
577
|
-
function stripSqlComments(sql) {
|
|
578
|
-
// Strip -- line comments and /* block comments */. Not a full SQL parser,
|
|
579
|
-
// but sufficient to catch the common bypass attempts.
|
|
580
|
-
return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
581
|
-
}
|
|
582
552
|
function serializeRow(row) {
|
|
583
553
|
const out = {};
|
|
584
554
|
for (const [k, v] of Object.entries(row)) {
|
package/dist/cjs/client.js
CHANGED
|
@@ -331,12 +331,14 @@ class TurbineClient {
|
|
|
331
331
|
// Middleware — intercept all queries
|
|
332
332
|
// -------------------------------------------------------------------------
|
|
333
333
|
/**
|
|
334
|
-
* Register a middleware function that runs
|
|
334
|
+
* Register a middleware function that runs around every query.
|
|
335
335
|
*
|
|
336
|
-
* Middleware can inspect and log query parameters,
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
336
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
337
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
338
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
339
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
340
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
341
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
340
342
|
*
|
|
341
343
|
* @example
|
|
342
344
|
* ```ts
|
|
@@ -348,16 +350,13 @@ class TurbineClient {
|
|
|
348
350
|
* return result;
|
|
349
351
|
* });
|
|
350
352
|
*
|
|
351
|
-
* //
|
|
353
|
+
* // Result transformation middleware — redact a field on the way out
|
|
352
354
|
* db.$use(async (params, next) => {
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* if (params.action === 'delete') {
|
|
357
|
-
* params.action = 'update';
|
|
358
|
-
* params.args = { where: params.args.where, data: { deletedAt: new Date() } };
|
|
355
|
+
* const result = await next(params);
|
|
356
|
+
* if (params.model === 'users' && Array.isArray(result)) {
|
|
357
|
+
* for (const row of result as { email?: string }[]) row.email = '[redacted]';
|
|
359
358
|
* }
|
|
360
|
-
* return
|
|
359
|
+
* return result;
|
|
361
360
|
* });
|
|
362
361
|
* ```
|
|
363
362
|
*/
|
|
@@ -80,6 +80,53 @@ function isUnmatchedPlainObject(value) {
|
|
|
80
80
|
const proto = Object.getPrototypeOf(value);
|
|
81
81
|
return proto === Object.prototype || proto === null;
|
|
82
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
|
+
}
|
|
83
130
|
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
84
131
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
85
132
|
/** Known JSONB operator keys */
|
|
@@ -89,9 +136,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
|
89
136
|
* appear in any other where-filter shape, so the presence of one of these is
|
|
90
137
|
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
91
138
|
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
92
|
-
* `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.
|
|
93
142
|
*/
|
|
94
|
-
const JSONB_UNIQUE_KEYS = new Set(['path', '
|
|
143
|
+
const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
|
|
95
144
|
/** Check if a value is a JSONB filter object */
|
|
96
145
|
function isJsonFilter(value) {
|
|
97
146
|
if (value === null ||
|
|
@@ -392,10 +441,12 @@ class QueryInterface {
|
|
|
392
441
|
* Execute a query through the middleware chain.
|
|
393
442
|
* If no middlewares are registered, executes directly.
|
|
394
443
|
*
|
|
395
|
-
* Middleware can inspect and log query parameters,
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
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.
|
|
399
450
|
*/
|
|
400
451
|
async executeWithMiddleware(action, args, executor) {
|
|
401
452
|
this.currentAction = action;
|
|
@@ -434,8 +485,12 @@ class QueryInterface {
|
|
|
434
485
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
435
486
|
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
436
487
|
const params = [];
|
|
437
|
-
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
438
|
-
|
|
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();
|
|
439
494
|
const isSimpleWhere = !whereObj.OR &&
|
|
440
495
|
!whereObj.AND &&
|
|
441
496
|
!whereObj.NOT &&
|
|
@@ -639,7 +694,8 @@ class QueryInterface {
|
|
|
639
694
|
}
|
|
640
695
|
let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
|
|
641
696
|
if (args?.cursor) {
|
|
642
|
-
|
|
697
|
+
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
698
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
643
699
|
if (cursorEntries.length > 0) {
|
|
644
700
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
645
701
|
const col = this.toSqlColumn(k);
|
|
@@ -680,9 +736,9 @@ class QueryInterface {
|
|
|
680
736
|
if (args?.with) {
|
|
681
737
|
this.collectWithParams(args.with, params);
|
|
682
738
|
}
|
|
683
|
-
// 3. Cursor params
|
|
739
|
+
// 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
|
|
684
740
|
if (args?.cursor) {
|
|
685
|
-
const cursorEntries =
|
|
741
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
686
742
|
for (const [, v] of cursorEntries) {
|
|
687
743
|
params.push(v);
|
|
688
744
|
}
|
|
@@ -1922,12 +1978,7 @@ class QueryInterface {
|
|
|
1922
1978
|
}
|
|
1923
1979
|
// Operator objects
|
|
1924
1980
|
if (isWhereOperator(value)) {
|
|
1925
|
-
|
|
1926
|
-
.filter((k) => k !== 'mode')
|
|
1927
|
-
.sort();
|
|
1928
|
-
const mode = value.mode;
|
|
1929
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1930
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1981
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
1931
1982
|
continue;
|
|
1932
1983
|
}
|
|
1933
1984
|
// Vector distance filter — metric (operator) and present comparators
|
|
@@ -2000,12 +2051,7 @@ class QueryInterface {
|
|
|
2000
2051
|
parts.push(`${key}:null`);
|
|
2001
2052
|
}
|
|
2002
2053
|
else if (isWhereOperator(value)) {
|
|
2003
|
-
|
|
2004
|
-
.filter((k) => k !== 'mode')
|
|
2005
|
-
.sort();
|
|
2006
|
-
const mode = value.mode;
|
|
2007
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
2008
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
2054
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2009
2055
|
}
|
|
2010
2056
|
else if (isUnmatchedPlainObject(value)) {
|
|
2011
2057
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
@@ -2026,7 +2072,8 @@ class QueryInterface {
|
|
|
2026
2072
|
* @internal Exposed as package-private for testing.
|
|
2027
2073
|
*/
|
|
2028
2074
|
collectWhereParams(where, params) {
|
|
2029
|
-
|
|
2075
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
|
|
2076
|
+
const keys = sortedKeys(where);
|
|
2030
2077
|
for (const key of keys) {
|
|
2031
2078
|
const value = where[key];
|
|
2032
2079
|
if (value === undefined)
|
|
@@ -2111,7 +2158,7 @@ class QueryInterface {
|
|
|
2111
2158
|
}
|
|
2112
2159
|
// Operator objects
|
|
2113
2160
|
if (isWhereOperator(value)) {
|
|
2114
|
-
this.collectOperatorParams(value, params);
|
|
2161
|
+
this.collectOperatorParams(rawColumn, value, params);
|
|
2115
2162
|
continue;
|
|
2116
2163
|
}
|
|
2117
2164
|
// Plain equality — same strict validation as the build path, so a
|
|
@@ -2125,22 +2172,28 @@ class QueryInterface {
|
|
|
2125
2172
|
const meta = this.schema.tables[targetTable];
|
|
2126
2173
|
if (!meta)
|
|
2127
2174
|
return;
|
|
2128
|
-
|
|
2175
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and buildSubWhereForRelation.
|
|
2176
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2177
|
+
const value = subWhere[field];
|
|
2129
2178
|
if (value === undefined)
|
|
2130
2179
|
continue;
|
|
2131
2180
|
if (value === null)
|
|
2132
2181
|
continue;
|
|
2182
|
+
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2133
2183
|
if (isWhereOperator(value)) {
|
|
2134
|
-
this.collectOperatorParams(value, params);
|
|
2184
|
+
this.collectOperatorParams(col, value, params);
|
|
2135
2185
|
continue;
|
|
2136
2186
|
}
|
|
2137
|
-
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2138
2187
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2139
2188
|
params.push(value);
|
|
2140
2189
|
}
|
|
2141
2190
|
}
|
|
2142
2191
|
/** Collect params from operator clauses. Mirrors buildOperatorClauses. */
|
|
2143
|
-
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
|
+
}
|
|
2144
2197
|
if (op.gt !== undefined)
|
|
2145
2198
|
params.push(op.gt);
|
|
2146
2199
|
if (op.gte !== undefined)
|
|
@@ -2296,7 +2349,7 @@ class QueryInterface {
|
|
|
2296
2349
|
const meta = this.schema.tables[table ?? this.table];
|
|
2297
2350
|
if (!meta)
|
|
2298
2351
|
return;
|
|
2299
|
-
for (const [relName, relSpec] of
|
|
2352
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
2300
2353
|
const relDef = meta.relations[relName];
|
|
2301
2354
|
if (!relDef)
|
|
2302
2355
|
continue;
|
|
@@ -2323,7 +2376,7 @@ class QueryInterface {
|
|
|
2323
2376
|
params.push(Number(spec.limit));
|
|
2324
2377
|
}
|
|
2325
2378
|
if (spec.with) {
|
|
2326
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2379
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2327
2380
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2328
2381
|
if (!nestedRelDef)
|
|
2329
2382
|
continue;
|
|
@@ -2337,7 +2390,7 @@ class QueryInterface {
|
|
|
2337
2390
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2338
2391
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2339
2392
|
if (!willWrap && spec.with) {
|
|
2340
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2393
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2341
2394
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2342
2395
|
if (!nestedRelDef)
|
|
2343
2396
|
continue;
|
|
@@ -2357,7 +2410,7 @@ class QueryInterface {
|
|
|
2357
2410
|
}
|
|
2358
2411
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
2359
2412
|
if (willWrap && spec.with) {
|
|
2360
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2413
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2361
2414
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2362
2415
|
if (!nestedRelDef)
|
|
2363
2416
|
continue;
|
|
@@ -2439,7 +2492,8 @@ class QueryInterface {
|
|
|
2439
2492
|
* Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
|
|
2440
2493
|
*/
|
|
2441
2494
|
buildWhereClause(where, params) {
|
|
2442
|
-
|
|
2495
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
|
|
2496
|
+
const keys = sortedKeys(where);
|
|
2443
2497
|
if (keys.length === 0)
|
|
2444
2498
|
return null;
|
|
2445
2499
|
const andClauses = [];
|
|
@@ -2646,7 +2700,9 @@ class QueryInterface {
|
|
|
2646
2700
|
return null;
|
|
2647
2701
|
const qt = this.q(targetTable);
|
|
2648
2702
|
const conditions = [];
|
|
2649
|
-
|
|
2703
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and collectRelFilterParams.
|
|
2704
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2705
|
+
const value = subWhere[field];
|
|
2650
2706
|
if (value === undefined)
|
|
2651
2707
|
continue;
|
|
2652
2708
|
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
@@ -2711,7 +2767,9 @@ class QueryInterface {
|
|
|
2711
2767
|
*/
|
|
2712
2768
|
buildAliasWhere(targetTable, targetMeta, alias, where, params) {
|
|
2713
2769
|
const clauses = [];
|
|
2714
|
-
|
|
2770
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
|
|
2771
|
+
for (const key of sortedKeys(where)) {
|
|
2772
|
+
const value = where[key];
|
|
2715
2773
|
if (value === undefined)
|
|
2716
2774
|
continue;
|
|
2717
2775
|
if (key === 'OR' || key === 'AND') {
|
|
@@ -2753,7 +2811,9 @@ class QueryInterface {
|
|
|
2753
2811
|
}
|
|
2754
2812
|
/** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
|
|
2755
2813
|
collectAliasWhereParams(targetTable, targetMeta, where, params) {
|
|
2756
|
-
|
|
2814
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
|
|
2815
|
+
for (const key of sortedKeys(where)) {
|
|
2816
|
+
const value = where[key];
|
|
2757
2817
|
if (value === undefined)
|
|
2758
2818
|
continue;
|
|
2759
2819
|
if (key === 'OR' || key === 'AND') {
|
|
@@ -2771,11 +2831,11 @@ class QueryInterface {
|
|
|
2771
2831
|
}
|
|
2772
2832
|
if (value === null)
|
|
2773
2833
|
continue;
|
|
2834
|
+
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
2774
2835
|
if (isWhereOperator(value)) {
|
|
2775
|
-
this.collectOperatorParams(value, params);
|
|
2836
|
+
this.collectOperatorParams(col, value, params);
|
|
2776
2837
|
continue;
|
|
2777
2838
|
}
|
|
2778
|
-
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
2779
2839
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2780
2840
|
params.push(value);
|
|
2781
2841
|
}
|
|
@@ -2809,11 +2869,7 @@ class QueryInterface {
|
|
|
2809
2869
|
continue;
|
|
2810
2870
|
}
|
|
2811
2871
|
if (isWhereOperator(value)) {
|
|
2812
|
-
|
|
2813
|
-
.filter((k) => k !== 'mode')
|
|
2814
|
-
.sort();
|
|
2815
|
-
const mode = value.mode;
|
|
2816
|
-
parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
|
|
2872
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2817
2873
|
continue;
|
|
2818
2874
|
}
|
|
2819
2875
|
if (isUnmatchedPlainObject(value)) {
|
|
@@ -2832,6 +2888,16 @@ class QueryInterface {
|
|
|
2832
2888
|
*/
|
|
2833
2889
|
buildOperatorClauses(column, op, params) {
|
|
2834
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
|
+
}
|
|
2835
2901
|
if (op.gt !== undefined) {
|
|
2836
2902
|
params.push(op.gt);
|
|
2837
2903
|
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
@@ -3130,7 +3196,7 @@ class QueryInterface {
|
|
|
3130
3196
|
const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
|
|
3131
3197
|
const relationSelects = [];
|
|
3132
3198
|
const aliasCounter = { n: 0 };
|
|
3133
|
-
for (const [relName, relSpec] of
|
|
3199
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
3134
3200
|
const relDef = meta.relations[relName];
|
|
3135
3201
|
if (!relDef) {
|
|
3136
3202
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3285,7 +3351,7 @@ class QueryInterface {
|
|
|
3285
3351
|
}
|
|
3286
3352
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
3287
3353
|
if (!willWrap && spec !== true && spec.with) {
|
|
3288
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3354
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3289
3355
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3290
3356
|
if (!nestedRelDef) {
|
|
3291
3357
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3361,7 +3427,7 @@ class QueryInterface {
|
|
|
3361
3427
|
]);
|
|
3362
3428
|
// Build nested relation subqueries referencing innerAlias
|
|
3363
3429
|
if (spec !== true && spec.with) {
|
|
3364
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3430
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3365
3431
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3366
3432
|
if (!nestedRelDef) {
|
|
3367
3433
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3479,7 +3545,7 @@ class QueryInterface {
|
|
|
3479
3545
|
]);
|
|
3480
3546
|
// Nested relations reference the inner alias.
|
|
3481
3547
|
if (spec !== true && spec.with) {
|
|
3482
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3548
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3483
3549
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3484
3550
|
if (!nestedRelDef) {
|
|
3485
3551
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3502,7 +3568,7 @@ class QueryInterface {
|
|
|
3502
3568
|
`${talias}.${this.q(col)}`,
|
|
3503
3569
|
]);
|
|
3504
3570
|
if (spec !== true && spec.with) {
|
|
3505
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3571
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3506
3572
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3507
3573
|
if (!nestedRelDef) {
|
|
3508
3574
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
package/dist/cjs/query/utils.js
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -1129,13 +1129,15 @@ function showMigrateHelp() {
|
|
|
1129
1129
|
newline();
|
|
1130
1130
|
console.log(` ${bold('Options:')}`);
|
|
1131
1131
|
console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
|
|
1132
|
+
console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create only)')}`);
|
|
1132
1133
|
console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
|
|
1133
|
-
console.log(` ${cyan('--dry-run')}
|
|
1134
|
-
console.log(` ${cyan('--allow-drift')}
|
|
1135
|
-
console.log(` ${cyan('--verbose, -v')}
|
|
1134
|
+
console.log(` ${cyan('--dry-run')} Show SQL without executing`);
|
|
1135
|
+
console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
|
|
1136
|
+
console.log(` ${cyan('--verbose, -v')} Show detailed output`);
|
|
1136
1137
|
newline();
|
|
1137
1138
|
console.log(` ${bold('Examples:')}`);
|
|
1138
1139
|
console.log(` ${dim('$')} npx turbine migrate create add_users_table`);
|
|
1140
|
+
console.log(` ${dim('$')} npx turbine migrate create add_email_index --auto`);
|
|
1139
1141
|
console.log(` ${dim('$')} npx turbine migrate up`);
|
|
1140
1142
|
console.log(` ${dim('$')} npx turbine migrate down --step 2`);
|
|
1141
1143
|
console.log(` ${dim('$')} npx turbine migrate status`);
|
|
@@ -1180,16 +1182,17 @@ function showHelp() {
|
|
|
1180
1182
|
newline();
|
|
1181
1183
|
console.log(` ${bold('Commands:')}`);
|
|
1182
1184
|
console.log(` ${cyan('init')} Initialize a Turbine project`);
|
|
1183
|
-
console.log(` ${cyan('generate')} ${dim('| pull')}
|
|
1185
|
+
console.log(` ${cyan('generate')} ${dim('| pull')} Introspect database ${symbols.arrow} generate types`);
|
|
1184
1186
|
console.log(` ${cyan('push')} Apply schema definitions to database`);
|
|
1185
|
-
console.log(` ${cyan('migrate')} ${dim('<sub>')}
|
|
1186
|
-
console.log(` ${dim('create <name>')}
|
|
1187
|
+
console.log(` ${cyan('migrate')} ${dim('<sub>')} SQL migration management`);
|
|
1188
|
+
console.log(` ${dim('create <name>')} Create a new migration file`);
|
|
1187
1189
|
console.log(` ${dim('up')} Apply pending migrations`);
|
|
1188
1190
|
console.log(` ${dim('down')} Rollback last migration`);
|
|
1189
1191
|
console.log(` ${dim('status')} Show applied/pending migrations`);
|
|
1190
1192
|
console.log(` ${cyan('seed')} Run seed file`);
|
|
1191
|
-
console.log(` ${cyan('status')} ${dim('| info')}
|
|
1193
|
+
console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
|
|
1192
1194
|
console.log(` ${cyan('studio')} Launch local read-only web UI`);
|
|
1195
|
+
console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
|
|
1193
1196
|
newline();
|
|
1194
1197
|
console.log(` ${bold('Options:')}`);
|
|
1195
1198
|
console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
|
|
@@ -1201,8 +1204,13 @@ function showHelp() {
|
|
|
1201
1204
|
console.log(` ${cyan('--verbose, -v')} Show detailed output`);
|
|
1202
1205
|
console.log(` ${cyan('--force, -f')} Overwrite existing files`);
|
|
1203
1206
|
newline();
|
|
1204
|
-
console.log(` ${bold('
|
|
1205
|
-
console.log(` ${cyan('--
|
|
1207
|
+
console.log(` ${bold('Migrate options:')}`);
|
|
1208
|
+
console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create)')}`);
|
|
1209
|
+
console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
|
|
1210
|
+
console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} ${dim('(advanced)')}`);
|
|
1211
|
+
newline();
|
|
1212
|
+
console.log(` ${bold('Studio / observe options:')}`);
|
|
1213
|
+
console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983 studio, 4984 observe)')}`);
|
|
1206
1214
|
console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
|
|
1207
1215
|
console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
|
|
1208
1216
|
newline();
|
package/dist/cli/migrate.d.ts
CHANGED
|
@@ -113,8 +113,8 @@ export declare function deriveLockId(databaseName: string): number;
|
|
|
113
113
|
*/
|
|
114
114
|
export declare function migrateUp(connectionString: string, migrationsDir: string, options?: {
|
|
115
115
|
step?: number;
|
|
116
|
-
allowDrift?: boolean
|
|
117
|
-
force?: boolean
|
|
116
|
+
allowDrift?: boolean;
|
|
117
|
+
force?: boolean /** @deprecated use allowDrift */;
|
|
118
118
|
adapter?: DatabaseAdapter;
|
|
119
119
|
dialect?: Dialect;
|
|
120
120
|
}): Promise<{
|