turbine-orm 0.29.0 → 0.31.0
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 +1 -1
- package/dist/cjs/cli/index.js +5 -0
- package/dist/cjs/cli/mcp.js +22 -92
- package/dist/cjs/client.js +47 -6
- package/dist/cjs/generate.js +71 -25
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/introspect.js +350 -120
- package/dist/cjs/mssql.js +42 -136
- package/dist/cjs/mysql.js +16 -129
- package/dist/cjs/optional-peer-import.cjs +122 -0
- package/dist/cjs/powdb.js +579 -89
- package/dist/cjs/powql.js +56 -26
- package/dist/cjs/query/builder.js +601 -86
- package/dist/cjs/query/filters.js +80 -2
- package/dist/cjs/schema-metadata.js +316 -0
- package/dist/cjs/sqlite.js +8 -89
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/mcp.d.ts +18 -0
- package/dist/cli/mcp.js +22 -93
- package/dist/client.d.ts +19 -2
- package/dist/client.js +47 -6
- package/dist/generate.d.ts +16 -4
- package/dist/generate.js +71 -25
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -0
- package/dist/introspect.d.ts +94 -1
- package/dist/introspect.js +345 -120
- package/dist/mssql.js +40 -104
- package/dist/mysql.js +14 -97
- package/dist/optional-peer-import.cjs +89 -0
- package/dist/optional-peer-import.d.cts +53 -0
- package/dist/powdb.d.ts +118 -23
- package/dist/powdb.js +574 -88
- package/dist/powql.d.ts +6 -0
- package/dist/powql.js +58 -28
- package/dist/query/builder.d.ts +145 -8
- package/dist/query/builder.js +602 -87
- package/dist/query/deferred.d.ts +7 -2
- package/dist/query/filters.d.ts +46 -1
- package/dist/query/filters.js +76 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +85 -11
- package/dist/schema-metadata.d.ts +77 -0
- package/dist/schema-metadata.js +313 -0
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +9 -90
- package/package.json +3 -3
|
@@ -97,6 +97,12 @@ class QueryInterface {
|
|
|
97
97
|
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
98
98
|
columnPgTypeMap;
|
|
99
99
|
columnArrayTypeMap;
|
|
100
|
+
/**
|
|
101
|
+
* Columns whose type lives in a DIFFERENT schema than the introspected one
|
|
102
|
+
* (ColumnMetadata.pgTypeSchema is recorded only in that case) — such columns
|
|
103
|
+
* must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
|
|
104
|
+
*/
|
|
105
|
+
crossSchemaTypeColumns;
|
|
100
106
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
101
107
|
deepWithWarned = new Set();
|
|
102
108
|
/**
|
|
@@ -134,9 +140,19 @@ class QueryInterface {
|
|
|
134
140
|
this.middlewares = middlewares ?? [];
|
|
135
141
|
this.defaultLimit = options?.defaultLimit;
|
|
136
142
|
// Default to ON: surfacing accidental full-table scans is more valuable
|
|
137
|
-
// than the (small) risk of noisy logs. Callers
|
|
138
|
-
// `warnOnUnlimited: false
|
|
139
|
-
|
|
143
|
+
// than the (small) risk of noisy logs. Callers opt out globally with
|
|
144
|
+
// `warnOnUnlimited: false`, per table with `warnOnUnlimited: { users:
|
|
145
|
+
// false }` (unlisted tables keep the default), or per call via
|
|
146
|
+
// `findMany({ warnOnUnlimited: false })`.
|
|
147
|
+
// Per-table maps accept BOTH key forms — the snake_case table name
|
|
148
|
+
// (`user_profiles`) and the camelCase accessor (`userProfiles`) — since
|
|
149
|
+
// users naturally key by the accessor they type everywhere else. The
|
|
150
|
+
// snake_case entry wins when both are present.
|
|
151
|
+
const warnOpt = options?.warnOnUnlimited;
|
|
152
|
+
this.warnOnUnlimited =
|
|
153
|
+
typeof warnOpt === 'object' && warnOpt !== null
|
|
154
|
+
? (warnOpt[table] ?? warnOpt[(0, schema_js_1.snakeToCamel)(table)]) !== false
|
|
155
|
+
: warnOpt !== false;
|
|
140
156
|
this.utcTimestamps = options?.utcTimestamps !== false;
|
|
141
157
|
this.preparedStatementsEnabled = options?.preparedStatements ?? true;
|
|
142
158
|
this.sqlCacheEnabled = options?.sqlCache !== false;
|
|
@@ -152,9 +168,12 @@ class QueryInterface {
|
|
|
152
168
|
// Pre-compute column type lookup maps (TASK-26)
|
|
153
169
|
this.columnPgTypeMap = new Map();
|
|
154
170
|
this.columnArrayTypeMap = new Map();
|
|
171
|
+
this.crossSchemaTypeColumns = new Set();
|
|
155
172
|
for (const col of this.tableMeta.columns) {
|
|
156
173
|
this.columnPgTypeMap.set(col.name, col.dialectType ?? col.pgType);
|
|
157
174
|
this.columnArrayTypeMap.set(col.name, col.arrayType ?? col.pgArrayType);
|
|
175
|
+
if (col.pgTypeSchema !== undefined)
|
|
176
|
+
this.crossSchemaTypeColumns.add(col.name);
|
|
158
177
|
}
|
|
159
178
|
}
|
|
160
179
|
/** Quote an identifier through the active SQL dialect. */
|
|
@@ -680,10 +699,16 @@ class QueryInterface {
|
|
|
680
699
|
* loop calling `db.users.findMany()` thousands of times only logs once.
|
|
681
700
|
* Suppressed when `defaultLimit` is configured (the caller has already
|
|
682
701
|
* opted in to a bounded query) and when the user passed an explicit
|
|
683
|
-
* `limit`, `take`, or `cursor`.
|
|
702
|
+
* `limit`, `take`, or `cursor`. A per-call `warnOnUnlimited` overrides the
|
|
703
|
+
* config-level setting in either direction (`false` silences a call that
|
|
704
|
+
* intentionally reads the full set; `true` forces the warning even when
|
|
705
|
+
* disabled in config).
|
|
684
706
|
*/
|
|
685
707
|
maybeWarnUnlimited(args) {
|
|
686
|
-
|
|
708
|
+
const perCall = args?.warnOnUnlimited;
|
|
709
|
+
if (perCall === false)
|
|
710
|
+
return;
|
|
711
|
+
if (perCall === undefined && !this.warnOnUnlimited)
|
|
687
712
|
return;
|
|
688
713
|
if (this.defaultLimit !== undefined)
|
|
689
714
|
return;
|
|
@@ -694,7 +719,7 @@ class QueryInterface {
|
|
|
694
719
|
return;
|
|
695
720
|
this.warnedTables.add(this.table);
|
|
696
721
|
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
|
|
697
|
-
'Pass `limit
|
|
722
|
+
'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
|
|
698
723
|
}
|
|
699
724
|
/**
|
|
700
725
|
* Recursively measure the maximum depth of a `with` clause tree.
|
|
@@ -1043,7 +1068,8 @@ class QueryInterface {
|
|
|
1043
1068
|
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
1044
1069
|
const columns = entries.map(([k]) => this.toSqlColumn(k));
|
|
1045
1070
|
const params = entries.map(([, v]) => v);
|
|
1046
|
-
|
|
1071
|
+
// Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
|
|
1072
|
+
const placeholders = entries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
|
|
1047
1073
|
const sql = this.dialect.buildInsertStatement({
|
|
1048
1074
|
table: this.q(this.table),
|
|
1049
1075
|
columns,
|
|
@@ -1125,7 +1151,13 @@ class QueryInterface {
|
|
|
1125
1151
|
return keys.map((key) => record[key]);
|
|
1126
1152
|
});
|
|
1127
1153
|
// Use actual Postgres types for array casts in the default PostgreSQL dialect.
|
|
1128
|
-
|
|
1154
|
+
// Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
|
|
1155
|
+
// type the UNNEST output as text, which Postgres refuses to coerce to the
|
|
1156
|
+
// enum ("column X is of type Y but expression is of type text").
|
|
1157
|
+
const typeCasts = columns.map((col) => {
|
|
1158
|
+
const enumType = this.enumTypeForColumn(col);
|
|
1159
|
+
return enumType ? `${this.q(enumType)}[]` : this.getColumnArrayType(col);
|
|
1160
|
+
});
|
|
1129
1161
|
const quotedColumns = columns.map((c) => this.q(c));
|
|
1130
1162
|
const built = this.dialect.buildBulkInsertStatement({
|
|
1131
1163
|
table: qt,
|
|
@@ -1399,7 +1431,8 @@ class QueryInterface {
|
|
|
1399
1431
|
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
1400
1432
|
const columns = createEntries.map(([k]) => this.toSqlColumn(k));
|
|
1401
1433
|
const createParams = createEntries.map(([, v]) => v);
|
|
1402
|
-
|
|
1434
|
+
// Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
|
|
1435
|
+
const placeholders = createEntries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
|
|
1403
1436
|
// The conflict target comes from `where` keys — must be unique/PK columns
|
|
1404
1437
|
const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
|
|
1405
1438
|
const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
|
|
@@ -1407,7 +1440,7 @@ class QueryInterface {
|
|
|
1407
1440
|
const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
|
|
1408
1441
|
let paramIdx = createParams.length + 1;
|
|
1409
1442
|
const setClauses = updateEntries.map(([k]) => {
|
|
1410
|
-
const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}`;
|
|
1443
|
+
const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}${this.enumCastSuffix(this.toColumn(k))}`;
|
|
1411
1444
|
paramIdx++;
|
|
1412
1445
|
return clause;
|
|
1413
1446
|
});
|
|
@@ -2087,6 +2120,10 @@ class QueryInterface {
|
|
|
2087
2120
|
*/
|
|
2088
2121
|
buildSetClause(key, value, params) {
|
|
2089
2122
|
const col = this.toSqlColumn(key);
|
|
2123
|
+
// Enum columns get an explicit `::"EnumName"` cast on their value bind
|
|
2124
|
+
// (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
|
|
2125
|
+
// SQL cache and collectSetParams are unaffected.
|
|
2126
|
+
const cast = this.enumCastSuffix(this.toColumn(key));
|
|
2090
2127
|
// Detect atomic-operator object: plain object (not null, not array, not
|
|
2091
2128
|
// Date, not Buffer) with EXACTLY one key matching an operator name.
|
|
2092
2129
|
if (value !== null &&
|
|
@@ -2101,7 +2138,7 @@ class QueryInterface {
|
|
|
2101
2138
|
const opValue = v[op];
|
|
2102
2139
|
if (op === 'set') {
|
|
2103
2140
|
params.push(opValue);
|
|
2104
|
-
return `${col} = ${this.p(params.length)}`;
|
|
2141
|
+
return `${col} = ${this.p(params.length)}${cast}`;
|
|
2105
2142
|
}
|
|
2106
2143
|
// Arithmetic operators: must be finite numbers
|
|
2107
2144
|
if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
|
|
@@ -2129,7 +2166,7 @@ class QueryInterface {
|
|
|
2129
2166
|
}
|
|
2130
2167
|
// Plain value (including null, Date, Buffer, arrays, JSON objects)
|
|
2131
2168
|
params.push(value);
|
|
2132
|
-
return `${col} = ${this.p(params.length)}`;
|
|
2169
|
+
return `${col} = ${this.p(params.length)}${cast}`;
|
|
2133
2170
|
}
|
|
2134
2171
|
// =========================================================================
|
|
2135
2172
|
// Fingerprinting — value-invariant shape keys for SQL cache lookup
|
|
@@ -2228,10 +2265,10 @@ class QueryInterface {
|
|
|
2228
2265
|
parts.push(`${key}:vec(${dist.metric},${cmps})`);
|
|
2229
2266
|
continue;
|
|
2230
2267
|
}
|
|
2231
|
-
// JSON filter
|
|
2268
|
+
// JSON filter — range ops carry a numeric/string annotation because the
|
|
2269
|
+
// numeric compile emits a cast (different SQL shape).
|
|
2232
2270
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2233
|
-
|
|
2234
|
-
parts.push(`${key}:json(${jKeys.join(',')})`);
|
|
2271
|
+
parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
|
|
2235
2272
|
continue;
|
|
2236
2273
|
}
|
|
2237
2274
|
// Array filter
|
|
@@ -2317,6 +2354,15 @@ class QueryInterface {
|
|
|
2317
2354
|
else if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2318
2355
|
parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
|
|
2319
2356
|
}
|
|
2357
|
+
else if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2358
|
+
// Mirrors fingerprintWhere: JSON filters inside relation sub-wheres
|
|
2359
|
+
// build real JSON clauses (buildSubWhereForRelation), so their shape
|
|
2360
|
+
// must be cache-distinct from plain equality.
|
|
2361
|
+
parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
|
|
2362
|
+
}
|
|
2363
|
+
else if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
2364
|
+
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
2365
|
+
}
|
|
2320
2366
|
else if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
|
|
2321
2367
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
2322
2368
|
.sort()
|
|
@@ -2394,7 +2440,7 @@ class QueryInterface {
|
|
|
2394
2440
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2395
2441
|
const colType = this.getColumnPgType(rawColumn);
|
|
2396
2442
|
if (colType === 'json' || colType === 'jsonb') {
|
|
2397
|
-
this.collectJsonFilterParams(value, params);
|
|
2443
|
+
this.collectJsonFilterParams(value, params, this.q(rawColumn));
|
|
2398
2444
|
continue;
|
|
2399
2445
|
}
|
|
2400
2446
|
}
|
|
@@ -2413,7 +2459,11 @@ class QueryInterface {
|
|
|
2413
2459
|
}
|
|
2414
2460
|
// Operator objects
|
|
2415
2461
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2416
|
-
this.collectOperatorParams(rawColumn, value, params
|
|
2462
|
+
this.collectOperatorParams(rawColumn, value, params, {
|
|
2463
|
+
meta: this.tableMeta,
|
|
2464
|
+
table: this.table,
|
|
2465
|
+
prefix: '',
|
|
2466
|
+
});
|
|
2417
2467
|
continue;
|
|
2418
2468
|
}
|
|
2419
2469
|
// Plain equality — same strict validation as the build path, so a
|
|
@@ -2495,29 +2545,59 @@ class QueryInterface {
|
|
|
2495
2545
|
}
|
|
2496
2546
|
}
|
|
2497
2547
|
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2548
|
+
// JSONB filter — mirrors buildSubWhereForRelation (which mirrors the
|
|
2549
|
+
// top-level buildWhereClause): route to the JSON param collector when
|
|
2550
|
+
// the target column is json/jsonb.
|
|
2551
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2552
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
2553
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
2554
|
+
this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
|
|
2555
|
+
continue;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
// Array filter — mirrors buildSubWhereForRelation.
|
|
2559
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
2560
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
2561
|
+
if (colType.startsWith('_')) {
|
|
2562
|
+
this.collectArrayFilterParams(value, params);
|
|
2563
|
+
continue;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2498
2566
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2499
|
-
this.collectOperatorParams(col, value, params);
|
|
2567
|
+
this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
|
|
2500
2568
|
continue;
|
|
2501
2569
|
}
|
|
2502
2570
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2503
2571
|
params.push(value);
|
|
2504
2572
|
}
|
|
2505
2573
|
}
|
|
2506
|
-
/**
|
|
2507
|
-
|
|
2508
|
-
|
|
2574
|
+
/**
|
|
2575
|
+
* Collect params from operator clauses. Mirrors buildOperatorClauses:
|
|
2576
|
+
* {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
|
|
2577
|
+
* but they re-run the same validation (unknown ref / insensitive mode) so a
|
|
2578
|
+
* warmed cache can never skip a check the build path enforces.
|
|
2579
|
+
*/
|
|
2580
|
+
collectOperatorParams(column, op, params, refCtx) {
|
|
2581
|
+
const skipRef = (v) => {
|
|
2582
|
+
if (!(0, filters_js_1.isColumnRef)(v))
|
|
2583
|
+
return false;
|
|
2584
|
+
if (refCtx)
|
|
2585
|
+
this.resolveColumnRef(v, refCtx, op.mode);
|
|
2586
|
+
return true;
|
|
2587
|
+
};
|
|
2588
|
+
if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
|
|
2509
2589
|
(0, filters_js_1.assertBindableEqualsOperand)(op.equals, `"${column}"`);
|
|
2510
2590
|
params.push(op.equals);
|
|
2511
2591
|
}
|
|
2512
|
-
if (op.gt !== undefined)
|
|
2592
|
+
if (op.gt !== undefined && !skipRef(op.gt))
|
|
2513
2593
|
params.push(op.gt);
|
|
2514
|
-
if (op.gte !== undefined)
|
|
2594
|
+
if (op.gte !== undefined && !skipRef(op.gte))
|
|
2515
2595
|
params.push(op.gte);
|
|
2516
|
-
if (op.lt !== undefined)
|
|
2596
|
+
if (op.lt !== undefined && !skipRef(op.lt))
|
|
2517
2597
|
params.push(op.lt);
|
|
2518
|
-
if (op.lte !== undefined)
|
|
2598
|
+
if (op.lte !== undefined && !skipRef(op.lte))
|
|
2519
2599
|
params.push(op.lte);
|
|
2520
|
-
if (op.not !== undefined && op.not !== null)
|
|
2600
|
+
if (op.not !== undefined && op.not !== null && !skipRef(op.not))
|
|
2521
2601
|
params.push(op.not);
|
|
2522
2602
|
if (op.in !== undefined)
|
|
2523
2603
|
params.push(this.inParam(op.in));
|
|
@@ -2530,10 +2610,22 @@ class QueryInterface {
|
|
|
2530
2610
|
if (op.endsWith !== undefined)
|
|
2531
2611
|
params.push(`%${(0, utils_js_1.escapeLike)(op.endsWith)}`);
|
|
2532
2612
|
}
|
|
2533
|
-
/**
|
|
2534
|
-
|
|
2613
|
+
/**
|
|
2614
|
+
* Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
|
|
2615
|
+
* the `path` is bound at most once (its placeholder is shared by every
|
|
2616
|
+
* extraction clause), then equals/contains/hasKey values, then the range
|
|
2617
|
+
* comparison values in {@link JSON_RANGE_OPERATORS} order.
|
|
2618
|
+
*/
|
|
2619
|
+
collectJsonFilterParams(filter, params, column) {
|
|
2620
|
+
let pathPushed = false;
|
|
2621
|
+
const pushPathOnce = () => {
|
|
2622
|
+
if (!pathPushed) {
|
|
2623
|
+
params.push(filter.path);
|
|
2624
|
+
pathPushed = true;
|
|
2625
|
+
}
|
|
2626
|
+
};
|
|
2535
2627
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
2536
|
-
|
|
2628
|
+
pushPathOnce();
|
|
2537
2629
|
params.push(String(filter.equals));
|
|
2538
2630
|
}
|
|
2539
2631
|
else if (filter.equals !== undefined) {
|
|
@@ -2545,6 +2637,10 @@ class QueryInterface {
|
|
|
2545
2637
|
if (filter.hasKey !== undefined) {
|
|
2546
2638
|
params.push(filter.hasKey);
|
|
2547
2639
|
}
|
|
2640
|
+
for (const { value } of this.jsonRangeEntries(filter, column)) {
|
|
2641
|
+
pushPathOnce();
|
|
2642
|
+
params.push(value);
|
|
2643
|
+
}
|
|
2548
2644
|
}
|
|
2549
2645
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
2550
2646
|
collectArrayFilterParams(filter, params) {
|
|
@@ -2557,10 +2653,10 @@ class QueryInterface {
|
|
|
2557
2653
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
2558
2654
|
}
|
|
2559
2655
|
/**
|
|
2560
|
-
* Collect params for an orderBy clause.
|
|
2561
|
-
*
|
|
2562
|
-
* parameterless. Mirrors buildOrderBy's push
|
|
2563
|
-
* param re-collection stays in lockstep.
|
|
2656
|
+
* Collect params for an orderBy clause. Vector KNN ordering pushes the
|
|
2657
|
+
* `$n::vector` query vector and JSON-path ordering pushes its text[] path;
|
|
2658
|
+
* plain direction ordering is parameterless. Mirrors buildOrderBy's push
|
|
2659
|
+
* order exactly so the cached-SQL param re-collection stays in lockstep.
|
|
2564
2660
|
*/
|
|
2565
2661
|
collectOrderByParams(orderBy, params) {
|
|
2566
2662
|
for (const [key, dir] of Object.entries(orderBy)) {
|
|
@@ -2572,6 +2668,13 @@ class QueryInterface {
|
|
|
2572
2668
|
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2573
2669
|
continue;
|
|
2574
2670
|
}
|
|
2671
|
+
// JSON-path ordering: mirrors buildJsonPathOrderEntry: same validation,
|
|
2672
|
+
// then the path bound as one text[] param.
|
|
2673
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(dir)) {
|
|
2674
|
+
this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
|
|
2675
|
+
params.push(dir.path.map(String));
|
|
2676
|
+
continue;
|
|
2677
|
+
}
|
|
2575
2678
|
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2576
2679
|
// subquery as `_count` — mirror its global-filter params. To-one relation
|
|
2577
2680
|
// orderBy carries the target's global filter once per ordered column.
|
|
@@ -2718,9 +2821,20 @@ class QueryInterface {
|
|
|
2718
2821
|
const targetMeta = this.schema.tables[targetTable];
|
|
2719
2822
|
if (!targetMeta)
|
|
2720
2823
|
return;
|
|
2824
|
+
// A dialect that owns the whole subquery (buildRelationSubquery override,
|
|
2825
|
+
// SQL Server FOR JSON) compiles orderBy through its OWN paging clause -
|
|
2826
|
+
// plain directions only, no order params: so the native order-param
|
|
2827
|
+
// mirrors below must stay off for it (its documented contract remains
|
|
2828
|
+
// where → limit → nested).
|
|
2829
|
+
const nativeOrderPath = !this.dialect.buildRelationSubquery;
|
|
2721
2830
|
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2722
|
-
// where params → limit param → nested-with params
|
|
2831
|
+
// orderBy params → where params → limit param → nested-with params
|
|
2832
|
+
// (always, both paths).
|
|
2723
2833
|
if (relDef.type === 'manyToMany') {
|
|
2834
|
+
const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
2835
|
+
if (nativeOrderPath && m2mOrderEntries.length > 0) {
|
|
2836
|
+
this.collectRelationOrderParams(targetTable, targetMeta, m2mOrderEntries, params);
|
|
2837
|
+
}
|
|
2724
2838
|
if (spec.where) {
|
|
2725
2839
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2726
2840
|
}
|
|
@@ -2739,7 +2853,8 @@ class QueryInterface {
|
|
|
2739
2853
|
return;
|
|
2740
2854
|
}
|
|
2741
2855
|
// Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
|
|
2742
|
-
const
|
|
2856
|
+
const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
2857
|
+
const hasOrder = relOrderEntries.length > 0;
|
|
2743
2858
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2744
2859
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2745
2860
|
if (!willWrap && spec.with) {
|
|
@@ -2750,6 +2865,12 @@ class QueryInterface {
|
|
|
2750
2865
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2751
2866
|
}
|
|
2752
2867
|
}
|
|
2868
|
+
// orderBy params (JSON paths / relation-order global filters): mirrors
|
|
2869
|
+
// buildRelationSubquery, which builds its ORDER BY terms BEFORE compiling
|
|
2870
|
+
// spec.where (both wrapped and non-wrapped paths).
|
|
2871
|
+
if (nativeOrderPath && hasOrder) {
|
|
2872
|
+
this.collectRelationOrderParams(targetTable, targetMeta, relOrderEntries, params);
|
|
2873
|
+
}
|
|
2753
2874
|
// where params — mirrors buildAliasWhere push order
|
|
2754
2875
|
if (spec.where) {
|
|
2755
2876
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
@@ -3100,7 +3221,11 @@ class QueryInterface {
|
|
|
3100
3221
|
}
|
|
3101
3222
|
// Handle operator objects
|
|
3102
3223
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3103
|
-
const opClauses = this.buildOperatorClauses(column, value, params
|
|
3224
|
+
const opClauses = this.buildOperatorClauses(column, value, params, {
|
|
3225
|
+
meta: this.tableMeta,
|
|
3226
|
+
table: this.table,
|
|
3227
|
+
prefix: '',
|
|
3228
|
+
});
|
|
3104
3229
|
andClauses.push(...opClauses);
|
|
3105
3230
|
continue;
|
|
3106
3231
|
}
|
|
@@ -3264,8 +3389,41 @@ class QueryInterface {
|
|
|
3264
3389
|
conditions.push(`${qCol} IS NULL`);
|
|
3265
3390
|
continue;
|
|
3266
3391
|
}
|
|
3392
|
+
// JSONB filter on a json/jsonb column of the relation target — mirrors
|
|
3393
|
+
// the top-level WHERE path (buildWhereClause). Without this branch the
|
|
3394
|
+
// filter object used to fall through to plain equality and bind as a
|
|
3395
|
+
// jsonb value, silently matching nothing.
|
|
3396
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3397
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3398
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3399
|
+
conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3400
|
+
continue;
|
|
3401
|
+
}
|
|
3402
|
+
const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
|
|
3403
|
+
if (jsonKey) {
|
|
3404
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
|
|
3405
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
// Array filter on an array column of the relation target — same mirror.
|
|
3409
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
3410
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3411
|
+
if (colType.startsWith('_')) {
|
|
3412
|
+
conditions.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
|
|
3413
|
+
continue;
|
|
3414
|
+
}
|
|
3415
|
+
const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
|
|
3416
|
+
if (arrayKey) {
|
|
3417
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
|
|
3418
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3267
3421
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3268
|
-
const opClauses = this.buildOperatorClauses(qCol, value, params
|
|
3422
|
+
const opClauses = this.buildOperatorClauses(qCol, value, params, {
|
|
3423
|
+
meta,
|
|
3424
|
+
table: targetTable,
|
|
3425
|
+
prefix: `${qt}.`,
|
|
3426
|
+
});
|
|
3269
3427
|
conditions.push(...opClauses);
|
|
3270
3428
|
continue;
|
|
3271
3429
|
}
|
|
@@ -3282,6 +3440,49 @@ class QueryInterface {
|
|
|
3282
3440
|
pgTypeForColumn(meta, column) {
|
|
3283
3441
|
return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
|
|
3284
3442
|
}
|
|
3443
|
+
/**
|
|
3444
|
+
* The Postgres enum type name for a column, when the schema knows one.
|
|
3445
|
+
*
|
|
3446
|
+
* Introspection stores each column's `udt_name` in `pgTypes` and every
|
|
3447
|
+
* database enum in `schema.enums` (typname → labels); a column whose type
|
|
3448
|
+
* matches an enum key needs an explicit `::"EnumName"` cast on its write
|
|
3449
|
+
* binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
|
|
3450
|
+
* value as text and Postgres refuses the implicit text→enum coercion
|
|
3451
|
+
* ("column X is of type Y but expression is of type text").
|
|
3452
|
+
*
|
|
3453
|
+
* Postgres-only by construction: gated on the active dialect being
|
|
3454
|
+
* `postgresql` AND on `schema.enums` having entries (only PG introspection
|
|
3455
|
+
* produces them — `defineSchema` and the other engines leave it empty), so
|
|
3456
|
+
* SQLite/MySQL/MSSQL/PowDB output is byte-identical.
|
|
3457
|
+
*/
|
|
3458
|
+
enumTypeForColumn(column) {
|
|
3459
|
+
if (this.dialect.name !== 'postgresql')
|
|
3460
|
+
return null;
|
|
3461
|
+
const enums = this.schema.enums;
|
|
3462
|
+
if (!enums)
|
|
3463
|
+
return null;
|
|
3464
|
+
// Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
|
|
3465
|
+
// the column's type lives OUTSIDE the introspected schema. A same-named
|
|
3466
|
+
// enum in another schema must not get this schema's cast — search_path
|
|
3467
|
+
// would resolve `::"status"` to the wrong type. Skipping the cast restores
|
|
3468
|
+
// the pre-cast behavior for such columns. Columns without pgTypeSchema
|
|
3469
|
+
// (same-schema types, defineSchema/legacy metadata) keep the cast.
|
|
3470
|
+
if (this.crossSchemaTypeColumns.has(column))
|
|
3471
|
+
return null;
|
|
3472
|
+
const pgType = this.columnPgTypeMap.get(column) ?? this.tableMeta.pgTypes?.[column];
|
|
3473
|
+
if (!pgType || pgType.startsWith('_'))
|
|
3474
|
+
return null;
|
|
3475
|
+
return Object.hasOwn(enums, pgType) ? pgType : null;
|
|
3476
|
+
}
|
|
3477
|
+
/**
|
|
3478
|
+
* `::"EnumName"` cast suffix for a write-bind placeholder on an enum
|
|
3479
|
+
* column; `''` for every other column, so non-enum SQL stays byte-identical.
|
|
3480
|
+
* The type name is an introspected identifier and is quoted via the dialect.
|
|
3481
|
+
*/
|
|
3482
|
+
enumCastSuffix(column) {
|
|
3483
|
+
const enumType = this.enumTypeForColumn(column);
|
|
3484
|
+
return enumType ? `::${this.q(enumType)}` : '';
|
|
3485
|
+
}
|
|
3285
3486
|
/**
|
|
3286
3487
|
* Equality-fallthrough guard shared by every SQL-build path AND every
|
|
3287
3488
|
* cache-hit param-collect path. A plain object literal that matched no known
|
|
@@ -3360,8 +3561,40 @@ class QueryInterface {
|
|
|
3360
3561
|
clauses.push(`${qCol} IS NULL`);
|
|
3361
3562
|
continue;
|
|
3362
3563
|
}
|
|
3564
|
+
// JSONB filter on a json/jsonb column — mirrors the top-level WHERE path
|
|
3565
|
+
// (buildWhereClause) so a `with.where` JSON filter is never silently
|
|
3566
|
+
// bound as a plain equality value.
|
|
3567
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3568
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3569
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3570
|
+
clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3571
|
+
continue;
|
|
3572
|
+
}
|
|
3573
|
+
const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
|
|
3574
|
+
if (jsonKey) {
|
|
3575
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
|
|
3576
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
// Array filter on an array column — same mirror.
|
|
3580
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
3581
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3582
|
+
if (colType.startsWith('_')) {
|
|
3583
|
+
clauses.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
|
|
3584
|
+
continue;
|
|
3585
|
+
}
|
|
3586
|
+
const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
|
|
3587
|
+
if (arrayKey) {
|
|
3588
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
|
|
3589
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3363
3592
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3364
|
-
clauses.push(...this.buildOperatorClauses(qCol, value, params
|
|
3593
|
+
clauses.push(...this.buildOperatorClauses(qCol, value, params, {
|
|
3594
|
+
meta: targetMeta,
|
|
3595
|
+
table: targetTable,
|
|
3596
|
+
prefix: `${alias}.`,
|
|
3597
|
+
}));
|
|
3365
3598
|
continue;
|
|
3366
3599
|
}
|
|
3367
3600
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
@@ -3403,8 +3636,24 @@ class QueryInterface {
|
|
|
3403
3636
|
}
|
|
3404
3637
|
}
|
|
3405
3638
|
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
3639
|
+
// JSONB filter — mirrors buildAliasWhere.
|
|
3640
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3641
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3642
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3643
|
+
this.collectJsonFilterParams(value, params, this.q(col));
|
|
3644
|
+
continue;
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
// Array filter — mirrors buildAliasWhere.
|
|
3648
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
3649
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3650
|
+
if (colType.startsWith('_')) {
|
|
3651
|
+
this.collectArrayFilterParams(value, params);
|
|
3652
|
+
continue;
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3406
3655
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3407
|
-
this.collectOperatorParams(col, value, params);
|
|
3656
|
+
this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
|
|
3408
3657
|
continue;
|
|
3409
3658
|
}
|
|
3410
3659
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
@@ -3461,6 +3710,16 @@ class QueryInterface {
|
|
|
3461
3710
|
parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
|
|
3462
3711
|
continue;
|
|
3463
3712
|
}
|
|
3713
|
+
// JSON / array filters build real clauses in buildAliasWhere, so their
|
|
3714
|
+
// shape must be cache-distinct from plain equality (mirrors fingerprintWhere).
|
|
3715
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3716
|
+
parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
|
|
3717
|
+
continue;
|
|
3718
|
+
}
|
|
3719
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
3720
|
+
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
3721
|
+
continue;
|
|
3722
|
+
}
|
|
3464
3723
|
if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
|
|
3465
3724
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
3466
3725
|
.sort()
|
|
@@ -3471,16 +3730,55 @@ class QueryInterface {
|
|
|
3471
3730
|
}
|
|
3472
3731
|
return parts.join('&');
|
|
3473
3732
|
}
|
|
3733
|
+
/**
|
|
3734
|
+
* Validate a `{ col }` column reference against its table and return the
|
|
3735
|
+
* resolved snake_case column name. Shared by the SQL-build path
|
|
3736
|
+
* ({@link buildOperatorClauses}) and the cache-hit param-collect path
|
|
3737
|
+
* (`collectOperatorParams`) so both always throw identically: a warmed
|
|
3738
|
+
* cache can never skip the check.
|
|
3739
|
+
*/
|
|
3740
|
+
resolveColumnRef(ref, ctx, mode) {
|
|
3741
|
+
if (mode === 'insensitive') {
|
|
3742
|
+
throw new errors_js_1.ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
|
|
3743
|
+
`Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
|
|
3744
|
+
`for lower(a) = lower(b).`);
|
|
3745
|
+
}
|
|
3746
|
+
const col = ctx.meta.columnMap[ref.col] ?? (0, schema_js_1.camelToSnake)(ref.col);
|
|
3747
|
+
if (!ctx.meta.allColumns.includes(col)) {
|
|
3748
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
|
|
3749
|
+
`Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
|
|
3750
|
+
}
|
|
3751
|
+
return col;
|
|
3752
|
+
}
|
|
3753
|
+
/**
|
|
3754
|
+
* Compile a `{ col }` reference to its quoted, prefix-matched SQL identifier.
|
|
3755
|
+
* NO param is bound: the referenced column is part of the SQL text (and of
|
|
3756
|
+
* the where fingerprint, see {@link fingerprintOperatorShape}).
|
|
3757
|
+
*/
|
|
3758
|
+
columnRefSql(ref, ctx, mode) {
|
|
3759
|
+
if (!ctx) {
|
|
3760
|
+
throw new errors_js_1.ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
|
|
3761
|
+
}
|
|
3762
|
+
return `${ctx.prefix}${this.q(this.resolveColumnRef(ref, ctx, mode))}`;
|
|
3763
|
+
}
|
|
3474
3764
|
/**
|
|
3475
3765
|
* Build SQL clauses for a single operator object on a column.
|
|
3476
3766
|
* Each operator key becomes its own clause, all ANDed together.
|
|
3767
|
+
*
|
|
3768
|
+
* `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
|
|
3769
|
+
* (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
|
|
3770
|
+
* against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
|
|
3771
|
+
* pushing nothing and the referenced name lives in the fingerprint.
|
|
3477
3772
|
*/
|
|
3478
|
-
buildOperatorClauses(column, op, params) {
|
|
3773
|
+
buildOperatorClauses(column, op, params, refCtx) {
|
|
3479
3774
|
const clauses = [];
|
|
3480
3775
|
if (op.equals !== undefined) {
|
|
3481
3776
|
if (op.equals === null) {
|
|
3482
3777
|
clauses.push(`${column} IS NULL`);
|
|
3483
3778
|
}
|
|
3779
|
+
else if ((0, filters_js_1.isColumnRef)(op.equals)) {
|
|
3780
|
+
clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
|
|
3781
|
+
}
|
|
3484
3782
|
else {
|
|
3485
3783
|
(0, filters_js_1.assertBindableEqualsOperand)(op.equals, column);
|
|
3486
3784
|
params.push(op.equals);
|
|
@@ -3488,25 +3786,48 @@ class QueryInterface {
|
|
|
3488
3786
|
}
|
|
3489
3787
|
}
|
|
3490
3788
|
if (op.gt !== undefined) {
|
|
3491
|
-
|
|
3492
|
-
|
|
3789
|
+
if ((0, filters_js_1.isColumnRef)(op.gt)) {
|
|
3790
|
+
clauses.push(`${column} > ${this.columnRefSql(op.gt, refCtx, op.mode)}`);
|
|
3791
|
+
}
|
|
3792
|
+
else {
|
|
3793
|
+
params.push(op.gt);
|
|
3794
|
+
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
3795
|
+
}
|
|
3493
3796
|
}
|
|
3494
3797
|
if (op.gte !== undefined) {
|
|
3495
|
-
|
|
3496
|
-
|
|
3798
|
+
if ((0, filters_js_1.isColumnRef)(op.gte)) {
|
|
3799
|
+
clauses.push(`${column} >= ${this.columnRefSql(op.gte, refCtx, op.mode)}`);
|
|
3800
|
+
}
|
|
3801
|
+
else {
|
|
3802
|
+
params.push(op.gte);
|
|
3803
|
+
clauses.push(`${column} >= ${this.p(params.length)}`);
|
|
3804
|
+
}
|
|
3497
3805
|
}
|
|
3498
3806
|
if (op.lt !== undefined) {
|
|
3499
|
-
|
|
3500
|
-
|
|
3807
|
+
if ((0, filters_js_1.isColumnRef)(op.lt)) {
|
|
3808
|
+
clauses.push(`${column} < ${this.columnRefSql(op.lt, refCtx, op.mode)}`);
|
|
3809
|
+
}
|
|
3810
|
+
else {
|
|
3811
|
+
params.push(op.lt);
|
|
3812
|
+
clauses.push(`${column} < ${this.p(params.length)}`);
|
|
3813
|
+
}
|
|
3501
3814
|
}
|
|
3502
3815
|
if (op.lte !== undefined) {
|
|
3503
|
-
|
|
3504
|
-
|
|
3816
|
+
if ((0, filters_js_1.isColumnRef)(op.lte)) {
|
|
3817
|
+
clauses.push(`${column} <= ${this.columnRefSql(op.lte, refCtx, op.mode)}`);
|
|
3818
|
+
}
|
|
3819
|
+
else {
|
|
3820
|
+
params.push(op.lte);
|
|
3821
|
+
clauses.push(`${column} <= ${this.p(params.length)}`);
|
|
3822
|
+
}
|
|
3505
3823
|
}
|
|
3506
3824
|
if (op.not !== undefined) {
|
|
3507
3825
|
if (op.not === null) {
|
|
3508
3826
|
clauses.push(`${column} IS NOT NULL`);
|
|
3509
3827
|
}
|
|
3828
|
+
else if ((0, filters_js_1.isColumnRef)(op.not)) {
|
|
3829
|
+
clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
|
|
3830
|
+
}
|
|
3510
3831
|
else {
|
|
3511
3832
|
params.push(op.not);
|
|
3512
3833
|
clauses.push(`${column} != ${this.p(params.length)}`);
|
|
@@ -3557,6 +3878,11 @@ class QueryInterface {
|
|
|
3557
3878
|
if ((0, filters_js_1.isVectorOrderBy)(d)) {
|
|
3558
3879
|
return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
3559
3880
|
}
|
|
3881
|
+
// JSON-path ordering: direction, cast kind, and nulls placement change the
|
|
3882
|
+
// SQL text; the path itself is a bound param and stays OUT of the key.
|
|
3883
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(d)) {
|
|
3884
|
+
return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
|
|
3885
|
+
}
|
|
3560
3886
|
if ((0, filters_js_1.isOrderBySpec)(d))
|
|
3561
3887
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3562
3888
|
if (d && typeof d === 'object') {
|
|
@@ -3602,6 +3928,11 @@ class QueryInterface {
|
|
|
3602
3928
|
const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3603
3929
|
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
3604
3930
|
}
|
|
3931
|
+
// JSON-path ordering: { path: [...], direction?, type?, nulls? } on a
|
|
3932
|
+
// json/jsonb column of THIS table. Path is bound as one text[] param.
|
|
3933
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(value)) {
|
|
3934
|
+
return this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params);
|
|
3935
|
+
}
|
|
3605
3936
|
// Relation ordering: an object value that is not a vector or OrderBySpec,
|
|
3606
3937
|
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
3607
3938
|
// { name: 'asc' } }`).
|
|
@@ -3628,6 +3959,7 @@ class QueryInterface {
|
|
|
3628
3959
|
value !== null &&
|
|
3629
3960
|
!Array.isArray(value) &&
|
|
3630
3961
|
!(0, filters_js_1.isVectorOrderBy)(value) &&
|
|
3962
|
+
!(0, filters_js_1.isJsonPathOrderBy)(value) &&
|
|
3631
3963
|
!(0, filters_js_1.isOrderBySpec)(value));
|
|
3632
3964
|
}
|
|
3633
3965
|
/**
|
|
@@ -3644,6 +3976,63 @@ class QueryInterface {
|
|
|
3644
3976
|
}
|
|
3645
3977
|
return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
|
|
3646
3978
|
}
|
|
3979
|
+
/**
|
|
3980
|
+
* Resolve an orderBy key to its snake_case column via the table's columnMap
|
|
3981
|
+
* (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
|
|
3982
|
+
* where path uses. Shared by top-level JSON-path ordering and every nested
|
|
3983
|
+
* relation orderBy path so nested orderBy accepts exactly what top-level
|
|
3984
|
+
* accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
|
|
3985
|
+
* camelCase-named DB columns like "sortOrder").
|
|
3986
|
+
*/
|
|
3987
|
+
resolveOrderByColumn(table, meta, key) {
|
|
3988
|
+
const col = meta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
3989
|
+
if (!meta.allColumns.includes(col)) {
|
|
3990
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
|
|
3991
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3992
|
+
}
|
|
3993
|
+
return col;
|
|
3994
|
+
}
|
|
3995
|
+
/**
|
|
3996
|
+
* Validate a {@link JsonPathOrderBy} entry: column must exist AND be
|
|
3997
|
+
* json/jsonb, path must be a non-empty array of keys/indexes: and return
|
|
3998
|
+
* the resolved column. Shared by the SQL-build path
|
|
3999
|
+
* ({@link buildJsonPathOrderEntry}) and the cache-hit param-collect mirrors
|
|
4000
|
+
* so both always throw identically.
|
|
4001
|
+
*/
|
|
4002
|
+
validateJsonPathOrderBy(table, meta, field, spec) {
|
|
4003
|
+
const col = this.resolveOrderByColumn(table, meta, field);
|
|
4004
|
+
if (spec.path.length === 0 ||
|
|
4005
|
+
spec.path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
4006
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}" (table "${table}") requires a non-empty \`path\` array ` +
|
|
4007
|
+
`of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
|
|
4008
|
+
}
|
|
4009
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
4010
|
+
if (colType !== 'json' && colType !== 'jsonb') {
|
|
4011
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
|
|
4012
|
+
`(actual type: ${colType}).`);
|
|
4013
|
+
}
|
|
4014
|
+
return col;
|
|
4015
|
+
}
|
|
4016
|
+
/**
|
|
4017
|
+
* Compile one {@link JsonPathOrderBy} entry:
|
|
4018
|
+
* `("col" #>> $n::text[])::numeric ASC`: the numeric cast only with
|
|
4019
|
+
* `type: 'numeric'` (default is text comparison), the extraction routed
|
|
4020
|
+
* through the dialect's JSON hook exactly like the JSON where-filters, the
|
|
4021
|
+
* path bound as ONE text[] param (mirrored by the order-param collectors).
|
|
4022
|
+
* `prefix` scopes the column (`''` top-level, `t0.` inside a relation
|
|
4023
|
+
* subquery).
|
|
4024
|
+
*/
|
|
4025
|
+
buildJsonPathOrderEntry(table, meta, field, spec, prefix, params) {
|
|
4026
|
+
const col = this.validateJsonPathOrderBy(table, meta, field, spec);
|
|
4027
|
+
if (!params) {
|
|
4028
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
|
|
4029
|
+
}
|
|
4030
|
+
params.push(spec.path.map(String));
|
|
4031
|
+
const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
|
|
4032
|
+
const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4033
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4034
|
+
return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
|
|
4035
|
+
}
|
|
3647
4036
|
/**
|
|
3648
4037
|
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
3649
4038
|
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
@@ -3652,12 +4041,19 @@ class QueryInterface {
|
|
|
3652
4041
|
*
|
|
3653
4042
|
* Validation: relation must exist (E005); to-many only allows `_count`, and
|
|
3654
4043
|
* to-one only allows real target columns (E003).
|
|
4044
|
+
*
|
|
4045
|
+
* `ctx` generalizes the term beyond the root table: inside a relation
|
|
4046
|
+
* subquery's orderBy the relations live on the TARGET table's metadata and
|
|
4047
|
+
* the correlation parent is the relation's alias, not `this.table`.
|
|
3655
4048
|
*/
|
|
3656
|
-
buildRelationOrderBy(relName, value, alias, params) {
|
|
3657
|
-
const
|
|
4049
|
+
buildRelationOrderBy(relName, value, alias, params, ctx) {
|
|
4050
|
+
const ownerMeta = ctx?.meta ?? this.tableMeta;
|
|
4051
|
+
const ownerTable = ctx?.table ?? this.table;
|
|
4052
|
+
const parentRef = ctx?.parentRef ?? this.table;
|
|
4053
|
+
const relDef = ownerMeta.relations[relName];
|
|
3658
4054
|
if (!relDef) {
|
|
3659
|
-
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${
|
|
3660
|
-
`Available: ${Object.keys(
|
|
4055
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
4056
|
+
`Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
|
|
3661
4057
|
}
|
|
3662
4058
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
3663
4059
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
@@ -3667,14 +4063,14 @@ class QueryInterface {
|
|
|
3667
4063
|
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
3668
4064
|
}
|
|
3669
4065
|
const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
|
|
3670
|
-
return `${this.buildRelationCountExpr(relDef,
|
|
4066
|
+
return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
|
|
3671
4067
|
}
|
|
3672
4068
|
// To-one: each entry orders by a correlated scalar subquery on a target column.
|
|
3673
4069
|
const targetMeta = this.schema.tables[relDef.to];
|
|
3674
4070
|
if (!targetMeta)
|
|
3675
4071
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
3676
4072
|
const qTarget = this.q(relDef.to);
|
|
3677
|
-
const qParent = this.q(
|
|
4073
|
+
const qParent = this.q(parentRef);
|
|
3678
4074
|
// belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
|
|
3679
4075
|
const correlation = relDef.type === 'belongsTo'
|
|
3680
4076
|
? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
@@ -3685,7 +4081,9 @@ class QueryInterface {
|
|
|
3685
4081
|
}
|
|
3686
4082
|
return entries
|
|
3687
4083
|
.map(([col, dirValue]) => {
|
|
3688
|
-
|
|
4084
|
+
// columnMap-first resolution (camelToSnake fallback): mirrors the
|
|
4085
|
+
// scalar orderBy path so camelCase-named DB columns resolve here too.
|
|
4086
|
+
const snakeCol = targetMeta.columnMap[col] ?? (0, schema_js_1.camelToSnake)(col);
|
|
3689
4087
|
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
3690
4088
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
3691
4089
|
}
|
|
@@ -3703,6 +4101,74 @@ class QueryInterface {
|
|
|
3703
4101
|
})
|
|
3704
4102
|
.join(', ');
|
|
3705
4103
|
}
|
|
4104
|
+
/**
|
|
4105
|
+
* Compile the ORDER BY terms of a relation `with` clause against the
|
|
4106
|
+
* relation's table alias. One unified path for every relation shape
|
|
4107
|
+
* (hasMany / manyToMany / belongsTo / hasOne) supporting exactly what the
|
|
4108
|
+
* top-level orderBy accepts at this level:
|
|
4109
|
+
*
|
|
4110
|
+
* - scalar columns via columnMap resolution (camelToSnake fallback) with
|
|
4111
|
+
* {@link OrderBySpec} nulls placement,
|
|
4112
|
+
* - {@link JsonPathOrderBy} entries (path bound as one text[] param),
|
|
4113
|
+
* - relation ordering on the TARGET's relations (`_count` for to-many, a
|
|
4114
|
+
* target column for to-one), correlated to the relation alias,
|
|
4115
|
+
* - vector KNN ordering stays top-level-only (E003, same as before).
|
|
4116
|
+
*
|
|
4117
|
+
* Param pushes (JSON paths, relation-order global filters) MUST be mirrored,
|
|
4118
|
+
* in the same order, by {@link collectRelationOrderParams}.
|
|
4119
|
+
*/
|
|
4120
|
+
buildRelationOrderClause(targetTable, targetMeta, alias, orderEntries, params) {
|
|
4121
|
+
let relOrdCounter = 0;
|
|
4122
|
+
const orders = orderEntries
|
|
4123
|
+
.map(([key, dirValue]) => {
|
|
4124
|
+
if ((0, filters_js_1.isVectorOrderBy)(dirValue)) {
|
|
4125
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
4126
|
+
}
|
|
4127
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
|
|
4128
|
+
return this.buildJsonPathOrderEntry(targetTable, targetMeta, key, dirValue, `${alias}.`, params);
|
|
4129
|
+
}
|
|
4130
|
+
if (this.isRelationOrderByValue(dirValue)) {
|
|
4131
|
+
return this.buildRelationOrderBy(key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
|
|
4132
|
+
}
|
|
4133
|
+
const col = this.resolveOrderByColumn(targetTable, targetMeta, key);
|
|
4134
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
4135
|
+
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4136
|
+
})
|
|
4137
|
+
.join(', ');
|
|
4138
|
+
return ` ORDER BY ${orders}`;
|
|
4139
|
+
}
|
|
4140
|
+
/**
|
|
4141
|
+
* Param-collect mirror of {@link buildRelationOrderClause}: JSON-path
|
|
4142
|
+
* entries push their path (one text[] param each); relation-order entries
|
|
4143
|
+
* mirror {@link collectOrderByParams}' relation branch (count / to-one
|
|
4144
|
+
* global-filter params); scalar entries push nothing but re-run the same
|
|
4145
|
+
* column validation so a warmed cache can never skip it.
|
|
4146
|
+
*/
|
|
4147
|
+
collectRelationOrderParams(targetTable, targetMeta, orderEntries, params) {
|
|
4148
|
+
for (const [key, dirValue] of orderEntries) {
|
|
4149
|
+
if ((0, filters_js_1.isVectorOrderBy)(dirValue)) {
|
|
4150
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
4151
|
+
}
|
|
4152
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
|
|
4153
|
+
this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
|
|
4154
|
+
params.push(dirValue.path.map(String));
|
|
4155
|
+
continue;
|
|
4156
|
+
}
|
|
4157
|
+
if (this.isRelationOrderByValue(dirValue)) {
|
|
4158
|
+
const relDef = targetMeta.relations[key];
|
|
4159
|
+
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
4160
|
+
this.collectRelationCountParams(relDef, params);
|
|
4161
|
+
}
|
|
4162
|
+
else if (relDef) {
|
|
4163
|
+
for (const _col of Object.keys(dirValue)) {
|
|
4164
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
continue;
|
|
4168
|
+
}
|
|
4169
|
+
this.resolveOrderByColumn(targetTable, targetMeta, key);
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
3706
4172
|
/**
|
|
3707
4173
|
* Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
|
|
3708
4174
|
* relation, correlated to `parentRef`. hasMany counts child rows via the FK;
|
|
@@ -4422,20 +4888,13 @@ class QueryInterface {
|
|
|
4422
4888
|
// Quote parent ref — can be a table name or auto-generated alias
|
|
4423
4889
|
const qParent = this.q(parentRef);
|
|
4424
4890
|
const qTarget = this.q(targetTable);
|
|
4425
|
-
// Build ORDER BY for json_agg
|
|
4891
|
+
// Build ORDER BY for json_agg: unified with the top-level orderBy surface
|
|
4892
|
+
// (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
|
|
4893
|
+
// Param pushes here land BEFORE the spec.where params, mirrored by
|
|
4894
|
+
// collectRelationSubqueryParams.
|
|
4426
4895
|
let orderClause = '';
|
|
4427
4896
|
if (relOrderEntries.length > 0) {
|
|
4428
|
-
|
|
4429
|
-
.map(([k, dirValue]) => {
|
|
4430
|
-
const col = (0, schema_js_1.camelToSnake)(k);
|
|
4431
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
4432
|
-
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4433
|
-
}
|
|
4434
|
-
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
4435
|
-
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4436
|
-
})
|
|
4437
|
-
.join(', ');
|
|
4438
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
4897
|
+
orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
|
|
4439
4898
|
}
|
|
4440
4899
|
// Build WHERE — correlate to parent via parentRef (alias or table name).
|
|
4441
4900
|
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
@@ -4509,8 +4968,10 @@ class QueryInterface {
|
|
|
4509
4968
|
const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
|
|
4510
4969
|
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
|
|
4511
4970
|
}
|
|
4512
|
-
// belongsTo / hasOne
|
|
4513
|
-
|
|
4971
|
+
// belongsTo / hasOne: return single object. An orderBy picks WHICH row
|
|
4972
|
+
// the LIMIT 1 keeps (deterministic hasOne over a non-unique FK): matching
|
|
4973
|
+
// the batched strategy, which orders its flat follow-up and takes bucket[0].
|
|
4974
|
+
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause} LIMIT 1`;
|
|
4514
4975
|
}
|
|
4515
4976
|
/**
|
|
4516
4977
|
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
@@ -4568,22 +5029,15 @@ class QueryInterface {
|
|
|
4568
5029
|
let whereClause = sourceKeys
|
|
4569
5030
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
4570
5031
|
.join(' AND ');
|
|
4571
|
-
// ORDER BY on the target rows
|
|
4572
|
-
//
|
|
5032
|
+
// ORDER BY on the target rows: unified with the top-level orderBy surface
|
|
5033
|
+
// (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
|
|
5034
|
+
// `orderBy: {}` (no defined entries) is treated as absent: it must not
|
|
5035
|
+
// render a dangling `ORDER BY `. Param pushes here land BEFORE the
|
|
5036
|
+
// spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
|
|
4573
5037
|
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
4574
5038
|
let orderClause = '';
|
|
4575
5039
|
if (relOrderEntries.length > 0) {
|
|
4576
|
-
|
|
4577
|
-
.map(([k, dirValue]) => {
|
|
4578
|
-
const col = (0, schema_js_1.camelToSnake)(k);
|
|
4579
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
4580
|
-
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4581
|
-
}
|
|
4582
|
-
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
4583
|
-
return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4584
|
-
})
|
|
4585
|
-
.join(', ');
|
|
4586
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
5040
|
+
orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
|
|
4587
5041
|
}
|
|
4588
5042
|
// Additional WHERE filters on the target — full scalar where surface,
|
|
4589
5043
|
// properly parameterized against the target alias.
|
|
@@ -4685,18 +5139,59 @@ class QueryInterface {
|
|
|
4685
5139
|
};
|
|
4686
5140
|
return typeMap[baseType] ?? 'text';
|
|
4687
5141
|
}
|
|
5142
|
+
/**
|
|
5143
|
+
* Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
|
|
5144
|
+
* JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
|
|
5145
|
+
* the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
|
|
5146
|
+
* param-collect path ({@link collectJsonFilterParams}) so both always agree
|
|
5147
|
+
* on which params are pushed — and both throw identically for invalid
|
|
5148
|
+
* shapes, so a warmed cache can never skip validation.
|
|
5149
|
+
*/
|
|
5150
|
+
jsonRangeEntries(filter, column) {
|
|
5151
|
+
const entries = [];
|
|
5152
|
+
for (const [op, sqlOp] of Object.entries(filters_js_1.JSON_RANGE_OPERATORS)) {
|
|
5153
|
+
const value = filter[op];
|
|
5154
|
+
if (value === undefined)
|
|
5155
|
+
continue;
|
|
5156
|
+
if (filter.path === undefined) {
|
|
5157
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
|
|
5158
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
|
|
5159
|
+
}
|
|
5160
|
+
if (typeof value !== 'number' && typeof value !== 'string') {
|
|
5161
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
|
|
5162
|
+
`got ${JSON.stringify(value)}.`);
|
|
5163
|
+
}
|
|
5164
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
5165
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
|
|
5166
|
+
}
|
|
5167
|
+
entries.push({ sqlOp, value });
|
|
5168
|
+
}
|
|
5169
|
+
return entries;
|
|
5170
|
+
}
|
|
4688
5171
|
/**
|
|
4689
5172
|
* Build SQL clauses for JSONB filter operators on a column.
|
|
4690
|
-
* Supports: path, equals, contains, hasKey.
|
|
5173
|
+
* Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
|
|
5174
|
+
*
|
|
5175
|
+
* The `path` param is bound at most once and its placeholder is shared by
|
|
5176
|
+
* every clause that extracts it (equals + range ops), so the param list
|
|
5177
|
+
* stays byte-identical to {@link collectJsonFilterParams}.
|
|
4691
5178
|
*/
|
|
4692
5179
|
buildJsonFilterClauses(column, filter, params) {
|
|
4693
5180
|
const clauses = [];
|
|
5181
|
+
// Lazily bind the path once; reuse the same $N in every extraction clause.
|
|
5182
|
+
let pathParamIdx = null;
|
|
5183
|
+
const pathExtract = () => {
|
|
5184
|
+
if (pathParamIdx === null) {
|
|
5185
|
+
params.push(filter.path);
|
|
5186
|
+
pathParamIdx = params.length;
|
|
5187
|
+
}
|
|
5188
|
+
return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
|
|
5189
|
+
};
|
|
4694
5190
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
4695
5191
|
// Path access + equals: column #>> $N::text[] = $M
|
|
4696
|
-
|
|
4697
|
-
const pathParam = params.length;
|
|
5192
|
+
const extract = pathExtract();
|
|
4698
5193
|
params.push(String(filter.equals));
|
|
4699
|
-
clauses.push(`${
|
|
5194
|
+
clauses.push(`${extract} = ${this.p(params.length)}`);
|
|
4700
5195
|
}
|
|
4701
5196
|
else if (filter.equals !== undefined) {
|
|
4702
5197
|
// Containment equality: column @> $N::jsonb
|
|
@@ -4713,8 +5208,28 @@ class QueryInterface {
|
|
|
4713
5208
|
params.push(filter.hasKey);
|
|
4714
5209
|
clauses.push(`${column} ? ${this.p(params.length)}`);
|
|
4715
5210
|
}
|
|
5211
|
+
// Range comparisons on the extracted path: numbers compare numerically
|
|
5212
|
+
// (cast through the dialect), strings compare as text.
|
|
5213
|
+
for (const { sqlOp, value } of this.jsonRangeEntries(filter, column)) {
|
|
5214
|
+
const extract = pathExtract();
|
|
5215
|
+
params.push(value);
|
|
5216
|
+
const lhs = typeof value === 'number' ? this.castJsonNumeric(extract) : extract;
|
|
5217
|
+
clauses.push(`${lhs} ${sqlOp} ${this.p(params.length)}`);
|
|
5218
|
+
}
|
|
4716
5219
|
return clauses;
|
|
4717
5220
|
}
|
|
5221
|
+
/**
|
|
5222
|
+
* Cast an extracted JSON path text value to a numeric type for range
|
|
5223
|
+
* comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
|
|
5224
|
+
* compare JSON numbers, and `::float` would lose precision on big ints);
|
|
5225
|
+
* other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
|
|
5226
|
+
* SQL Server have no `::` operator) as a float cast.
|
|
5227
|
+
*/
|
|
5228
|
+
castJsonNumeric(extract) {
|
|
5229
|
+
if (this.dialect.name === 'postgresql')
|
|
5230
|
+
return `(${extract})::numeric`;
|
|
5231
|
+
return this.dialect.castAggregate ? this.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
|
|
5232
|
+
}
|
|
4718
5233
|
/**
|
|
4719
5234
|
* Build SQL clauses for Array filter operators on a column.
|
|
4720
5235
|
* Supports: has, hasEvery, hasSome, isEmpty.
|