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
package/dist/query/builder.js
CHANGED
|
@@ -16,7 +16,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
|
|
|
16
16
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
17
17
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
18
18
|
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
19
|
-
import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
19
|
+
import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
20
20
|
import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
|
|
21
21
|
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
22
22
|
const unindexedRelationWarned = new Set();
|
|
@@ -61,6 +61,12 @@ export class QueryInterface {
|
|
|
61
61
|
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
62
62
|
columnPgTypeMap;
|
|
63
63
|
columnArrayTypeMap;
|
|
64
|
+
/**
|
|
65
|
+
* Columns whose type lives in a DIFFERENT schema than the introspected one
|
|
66
|
+
* (ColumnMetadata.pgTypeSchema is recorded only in that case) — such columns
|
|
67
|
+
* must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
|
|
68
|
+
*/
|
|
69
|
+
crossSchemaTypeColumns;
|
|
64
70
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
65
71
|
deepWithWarned = new Set();
|
|
66
72
|
/**
|
|
@@ -98,9 +104,19 @@ export class QueryInterface {
|
|
|
98
104
|
this.middlewares = middlewares ?? [];
|
|
99
105
|
this.defaultLimit = options?.defaultLimit;
|
|
100
106
|
// Default to ON: surfacing accidental full-table scans is more valuable
|
|
101
|
-
// than the (small) risk of noisy logs. Callers
|
|
102
|
-
// `warnOnUnlimited: false
|
|
103
|
-
|
|
107
|
+
// than the (small) risk of noisy logs. Callers opt out globally with
|
|
108
|
+
// `warnOnUnlimited: false`, per table with `warnOnUnlimited: { users:
|
|
109
|
+
// false }` (unlisted tables keep the default), or per call via
|
|
110
|
+
// `findMany({ warnOnUnlimited: false })`.
|
|
111
|
+
// Per-table maps accept BOTH key forms — the snake_case table name
|
|
112
|
+
// (`user_profiles`) and the camelCase accessor (`userProfiles`) — since
|
|
113
|
+
// users naturally key by the accessor they type everywhere else. The
|
|
114
|
+
// snake_case entry wins when both are present.
|
|
115
|
+
const warnOpt = options?.warnOnUnlimited;
|
|
116
|
+
this.warnOnUnlimited =
|
|
117
|
+
typeof warnOpt === 'object' && warnOpt !== null
|
|
118
|
+
? (warnOpt[table] ?? warnOpt[snakeToCamel(table)]) !== false
|
|
119
|
+
: warnOpt !== false;
|
|
104
120
|
this.utcTimestamps = options?.utcTimestamps !== false;
|
|
105
121
|
this.preparedStatementsEnabled = options?.preparedStatements ?? true;
|
|
106
122
|
this.sqlCacheEnabled = options?.sqlCache !== false;
|
|
@@ -116,9 +132,12 @@ export class QueryInterface {
|
|
|
116
132
|
// Pre-compute column type lookup maps (TASK-26)
|
|
117
133
|
this.columnPgTypeMap = new Map();
|
|
118
134
|
this.columnArrayTypeMap = new Map();
|
|
135
|
+
this.crossSchemaTypeColumns = new Set();
|
|
119
136
|
for (const col of this.tableMeta.columns) {
|
|
120
137
|
this.columnPgTypeMap.set(col.name, col.dialectType ?? col.pgType);
|
|
121
138
|
this.columnArrayTypeMap.set(col.name, col.arrayType ?? col.pgArrayType);
|
|
139
|
+
if (col.pgTypeSchema !== undefined)
|
|
140
|
+
this.crossSchemaTypeColumns.add(col.name);
|
|
122
141
|
}
|
|
123
142
|
}
|
|
124
143
|
/** Quote an identifier through the active SQL dialect. */
|
|
@@ -644,10 +663,16 @@ export class QueryInterface {
|
|
|
644
663
|
* loop calling `db.users.findMany()` thousands of times only logs once.
|
|
645
664
|
* Suppressed when `defaultLimit` is configured (the caller has already
|
|
646
665
|
* opted in to a bounded query) and when the user passed an explicit
|
|
647
|
-
* `limit`, `take`, or `cursor`.
|
|
666
|
+
* `limit`, `take`, or `cursor`. A per-call `warnOnUnlimited` overrides the
|
|
667
|
+
* config-level setting in either direction (`false` silences a call that
|
|
668
|
+
* intentionally reads the full set; `true` forces the warning even when
|
|
669
|
+
* disabled in config).
|
|
648
670
|
*/
|
|
649
671
|
maybeWarnUnlimited(args) {
|
|
650
|
-
|
|
672
|
+
const perCall = args?.warnOnUnlimited;
|
|
673
|
+
if (perCall === false)
|
|
674
|
+
return;
|
|
675
|
+
if (perCall === undefined && !this.warnOnUnlimited)
|
|
651
676
|
return;
|
|
652
677
|
if (this.defaultLimit !== undefined)
|
|
653
678
|
return;
|
|
@@ -658,7 +683,7 @@ export class QueryInterface {
|
|
|
658
683
|
return;
|
|
659
684
|
this.warnedTables.add(this.table);
|
|
660
685
|
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
|
|
661
|
-
'Pass `limit
|
|
686
|
+
'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
|
|
662
687
|
}
|
|
663
688
|
/**
|
|
664
689
|
* Recursively measure the maximum depth of a `with` clause tree.
|
|
@@ -1007,7 +1032,8 @@ export class QueryInterface {
|
|
|
1007
1032
|
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
1008
1033
|
const columns = entries.map(([k]) => this.toSqlColumn(k));
|
|
1009
1034
|
const params = entries.map(([, v]) => v);
|
|
1010
|
-
|
|
1035
|
+
// Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
|
|
1036
|
+
const placeholders = entries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
|
|
1011
1037
|
const sql = this.dialect.buildInsertStatement({
|
|
1012
1038
|
table: this.q(this.table),
|
|
1013
1039
|
columns,
|
|
@@ -1089,7 +1115,13 @@ export class QueryInterface {
|
|
|
1089
1115
|
return keys.map((key) => record[key]);
|
|
1090
1116
|
});
|
|
1091
1117
|
// Use actual Postgres types for array casts in the default PostgreSQL dialect.
|
|
1092
|
-
|
|
1118
|
+
// Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
|
|
1119
|
+
// type the UNNEST output as text, which Postgres refuses to coerce to the
|
|
1120
|
+
// enum ("column X is of type Y but expression is of type text").
|
|
1121
|
+
const typeCasts = columns.map((col) => {
|
|
1122
|
+
const enumType = this.enumTypeForColumn(col);
|
|
1123
|
+
return enumType ? `${this.q(enumType)}[]` : this.getColumnArrayType(col);
|
|
1124
|
+
});
|
|
1093
1125
|
const quotedColumns = columns.map((c) => this.q(c));
|
|
1094
1126
|
const built = this.dialect.buildBulkInsertStatement({
|
|
1095
1127
|
table: qt,
|
|
@@ -1363,7 +1395,8 @@ export class QueryInterface {
|
|
|
1363
1395
|
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
1364
1396
|
const columns = createEntries.map(([k]) => this.toSqlColumn(k));
|
|
1365
1397
|
const createParams = createEntries.map(([, v]) => v);
|
|
1366
|
-
|
|
1398
|
+
// Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
|
|
1399
|
+
const placeholders = createEntries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
|
|
1367
1400
|
// The conflict target comes from `where` keys — must be unique/PK columns
|
|
1368
1401
|
const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
|
|
1369
1402
|
const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
|
|
@@ -1371,7 +1404,7 @@ export class QueryInterface {
|
|
|
1371
1404
|
const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
|
|
1372
1405
|
let paramIdx = createParams.length + 1;
|
|
1373
1406
|
const setClauses = updateEntries.map(([k]) => {
|
|
1374
|
-
const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}`;
|
|
1407
|
+
const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}${this.enumCastSuffix(this.toColumn(k))}`;
|
|
1375
1408
|
paramIdx++;
|
|
1376
1409
|
return clause;
|
|
1377
1410
|
});
|
|
@@ -2051,6 +2084,10 @@ export class QueryInterface {
|
|
|
2051
2084
|
*/
|
|
2052
2085
|
buildSetClause(key, value, params) {
|
|
2053
2086
|
const col = this.toSqlColumn(key);
|
|
2087
|
+
// Enum columns get an explicit `::"EnumName"` cast on their value bind
|
|
2088
|
+
// (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
|
|
2089
|
+
// SQL cache and collectSetParams are unaffected.
|
|
2090
|
+
const cast = this.enumCastSuffix(this.toColumn(key));
|
|
2054
2091
|
// Detect atomic-operator object: plain object (not null, not array, not
|
|
2055
2092
|
// Date, not Buffer) with EXACTLY one key matching an operator name.
|
|
2056
2093
|
if (value !== null &&
|
|
@@ -2065,7 +2102,7 @@ export class QueryInterface {
|
|
|
2065
2102
|
const opValue = v[op];
|
|
2066
2103
|
if (op === 'set') {
|
|
2067
2104
|
params.push(opValue);
|
|
2068
|
-
return `${col} = ${this.p(params.length)}`;
|
|
2105
|
+
return `${col} = ${this.p(params.length)}${cast}`;
|
|
2069
2106
|
}
|
|
2070
2107
|
// Arithmetic operators: must be finite numbers
|
|
2071
2108
|
if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
|
|
@@ -2093,7 +2130,7 @@ export class QueryInterface {
|
|
|
2093
2130
|
}
|
|
2094
2131
|
// Plain value (including null, Date, Buffer, arrays, JSON objects)
|
|
2095
2132
|
params.push(value);
|
|
2096
|
-
return `${col} = ${this.p(params.length)}`;
|
|
2133
|
+
return `${col} = ${this.p(params.length)}${cast}`;
|
|
2097
2134
|
}
|
|
2098
2135
|
// =========================================================================
|
|
2099
2136
|
// Fingerprinting — value-invariant shape keys for SQL cache lookup
|
|
@@ -2192,10 +2229,10 @@ export class QueryInterface {
|
|
|
2192
2229
|
parts.push(`${key}:vec(${dist.metric},${cmps})`);
|
|
2193
2230
|
continue;
|
|
2194
2231
|
}
|
|
2195
|
-
// JSON filter
|
|
2232
|
+
// JSON filter — range ops carry a numeric/string annotation because the
|
|
2233
|
+
// numeric compile emits a cast (different SQL shape).
|
|
2196
2234
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2197
|
-
|
|
2198
|
-
parts.push(`${key}:json(${jKeys.join(',')})`);
|
|
2235
|
+
parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
|
|
2199
2236
|
continue;
|
|
2200
2237
|
}
|
|
2201
2238
|
// Array filter
|
|
@@ -2281,6 +2318,15 @@ export class QueryInterface {
|
|
|
2281
2318
|
else if (isWhereOperator(value)) {
|
|
2282
2319
|
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2283
2320
|
}
|
|
2321
|
+
else if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2322
|
+
// Mirrors fingerprintWhere: JSON filters inside relation sub-wheres
|
|
2323
|
+
// build real JSON clauses (buildSubWhereForRelation), so their shape
|
|
2324
|
+
// must be cache-distinct from plain equality.
|
|
2325
|
+
parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
|
|
2326
|
+
}
|
|
2327
|
+
else if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
2328
|
+
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
2329
|
+
}
|
|
2284
2330
|
else if (isUnmatchedPlainObject(value)) {
|
|
2285
2331
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
2286
2332
|
.sort()
|
|
@@ -2358,7 +2404,7 @@ export class QueryInterface {
|
|
|
2358
2404
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2359
2405
|
const colType = this.getColumnPgType(rawColumn);
|
|
2360
2406
|
if (colType === 'json' || colType === 'jsonb') {
|
|
2361
|
-
this.collectJsonFilterParams(value, params);
|
|
2407
|
+
this.collectJsonFilterParams(value, params, this.q(rawColumn));
|
|
2362
2408
|
continue;
|
|
2363
2409
|
}
|
|
2364
2410
|
}
|
|
@@ -2377,7 +2423,11 @@ export class QueryInterface {
|
|
|
2377
2423
|
}
|
|
2378
2424
|
// Operator objects
|
|
2379
2425
|
if (isWhereOperator(value)) {
|
|
2380
|
-
this.collectOperatorParams(rawColumn, value, params
|
|
2426
|
+
this.collectOperatorParams(rawColumn, value, params, {
|
|
2427
|
+
meta: this.tableMeta,
|
|
2428
|
+
table: this.table,
|
|
2429
|
+
prefix: '',
|
|
2430
|
+
});
|
|
2381
2431
|
continue;
|
|
2382
2432
|
}
|
|
2383
2433
|
// Plain equality — same strict validation as the build path, so a
|
|
@@ -2459,29 +2509,59 @@ export class QueryInterface {
|
|
|
2459
2509
|
}
|
|
2460
2510
|
}
|
|
2461
2511
|
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2512
|
+
// JSONB filter — mirrors buildSubWhereForRelation (which mirrors the
|
|
2513
|
+
// top-level buildWhereClause): route to the JSON param collector when
|
|
2514
|
+
// the target column is json/jsonb.
|
|
2515
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2516
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
2517
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
2518
|
+
this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
// Array filter — mirrors buildSubWhereForRelation.
|
|
2523
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
2524
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
2525
|
+
if (colType.startsWith('_')) {
|
|
2526
|
+
this.collectArrayFilterParams(value, params);
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2462
2530
|
if (isWhereOperator(value)) {
|
|
2463
|
-
this.collectOperatorParams(col, value, params);
|
|
2531
|
+
this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
|
|
2464
2532
|
continue;
|
|
2465
2533
|
}
|
|
2466
2534
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2467
2535
|
params.push(value);
|
|
2468
2536
|
}
|
|
2469
2537
|
}
|
|
2470
|
-
/**
|
|
2471
|
-
|
|
2472
|
-
|
|
2538
|
+
/**
|
|
2539
|
+
* Collect params from operator clauses. Mirrors buildOperatorClauses:
|
|
2540
|
+
* {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
|
|
2541
|
+
* but they re-run the same validation (unknown ref / insensitive mode) so a
|
|
2542
|
+
* warmed cache can never skip a check the build path enforces.
|
|
2543
|
+
*/
|
|
2544
|
+
collectOperatorParams(column, op, params, refCtx) {
|
|
2545
|
+
const skipRef = (v) => {
|
|
2546
|
+
if (!isColumnRef(v))
|
|
2547
|
+
return false;
|
|
2548
|
+
if (refCtx)
|
|
2549
|
+
this.resolveColumnRef(v, refCtx, op.mode);
|
|
2550
|
+
return true;
|
|
2551
|
+
};
|
|
2552
|
+
if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
|
|
2473
2553
|
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
2474
2554
|
params.push(op.equals);
|
|
2475
2555
|
}
|
|
2476
|
-
if (op.gt !== undefined)
|
|
2556
|
+
if (op.gt !== undefined && !skipRef(op.gt))
|
|
2477
2557
|
params.push(op.gt);
|
|
2478
|
-
if (op.gte !== undefined)
|
|
2558
|
+
if (op.gte !== undefined && !skipRef(op.gte))
|
|
2479
2559
|
params.push(op.gte);
|
|
2480
|
-
if (op.lt !== undefined)
|
|
2560
|
+
if (op.lt !== undefined && !skipRef(op.lt))
|
|
2481
2561
|
params.push(op.lt);
|
|
2482
|
-
if (op.lte !== undefined)
|
|
2562
|
+
if (op.lte !== undefined && !skipRef(op.lte))
|
|
2483
2563
|
params.push(op.lte);
|
|
2484
|
-
if (op.not !== undefined && op.not !== null)
|
|
2564
|
+
if (op.not !== undefined && op.not !== null && !skipRef(op.not))
|
|
2485
2565
|
params.push(op.not);
|
|
2486
2566
|
if (op.in !== undefined)
|
|
2487
2567
|
params.push(this.inParam(op.in));
|
|
@@ -2494,10 +2574,22 @@ export class QueryInterface {
|
|
|
2494
2574
|
if (op.endsWith !== undefined)
|
|
2495
2575
|
params.push(`%${escapeLike(op.endsWith)}`);
|
|
2496
2576
|
}
|
|
2497
|
-
/**
|
|
2498
|
-
|
|
2577
|
+
/**
|
|
2578
|
+
* Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
|
|
2579
|
+
* the `path` is bound at most once (its placeholder is shared by every
|
|
2580
|
+
* extraction clause), then equals/contains/hasKey values, then the range
|
|
2581
|
+
* comparison values in {@link JSON_RANGE_OPERATORS} order.
|
|
2582
|
+
*/
|
|
2583
|
+
collectJsonFilterParams(filter, params, column) {
|
|
2584
|
+
let pathPushed = false;
|
|
2585
|
+
const pushPathOnce = () => {
|
|
2586
|
+
if (!pathPushed) {
|
|
2587
|
+
params.push(filter.path);
|
|
2588
|
+
pathPushed = true;
|
|
2589
|
+
}
|
|
2590
|
+
};
|
|
2499
2591
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
2500
|
-
|
|
2592
|
+
pushPathOnce();
|
|
2501
2593
|
params.push(String(filter.equals));
|
|
2502
2594
|
}
|
|
2503
2595
|
else if (filter.equals !== undefined) {
|
|
@@ -2509,6 +2601,10 @@ export class QueryInterface {
|
|
|
2509
2601
|
if (filter.hasKey !== undefined) {
|
|
2510
2602
|
params.push(filter.hasKey);
|
|
2511
2603
|
}
|
|
2604
|
+
for (const { value } of this.jsonRangeEntries(filter, column)) {
|
|
2605
|
+
pushPathOnce();
|
|
2606
|
+
params.push(value);
|
|
2607
|
+
}
|
|
2512
2608
|
}
|
|
2513
2609
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
2514
2610
|
collectArrayFilterParams(filter, params) {
|
|
@@ -2521,10 +2617,10 @@ export class QueryInterface {
|
|
|
2521
2617
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
2522
2618
|
}
|
|
2523
2619
|
/**
|
|
2524
|
-
* Collect params for an orderBy clause.
|
|
2525
|
-
*
|
|
2526
|
-
* parameterless. Mirrors buildOrderBy's push
|
|
2527
|
-
* param re-collection stays in lockstep.
|
|
2620
|
+
* Collect params for an orderBy clause. Vector KNN ordering pushes the
|
|
2621
|
+
* `$n::vector` query vector and JSON-path ordering pushes its text[] path;
|
|
2622
|
+
* plain direction ordering is parameterless. Mirrors buildOrderBy's push
|
|
2623
|
+
* order exactly so the cached-SQL param re-collection stays in lockstep.
|
|
2528
2624
|
*/
|
|
2529
2625
|
collectOrderByParams(orderBy, params) {
|
|
2530
2626
|
for (const [key, dir] of Object.entries(orderBy)) {
|
|
@@ -2536,6 +2632,13 @@ export class QueryInterface {
|
|
|
2536
2632
|
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2537
2633
|
continue;
|
|
2538
2634
|
}
|
|
2635
|
+
// JSON-path ordering: mirrors buildJsonPathOrderEntry: same validation,
|
|
2636
|
+
// then the path bound as one text[] param.
|
|
2637
|
+
if (isJsonPathOrderBy(dir)) {
|
|
2638
|
+
this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
|
|
2639
|
+
params.push(dir.path.map(String));
|
|
2640
|
+
continue;
|
|
2641
|
+
}
|
|
2539
2642
|
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2540
2643
|
// subquery as `_count` — mirror its global-filter params. To-one relation
|
|
2541
2644
|
// orderBy carries the target's global filter once per ordered column.
|
|
@@ -2682,9 +2785,20 @@ export class QueryInterface {
|
|
|
2682
2785
|
const targetMeta = this.schema.tables[targetTable];
|
|
2683
2786
|
if (!targetMeta)
|
|
2684
2787
|
return;
|
|
2788
|
+
// A dialect that owns the whole subquery (buildRelationSubquery override,
|
|
2789
|
+
// SQL Server FOR JSON) compiles orderBy through its OWN paging clause -
|
|
2790
|
+
// plain directions only, no order params: so the native order-param
|
|
2791
|
+
// mirrors below must stay off for it (its documented contract remains
|
|
2792
|
+
// where → limit → nested).
|
|
2793
|
+
const nativeOrderPath = !this.dialect.buildRelationSubquery;
|
|
2685
2794
|
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2686
|
-
// where params → limit param → nested-with params
|
|
2795
|
+
// orderBy params → where params → limit param → nested-with params
|
|
2796
|
+
// (always, both paths).
|
|
2687
2797
|
if (relDef.type === 'manyToMany') {
|
|
2798
|
+
const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
2799
|
+
if (nativeOrderPath && m2mOrderEntries.length > 0) {
|
|
2800
|
+
this.collectRelationOrderParams(targetTable, targetMeta, m2mOrderEntries, params);
|
|
2801
|
+
}
|
|
2688
2802
|
if (spec.where) {
|
|
2689
2803
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2690
2804
|
}
|
|
@@ -2703,7 +2817,8 @@ export class QueryInterface {
|
|
|
2703
2817
|
return;
|
|
2704
2818
|
}
|
|
2705
2819
|
// Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
|
|
2706
|
-
const
|
|
2820
|
+
const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
2821
|
+
const hasOrder = relOrderEntries.length > 0;
|
|
2707
2822
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2708
2823
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2709
2824
|
if (!willWrap && spec.with) {
|
|
@@ -2714,6 +2829,12 @@ export class QueryInterface {
|
|
|
2714
2829
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2715
2830
|
}
|
|
2716
2831
|
}
|
|
2832
|
+
// orderBy params (JSON paths / relation-order global filters): mirrors
|
|
2833
|
+
// buildRelationSubquery, which builds its ORDER BY terms BEFORE compiling
|
|
2834
|
+
// spec.where (both wrapped and non-wrapped paths).
|
|
2835
|
+
if (nativeOrderPath && hasOrder) {
|
|
2836
|
+
this.collectRelationOrderParams(targetTable, targetMeta, relOrderEntries, params);
|
|
2837
|
+
}
|
|
2717
2838
|
// where params — mirrors buildAliasWhere push order
|
|
2718
2839
|
if (spec.where) {
|
|
2719
2840
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
@@ -3064,7 +3185,11 @@ export class QueryInterface {
|
|
|
3064
3185
|
}
|
|
3065
3186
|
// Handle operator objects
|
|
3066
3187
|
if (isWhereOperator(value)) {
|
|
3067
|
-
const opClauses = this.buildOperatorClauses(column, value, params
|
|
3188
|
+
const opClauses = this.buildOperatorClauses(column, value, params, {
|
|
3189
|
+
meta: this.tableMeta,
|
|
3190
|
+
table: this.table,
|
|
3191
|
+
prefix: '',
|
|
3192
|
+
});
|
|
3068
3193
|
andClauses.push(...opClauses);
|
|
3069
3194
|
continue;
|
|
3070
3195
|
}
|
|
@@ -3228,8 +3353,41 @@ export class QueryInterface {
|
|
|
3228
3353
|
conditions.push(`${qCol} IS NULL`);
|
|
3229
3354
|
continue;
|
|
3230
3355
|
}
|
|
3356
|
+
// JSONB filter on a json/jsonb column of the relation target — mirrors
|
|
3357
|
+
// the top-level WHERE path (buildWhereClause). Without this branch the
|
|
3358
|
+
// filter object used to fall through to plain equality and bind as a
|
|
3359
|
+
// jsonb value, silently matching nothing.
|
|
3360
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3361
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3362
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3363
|
+
conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3364
|
+
continue;
|
|
3365
|
+
}
|
|
3366
|
+
const jsonKey = findJsonUniqueKey(value);
|
|
3367
|
+
if (jsonKey) {
|
|
3368
|
+
throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
|
|
3369
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
// Array filter on an array column of the relation target — same mirror.
|
|
3373
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3374
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3375
|
+
if (colType.startsWith('_')) {
|
|
3376
|
+
conditions.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
|
|
3377
|
+
continue;
|
|
3378
|
+
}
|
|
3379
|
+
const arrayKey = findArrayUniqueKey(value);
|
|
3380
|
+
if (arrayKey) {
|
|
3381
|
+
throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
|
|
3382
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3231
3385
|
if (isWhereOperator(value)) {
|
|
3232
|
-
const opClauses = this.buildOperatorClauses(qCol, value, params
|
|
3386
|
+
const opClauses = this.buildOperatorClauses(qCol, value, params, {
|
|
3387
|
+
meta,
|
|
3388
|
+
table: targetTable,
|
|
3389
|
+
prefix: `${qt}.`,
|
|
3390
|
+
});
|
|
3233
3391
|
conditions.push(...opClauses);
|
|
3234
3392
|
continue;
|
|
3235
3393
|
}
|
|
@@ -3246,6 +3404,49 @@ export class QueryInterface {
|
|
|
3246
3404
|
pgTypeForColumn(meta, column) {
|
|
3247
3405
|
return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
|
|
3248
3406
|
}
|
|
3407
|
+
/**
|
|
3408
|
+
* The Postgres enum type name for a column, when the schema knows one.
|
|
3409
|
+
*
|
|
3410
|
+
* Introspection stores each column's `udt_name` in `pgTypes` and every
|
|
3411
|
+
* database enum in `schema.enums` (typname → labels); a column whose type
|
|
3412
|
+
* matches an enum key needs an explicit `::"EnumName"` cast on its write
|
|
3413
|
+
* binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
|
|
3414
|
+
* value as text and Postgres refuses the implicit text→enum coercion
|
|
3415
|
+
* ("column X is of type Y but expression is of type text").
|
|
3416
|
+
*
|
|
3417
|
+
* Postgres-only by construction: gated on the active dialect being
|
|
3418
|
+
* `postgresql` AND on `schema.enums` having entries (only PG introspection
|
|
3419
|
+
* produces them — `defineSchema` and the other engines leave it empty), so
|
|
3420
|
+
* SQLite/MySQL/MSSQL/PowDB output is byte-identical.
|
|
3421
|
+
*/
|
|
3422
|
+
enumTypeForColumn(column) {
|
|
3423
|
+
if (this.dialect.name !== 'postgresql')
|
|
3424
|
+
return null;
|
|
3425
|
+
const enums = this.schema.enums;
|
|
3426
|
+
if (!enums)
|
|
3427
|
+
return null;
|
|
3428
|
+
// Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
|
|
3429
|
+
// the column's type lives OUTSIDE the introspected schema. A same-named
|
|
3430
|
+
// enum in another schema must not get this schema's cast — search_path
|
|
3431
|
+
// would resolve `::"status"` to the wrong type. Skipping the cast restores
|
|
3432
|
+
// the pre-cast behavior for such columns. Columns without pgTypeSchema
|
|
3433
|
+
// (same-schema types, defineSchema/legacy metadata) keep the cast.
|
|
3434
|
+
if (this.crossSchemaTypeColumns.has(column))
|
|
3435
|
+
return null;
|
|
3436
|
+
const pgType = this.columnPgTypeMap.get(column) ?? this.tableMeta.pgTypes?.[column];
|
|
3437
|
+
if (!pgType || pgType.startsWith('_'))
|
|
3438
|
+
return null;
|
|
3439
|
+
return Object.hasOwn(enums, pgType) ? pgType : null;
|
|
3440
|
+
}
|
|
3441
|
+
/**
|
|
3442
|
+
* `::"EnumName"` cast suffix for a write-bind placeholder on an enum
|
|
3443
|
+
* column; `''` for every other column, so non-enum SQL stays byte-identical.
|
|
3444
|
+
* The type name is an introspected identifier and is quoted via the dialect.
|
|
3445
|
+
*/
|
|
3446
|
+
enumCastSuffix(column) {
|
|
3447
|
+
const enumType = this.enumTypeForColumn(column);
|
|
3448
|
+
return enumType ? `::${this.q(enumType)}` : '';
|
|
3449
|
+
}
|
|
3249
3450
|
/**
|
|
3250
3451
|
* Equality-fallthrough guard shared by every SQL-build path AND every
|
|
3251
3452
|
* cache-hit param-collect path. A plain object literal that matched no known
|
|
@@ -3324,8 +3525,40 @@ export class QueryInterface {
|
|
|
3324
3525
|
clauses.push(`${qCol} IS NULL`);
|
|
3325
3526
|
continue;
|
|
3326
3527
|
}
|
|
3528
|
+
// JSONB filter on a json/jsonb column — mirrors the top-level WHERE path
|
|
3529
|
+
// (buildWhereClause) so a `with.where` JSON filter is never silently
|
|
3530
|
+
// bound as a plain equality value.
|
|
3531
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3532
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3533
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3534
|
+
clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3535
|
+
continue;
|
|
3536
|
+
}
|
|
3537
|
+
const jsonKey = findJsonUniqueKey(value);
|
|
3538
|
+
if (jsonKey) {
|
|
3539
|
+
throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
|
|
3540
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
// Array filter on an array column — same mirror.
|
|
3544
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3545
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3546
|
+
if (colType.startsWith('_')) {
|
|
3547
|
+
clauses.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
|
|
3548
|
+
continue;
|
|
3549
|
+
}
|
|
3550
|
+
const arrayKey = findArrayUniqueKey(value);
|
|
3551
|
+
if (arrayKey) {
|
|
3552
|
+
throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
|
|
3553
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3327
3556
|
if (isWhereOperator(value)) {
|
|
3328
|
-
clauses.push(...this.buildOperatorClauses(qCol, value, params
|
|
3557
|
+
clauses.push(...this.buildOperatorClauses(qCol, value, params, {
|
|
3558
|
+
meta: targetMeta,
|
|
3559
|
+
table: targetTable,
|
|
3560
|
+
prefix: `${alias}.`,
|
|
3561
|
+
}));
|
|
3329
3562
|
continue;
|
|
3330
3563
|
}
|
|
3331
3564
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
@@ -3367,8 +3600,24 @@ export class QueryInterface {
|
|
|
3367
3600
|
}
|
|
3368
3601
|
}
|
|
3369
3602
|
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
3603
|
+
// JSONB filter — mirrors buildAliasWhere.
|
|
3604
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3605
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3606
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3607
|
+
this.collectJsonFilterParams(value, params, this.q(col));
|
|
3608
|
+
continue;
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
// Array filter — mirrors buildAliasWhere.
|
|
3612
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3613
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3614
|
+
if (colType.startsWith('_')) {
|
|
3615
|
+
this.collectArrayFilterParams(value, params);
|
|
3616
|
+
continue;
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3370
3619
|
if (isWhereOperator(value)) {
|
|
3371
|
-
this.collectOperatorParams(col, value, params);
|
|
3620
|
+
this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
|
|
3372
3621
|
continue;
|
|
3373
3622
|
}
|
|
3374
3623
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
@@ -3425,6 +3674,16 @@ export class QueryInterface {
|
|
|
3425
3674
|
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
3426
3675
|
continue;
|
|
3427
3676
|
}
|
|
3677
|
+
// JSON / array filters build real clauses in buildAliasWhere, so their
|
|
3678
|
+
// shape must be cache-distinct from plain equality (mirrors fingerprintWhere).
|
|
3679
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3680
|
+
parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
|
|
3681
|
+
continue;
|
|
3682
|
+
}
|
|
3683
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3684
|
+
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
3685
|
+
continue;
|
|
3686
|
+
}
|
|
3428
3687
|
if (isUnmatchedPlainObject(value)) {
|
|
3429
3688
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
3430
3689
|
.sort()
|
|
@@ -3435,16 +3694,55 @@ export class QueryInterface {
|
|
|
3435
3694
|
}
|
|
3436
3695
|
return parts.join('&');
|
|
3437
3696
|
}
|
|
3697
|
+
/**
|
|
3698
|
+
* Validate a `{ col }` column reference against its table and return the
|
|
3699
|
+
* resolved snake_case column name. Shared by the SQL-build path
|
|
3700
|
+
* ({@link buildOperatorClauses}) and the cache-hit param-collect path
|
|
3701
|
+
* (`collectOperatorParams`) so both always throw identically: a warmed
|
|
3702
|
+
* cache can never skip the check.
|
|
3703
|
+
*/
|
|
3704
|
+
resolveColumnRef(ref, ctx, mode) {
|
|
3705
|
+
if (mode === 'insensitive') {
|
|
3706
|
+
throw new ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
|
|
3707
|
+
`Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
|
|
3708
|
+
`for lower(a) = lower(b).`);
|
|
3709
|
+
}
|
|
3710
|
+
const col = ctx.meta.columnMap[ref.col] ?? camelToSnake(ref.col);
|
|
3711
|
+
if (!ctx.meta.allColumns.includes(col)) {
|
|
3712
|
+
throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
|
|
3713
|
+
`Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
|
|
3714
|
+
}
|
|
3715
|
+
return col;
|
|
3716
|
+
}
|
|
3717
|
+
/**
|
|
3718
|
+
* Compile a `{ col }` reference to its quoted, prefix-matched SQL identifier.
|
|
3719
|
+
* NO param is bound: the referenced column is part of the SQL text (and of
|
|
3720
|
+
* the where fingerprint, see {@link fingerprintOperatorShape}).
|
|
3721
|
+
*/
|
|
3722
|
+
columnRefSql(ref, ctx, mode) {
|
|
3723
|
+
if (!ctx) {
|
|
3724
|
+
throw new ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
|
|
3725
|
+
}
|
|
3726
|
+
return `${ctx.prefix}${this.q(this.resolveColumnRef(ref, ctx, mode))}`;
|
|
3727
|
+
}
|
|
3438
3728
|
/**
|
|
3439
3729
|
* Build SQL clauses for a single operator object on a column.
|
|
3440
3730
|
* Each operator key becomes its own clause, all ANDed together.
|
|
3731
|
+
*
|
|
3732
|
+
* `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
|
|
3733
|
+
* (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
|
|
3734
|
+
* against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
|
|
3735
|
+
* pushing nothing and the referenced name lives in the fingerprint.
|
|
3441
3736
|
*/
|
|
3442
|
-
buildOperatorClauses(column, op, params) {
|
|
3737
|
+
buildOperatorClauses(column, op, params, refCtx) {
|
|
3443
3738
|
const clauses = [];
|
|
3444
3739
|
if (op.equals !== undefined) {
|
|
3445
3740
|
if (op.equals === null) {
|
|
3446
3741
|
clauses.push(`${column} IS NULL`);
|
|
3447
3742
|
}
|
|
3743
|
+
else if (isColumnRef(op.equals)) {
|
|
3744
|
+
clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
|
|
3745
|
+
}
|
|
3448
3746
|
else {
|
|
3449
3747
|
assertBindableEqualsOperand(op.equals, column);
|
|
3450
3748
|
params.push(op.equals);
|
|
@@ -3452,25 +3750,48 @@ export class QueryInterface {
|
|
|
3452
3750
|
}
|
|
3453
3751
|
}
|
|
3454
3752
|
if (op.gt !== undefined) {
|
|
3455
|
-
|
|
3456
|
-
|
|
3753
|
+
if (isColumnRef(op.gt)) {
|
|
3754
|
+
clauses.push(`${column} > ${this.columnRefSql(op.gt, refCtx, op.mode)}`);
|
|
3755
|
+
}
|
|
3756
|
+
else {
|
|
3757
|
+
params.push(op.gt);
|
|
3758
|
+
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
3759
|
+
}
|
|
3457
3760
|
}
|
|
3458
3761
|
if (op.gte !== undefined) {
|
|
3459
|
-
|
|
3460
|
-
|
|
3762
|
+
if (isColumnRef(op.gte)) {
|
|
3763
|
+
clauses.push(`${column} >= ${this.columnRefSql(op.gte, refCtx, op.mode)}`);
|
|
3764
|
+
}
|
|
3765
|
+
else {
|
|
3766
|
+
params.push(op.gte);
|
|
3767
|
+
clauses.push(`${column} >= ${this.p(params.length)}`);
|
|
3768
|
+
}
|
|
3461
3769
|
}
|
|
3462
3770
|
if (op.lt !== undefined) {
|
|
3463
|
-
|
|
3464
|
-
|
|
3771
|
+
if (isColumnRef(op.lt)) {
|
|
3772
|
+
clauses.push(`${column} < ${this.columnRefSql(op.lt, refCtx, op.mode)}`);
|
|
3773
|
+
}
|
|
3774
|
+
else {
|
|
3775
|
+
params.push(op.lt);
|
|
3776
|
+
clauses.push(`${column} < ${this.p(params.length)}`);
|
|
3777
|
+
}
|
|
3465
3778
|
}
|
|
3466
3779
|
if (op.lte !== undefined) {
|
|
3467
|
-
|
|
3468
|
-
|
|
3780
|
+
if (isColumnRef(op.lte)) {
|
|
3781
|
+
clauses.push(`${column} <= ${this.columnRefSql(op.lte, refCtx, op.mode)}`);
|
|
3782
|
+
}
|
|
3783
|
+
else {
|
|
3784
|
+
params.push(op.lte);
|
|
3785
|
+
clauses.push(`${column} <= ${this.p(params.length)}`);
|
|
3786
|
+
}
|
|
3469
3787
|
}
|
|
3470
3788
|
if (op.not !== undefined) {
|
|
3471
3789
|
if (op.not === null) {
|
|
3472
3790
|
clauses.push(`${column} IS NOT NULL`);
|
|
3473
3791
|
}
|
|
3792
|
+
else if (isColumnRef(op.not)) {
|
|
3793
|
+
clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
|
|
3794
|
+
}
|
|
3474
3795
|
else {
|
|
3475
3796
|
params.push(op.not);
|
|
3476
3797
|
clauses.push(`${column} != ${this.p(params.length)}`);
|
|
@@ -3521,6 +3842,11 @@ export class QueryInterface {
|
|
|
3521
3842
|
if (isVectorOrderBy(d)) {
|
|
3522
3843
|
return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
3523
3844
|
}
|
|
3845
|
+
// JSON-path ordering: direction, cast kind, and nulls placement change the
|
|
3846
|
+
// SQL text; the path itself is a bound param and stays OUT of the key.
|
|
3847
|
+
if (isJsonPathOrderBy(d)) {
|
|
3848
|
+
return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
|
|
3849
|
+
}
|
|
3524
3850
|
if (isOrderBySpec(d))
|
|
3525
3851
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3526
3852
|
if (d && typeof d === 'object') {
|
|
@@ -3566,6 +3892,11 @@ export class QueryInterface {
|
|
|
3566
3892
|
const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3567
3893
|
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
3568
3894
|
}
|
|
3895
|
+
// JSON-path ordering: { path: [...], direction?, type?, nulls? } on a
|
|
3896
|
+
// json/jsonb column of THIS table. Path is bound as one text[] param.
|
|
3897
|
+
if (isJsonPathOrderBy(value)) {
|
|
3898
|
+
return this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params);
|
|
3899
|
+
}
|
|
3569
3900
|
// Relation ordering: an object value that is not a vector or OrderBySpec,
|
|
3570
3901
|
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
3571
3902
|
// { name: 'asc' } }`).
|
|
@@ -3592,6 +3923,7 @@ export class QueryInterface {
|
|
|
3592
3923
|
value !== null &&
|
|
3593
3924
|
!Array.isArray(value) &&
|
|
3594
3925
|
!isVectorOrderBy(value) &&
|
|
3926
|
+
!isJsonPathOrderBy(value) &&
|
|
3595
3927
|
!isOrderBySpec(value));
|
|
3596
3928
|
}
|
|
3597
3929
|
/**
|
|
@@ -3608,6 +3940,63 @@ export class QueryInterface {
|
|
|
3608
3940
|
}
|
|
3609
3941
|
return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
|
|
3610
3942
|
}
|
|
3943
|
+
/**
|
|
3944
|
+
* Resolve an orderBy key to its snake_case column via the table's columnMap
|
|
3945
|
+
* (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
|
|
3946
|
+
* where path uses. Shared by top-level JSON-path ordering and every nested
|
|
3947
|
+
* relation orderBy path so nested orderBy accepts exactly what top-level
|
|
3948
|
+
* accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
|
|
3949
|
+
* camelCase-named DB columns like "sortOrder").
|
|
3950
|
+
*/
|
|
3951
|
+
resolveOrderByColumn(table, meta, key) {
|
|
3952
|
+
const col = meta.columnMap[key] ?? camelToSnake(key);
|
|
3953
|
+
if (!meta.allColumns.includes(col)) {
|
|
3954
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
|
|
3955
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3956
|
+
}
|
|
3957
|
+
return col;
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* Validate a {@link JsonPathOrderBy} entry: column must exist AND be
|
|
3961
|
+
* json/jsonb, path must be a non-empty array of keys/indexes: and return
|
|
3962
|
+
* the resolved column. Shared by the SQL-build path
|
|
3963
|
+
* ({@link buildJsonPathOrderEntry}) and the cache-hit param-collect mirrors
|
|
3964
|
+
* so both always throw identically.
|
|
3965
|
+
*/
|
|
3966
|
+
validateJsonPathOrderBy(table, meta, field, spec) {
|
|
3967
|
+
const col = this.resolveOrderByColumn(table, meta, field);
|
|
3968
|
+
if (spec.path.length === 0 ||
|
|
3969
|
+
spec.path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
3970
|
+
throw new ValidationError(`[turbine] JSON-path orderBy on "${field}" (table "${table}") requires a non-empty \`path\` array ` +
|
|
3971
|
+
`of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
|
|
3972
|
+
}
|
|
3973
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3974
|
+
if (colType !== 'json' && colType !== 'jsonb') {
|
|
3975
|
+
throw new ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
|
|
3976
|
+
`(actual type: ${colType}).`);
|
|
3977
|
+
}
|
|
3978
|
+
return col;
|
|
3979
|
+
}
|
|
3980
|
+
/**
|
|
3981
|
+
* Compile one {@link JsonPathOrderBy} entry:
|
|
3982
|
+
* `("col" #>> $n::text[])::numeric ASC`: the numeric cast only with
|
|
3983
|
+
* `type: 'numeric'` (default is text comparison), the extraction routed
|
|
3984
|
+
* through the dialect's JSON hook exactly like the JSON where-filters, the
|
|
3985
|
+
* path bound as ONE text[] param (mirrored by the order-param collectors).
|
|
3986
|
+
* `prefix` scopes the column (`''` top-level, `t0.` inside a relation
|
|
3987
|
+
* subquery).
|
|
3988
|
+
*/
|
|
3989
|
+
buildJsonPathOrderEntry(table, meta, field, spec, prefix, params) {
|
|
3990
|
+
const col = this.validateJsonPathOrderBy(table, meta, field, spec);
|
|
3991
|
+
if (!params) {
|
|
3992
|
+
throw new ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
|
|
3993
|
+
}
|
|
3994
|
+
params.push(spec.path.map(String));
|
|
3995
|
+
const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
|
|
3996
|
+
const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
3997
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3998
|
+
return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
|
|
3999
|
+
}
|
|
3611
4000
|
/**
|
|
3612
4001
|
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
3613
4002
|
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
@@ -3616,12 +4005,19 @@ export class QueryInterface {
|
|
|
3616
4005
|
*
|
|
3617
4006
|
* Validation: relation must exist (E005); to-many only allows `_count`, and
|
|
3618
4007
|
* to-one only allows real target columns (E003).
|
|
4008
|
+
*
|
|
4009
|
+
* `ctx` generalizes the term beyond the root table: inside a relation
|
|
4010
|
+
* subquery's orderBy the relations live on the TARGET table's metadata and
|
|
4011
|
+
* the correlation parent is the relation's alias, not `this.table`.
|
|
3619
4012
|
*/
|
|
3620
|
-
buildRelationOrderBy(relName, value, alias, params) {
|
|
3621
|
-
const
|
|
4013
|
+
buildRelationOrderBy(relName, value, alias, params, ctx) {
|
|
4014
|
+
const ownerMeta = ctx?.meta ?? this.tableMeta;
|
|
4015
|
+
const ownerTable = ctx?.table ?? this.table;
|
|
4016
|
+
const parentRef = ctx?.parentRef ?? this.table;
|
|
4017
|
+
const relDef = ownerMeta.relations[relName];
|
|
3622
4018
|
if (!relDef) {
|
|
3623
|
-
throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${
|
|
3624
|
-
`Available: ${Object.keys(
|
|
4019
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
4020
|
+
`Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
|
|
3625
4021
|
}
|
|
3626
4022
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
3627
4023
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
@@ -3631,14 +4027,14 @@ export class QueryInterface {
|
|
|
3631
4027
|
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
3632
4028
|
}
|
|
3633
4029
|
const { dir } = normalizeOrderBy(value._count);
|
|
3634
|
-
return `${this.buildRelationCountExpr(relDef,
|
|
4030
|
+
return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
|
|
3635
4031
|
}
|
|
3636
4032
|
// To-one: each entry orders by a correlated scalar subquery on a target column.
|
|
3637
4033
|
const targetMeta = this.schema.tables[relDef.to];
|
|
3638
4034
|
if (!targetMeta)
|
|
3639
4035
|
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
3640
4036
|
const qTarget = this.q(relDef.to);
|
|
3641
|
-
const qParent = this.q(
|
|
4037
|
+
const qParent = this.q(parentRef);
|
|
3642
4038
|
// belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
|
|
3643
4039
|
const correlation = relDef.type === 'belongsTo'
|
|
3644
4040
|
? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
@@ -3649,7 +4045,9 @@ export class QueryInterface {
|
|
|
3649
4045
|
}
|
|
3650
4046
|
return entries
|
|
3651
4047
|
.map(([col, dirValue]) => {
|
|
3652
|
-
|
|
4048
|
+
// columnMap-first resolution (camelToSnake fallback): mirrors the
|
|
4049
|
+
// scalar orderBy path so camelCase-named DB columns resolve here too.
|
|
4050
|
+
const snakeCol = targetMeta.columnMap[col] ?? camelToSnake(col);
|
|
3653
4051
|
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
3654
4052
|
throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
3655
4053
|
}
|
|
@@ -3667,6 +4065,74 @@ export class QueryInterface {
|
|
|
3667
4065
|
})
|
|
3668
4066
|
.join(', ');
|
|
3669
4067
|
}
|
|
4068
|
+
/**
|
|
4069
|
+
* Compile the ORDER BY terms of a relation `with` clause against the
|
|
4070
|
+
* relation's table alias. One unified path for every relation shape
|
|
4071
|
+
* (hasMany / manyToMany / belongsTo / hasOne) supporting exactly what the
|
|
4072
|
+
* top-level orderBy accepts at this level:
|
|
4073
|
+
*
|
|
4074
|
+
* - scalar columns via columnMap resolution (camelToSnake fallback) with
|
|
4075
|
+
* {@link OrderBySpec} nulls placement,
|
|
4076
|
+
* - {@link JsonPathOrderBy} entries (path bound as one text[] param),
|
|
4077
|
+
* - relation ordering on the TARGET's relations (`_count` for to-many, a
|
|
4078
|
+
* target column for to-one), correlated to the relation alias,
|
|
4079
|
+
* - vector KNN ordering stays top-level-only (E003, same as before).
|
|
4080
|
+
*
|
|
4081
|
+
* Param pushes (JSON paths, relation-order global filters) MUST be mirrored,
|
|
4082
|
+
* in the same order, by {@link collectRelationOrderParams}.
|
|
4083
|
+
*/
|
|
4084
|
+
buildRelationOrderClause(targetTable, targetMeta, alias, orderEntries, params) {
|
|
4085
|
+
let relOrdCounter = 0;
|
|
4086
|
+
const orders = orderEntries
|
|
4087
|
+
.map(([key, dirValue]) => {
|
|
4088
|
+
if (isVectorOrderBy(dirValue)) {
|
|
4089
|
+
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
4090
|
+
}
|
|
4091
|
+
if (isJsonPathOrderBy(dirValue)) {
|
|
4092
|
+
return this.buildJsonPathOrderEntry(targetTable, targetMeta, key, dirValue, `${alias}.`, params);
|
|
4093
|
+
}
|
|
4094
|
+
if (this.isRelationOrderByValue(dirValue)) {
|
|
4095
|
+
return this.buildRelationOrderBy(key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
|
|
4096
|
+
}
|
|
4097
|
+
const col = this.resolveOrderByColumn(targetTable, targetMeta, key);
|
|
4098
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4099
|
+
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4100
|
+
})
|
|
4101
|
+
.join(', ');
|
|
4102
|
+
return ` ORDER BY ${orders}`;
|
|
4103
|
+
}
|
|
4104
|
+
/**
|
|
4105
|
+
* Param-collect mirror of {@link buildRelationOrderClause}: JSON-path
|
|
4106
|
+
* entries push their path (one text[] param each); relation-order entries
|
|
4107
|
+
* mirror {@link collectOrderByParams}' relation branch (count / to-one
|
|
4108
|
+
* global-filter params); scalar entries push nothing but re-run the same
|
|
4109
|
+
* column validation so a warmed cache can never skip it.
|
|
4110
|
+
*/
|
|
4111
|
+
collectRelationOrderParams(targetTable, targetMeta, orderEntries, params) {
|
|
4112
|
+
for (const [key, dirValue] of orderEntries) {
|
|
4113
|
+
if (isVectorOrderBy(dirValue)) {
|
|
4114
|
+
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
4115
|
+
}
|
|
4116
|
+
if (isJsonPathOrderBy(dirValue)) {
|
|
4117
|
+
this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
|
|
4118
|
+
params.push(dirValue.path.map(String));
|
|
4119
|
+
continue;
|
|
4120
|
+
}
|
|
4121
|
+
if (this.isRelationOrderByValue(dirValue)) {
|
|
4122
|
+
const relDef = targetMeta.relations[key];
|
|
4123
|
+
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
4124
|
+
this.collectRelationCountParams(relDef, params);
|
|
4125
|
+
}
|
|
4126
|
+
else if (relDef) {
|
|
4127
|
+
for (const _col of Object.keys(dirValue)) {
|
|
4128
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
continue;
|
|
4132
|
+
}
|
|
4133
|
+
this.resolveOrderByColumn(targetTable, targetMeta, key);
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
3670
4136
|
/**
|
|
3671
4137
|
* Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
|
|
3672
4138
|
* relation, correlated to `parentRef`. hasMany counts child rows via the FK;
|
|
@@ -4386,20 +4852,13 @@ export class QueryInterface {
|
|
|
4386
4852
|
// Quote parent ref — can be a table name or auto-generated alias
|
|
4387
4853
|
const qParent = this.q(parentRef);
|
|
4388
4854
|
const qTarget = this.q(targetTable);
|
|
4389
|
-
// Build ORDER BY for json_agg
|
|
4855
|
+
// Build ORDER BY for json_agg: unified with the top-level orderBy surface
|
|
4856
|
+
// (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
|
|
4857
|
+
// Param pushes here land BEFORE the spec.where params, mirrored by
|
|
4858
|
+
// collectRelationSubqueryParams.
|
|
4390
4859
|
let orderClause = '';
|
|
4391
4860
|
if (relOrderEntries.length > 0) {
|
|
4392
|
-
|
|
4393
|
-
.map(([k, dirValue]) => {
|
|
4394
|
-
const col = camelToSnake(k);
|
|
4395
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
4396
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4397
|
-
}
|
|
4398
|
-
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4399
|
-
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4400
|
-
})
|
|
4401
|
-
.join(', ');
|
|
4402
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
4861
|
+
orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
|
|
4403
4862
|
}
|
|
4404
4863
|
// Build WHERE — correlate to parent via parentRef (alias or table name).
|
|
4405
4864
|
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
@@ -4473,8 +4932,10 @@ export class QueryInterface {
|
|
|
4473
4932
|
const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
|
|
4474
4933
|
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
|
|
4475
4934
|
}
|
|
4476
|
-
// belongsTo / hasOne
|
|
4477
|
-
|
|
4935
|
+
// belongsTo / hasOne: return single object. An orderBy picks WHICH row
|
|
4936
|
+
// the LIMIT 1 keeps (deterministic hasOne over a non-unique FK): matching
|
|
4937
|
+
// the batched strategy, which orders its flat follow-up and takes bucket[0].
|
|
4938
|
+
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause} LIMIT 1`;
|
|
4478
4939
|
}
|
|
4479
4940
|
/**
|
|
4480
4941
|
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
@@ -4532,22 +4993,15 @@ export class QueryInterface {
|
|
|
4532
4993
|
let whereClause = sourceKeys
|
|
4533
4994
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
4534
4995
|
.join(' AND ');
|
|
4535
|
-
// ORDER BY on the target rows
|
|
4536
|
-
//
|
|
4996
|
+
// ORDER BY on the target rows: unified with the top-level orderBy surface
|
|
4997
|
+
// (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
|
|
4998
|
+
// `orderBy: {}` (no defined entries) is treated as absent: it must not
|
|
4999
|
+
// render a dangling `ORDER BY `. Param pushes here land BEFORE the
|
|
5000
|
+
// spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
|
|
4537
5001
|
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
4538
5002
|
let orderClause = '';
|
|
4539
5003
|
if (relOrderEntries.length > 0) {
|
|
4540
|
-
|
|
4541
|
-
.map(([k, dirValue]) => {
|
|
4542
|
-
const col = camelToSnake(k);
|
|
4543
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
4544
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4545
|
-
}
|
|
4546
|
-
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4547
|
-
return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4548
|
-
})
|
|
4549
|
-
.join(', ');
|
|
4550
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
5004
|
+
orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
|
|
4551
5005
|
}
|
|
4552
5006
|
// Additional WHERE filters on the target — full scalar where surface,
|
|
4553
5007
|
// properly parameterized against the target alias.
|
|
@@ -4649,18 +5103,59 @@ export class QueryInterface {
|
|
|
4649
5103
|
};
|
|
4650
5104
|
return typeMap[baseType] ?? 'text';
|
|
4651
5105
|
}
|
|
5106
|
+
/**
|
|
5107
|
+
* Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
|
|
5108
|
+
* JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
|
|
5109
|
+
* the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
|
|
5110
|
+
* param-collect path ({@link collectJsonFilterParams}) so both always agree
|
|
5111
|
+
* on which params are pushed — and both throw identically for invalid
|
|
5112
|
+
* shapes, so a warmed cache can never skip validation.
|
|
5113
|
+
*/
|
|
5114
|
+
jsonRangeEntries(filter, column) {
|
|
5115
|
+
const entries = [];
|
|
5116
|
+
for (const [op, sqlOp] of Object.entries(JSON_RANGE_OPERATORS)) {
|
|
5117
|
+
const value = filter[op];
|
|
5118
|
+
if (value === undefined)
|
|
5119
|
+
continue;
|
|
5120
|
+
if (filter.path === undefined) {
|
|
5121
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
|
|
5122
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
|
|
5123
|
+
}
|
|
5124
|
+
if (typeof value !== 'number' && typeof value !== 'string') {
|
|
5125
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
|
|
5126
|
+
`got ${JSON.stringify(value)}.`);
|
|
5127
|
+
}
|
|
5128
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
5129
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
|
|
5130
|
+
}
|
|
5131
|
+
entries.push({ sqlOp, value });
|
|
5132
|
+
}
|
|
5133
|
+
return entries;
|
|
5134
|
+
}
|
|
4652
5135
|
/**
|
|
4653
5136
|
* Build SQL clauses for JSONB filter operators on a column.
|
|
4654
|
-
* Supports: path, equals, contains, hasKey.
|
|
5137
|
+
* Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
|
|
5138
|
+
*
|
|
5139
|
+
* The `path` param is bound at most once and its placeholder is shared by
|
|
5140
|
+
* every clause that extracts it (equals + range ops), so the param list
|
|
5141
|
+
* stays byte-identical to {@link collectJsonFilterParams}.
|
|
4655
5142
|
*/
|
|
4656
5143
|
buildJsonFilterClauses(column, filter, params) {
|
|
4657
5144
|
const clauses = [];
|
|
5145
|
+
// Lazily bind the path once; reuse the same $N in every extraction clause.
|
|
5146
|
+
let pathParamIdx = null;
|
|
5147
|
+
const pathExtract = () => {
|
|
5148
|
+
if (pathParamIdx === null) {
|
|
5149
|
+
params.push(filter.path);
|
|
5150
|
+
pathParamIdx = params.length;
|
|
5151
|
+
}
|
|
5152
|
+
return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
|
|
5153
|
+
};
|
|
4658
5154
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
4659
5155
|
// Path access + equals: column #>> $N::text[] = $M
|
|
4660
|
-
|
|
4661
|
-
const pathParam = params.length;
|
|
5156
|
+
const extract = pathExtract();
|
|
4662
5157
|
params.push(String(filter.equals));
|
|
4663
|
-
clauses.push(`${
|
|
5158
|
+
clauses.push(`${extract} = ${this.p(params.length)}`);
|
|
4664
5159
|
}
|
|
4665
5160
|
else if (filter.equals !== undefined) {
|
|
4666
5161
|
// Containment equality: column @> $N::jsonb
|
|
@@ -4677,8 +5172,28 @@ export class QueryInterface {
|
|
|
4677
5172
|
params.push(filter.hasKey);
|
|
4678
5173
|
clauses.push(`${column} ? ${this.p(params.length)}`);
|
|
4679
5174
|
}
|
|
5175
|
+
// Range comparisons on the extracted path: numbers compare numerically
|
|
5176
|
+
// (cast through the dialect), strings compare as text.
|
|
5177
|
+
for (const { sqlOp, value } of this.jsonRangeEntries(filter, column)) {
|
|
5178
|
+
const extract = pathExtract();
|
|
5179
|
+
params.push(value);
|
|
5180
|
+
const lhs = typeof value === 'number' ? this.castJsonNumeric(extract) : extract;
|
|
5181
|
+
clauses.push(`${lhs} ${sqlOp} ${this.p(params.length)}`);
|
|
5182
|
+
}
|
|
4680
5183
|
return clauses;
|
|
4681
5184
|
}
|
|
5185
|
+
/**
|
|
5186
|
+
* Cast an extracted JSON path text value to a numeric type for range
|
|
5187
|
+
* comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
|
|
5188
|
+
* compare JSON numbers, and `::float` would lose precision on big ints);
|
|
5189
|
+
* other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
|
|
5190
|
+
* SQL Server have no `::` operator) as a float cast.
|
|
5191
|
+
*/
|
|
5192
|
+
castJsonNumeric(extract) {
|
|
5193
|
+
if (this.dialect.name === 'postgresql')
|
|
5194
|
+
return `(${extract})::numeric`;
|
|
5195
|
+
return this.dialect.castAggregate ? this.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
|
|
5196
|
+
}
|
|
4682
5197
|
/**
|
|
4683
5198
|
* Build SQL clauses for Array filter operators on a column.
|
|
4684
5199
|
* Supports: has, hasEvery, hasSome, isEmpty.
|