turbine-orm 0.16.0 → 0.18.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 +180 -12
- package/dist/adapters/cockroachdb.js +4 -2
- package/dist/adapters/index.js +4 -1
- package/dist/adapters/yugabytedb.js +4 -2
- package/dist/cjs/adapters/cockroachdb.js +4 -2
- package/dist/cjs/adapters/index.js +4 -1
- package/dist/cjs/adapters/yugabytedb.js +4 -2
- package/dist/cjs/cli/studio.js +5 -1
- package/dist/cjs/client.js +164 -0
- package/dist/cjs/errors.js +35 -5
- package/dist/cjs/generate.js +14 -3
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.js +81 -0
- package/dist/cjs/nested-write.js +70 -6
- package/dist/cjs/query/builder.js +538 -12
- package/dist/cjs/realtime.js +147 -0
- package/dist/cjs/schema-builder.js +86 -0
- package/dist/cjs/schema.js +10 -0
- package/dist/cjs/typed-sql.js +149 -0
- package/dist/cli/studio.js +5 -1
- package/dist/client.d.ts +120 -0
- package/dist/client.js +165 -1
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.js +70 -6
- package/dist/query/builder.d.ts +104 -1
- package/dist/query/builder.js +539 -13
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +126 -2
- package/dist/realtime.d.ts +71 -0
- package/dist/realtime.js +144 -0
- package/dist/schema-builder.d.ts +68 -1
- package/dist/schema-builder.js +85 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +10 -0
- package/dist/typed-sql.d.ts +101 -0
- package/dist/typed-sql.js +145 -0
- package/package.json +17 -15
package/dist/query/builder.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { postgresDialect } from '../dialect.js';
|
|
14
14
|
import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, ValidationError, wrapPgError, } from '../errors.js';
|
|
15
15
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
16
|
-
import { camelToSnake, snakeToCamel } from '../schema.js';
|
|
16
|
+
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
17
17
|
import { escapeLike, LRUCache, OPERATOR_KEYS, sqlToPreparedName } from './utils.js';
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Internal detection helpers — used by QueryInterface
|
|
@@ -120,6 +120,43 @@ function isTextSearchFilter(value) {
|
|
|
120
120
|
function validateTextSearchConfig(config) {
|
|
121
121
|
return /^[a-zA-Z0-9_]+$/.test(config);
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* pgvector distance metric → operator allow-list. This is the ONLY mapping
|
|
125
|
+
* from a user-supplied metric token to a SQL operator; any token not present
|
|
126
|
+
* here is rejected, so a user value can never become an arbitrary operator.
|
|
127
|
+
*
|
|
128
|
+
* - `l2` → `<->` (Euclidean / L2 distance)
|
|
129
|
+
* - `cosine` → `<=>` (cosine distance)
|
|
130
|
+
* - `ip` → `<#>` (negative inner product)
|
|
131
|
+
*/
|
|
132
|
+
const VECTOR_METRIC_OPERATORS = {
|
|
133
|
+
l2: '<->',
|
|
134
|
+
cosine: '<=>',
|
|
135
|
+
ip: '<#>',
|
|
136
|
+
};
|
|
137
|
+
/** Comparison keys allowed on a {@link VectorDistanceFilter}. */
|
|
138
|
+
const VECTOR_DISTANCE_COMPARATORS = {
|
|
139
|
+
lt: '<',
|
|
140
|
+
lte: '<=',
|
|
141
|
+
gt: '>',
|
|
142
|
+
gte: '>=',
|
|
143
|
+
};
|
|
144
|
+
/** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
|
|
145
|
+
function isVectorFilter(value) {
|
|
146
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const dist = value.distance;
|
|
150
|
+
return (typeof dist === 'object' &&
|
|
151
|
+
dist !== null &&
|
|
152
|
+
!Array.isArray(dist) &&
|
|
153
|
+
'to' in dist &&
|
|
154
|
+
'metric' in dist);
|
|
155
|
+
}
|
|
156
|
+
/** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
|
|
157
|
+
function isVectorOrderBy(value) {
|
|
158
|
+
return isVectorFilter(value);
|
|
159
|
+
}
|
|
123
160
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
124
161
|
export class QueryInterface {
|
|
125
162
|
pool;
|
|
@@ -151,6 +188,14 @@ export class QueryInterface {
|
|
|
151
188
|
columnArrayTypeMap;
|
|
152
189
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
153
190
|
deepWithWarned = new Set();
|
|
191
|
+
/**
|
|
192
|
+
* Per-table memo of date columns keyed by their camelCase FIELD name.
|
|
193
|
+
* `meta.dateColumns` is keyed by raw snake_case column name, which matches
|
|
194
|
+
* top-level rows from pg. Nested relation rows arrive from json_build_object
|
|
195
|
+
* with camelCase keys, so they need this camelCase-keyed set to be coerced
|
|
196
|
+
* to Date as well (otherwise nested dates leak through as strings).
|
|
197
|
+
*/
|
|
198
|
+
camelDateFieldCache = new Map();
|
|
154
199
|
/** True when this QI runs inside an active transaction (set via _txScoped option). */
|
|
155
200
|
txScoped;
|
|
156
201
|
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
@@ -493,7 +538,16 @@ export class QueryInterface {
|
|
|
493
538
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
494
539
|
const orderFp = args?.orderBy
|
|
495
540
|
? Object.entries(args.orderBy)
|
|
496
|
-
.map(([k, d]) =>
|
|
541
|
+
.map(([k, d]) => {
|
|
542
|
+
// Vector KNN ordering changes the emitted SQL operator by metric and
|
|
543
|
+
// adds a `::vector` param, so the metric + direction must be part of
|
|
544
|
+
// the cache key — otherwise two KNN queries differing only in metric
|
|
545
|
+
// would collide on a single cached SQL string.
|
|
546
|
+
if (isVectorOrderBy(d)) {
|
|
547
|
+
return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
548
|
+
}
|
|
549
|
+
return `${k}:${d}`;
|
|
550
|
+
})
|
|
497
551
|
.join(',')
|
|
498
552
|
: '';
|
|
499
553
|
const cursorFp = args?.cursor
|
|
@@ -553,7 +607,9 @@ export class QueryInterface {
|
|
|
553
607
|
}
|
|
554
608
|
}
|
|
555
609
|
if (args?.orderBy) {
|
|
556
|
-
|
|
610
|
+
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
611
|
+
// vector at the correct position (after cursor params, before LIMIT).
|
|
612
|
+
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
|
|
557
613
|
}
|
|
558
614
|
if (effectiveLimit !== undefined) {
|
|
559
615
|
freshParams.push(Number(effectiveLimit));
|
|
@@ -581,11 +637,16 @@ export class QueryInterface {
|
|
|
581
637
|
params.push(v);
|
|
582
638
|
}
|
|
583
639
|
}
|
|
584
|
-
// 4.
|
|
640
|
+
// 4. ORDER BY params (vector KNN ordering binds a `$n::vector` query vector).
|
|
641
|
+
// Mirrors buildOrderBy's push order — between cursor and LIMIT.
|
|
642
|
+
if (args?.orderBy) {
|
|
643
|
+
this.collectOrderByParams(args.orderBy, params);
|
|
644
|
+
}
|
|
645
|
+
// 5. LIMIT param
|
|
585
646
|
if (effectiveLimit !== undefined) {
|
|
586
647
|
params.push(Number(effectiveLimit));
|
|
587
648
|
}
|
|
588
|
-
//
|
|
649
|
+
// 6. OFFSET param
|
|
589
650
|
if (args?.offset !== undefined) {
|
|
590
651
|
params.push(Number(args.offset));
|
|
591
652
|
}
|
|
@@ -1276,6 +1337,15 @@ export class QueryInterface {
|
|
|
1276
1337
|
}
|
|
1277
1338
|
}
|
|
1278
1339
|
let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
|
|
1340
|
+
// HAVING — filter whole groups by their aggregate values.
|
|
1341
|
+
// Appends to the same `params` array, so placeholders continue from the
|
|
1342
|
+
// WHERE clause's parameter positions (this.p(params.length) below).
|
|
1343
|
+
if (args.having) {
|
|
1344
|
+
const havingClauses = this.buildHavingClauses(args.having, params);
|
|
1345
|
+
if (havingClauses.length > 0) {
|
|
1346
|
+
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1279
1349
|
// ORDER BY
|
|
1280
1350
|
if (args.orderBy) {
|
|
1281
1351
|
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
@@ -1343,6 +1413,117 @@ export class QueryInterface {
|
|
|
1343
1413
|
tag: `${this.table}.groupBy`,
|
|
1344
1414
|
};
|
|
1345
1415
|
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Build the SQL fragments for a {@link HavingClause}.
|
|
1418
|
+
*
|
|
1419
|
+
* Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
|
|
1420
|
+
* from a **schema-validated, quoted** column identifier — `this.toColumn()`
|
|
1421
|
+
* throws {@link ValidationError} for unknown fields and `this.q()` quotes via
|
|
1422
|
+
* the dialect, so no unvalidated identifier ever reaches the SQL string. Every
|
|
1423
|
+
* comparison value is pushed onto the shared `params` array and referenced by
|
|
1424
|
+
* a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
|
|
1425
|
+
* interpolation of user values.
|
|
1426
|
+
*/
|
|
1427
|
+
buildHavingClauses(having, params) {
|
|
1428
|
+
const clauses = [];
|
|
1429
|
+
// Maps the per-field aggregate key to its SQL function name. The set of
|
|
1430
|
+
// allowed keys is fixed here — any other key on a field's filter object is
|
|
1431
|
+
// rejected by ValidationError below (never interpolated).
|
|
1432
|
+
const aggFnByKey = {
|
|
1433
|
+
_sum: 'SUM',
|
|
1434
|
+
_avg: 'AVG',
|
|
1435
|
+
_min: 'MIN',
|
|
1436
|
+
_max: 'MAX',
|
|
1437
|
+
_count: 'COUNT',
|
|
1438
|
+
};
|
|
1439
|
+
for (const [key, value] of Object.entries(having)) {
|
|
1440
|
+
if (value === undefined)
|
|
1441
|
+
continue;
|
|
1442
|
+
// Top-level `_count` (no field) → COUNT(*) for the whole group.
|
|
1443
|
+
if (key === '_count') {
|
|
1444
|
+
clauses.push(...this.buildHavingNumericClauses('COUNT(*)', value, params));
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
// Otherwise `key` is a field name mapping to a per-aggregate filter object.
|
|
1448
|
+
if (typeof value !== 'object' || value === null) {
|
|
1449
|
+
throw new ValidationError(`[turbine] Invalid having filter for field "${key}" on table "${this.table}": ` +
|
|
1450
|
+
`expected an aggregate object like { _sum: { gt: 100 } }.`);
|
|
1451
|
+
}
|
|
1452
|
+
// toColumn validates the field against schema metadata (throws
|
|
1453
|
+
// ValidationError on unknown columns) and q() quotes the identifier — no
|
|
1454
|
+
// unvalidated identifier ever reaches the SQL string.
|
|
1455
|
+
const quotedCol = this.q(this.toColumn(key));
|
|
1456
|
+
for (const [aggKey, filter] of Object.entries(value)) {
|
|
1457
|
+
if (filter === undefined)
|
|
1458
|
+
continue;
|
|
1459
|
+
const fn = aggFnByKey[aggKey];
|
|
1460
|
+
if (!fn) {
|
|
1461
|
+
throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
|
|
1462
|
+
`Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
|
|
1463
|
+
}
|
|
1464
|
+
const expr = `${fn}(${quotedCol})`;
|
|
1465
|
+
clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return clauses;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Convert a single having filter into one or more parameterized SQL
|
|
1472
|
+
* comparisons against the given aggregate expression. A bare number is
|
|
1473
|
+
* shorthand for equality. Unknown operator keys throw {@link ValidationError}.
|
|
1474
|
+
*/
|
|
1475
|
+
buildHavingNumericClauses(expr, filter, params) {
|
|
1476
|
+
// Bare number → equality.
|
|
1477
|
+
if (typeof filter === 'number') {
|
|
1478
|
+
params.push(filter);
|
|
1479
|
+
return [`${expr} = ${this.p(params.length)}`];
|
|
1480
|
+
}
|
|
1481
|
+
if (typeof filter !== 'object' || filter === null) {
|
|
1482
|
+
throw new ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${this.table}": expected a number or operator object.`);
|
|
1483
|
+
}
|
|
1484
|
+
const op = filter;
|
|
1485
|
+
const allowedKeys = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn']);
|
|
1486
|
+
for (const k of Object.keys(op)) {
|
|
1487
|
+
if (!allowedKeys.has(k)) {
|
|
1488
|
+
throw new ValidationError(`[turbine] Unknown having operator "${k}" on "${expr}" for table "${this.table}". ` +
|
|
1489
|
+
`Supported: ${[...allowedKeys].join(', ')}.`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
const clauses = [];
|
|
1493
|
+
if (op.equals !== undefined) {
|
|
1494
|
+
params.push(op.equals);
|
|
1495
|
+
clauses.push(`${expr} = ${this.p(params.length)}`);
|
|
1496
|
+
}
|
|
1497
|
+
if (op.not !== undefined) {
|
|
1498
|
+
params.push(op.not);
|
|
1499
|
+
clauses.push(`${expr} != ${this.p(params.length)}`);
|
|
1500
|
+
}
|
|
1501
|
+
if (op.gt !== undefined) {
|
|
1502
|
+
params.push(op.gt);
|
|
1503
|
+
clauses.push(`${expr} > ${this.p(params.length)}`);
|
|
1504
|
+
}
|
|
1505
|
+
if (op.gte !== undefined) {
|
|
1506
|
+
params.push(op.gte);
|
|
1507
|
+
clauses.push(`${expr} >= ${this.p(params.length)}`);
|
|
1508
|
+
}
|
|
1509
|
+
if (op.lt !== undefined) {
|
|
1510
|
+
params.push(op.lt);
|
|
1511
|
+
clauses.push(`${expr} < ${this.p(params.length)}`);
|
|
1512
|
+
}
|
|
1513
|
+
if (op.lte !== undefined) {
|
|
1514
|
+
params.push(op.lte);
|
|
1515
|
+
clauses.push(`${expr} <= ${this.p(params.length)}`);
|
|
1516
|
+
}
|
|
1517
|
+
if (op.in !== undefined) {
|
|
1518
|
+
params.push(op.in);
|
|
1519
|
+
clauses.push(`${expr} = ANY(${this.p(params.length)})`);
|
|
1520
|
+
}
|
|
1521
|
+
if (op.notIn !== undefined) {
|
|
1522
|
+
params.push(op.notIn);
|
|
1523
|
+
clauses.push(`${expr} != ALL(${this.p(params.length)})`);
|
|
1524
|
+
}
|
|
1525
|
+
return clauses;
|
|
1526
|
+
}
|
|
1346
1527
|
// -------------------------------------------------------------------------
|
|
1347
1528
|
// aggregate — standalone aggregation without groupBy
|
|
1348
1529
|
// -------------------------------------------------------------------------
|
|
@@ -1687,6 +1868,17 @@ export class QueryInterface {
|
|
|
1687
1868
|
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1688
1869
|
continue;
|
|
1689
1870
|
}
|
|
1871
|
+
// Vector distance filter — metric (operator) and present comparators
|
|
1872
|
+
// change the SQL shape, so both go in the fingerprint.
|
|
1873
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
1874
|
+
const dist = value.distance;
|
|
1875
|
+
const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
|
|
1876
|
+
.filter((c) => dist[c] !== undefined)
|
|
1877
|
+
.sort()
|
|
1878
|
+
.join('|');
|
|
1879
|
+
parts.push(`${key}:vec(${dist.metric},${cmps})`);
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1690
1882
|
// JSON filter
|
|
1691
1883
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1692
1884
|
const jKeys = Object.keys(value).sort();
|
|
@@ -1811,6 +2003,14 @@ export class QueryInterface {
|
|
|
1811
2003
|
if (value === null)
|
|
1812
2004
|
continue;
|
|
1813
2005
|
const rawColumn = this.toColumn(key);
|
|
2006
|
+
// Vector distance filter — mirrors buildVectorFilterClauses push order.
|
|
2007
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2008
|
+
// Validate the same way the build path does so the collect path never
|
|
2009
|
+
// diverges (it would throw before any param was pushed).
|
|
2010
|
+
this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
2011
|
+
this.collectVectorFilterParams(key, rawColumn, value, params);
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
1814
2014
|
// JSONB filter
|
|
1815
2015
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1816
2016
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -1907,6 +2107,37 @@ export class QueryInterface {
|
|
|
1907
2107
|
params.push(filter.hasSome);
|
|
1908
2108
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
1909
2109
|
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Collect params for an orderBy clause. Only vector KNN ordering pushes a
|
|
2112
|
+
* param (the `$n::vector` query vector); plain direction ordering is
|
|
2113
|
+
* parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
|
|
2114
|
+
* param re-collection stays in lockstep.
|
|
2115
|
+
*/
|
|
2116
|
+
collectOrderByParams(orderBy, params) {
|
|
2117
|
+
for (const [key, dir] of Object.entries(orderBy)) {
|
|
2118
|
+
if (isVectorOrderBy(dir)) {
|
|
2119
|
+
const rawColumn = this.toColumn(key);
|
|
2120
|
+
// Re-run the same validation as buildOrderBy so the collect path can
|
|
2121
|
+
// never push a param that the build path rejected (or vice versa).
|
|
2122
|
+
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2123
|
+
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Collect params for a vector distance WHERE filter. Mirrors
|
|
2129
|
+
* {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
|
|
2130
|
+
* the comparison threshold(s).
|
|
2131
|
+
*/
|
|
2132
|
+
collectVectorFilterParams(field, rawColumn, filter, params) {
|
|
2133
|
+
const dist = filter.distance;
|
|
2134
|
+
this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
2135
|
+
for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
|
|
2136
|
+
const threshold = dist[cmp];
|
|
2137
|
+
if (threshold !== undefined)
|
|
2138
|
+
params.push(threshold);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
1910
2141
|
/**
|
|
1911
2142
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
1912
2143
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -2003,6 +2234,27 @@ export class QueryInterface {
|
|
|
2003
2234
|
const targetMeta = this.schema.tables[targetTable];
|
|
2004
2235
|
if (!targetMeta)
|
|
2005
2236
|
return;
|
|
2237
|
+
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2238
|
+
// where params → limit param → nested-with params (always, both paths).
|
|
2239
|
+
if (relDef.type === 'manyToMany') {
|
|
2240
|
+
if (spec.where) {
|
|
2241
|
+
for (const [, v] of Object.entries(spec.where)) {
|
|
2242
|
+
params.push(v);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
if (spec.limit) {
|
|
2246
|
+
params.push(Number(spec.limit));
|
|
2247
|
+
}
|
|
2248
|
+
if (spec.with) {
|
|
2249
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
2250
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2251
|
+
if (!nestedRelDef)
|
|
2252
|
+
continue;
|
|
2253
|
+
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2006
2258
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
2007
2259
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2008
2260
|
if (!willWrap && spec.with) {
|
|
@@ -2174,6 +2426,12 @@ export class QueryInterface {
|
|
|
2174
2426
|
andClauses.push(`${column} IS NULL`);
|
|
2175
2427
|
continue;
|
|
2176
2428
|
}
|
|
2429
|
+
// Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
|
|
2430
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2431
|
+
const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
|
|
2432
|
+
andClauses.push(...vecClauses);
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2177
2435
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
2178
2436
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2179
2437
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -2380,8 +2638,17 @@ export class QueryInterface {
|
|
|
2380
2638
|
}
|
|
2381
2639
|
return clauses;
|
|
2382
2640
|
}
|
|
2383
|
-
/**
|
|
2384
|
-
|
|
2641
|
+
/**
|
|
2642
|
+
* Build ORDER BY clause from an object.
|
|
2643
|
+
*
|
|
2644
|
+
* Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
|
|
2645
|
+
* columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
|
|
2646
|
+
* Vector ordering binds the query vector as a `$n::vector` param, so a `params`
|
|
2647
|
+
* array MUST be supplied when a vector ordering may be present (top-level
|
|
2648
|
+
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
2649
|
+
* ordering throws — KNN ordering is only supported at the top level.
|
|
2650
|
+
*/
|
|
2651
|
+
buildOrderBy(orderBy, params) {
|
|
2385
2652
|
// Dev-only: validate that orderBy fields exist in the table schema
|
|
2386
2653
|
if (process.env.NODE_ENV !== 'production') {
|
|
2387
2654
|
for (const key of Object.keys(orderBy)) {
|
|
@@ -2398,12 +2665,85 @@ export class QueryInterface {
|
|
|
2398
2665
|
if (meta && !(key in meta.columnMap)) {
|
|
2399
2666
|
throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
|
|
2400
2667
|
}
|
|
2668
|
+
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
2669
|
+
if (isVectorOrderBy(dir)) {
|
|
2670
|
+
if (!params) {
|
|
2671
|
+
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
2672
|
+
}
|
|
2673
|
+
const rawColumn = this.toColumn(key);
|
|
2674
|
+
const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2675
|
+
const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2676
|
+
const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2677
|
+
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
2678
|
+
}
|
|
2401
2679
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2402
2680
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
2403
2681
|
})
|
|
2404
2682
|
.join(', ');
|
|
2405
2683
|
}
|
|
2684
|
+
// -------------------------------------------------------------------------
|
|
2685
|
+
// pgvector helpers (similarity search)
|
|
2686
|
+
// -------------------------------------------------------------------------
|
|
2687
|
+
/**
|
|
2688
|
+
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
2689
|
+
* fixed allow-list, validating the target column is actually a `vector`
|
|
2690
|
+
* column. Throws {@link ValidationError} for an unknown metric or a
|
|
2691
|
+
* non-vector column — a user-supplied string can never become a SQL operator.
|
|
2692
|
+
*/
|
|
2693
|
+
vectorOperator(field, rawColumn, metric) {
|
|
2694
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
2695
|
+
if (colType !== 'vector') {
|
|
2696
|
+
throw new ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
|
|
2697
|
+
`(actual type: ${colType}); cannot apply a vector distance operation.`);
|
|
2698
|
+
}
|
|
2699
|
+
const op = VECTOR_METRIC_OPERATORS[metric];
|
|
2700
|
+
if (!op) {
|
|
2701
|
+
throw new ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
|
|
2702
|
+
`Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
|
|
2703
|
+
}
|
|
2704
|
+
return op;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Validate and bind a query vector as a single `$n::vector` parameter.
|
|
2708
|
+
* Every element must be a finite number (no NaN / Infinity / strings) so a
|
|
2709
|
+
* malformed array can never produce a broken `::vector` literal, and the array
|
|
2710
|
+
* is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
|
|
2711
|
+
* placeholder string.
|
|
2712
|
+
*/
|
|
2713
|
+
pushVectorParam(field, _rawColumn, to, params) {
|
|
2714
|
+
if (!Array.isArray(to) || to.length === 0) {
|
|
2715
|
+
throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
|
|
2716
|
+
}
|
|
2717
|
+
for (const el of to) {
|
|
2718
|
+
if (typeof el !== 'number' || !Number.isFinite(el)) {
|
|
2719
|
+
throw new ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
|
|
2720
|
+
`got ${JSON.stringify(el)}.`);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
// Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
|
|
2724
|
+
// as finite numbers, so the joined string is safe; it is still passed as a
|
|
2725
|
+
// bound param (never interpolated) and cast with ::vector.
|
|
2726
|
+
params.push(`[${to.join(',')}]`);
|
|
2727
|
+
return `${this.p(params.length)}::vector`;
|
|
2728
|
+
}
|
|
2406
2729
|
/** Parse a flat row: convert snake_case to camelCase + Date coercion */
|
|
2730
|
+
/**
|
|
2731
|
+
* Returns the set of camelCase field names for a table's date columns,
|
|
2732
|
+
* derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
|
|
2733
|
+
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
2734
|
+
* dates the same way top-level rows do.
|
|
2735
|
+
*/
|
|
2736
|
+
getCamelDateFields(table, meta) {
|
|
2737
|
+
let camel = this.camelDateFieldCache.get(table);
|
|
2738
|
+
if (!camel) {
|
|
2739
|
+
camel = new Set();
|
|
2740
|
+
for (const col of meta.dateColumns) {
|
|
2741
|
+
camel.add(meta.reverseColumnMap[col] ?? col);
|
|
2742
|
+
}
|
|
2743
|
+
this.camelDateFieldCache.set(table, camel);
|
|
2744
|
+
}
|
|
2745
|
+
return camel;
|
|
2746
|
+
}
|
|
2407
2747
|
parseRow(row, table) {
|
|
2408
2748
|
const parsed = {};
|
|
2409
2749
|
const meta = this.schema.tables[table];
|
|
@@ -2411,12 +2751,16 @@ export class QueryInterface {
|
|
|
2411
2751
|
// Fast path: use pre-computed maps (avoids regex per column per row)
|
|
2412
2752
|
const reverseMap = meta.reverseColumnMap;
|
|
2413
2753
|
const dateCols = meta.dateColumns;
|
|
2754
|
+
// camelCase-keyed date fields, so nested json_build_object rows (whose
|
|
2755
|
+
// keys are already camelCase) get the same Date coercion as top-level rows.
|
|
2756
|
+
const camelDateFields = this.getCamelDateFields(table, meta);
|
|
2414
2757
|
const keys = Object.keys(row);
|
|
2415
2758
|
for (let i = 0; i < keys.length; i++) {
|
|
2416
2759
|
const col = keys[i];
|
|
2417
2760
|
const value = row[col];
|
|
2418
2761
|
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
2419
|
-
|
|
2762
|
+
// Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
|
|
2763
|
+
if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
|
|
2420
2764
|
parsed[field] = new Date(value);
|
|
2421
2765
|
}
|
|
2422
2766
|
else {
|
|
@@ -2460,14 +2804,15 @@ export class QueryInterface {
|
|
|
2460
2804
|
if (typeof rawValue === 'string') {
|
|
2461
2805
|
try {
|
|
2462
2806
|
const jsonVal = JSON.parse(rawValue);
|
|
2463
|
-
// After parsing,
|
|
2807
|
+
// After parsing, recurse via parseNestedRow so each item gets date
|
|
2808
|
+
// coercion AND its own sub-relations parsed at arbitrary depth.
|
|
2464
2809
|
if (Array.isArray(jsonVal)) {
|
|
2465
2810
|
parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
|
|
2466
|
-
? this.
|
|
2811
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2467
2812
|
: item);
|
|
2468
2813
|
}
|
|
2469
2814
|
else if (typeof jsonVal === 'object' && jsonVal !== null) {
|
|
2470
|
-
parsed[relName] = this.
|
|
2815
|
+
parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
|
|
2471
2816
|
}
|
|
2472
2817
|
else {
|
|
2473
2818
|
parsed[relName] = jsonVal;
|
|
@@ -2479,10 +2824,12 @@ export class QueryInterface {
|
|
|
2479
2824
|
}
|
|
2480
2825
|
}
|
|
2481
2826
|
else if (Array.isArray(rawValue)) {
|
|
2482
|
-
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2827
|
+
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2828
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2829
|
+
: item);
|
|
2483
2830
|
}
|
|
2484
2831
|
else if (typeof rawValue === 'object' && rawValue !== null) {
|
|
2485
|
-
parsed[relName] = this.
|
|
2832
|
+
parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
|
|
2486
2833
|
}
|
|
2487
2834
|
else {
|
|
2488
2835
|
parsed[relName] = rawValue;
|
|
@@ -2683,6 +3030,12 @@ export class QueryInterface {
|
|
|
2683
3030
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
2684
3031
|
// so we must NOT build them here (they would push orphaned params).
|
|
2685
3032
|
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
3033
|
+
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3034
|
+
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3035
|
+
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
3036
|
+
if (relDef.type === 'manyToMany') {
|
|
3037
|
+
return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
|
|
3038
|
+
}
|
|
2686
3039
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
2687
3040
|
if (!willWrap && spec !== true && spec.with) {
|
|
2688
3041
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -2779,6 +3132,146 @@ export class QueryInterface {
|
|
|
2779
3132
|
// belongsTo / hasOne — return single object
|
|
2780
3133
|
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
|
|
2781
3134
|
}
|
|
3135
|
+
/**
|
|
3136
|
+
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
3137
|
+
* table through a junction (join) table.
|
|
3138
|
+
*
|
|
3139
|
+
* Shape (no LIMIT/ORDER):
|
|
3140
|
+
* ```sql
|
|
3141
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
3142
|
+
* FROM <target> <talias>
|
|
3143
|
+
* JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
|
|
3144
|
+
* WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
|
|
3145
|
+
* ```
|
|
3146
|
+
*
|
|
3147
|
+
* With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
|
|
3148
|
+
* applies BEFORE aggregation (identical strategy to hasMany).
|
|
3149
|
+
*
|
|
3150
|
+
* Cardinality is always 'many' → empty-array fallback, never NULL.
|
|
3151
|
+
*
|
|
3152
|
+
* IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
|
|
3153
|
+
* {@link collectRelationSubqueryParams} or pipeline batching will desync.
|
|
3154
|
+
*/
|
|
3155
|
+
buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
|
|
3156
|
+
if (!relDef.through) {
|
|
3157
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
|
|
3158
|
+
}
|
|
3159
|
+
const targetTable = relDef.to;
|
|
3160
|
+
const qTarget = this.q(targetTable);
|
|
3161
|
+
const qJunction = this.q(relDef.through.table);
|
|
3162
|
+
const qParent = this.q(parentRef);
|
|
3163
|
+
const jalias = `${talias}j`; // junction alias, distinct from the target alias
|
|
3164
|
+
// JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
|
|
3165
|
+
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
3166
|
+
// The target PK is the column(s) the junction's targetKey references. An empty
|
|
3167
|
+
// introspected PK means we cannot know what to JOIN on — fail loudly rather than
|
|
3168
|
+
// silently guessing `id` and generating a wrong JOIN.
|
|
3169
|
+
if (targetMeta.primaryKey.length === 0) {
|
|
3170
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
|
|
3171
|
+
`cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
|
|
3172
|
+
}
|
|
3173
|
+
const targetPk = targetMeta.primaryKey;
|
|
3174
|
+
if (targetKeys.length !== targetPk.length) {
|
|
3175
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
|
|
3176
|
+
`but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
|
|
3177
|
+
}
|
|
3178
|
+
const joinOn = targetKeys
|
|
3179
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
|
|
3180
|
+
.join(' AND ');
|
|
3181
|
+
// Correlation: junction.sourceKey = parent.<referenceKey>.
|
|
3182
|
+
const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
|
|
3183
|
+
const refKeys = normalizeKeyColumns(relDef.referenceKey);
|
|
3184
|
+
if (sourceKeys.length !== refKeys.length) {
|
|
3185
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
|
|
3186
|
+
`but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
|
|
3187
|
+
}
|
|
3188
|
+
let whereClause = sourceKeys
|
|
3189
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3190
|
+
.join(' AND ');
|
|
3191
|
+
// ORDER BY on the target rows
|
|
3192
|
+
let orderClause = '';
|
|
3193
|
+
if (spec !== true && spec.orderBy) {
|
|
3194
|
+
const orders = Object.entries(spec.orderBy)
|
|
3195
|
+
.map(([k, dir]) => {
|
|
3196
|
+
const col = camelToSnake(k);
|
|
3197
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3198
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3199
|
+
}
|
|
3200
|
+
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3201
|
+
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3202
|
+
})
|
|
3203
|
+
.join(', ');
|
|
3204
|
+
orderClause = ` ORDER BY ${orders}`;
|
|
3205
|
+
}
|
|
3206
|
+
// Additional WHERE filters on the target — properly parameterized.
|
|
3207
|
+
if (spec !== true && spec.where) {
|
|
3208
|
+
for (const [k, v] of Object.entries(spec.where)) {
|
|
3209
|
+
const col = camelToSnake(k);
|
|
3210
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3211
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3212
|
+
}
|
|
3213
|
+
params.push(v);
|
|
3214
|
+
whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
// LIMIT
|
|
3218
|
+
let limitClause = '';
|
|
3219
|
+
if (spec !== true && spec.limit) {
|
|
3220
|
+
params.push(Number(spec.limit));
|
|
3221
|
+
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3222
|
+
}
|
|
3223
|
+
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
3224
|
+
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
3225
|
+
// so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
|
|
3226
|
+
if (limitClause || orderClause) {
|
|
3227
|
+
const innerAlias = `${talias}i`;
|
|
3228
|
+
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
|
|
3229
|
+
`${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
3230
|
+
const innerJsonPairs = targetColumns.map((col) => [
|
|
3231
|
+
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
3232
|
+
`${innerAlias}.${this.q(col)}`,
|
|
3233
|
+
]);
|
|
3234
|
+
// Nested relations reference the inner alias.
|
|
3235
|
+
if (spec !== true && spec.with) {
|
|
3236
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3237
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3238
|
+
if (!nestedRelDef) {
|
|
3239
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3240
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3241
|
+
}
|
|
3242
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3243
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3244
|
+
? this.dialect.nullJsonLiteral
|
|
3245
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3246
|
+
innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
|
|
3250
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3251
|
+
}
|
|
3252
|
+
// Simple path: build the json object pairs directly off the target alias,
|
|
3253
|
+
// including any nested relations (correlated to the target alias).
|
|
3254
|
+
const jsonPairs = targetColumns.map((col) => [
|
|
3255
|
+
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
3256
|
+
`${talias}.${this.q(col)}`,
|
|
3257
|
+
]);
|
|
3258
|
+
if (spec !== true && spec.with) {
|
|
3259
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3260
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3261
|
+
if (!nestedRelDef) {
|
|
3262
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3263
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3264
|
+
}
|
|
3265
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3266
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3267
|
+
? this.dialect.nullJsonLiteral
|
|
3268
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3269
|
+
jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
const jsonObj = this.dialect.buildJsonObject(jsonPairs);
|
|
3273
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
|
|
3274
|
+
}
|
|
2782
3275
|
/**
|
|
2783
3276
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
2784
3277
|
* Used to detect JSONB/array columns for specialized operators.
|
|
@@ -2872,6 +3365,39 @@ export class QueryInterface {
|
|
|
2872
3365
|
}
|
|
2873
3366
|
return clauses;
|
|
2874
3367
|
}
|
|
3368
|
+
/**
|
|
3369
|
+
* Build SQL clauses for a pgvector distance WHERE filter:
|
|
3370
|
+
*
|
|
3371
|
+
* `"embedding" <-> $1::vector < $2`
|
|
3372
|
+
*
|
|
3373
|
+
* The query vector is bound as a `$n::vector` param (never interpolated), the
|
|
3374
|
+
* metric maps to an operator via a fixed allow-list, and each comparison
|
|
3375
|
+
* threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
|
|
3376
|
+
* per supplied comparator (all ANDed). Param push order matches
|
|
3377
|
+
* {@link collectVectorFilterParams}.
|
|
3378
|
+
*/
|
|
3379
|
+
buildVectorFilterClauses(field, rawColumn, filter, params) {
|
|
3380
|
+
const dist = filter.distance;
|
|
3381
|
+
const operator = this.vectorOperator(field, rawColumn, dist.metric);
|
|
3382
|
+
const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
3383
|
+
const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
|
|
3384
|
+
const clauses = [];
|
|
3385
|
+
for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
|
|
3386
|
+
const threshold = dist[cmp];
|
|
3387
|
+
if (threshold === undefined)
|
|
3388
|
+
continue;
|
|
3389
|
+
if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
|
|
3390
|
+
throw new ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
|
|
3391
|
+
`got ${JSON.stringify(threshold)}.`);
|
|
3392
|
+
}
|
|
3393
|
+
params.push(threshold);
|
|
3394
|
+
clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
|
|
3395
|
+
}
|
|
3396
|
+
if (clauses.length === 0) {
|
|
3397
|
+
throw new ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
|
|
3398
|
+
}
|
|
3399
|
+
return clauses;
|
|
3400
|
+
}
|
|
2875
3401
|
/**
|
|
2876
3402
|
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
2877
3403
|
* The config name is validated to prevent injection (only alphanumeric + underscore).
|