turbine-orm 0.28.3 → 0.30.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 +69 -5
- 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 +18 -133
- package/dist/cjs/mysql.js +16 -129
- package/dist/cjs/optional-peer-import.cjs +122 -0
- package/dist/cjs/powdb.js +440 -81
- package/dist/cjs/powql.js +49 -25
- package/dist/cjs/query/builder.js +290 -23
- package/dist/cjs/query/filters.js +32 -1
- 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 +44 -6
- package/dist/client.js +69 -5
- package/dist/generate.d.ts +16 -4
- package/dist/generate.js +71 -25
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/introspect.d.ts +94 -1
- package/dist/introspect.js +345 -120
- package/dist/mssql.js +16 -101
- 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 +94 -26
- package/dist/powdb.js +435 -80
- package/dist/powql.d.ts +6 -0
- package/dist/powql.js +51 -27
- package/dist/query/builder.d.ts +60 -3
- package/dist/query/builder.js +291 -24
- package/dist/query/deferred.d.ts +7 -2
- package/dist/query/filters.d.ts +18 -0
- package/dist/query/filters.js +30 -0
- package/dist/query/types.d.ts +19 -0
- 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
|
}
|
|
@@ -2495,6 +2541,24 @@ class QueryInterface {
|
|
|
2495
2541
|
}
|
|
2496
2542
|
}
|
|
2497
2543
|
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2544
|
+
// JSONB filter — mirrors buildSubWhereForRelation (which mirrors the
|
|
2545
|
+
// top-level buildWhereClause): route to the JSON param collector when
|
|
2546
|
+
// the target column is json/jsonb.
|
|
2547
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2548
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
2549
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
2550
|
+
this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
// Array filter — mirrors buildSubWhereForRelation.
|
|
2555
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
2556
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
2557
|
+
if (colType.startsWith('_')) {
|
|
2558
|
+
this.collectArrayFilterParams(value, params);
|
|
2559
|
+
continue;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2498
2562
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2499
2563
|
this.collectOperatorParams(col, value, params);
|
|
2500
2564
|
continue;
|
|
@@ -2530,10 +2594,22 @@ class QueryInterface {
|
|
|
2530
2594
|
if (op.endsWith !== undefined)
|
|
2531
2595
|
params.push(`%${(0, utils_js_1.escapeLike)(op.endsWith)}`);
|
|
2532
2596
|
}
|
|
2533
|
-
/**
|
|
2534
|
-
|
|
2597
|
+
/**
|
|
2598
|
+
* Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
|
|
2599
|
+
* the `path` is bound at most once (its placeholder is shared by every
|
|
2600
|
+
* extraction clause), then equals/contains/hasKey values, then the range
|
|
2601
|
+
* comparison values in {@link JSON_RANGE_OPERATORS} order.
|
|
2602
|
+
*/
|
|
2603
|
+
collectJsonFilterParams(filter, params, column) {
|
|
2604
|
+
let pathPushed = false;
|
|
2605
|
+
const pushPathOnce = () => {
|
|
2606
|
+
if (!pathPushed) {
|
|
2607
|
+
params.push(filter.path);
|
|
2608
|
+
pathPushed = true;
|
|
2609
|
+
}
|
|
2610
|
+
};
|
|
2535
2611
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
2536
|
-
|
|
2612
|
+
pushPathOnce();
|
|
2537
2613
|
params.push(String(filter.equals));
|
|
2538
2614
|
}
|
|
2539
2615
|
else if (filter.equals !== undefined) {
|
|
@@ -2545,6 +2621,10 @@ class QueryInterface {
|
|
|
2545
2621
|
if (filter.hasKey !== undefined) {
|
|
2546
2622
|
params.push(filter.hasKey);
|
|
2547
2623
|
}
|
|
2624
|
+
for (const { value } of this.jsonRangeEntries(filter, column)) {
|
|
2625
|
+
pushPathOnce();
|
|
2626
|
+
params.push(value);
|
|
2627
|
+
}
|
|
2548
2628
|
}
|
|
2549
2629
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
2550
2630
|
collectArrayFilterParams(filter, params) {
|
|
@@ -3264,6 +3344,35 @@ class QueryInterface {
|
|
|
3264
3344
|
conditions.push(`${qCol} IS NULL`);
|
|
3265
3345
|
continue;
|
|
3266
3346
|
}
|
|
3347
|
+
// JSONB filter on a json/jsonb column of the relation target — mirrors
|
|
3348
|
+
// the top-level WHERE path (buildWhereClause). Without this branch the
|
|
3349
|
+
// filter object used to fall through to plain equality and bind as a
|
|
3350
|
+
// jsonb value, silently matching nothing.
|
|
3351
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3352
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3353
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3354
|
+
conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3355
|
+
continue;
|
|
3356
|
+
}
|
|
3357
|
+
const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
|
|
3358
|
+
if (jsonKey) {
|
|
3359
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
|
|
3360
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
// Array filter on an array column of the relation target — same mirror.
|
|
3364
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
3365
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3366
|
+
if (colType.startsWith('_')) {
|
|
3367
|
+
conditions.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
|
|
3368
|
+
continue;
|
|
3369
|
+
}
|
|
3370
|
+
const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
|
|
3371
|
+
if (arrayKey) {
|
|
3372
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
|
|
3373
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3267
3376
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3268
3377
|
const opClauses = this.buildOperatorClauses(qCol, value, params);
|
|
3269
3378
|
conditions.push(...opClauses);
|
|
@@ -3282,6 +3391,49 @@ class QueryInterface {
|
|
|
3282
3391
|
pgTypeForColumn(meta, column) {
|
|
3283
3392
|
return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
|
|
3284
3393
|
}
|
|
3394
|
+
/**
|
|
3395
|
+
* The Postgres enum type name for a column, when the schema knows one.
|
|
3396
|
+
*
|
|
3397
|
+
* Introspection stores each column's `udt_name` in `pgTypes` and every
|
|
3398
|
+
* database enum in `schema.enums` (typname → labels); a column whose type
|
|
3399
|
+
* matches an enum key needs an explicit `::"EnumName"` cast on its write
|
|
3400
|
+
* binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
|
|
3401
|
+
* value as text and Postgres refuses the implicit text→enum coercion
|
|
3402
|
+
* ("column X is of type Y but expression is of type text").
|
|
3403
|
+
*
|
|
3404
|
+
* Postgres-only by construction: gated on the active dialect being
|
|
3405
|
+
* `postgresql` AND on `schema.enums` having entries (only PG introspection
|
|
3406
|
+
* produces them — `defineSchema` and the other engines leave it empty), so
|
|
3407
|
+
* SQLite/MySQL/MSSQL/PowDB output is byte-identical.
|
|
3408
|
+
*/
|
|
3409
|
+
enumTypeForColumn(column) {
|
|
3410
|
+
if (this.dialect.name !== 'postgresql')
|
|
3411
|
+
return null;
|
|
3412
|
+
const enums = this.schema.enums;
|
|
3413
|
+
if (!enums)
|
|
3414
|
+
return null;
|
|
3415
|
+
// Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
|
|
3416
|
+
// the column's type lives OUTSIDE the introspected schema. A same-named
|
|
3417
|
+
// enum in another schema must not get this schema's cast — search_path
|
|
3418
|
+
// would resolve `::"status"` to the wrong type. Skipping the cast restores
|
|
3419
|
+
// the pre-cast behavior for such columns. Columns without pgTypeSchema
|
|
3420
|
+
// (same-schema types, defineSchema/legacy metadata) keep the cast.
|
|
3421
|
+
if (this.crossSchemaTypeColumns.has(column))
|
|
3422
|
+
return null;
|
|
3423
|
+
const pgType = this.columnPgTypeMap.get(column) ?? this.tableMeta.pgTypes?.[column];
|
|
3424
|
+
if (!pgType || pgType.startsWith('_'))
|
|
3425
|
+
return null;
|
|
3426
|
+
return Object.hasOwn(enums, pgType) ? pgType : null;
|
|
3427
|
+
}
|
|
3428
|
+
/**
|
|
3429
|
+
* `::"EnumName"` cast suffix for a write-bind placeholder on an enum
|
|
3430
|
+
* column; `''` for every other column, so non-enum SQL stays byte-identical.
|
|
3431
|
+
* The type name is an introspected identifier and is quoted via the dialect.
|
|
3432
|
+
*/
|
|
3433
|
+
enumCastSuffix(column) {
|
|
3434
|
+
const enumType = this.enumTypeForColumn(column);
|
|
3435
|
+
return enumType ? `::${this.q(enumType)}` : '';
|
|
3436
|
+
}
|
|
3285
3437
|
/**
|
|
3286
3438
|
* Equality-fallthrough guard shared by every SQL-build path AND every
|
|
3287
3439
|
* cache-hit param-collect path. A plain object literal that matched no known
|
|
@@ -3360,6 +3512,34 @@ class QueryInterface {
|
|
|
3360
3512
|
clauses.push(`${qCol} IS NULL`);
|
|
3361
3513
|
continue;
|
|
3362
3514
|
}
|
|
3515
|
+
// JSONB filter on a json/jsonb column — mirrors the top-level WHERE path
|
|
3516
|
+
// (buildWhereClause) so a `with.where` JSON filter is never silently
|
|
3517
|
+
// bound as a plain equality value.
|
|
3518
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3519
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3520
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3521
|
+
clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3522
|
+
continue;
|
|
3523
|
+
}
|
|
3524
|
+
const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
|
|
3525
|
+
if (jsonKey) {
|
|
3526
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
|
|
3527
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
// Array filter on an array column — same mirror.
|
|
3531
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
3532
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3533
|
+
if (colType.startsWith('_')) {
|
|
3534
|
+
clauses.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
|
|
3535
|
+
continue;
|
|
3536
|
+
}
|
|
3537
|
+
const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
|
|
3538
|
+
if (arrayKey) {
|
|
3539
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
|
|
3540
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3363
3543
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3364
3544
|
clauses.push(...this.buildOperatorClauses(qCol, value, params));
|
|
3365
3545
|
continue;
|
|
@@ -3403,6 +3583,22 @@ class QueryInterface {
|
|
|
3403
3583
|
}
|
|
3404
3584
|
}
|
|
3405
3585
|
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
3586
|
+
// JSONB filter — mirrors buildAliasWhere.
|
|
3587
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3588
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3589
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3590
|
+
this.collectJsonFilterParams(value, params, this.q(col));
|
|
3591
|
+
continue;
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
// Array filter — mirrors buildAliasWhere.
|
|
3595
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
3596
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3597
|
+
if (colType.startsWith('_')) {
|
|
3598
|
+
this.collectArrayFilterParams(value, params);
|
|
3599
|
+
continue;
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3406
3602
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3407
3603
|
this.collectOperatorParams(col, value, params);
|
|
3408
3604
|
continue;
|
|
@@ -3461,6 +3657,16 @@ class QueryInterface {
|
|
|
3461
3657
|
parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
|
|
3462
3658
|
continue;
|
|
3463
3659
|
}
|
|
3660
|
+
// JSON / array filters build real clauses in buildAliasWhere, so their
|
|
3661
|
+
// shape must be cache-distinct from plain equality (mirrors fingerprintWhere).
|
|
3662
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3663
|
+
parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
|
|
3664
|
+
continue;
|
|
3665
|
+
}
|
|
3666
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
3667
|
+
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
3668
|
+
continue;
|
|
3669
|
+
}
|
|
3464
3670
|
if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
|
|
3465
3671
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
3466
3672
|
.sort()
|
|
@@ -4685,18 +4891,59 @@ class QueryInterface {
|
|
|
4685
4891
|
};
|
|
4686
4892
|
return typeMap[baseType] ?? 'text';
|
|
4687
4893
|
}
|
|
4894
|
+
/**
|
|
4895
|
+
* Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
|
|
4896
|
+
* JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
|
|
4897
|
+
* the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
|
|
4898
|
+
* param-collect path ({@link collectJsonFilterParams}) so both always agree
|
|
4899
|
+
* on which params are pushed — and both throw identically for invalid
|
|
4900
|
+
* shapes, so a warmed cache can never skip validation.
|
|
4901
|
+
*/
|
|
4902
|
+
jsonRangeEntries(filter, column) {
|
|
4903
|
+
const entries = [];
|
|
4904
|
+
for (const [op, sqlOp] of Object.entries(filters_js_1.JSON_RANGE_OPERATORS)) {
|
|
4905
|
+
const value = filter[op];
|
|
4906
|
+
if (value === undefined)
|
|
4907
|
+
continue;
|
|
4908
|
+
if (filter.path === undefined) {
|
|
4909
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
|
|
4910
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
|
|
4911
|
+
}
|
|
4912
|
+
if (typeof value !== 'number' && typeof value !== 'string') {
|
|
4913
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
|
|
4914
|
+
`got ${JSON.stringify(value)}.`);
|
|
4915
|
+
}
|
|
4916
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
4917
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
|
|
4918
|
+
}
|
|
4919
|
+
entries.push({ sqlOp, value });
|
|
4920
|
+
}
|
|
4921
|
+
return entries;
|
|
4922
|
+
}
|
|
4688
4923
|
/**
|
|
4689
4924
|
* Build SQL clauses for JSONB filter operators on a column.
|
|
4690
|
-
* Supports: path, equals, contains, hasKey.
|
|
4925
|
+
* Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
|
|
4926
|
+
*
|
|
4927
|
+
* The `path` param is bound at most once and its placeholder is shared by
|
|
4928
|
+
* every clause that extracts it (equals + range ops), so the param list
|
|
4929
|
+
* stays byte-identical to {@link collectJsonFilterParams}.
|
|
4691
4930
|
*/
|
|
4692
4931
|
buildJsonFilterClauses(column, filter, params) {
|
|
4693
4932
|
const clauses = [];
|
|
4933
|
+
// Lazily bind the path once; reuse the same $N in every extraction clause.
|
|
4934
|
+
let pathParamIdx = null;
|
|
4935
|
+
const pathExtract = () => {
|
|
4936
|
+
if (pathParamIdx === null) {
|
|
4937
|
+
params.push(filter.path);
|
|
4938
|
+
pathParamIdx = params.length;
|
|
4939
|
+
}
|
|
4940
|
+
return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
|
|
4941
|
+
};
|
|
4694
4942
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
4695
4943
|
// Path access + equals: column #>> $N::text[] = $M
|
|
4696
|
-
|
|
4697
|
-
const pathParam = params.length;
|
|
4944
|
+
const extract = pathExtract();
|
|
4698
4945
|
params.push(String(filter.equals));
|
|
4699
|
-
clauses.push(`${
|
|
4946
|
+
clauses.push(`${extract} = ${this.p(params.length)}`);
|
|
4700
4947
|
}
|
|
4701
4948
|
else if (filter.equals !== undefined) {
|
|
4702
4949
|
// Containment equality: column @> $N::jsonb
|
|
@@ -4713,8 +4960,28 @@ class QueryInterface {
|
|
|
4713
4960
|
params.push(filter.hasKey);
|
|
4714
4961
|
clauses.push(`${column} ? ${this.p(params.length)}`);
|
|
4715
4962
|
}
|
|
4963
|
+
// Range comparisons on the extracted path: numbers compare numerically
|
|
4964
|
+
// (cast through the dialect), strings compare as text.
|
|
4965
|
+
for (const { sqlOp, value } of this.jsonRangeEntries(filter, column)) {
|
|
4966
|
+
const extract = pathExtract();
|
|
4967
|
+
params.push(value);
|
|
4968
|
+
const lhs = typeof value === 'number' ? this.castJsonNumeric(extract) : extract;
|
|
4969
|
+
clauses.push(`${lhs} ${sqlOp} ${this.p(params.length)}`);
|
|
4970
|
+
}
|
|
4716
4971
|
return clauses;
|
|
4717
4972
|
}
|
|
4973
|
+
/**
|
|
4974
|
+
* Cast an extracted JSON path text value to a numeric type for range
|
|
4975
|
+
* comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
|
|
4976
|
+
* compare JSON numbers, and `::float` would lose precision on big ints);
|
|
4977
|
+
* other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
|
|
4978
|
+
* SQL Server have no `::` operator) as a float cast.
|
|
4979
|
+
*/
|
|
4980
|
+
castJsonNumeric(extract) {
|
|
4981
|
+
if (this.dialect.name === 'postgresql')
|
|
4982
|
+
return `(${extract})::numeric`;
|
|
4983
|
+
return this.dialect.castAggregate ? this.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
|
|
4984
|
+
}
|
|
4718
4985
|
/**
|
|
4719
4986
|
* Build SQL clauses for Array filter operators on a column.
|
|
4720
4987
|
* Supports: has, hasEvery, hasSome, isEmpty.
|
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* and execution rather than filter-shape bookkeeping.
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = void 0;
|
|
10
|
+
exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = void 0;
|
|
11
11
|
exports.isWhereOperator = isWhereOperator;
|
|
12
12
|
exports.isUnmatchedPlainObject = isUnmatchedPlainObject;
|
|
13
13
|
exports.fingerprintOperatorShape = fingerprintOperatorShape;
|
|
14
14
|
exports.assertBindableEqualsOperand = assertBindableEqualsOperand;
|
|
15
15
|
exports.sortedKeys = sortedKeys;
|
|
16
16
|
exports.sortedEntries = sortedEntries;
|
|
17
|
+
exports.fingerprintJsonFilterShape = fingerprintJsonFilterShape;
|
|
17
18
|
exports.isJsonFilter = isJsonFilter;
|
|
18
19
|
exports.findJsonUniqueKey = findJsonUniqueKey;
|
|
19
20
|
exports.isArrayFilter = isArrayFilter;
|
|
@@ -109,6 +110,36 @@ function sortedEntries(obj) {
|
|
|
109
110
|
exports.UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
110
111
|
/** Known JSONB operator keys */
|
|
111
112
|
exports.JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
113
|
+
/**
|
|
114
|
+
* JSON range comparison operators → SQL comparison tokens, in the FIXED order
|
|
115
|
+
* the build and collect paths iterate them. These keys are deliberately NOT in
|
|
116
|
+
* {@link JSONB_OPERATOR_KEYS}: `gt`/`gte`/`lt`/`lte` overlap with
|
|
117
|
+
* `WhereOperator`, so a bare `{ gt: 5 }` must keep its column-comparison
|
|
118
|
+
* meaning. They only compile as JSON range ops when the object is already a
|
|
119
|
+
* {@link JsonFilter} (detected via `path` / `equals` / `contains` / `hasKey`),
|
|
120
|
+
* and they always require `path`.
|
|
121
|
+
*/
|
|
122
|
+
exports.JSON_RANGE_OPERATORS = {
|
|
123
|
+
gt: '>',
|
|
124
|
+
gte: '>=',
|
|
125
|
+
lt: '<',
|
|
126
|
+
lte: '<=',
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Value-invariant shape fingerprint for a {@link JsonFilter}. Range operators
|
|
130
|
+
* are annotated with the comparison value's kind (`#n` numeric / `#s` string)
|
|
131
|
+
* because a numeric comparison compiles to a `::numeric` cast — a different
|
|
132
|
+
* SQL text than the text comparison — so the two must never share a cached
|
|
133
|
+
* SQL entry.
|
|
134
|
+
*/
|
|
135
|
+
function fingerprintJsonFilterShape(filter) {
|
|
136
|
+
const obj = filter;
|
|
137
|
+
const parts = Object.keys(obj)
|
|
138
|
+
.filter((k) => obj[k] !== undefined)
|
|
139
|
+
.sort()
|
|
140
|
+
.map((k) => (k in exports.JSON_RANGE_OPERATORS ? `${k}#${typeof obj[k] === 'number' ? 'n' : 's'}` : k));
|
|
141
|
+
return `json(${parts.join(',')})`;
|
|
142
|
+
}
|
|
112
143
|
/**
|
|
113
144
|
* JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
|
|
114
145
|
* appear in any other where-filter shape, so the presence of one of these is
|