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
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, isJsonFilter, 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
|
}
|
|
@@ -2459,6 +2505,24 @@ export class QueryInterface {
|
|
|
2459
2505
|
}
|
|
2460
2506
|
}
|
|
2461
2507
|
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2508
|
+
// JSONB filter — mirrors buildSubWhereForRelation (which mirrors the
|
|
2509
|
+
// top-level buildWhereClause): route to the JSON param collector when
|
|
2510
|
+
// the target column is json/jsonb.
|
|
2511
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2512
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
2513
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
2514
|
+
this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
|
|
2515
|
+
continue;
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
// Array filter — mirrors buildSubWhereForRelation.
|
|
2519
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
2520
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
2521
|
+
if (colType.startsWith('_')) {
|
|
2522
|
+
this.collectArrayFilterParams(value, params);
|
|
2523
|
+
continue;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2462
2526
|
if (isWhereOperator(value)) {
|
|
2463
2527
|
this.collectOperatorParams(col, value, params);
|
|
2464
2528
|
continue;
|
|
@@ -2494,10 +2558,22 @@ export class QueryInterface {
|
|
|
2494
2558
|
if (op.endsWith !== undefined)
|
|
2495
2559
|
params.push(`%${escapeLike(op.endsWith)}`);
|
|
2496
2560
|
}
|
|
2497
|
-
/**
|
|
2498
|
-
|
|
2561
|
+
/**
|
|
2562
|
+
* Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
|
|
2563
|
+
* the `path` is bound at most once (its placeholder is shared by every
|
|
2564
|
+
* extraction clause), then equals/contains/hasKey values, then the range
|
|
2565
|
+
* comparison values in {@link JSON_RANGE_OPERATORS} order.
|
|
2566
|
+
*/
|
|
2567
|
+
collectJsonFilterParams(filter, params, column) {
|
|
2568
|
+
let pathPushed = false;
|
|
2569
|
+
const pushPathOnce = () => {
|
|
2570
|
+
if (!pathPushed) {
|
|
2571
|
+
params.push(filter.path);
|
|
2572
|
+
pathPushed = true;
|
|
2573
|
+
}
|
|
2574
|
+
};
|
|
2499
2575
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
2500
|
-
|
|
2576
|
+
pushPathOnce();
|
|
2501
2577
|
params.push(String(filter.equals));
|
|
2502
2578
|
}
|
|
2503
2579
|
else if (filter.equals !== undefined) {
|
|
@@ -2509,6 +2585,10 @@ export class QueryInterface {
|
|
|
2509
2585
|
if (filter.hasKey !== undefined) {
|
|
2510
2586
|
params.push(filter.hasKey);
|
|
2511
2587
|
}
|
|
2588
|
+
for (const { value } of this.jsonRangeEntries(filter, column)) {
|
|
2589
|
+
pushPathOnce();
|
|
2590
|
+
params.push(value);
|
|
2591
|
+
}
|
|
2512
2592
|
}
|
|
2513
2593
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
2514
2594
|
collectArrayFilterParams(filter, params) {
|
|
@@ -3228,6 +3308,35 @@ export class QueryInterface {
|
|
|
3228
3308
|
conditions.push(`${qCol} IS NULL`);
|
|
3229
3309
|
continue;
|
|
3230
3310
|
}
|
|
3311
|
+
// JSONB filter on a json/jsonb column of the relation target — mirrors
|
|
3312
|
+
// the top-level WHERE path (buildWhereClause). Without this branch the
|
|
3313
|
+
// filter object used to fall through to plain equality and bind as a
|
|
3314
|
+
// jsonb value, silently matching nothing.
|
|
3315
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3316
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3317
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3318
|
+
conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3319
|
+
continue;
|
|
3320
|
+
}
|
|
3321
|
+
const jsonKey = findJsonUniqueKey(value);
|
|
3322
|
+
if (jsonKey) {
|
|
3323
|
+
throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
|
|
3324
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
// Array filter on an array column of the relation target — same mirror.
|
|
3328
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3329
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3330
|
+
if (colType.startsWith('_')) {
|
|
3331
|
+
conditions.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
|
|
3332
|
+
continue;
|
|
3333
|
+
}
|
|
3334
|
+
const arrayKey = findArrayUniqueKey(value);
|
|
3335
|
+
if (arrayKey) {
|
|
3336
|
+
throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
|
|
3337
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3231
3340
|
if (isWhereOperator(value)) {
|
|
3232
3341
|
const opClauses = this.buildOperatorClauses(qCol, value, params);
|
|
3233
3342
|
conditions.push(...opClauses);
|
|
@@ -3246,6 +3355,49 @@ export class QueryInterface {
|
|
|
3246
3355
|
pgTypeForColumn(meta, column) {
|
|
3247
3356
|
return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
|
|
3248
3357
|
}
|
|
3358
|
+
/**
|
|
3359
|
+
* The Postgres enum type name for a column, when the schema knows one.
|
|
3360
|
+
*
|
|
3361
|
+
* Introspection stores each column's `udt_name` in `pgTypes` and every
|
|
3362
|
+
* database enum in `schema.enums` (typname → labels); a column whose type
|
|
3363
|
+
* matches an enum key needs an explicit `::"EnumName"` cast on its write
|
|
3364
|
+
* binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
|
|
3365
|
+
* value as text and Postgres refuses the implicit text→enum coercion
|
|
3366
|
+
* ("column X is of type Y but expression is of type text").
|
|
3367
|
+
*
|
|
3368
|
+
* Postgres-only by construction: gated on the active dialect being
|
|
3369
|
+
* `postgresql` AND on `schema.enums` having entries (only PG introspection
|
|
3370
|
+
* produces them — `defineSchema` and the other engines leave it empty), so
|
|
3371
|
+
* SQLite/MySQL/MSSQL/PowDB output is byte-identical.
|
|
3372
|
+
*/
|
|
3373
|
+
enumTypeForColumn(column) {
|
|
3374
|
+
if (this.dialect.name !== 'postgresql')
|
|
3375
|
+
return null;
|
|
3376
|
+
const enums = this.schema.enums;
|
|
3377
|
+
if (!enums)
|
|
3378
|
+
return null;
|
|
3379
|
+
// Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
|
|
3380
|
+
// the column's type lives OUTSIDE the introspected schema. A same-named
|
|
3381
|
+
// enum in another schema must not get this schema's cast — search_path
|
|
3382
|
+
// would resolve `::"status"` to the wrong type. Skipping the cast restores
|
|
3383
|
+
// the pre-cast behavior for such columns. Columns without pgTypeSchema
|
|
3384
|
+
// (same-schema types, defineSchema/legacy metadata) keep the cast.
|
|
3385
|
+
if (this.crossSchemaTypeColumns.has(column))
|
|
3386
|
+
return null;
|
|
3387
|
+
const pgType = this.columnPgTypeMap.get(column) ?? this.tableMeta.pgTypes?.[column];
|
|
3388
|
+
if (!pgType || pgType.startsWith('_'))
|
|
3389
|
+
return null;
|
|
3390
|
+
return Object.hasOwn(enums, pgType) ? pgType : null;
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* `::"EnumName"` cast suffix for a write-bind placeholder on an enum
|
|
3394
|
+
* column; `''` for every other column, so non-enum SQL stays byte-identical.
|
|
3395
|
+
* The type name is an introspected identifier and is quoted via the dialect.
|
|
3396
|
+
*/
|
|
3397
|
+
enumCastSuffix(column) {
|
|
3398
|
+
const enumType = this.enumTypeForColumn(column);
|
|
3399
|
+
return enumType ? `::${this.q(enumType)}` : '';
|
|
3400
|
+
}
|
|
3249
3401
|
/**
|
|
3250
3402
|
* Equality-fallthrough guard shared by every SQL-build path AND every
|
|
3251
3403
|
* cache-hit param-collect path. A plain object literal that matched no known
|
|
@@ -3324,6 +3476,34 @@ export class QueryInterface {
|
|
|
3324
3476
|
clauses.push(`${qCol} IS NULL`);
|
|
3325
3477
|
continue;
|
|
3326
3478
|
}
|
|
3479
|
+
// JSONB filter on a json/jsonb column — mirrors the top-level WHERE path
|
|
3480
|
+
// (buildWhereClause) so a `with.where` JSON filter is never silently
|
|
3481
|
+
// bound as a plain equality value.
|
|
3482
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3483
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3484
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3485
|
+
clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3486
|
+
continue;
|
|
3487
|
+
}
|
|
3488
|
+
const jsonKey = findJsonUniqueKey(value);
|
|
3489
|
+
if (jsonKey) {
|
|
3490
|
+
throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
|
|
3491
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
// Array filter on an array column — same mirror.
|
|
3495
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3496
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3497
|
+
if (colType.startsWith('_')) {
|
|
3498
|
+
clauses.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
|
|
3499
|
+
continue;
|
|
3500
|
+
}
|
|
3501
|
+
const arrayKey = findArrayUniqueKey(value);
|
|
3502
|
+
if (arrayKey) {
|
|
3503
|
+
throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
|
|
3504
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3327
3507
|
if (isWhereOperator(value)) {
|
|
3328
3508
|
clauses.push(...this.buildOperatorClauses(qCol, value, params));
|
|
3329
3509
|
continue;
|
|
@@ -3367,6 +3547,22 @@ export class QueryInterface {
|
|
|
3367
3547
|
}
|
|
3368
3548
|
}
|
|
3369
3549
|
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
3550
|
+
// JSONB filter — mirrors buildAliasWhere.
|
|
3551
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3552
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3553
|
+
if (colType === 'json' || colType === 'jsonb') {
|
|
3554
|
+
this.collectJsonFilterParams(value, params, this.q(col));
|
|
3555
|
+
continue;
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
// Array filter — mirrors buildAliasWhere.
|
|
3559
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3560
|
+
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3561
|
+
if (colType.startsWith('_')) {
|
|
3562
|
+
this.collectArrayFilterParams(value, params);
|
|
3563
|
+
continue;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3370
3566
|
if (isWhereOperator(value)) {
|
|
3371
3567
|
this.collectOperatorParams(col, value, params);
|
|
3372
3568
|
continue;
|
|
@@ -3425,6 +3621,16 @@ export class QueryInterface {
|
|
|
3425
3621
|
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
3426
3622
|
continue;
|
|
3427
3623
|
}
|
|
3624
|
+
// JSON / array filters build real clauses in buildAliasWhere, so their
|
|
3625
|
+
// shape must be cache-distinct from plain equality (mirrors fingerprintWhere).
|
|
3626
|
+
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3627
|
+
parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
|
|
3628
|
+
continue;
|
|
3629
|
+
}
|
|
3630
|
+
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3631
|
+
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
3632
|
+
continue;
|
|
3633
|
+
}
|
|
3428
3634
|
if (isUnmatchedPlainObject(value)) {
|
|
3429
3635
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
3430
3636
|
.sort()
|
|
@@ -4649,18 +4855,59 @@ export class QueryInterface {
|
|
|
4649
4855
|
};
|
|
4650
4856
|
return typeMap[baseType] ?? 'text';
|
|
4651
4857
|
}
|
|
4858
|
+
/**
|
|
4859
|
+
* Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
|
|
4860
|
+
* JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
|
|
4861
|
+
* the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
|
|
4862
|
+
* param-collect path ({@link collectJsonFilterParams}) so both always agree
|
|
4863
|
+
* on which params are pushed — and both throw identically for invalid
|
|
4864
|
+
* shapes, so a warmed cache can never skip validation.
|
|
4865
|
+
*/
|
|
4866
|
+
jsonRangeEntries(filter, column) {
|
|
4867
|
+
const entries = [];
|
|
4868
|
+
for (const [op, sqlOp] of Object.entries(JSON_RANGE_OPERATORS)) {
|
|
4869
|
+
const value = filter[op];
|
|
4870
|
+
if (value === undefined)
|
|
4871
|
+
continue;
|
|
4872
|
+
if (filter.path === undefined) {
|
|
4873
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
|
|
4874
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
|
|
4875
|
+
}
|
|
4876
|
+
if (typeof value !== 'number' && typeof value !== 'string') {
|
|
4877
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
|
|
4878
|
+
`got ${JSON.stringify(value)}.`);
|
|
4879
|
+
}
|
|
4880
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
4881
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
|
|
4882
|
+
}
|
|
4883
|
+
entries.push({ sqlOp, value });
|
|
4884
|
+
}
|
|
4885
|
+
return entries;
|
|
4886
|
+
}
|
|
4652
4887
|
/**
|
|
4653
4888
|
* Build SQL clauses for JSONB filter operators on a column.
|
|
4654
|
-
* Supports: path, equals, contains, hasKey.
|
|
4889
|
+
* Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
|
|
4890
|
+
*
|
|
4891
|
+
* The `path` param is bound at most once and its placeholder is shared by
|
|
4892
|
+
* every clause that extracts it (equals + range ops), so the param list
|
|
4893
|
+
* stays byte-identical to {@link collectJsonFilterParams}.
|
|
4655
4894
|
*/
|
|
4656
4895
|
buildJsonFilterClauses(column, filter, params) {
|
|
4657
4896
|
const clauses = [];
|
|
4897
|
+
// Lazily bind the path once; reuse the same $N in every extraction clause.
|
|
4898
|
+
let pathParamIdx = null;
|
|
4899
|
+
const pathExtract = () => {
|
|
4900
|
+
if (pathParamIdx === null) {
|
|
4901
|
+
params.push(filter.path);
|
|
4902
|
+
pathParamIdx = params.length;
|
|
4903
|
+
}
|
|
4904
|
+
return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
|
|
4905
|
+
};
|
|
4658
4906
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
4659
4907
|
// Path access + equals: column #>> $N::text[] = $M
|
|
4660
|
-
|
|
4661
|
-
const pathParam = params.length;
|
|
4908
|
+
const extract = pathExtract();
|
|
4662
4909
|
params.push(String(filter.equals));
|
|
4663
|
-
clauses.push(`${
|
|
4910
|
+
clauses.push(`${extract} = ${this.p(params.length)}`);
|
|
4664
4911
|
}
|
|
4665
4912
|
else if (filter.equals !== undefined) {
|
|
4666
4913
|
// Containment equality: column @> $N::jsonb
|
|
@@ -4677,8 +4924,28 @@ export class QueryInterface {
|
|
|
4677
4924
|
params.push(filter.hasKey);
|
|
4678
4925
|
clauses.push(`${column} ? ${this.p(params.length)}`);
|
|
4679
4926
|
}
|
|
4927
|
+
// Range comparisons on the extracted path: numbers compare numerically
|
|
4928
|
+
// (cast through the dialect), strings compare as text.
|
|
4929
|
+
for (const { sqlOp, value } of this.jsonRangeEntries(filter, column)) {
|
|
4930
|
+
const extract = pathExtract();
|
|
4931
|
+
params.push(value);
|
|
4932
|
+
const lhs = typeof value === 'number' ? this.castJsonNumeric(extract) : extract;
|
|
4933
|
+
clauses.push(`${lhs} ${sqlOp} ${this.p(params.length)}`);
|
|
4934
|
+
}
|
|
4680
4935
|
return clauses;
|
|
4681
4936
|
}
|
|
4937
|
+
/**
|
|
4938
|
+
* Cast an extracted JSON path text value to a numeric type for range
|
|
4939
|
+
* comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
|
|
4940
|
+
* compare JSON numbers, and `::float` would lose precision on big ints);
|
|
4941
|
+
* other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
|
|
4942
|
+
* SQL Server have no `::` operator) as a float cast.
|
|
4943
|
+
*/
|
|
4944
|
+
castJsonNumeric(extract) {
|
|
4945
|
+
if (this.dialect.name === 'postgresql')
|
|
4946
|
+
return `(${extract})::numeric`;
|
|
4947
|
+
return this.dialect.castAggregate ? this.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
|
|
4948
|
+
}
|
|
4682
4949
|
/**
|
|
4683
4950
|
* Build SQL clauses for Array filter operators on a column.
|
|
4684
4951
|
* Supports: has, hasEvery, hasSome, isEmpty.
|
package/dist/query/deferred.d.ts
CHANGED
|
@@ -66,9 +66,14 @@ export interface QueryInterfaceOptions {
|
|
|
66
66
|
* without a `limit`. Defaults to `true` so that accidental unbounded
|
|
67
67
|
* queries are surfaced loudly during development. Pass `false` to silence
|
|
68
68
|
* the warning entirely (e.g. for CLI tooling that intentionally streams
|
|
69
|
-
* full tables)
|
|
69
|
+
* full tables), or a per-table map (`{ userProfiles: false }`) to silence
|
|
70
|
+
* only the tables that intentionally read full sets — unlisted tables keep
|
|
71
|
+
* the default. Map keys accept BOTH the camelCase accessor name
|
|
72
|
+
* (`userProfiles`) and the snake_case table name (`user_profiles`); the
|
|
73
|
+
* snake_case entry wins if both are present. Individual calls can also
|
|
74
|
+
* override via `findMany({ warnOnUnlimited: false })`.
|
|
70
75
|
*/
|
|
71
|
-
warnOnUnlimited?: boolean
|
|
76
|
+
warnOnUnlimited?: boolean | Record<string, boolean>;
|
|
72
77
|
/**
|
|
73
78
|
* Enable prepared statements. When true, queries are submitted with a
|
|
74
79
|
* `{ name, text, values }` object to the pg driver, which caches the
|
package/dist/query/filters.d.ts
CHANGED
|
@@ -48,6 +48,24 @@ export declare function sortedEntries<V>(obj: Record<string, V>): [string, V][];
|
|
|
48
48
|
export declare const UPDATE_OPERATOR_KEYS: Set<string>;
|
|
49
49
|
/** Known JSONB operator keys */
|
|
50
50
|
export declare const JSONB_OPERATOR_KEYS: Set<string>;
|
|
51
|
+
/**
|
|
52
|
+
* JSON range comparison operators → SQL comparison tokens, in the FIXED order
|
|
53
|
+
* the build and collect paths iterate them. These keys are deliberately NOT in
|
|
54
|
+
* {@link JSONB_OPERATOR_KEYS}: `gt`/`gte`/`lt`/`lte` overlap with
|
|
55
|
+
* `WhereOperator`, so a bare `{ gt: 5 }` must keep its column-comparison
|
|
56
|
+
* meaning. They only compile as JSON range ops when the object is already a
|
|
57
|
+
* {@link JsonFilter} (detected via `path` / `equals` / `contains` / `hasKey`),
|
|
58
|
+
* and they always require `path`.
|
|
59
|
+
*/
|
|
60
|
+
export declare const JSON_RANGE_OPERATORS: Record<'gt' | 'gte' | 'lt' | 'lte', string>;
|
|
61
|
+
/**
|
|
62
|
+
* Value-invariant shape fingerprint for a {@link JsonFilter}. Range operators
|
|
63
|
+
* are annotated with the comparison value's kind (`#n` numeric / `#s` string)
|
|
64
|
+
* because a numeric comparison compiles to a `::numeric` cast — a different
|
|
65
|
+
* SQL text than the text comparison — so the two must never share a cached
|
|
66
|
+
* SQL entry.
|
|
67
|
+
*/
|
|
68
|
+
export declare function fingerprintJsonFilterShape(filter: JsonFilter): string;
|
|
51
69
|
/**
|
|
52
70
|
* JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
|
|
53
71
|
* appear in any other where-filter shape, so the presence of one of these is
|
package/dist/query/filters.js
CHANGED
|
@@ -90,6 +90,36 @@ export function sortedEntries(obj) {
|
|
|
90
90
|
export const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
91
91
|
/** Known JSONB operator keys */
|
|
92
92
|
export const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
93
|
+
/**
|
|
94
|
+
* JSON range comparison operators → SQL comparison tokens, in the FIXED order
|
|
95
|
+
* the build and collect paths iterate them. These keys are deliberately NOT in
|
|
96
|
+
* {@link JSONB_OPERATOR_KEYS}: `gt`/`gte`/`lt`/`lte` overlap with
|
|
97
|
+
* `WhereOperator`, so a bare `{ gt: 5 }` must keep its column-comparison
|
|
98
|
+
* meaning. They only compile as JSON range ops when the object is already a
|
|
99
|
+
* {@link JsonFilter} (detected via `path` / `equals` / `contains` / `hasKey`),
|
|
100
|
+
* and they always require `path`.
|
|
101
|
+
*/
|
|
102
|
+
export const JSON_RANGE_OPERATORS = {
|
|
103
|
+
gt: '>',
|
|
104
|
+
gte: '>=',
|
|
105
|
+
lt: '<',
|
|
106
|
+
lte: '<=',
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Value-invariant shape fingerprint for a {@link JsonFilter}. Range operators
|
|
110
|
+
* are annotated with the comparison value's kind (`#n` numeric / `#s` string)
|
|
111
|
+
* because a numeric comparison compiles to a `::numeric` cast — a different
|
|
112
|
+
* SQL text than the text comparison — so the two must never share a cached
|
|
113
|
+
* SQL entry.
|
|
114
|
+
*/
|
|
115
|
+
export function fingerprintJsonFilterShape(filter) {
|
|
116
|
+
const obj = filter;
|
|
117
|
+
const parts = Object.keys(obj)
|
|
118
|
+
.filter((k) => obj[k] !== undefined)
|
|
119
|
+
.sort()
|
|
120
|
+
.map((k) => (k in JSON_RANGE_OPERATORS ? `${k}#${typeof obj[k] === 'number' ? 'n' : 's'}` : k));
|
|
121
|
+
return `json(${parts.join(',')})`;
|
|
122
|
+
}
|
|
93
123
|
/**
|
|
94
124
|
* JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
|
|
95
125
|
* appear in any other where-filter shape, so the presence of one of these is
|
package/dist/query/types.d.ts
CHANGED
|
@@ -310,6 +310,13 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
|
|
|
310
310
|
relationLoadStrategy?: RelationLoadStrategy;
|
|
311
311
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
|
312
312
|
skipGlobalFilters?: SkipGlobalFilters;
|
|
313
|
+
/**
|
|
314
|
+
* Per-call override of the unbounded-findMany warning. Pass `false` when
|
|
315
|
+
* this call intentionally reads the full table (the config-level
|
|
316
|
+
* `warnOnUnlimited` stays in effect for every other call); pass `true` to
|
|
317
|
+
* force the warning even when it is disabled in config.
|
|
318
|
+
*/
|
|
319
|
+
warnOnUnlimited?: boolean;
|
|
313
320
|
}
|
|
314
321
|
export interface FindManyStreamArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> extends FindManyArgs<T, R, W, S, O> {
|
|
315
322
|
/**
|
|
@@ -628,6 +635,18 @@ export interface JsonFilter {
|
|
|
628
635
|
contains?: unknown;
|
|
629
636
|
/** Key existence check: column ? key */
|
|
630
637
|
hasKey?: string;
|
|
638
|
+
/**
|
|
639
|
+
* Greater-than comparison of the value at `path` (required). Numbers cast
|
|
640
|
+
* the extracted text to numeric — `(col #>> path)::numeric > $n` — while
|
|
641
|
+
* strings compare as text.
|
|
642
|
+
*/
|
|
643
|
+
gt?: number | string;
|
|
644
|
+
/** Greater-than-or-equal comparison of the value at `path` (required). See {@link JsonFilter.gt}. */
|
|
645
|
+
gte?: number | string;
|
|
646
|
+
/** Less-than comparison of the value at `path` (required). See {@link JsonFilter.gt}. */
|
|
647
|
+
lt?: number | string;
|
|
648
|
+
/** Less-than-or-equal comparison of the value at `path` (required). See {@link JsonFilter.gt}. */
|
|
649
|
+
lte?: number | string;
|
|
631
650
|
}
|
|
632
651
|
/** Array query operators for where clauses */
|
|
633
652
|
export interface ArrayFilter {
|