turbine-orm 0.16.0 → 0.19.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-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +35 -73
- 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 +581 -17
- 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-ui.generated.js +1 -1
- package/dist/cli/studio.js +35 -73
- 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 +582 -18
- 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
|
@@ -156,6 +156,43 @@ function isTextSearchFilter(value) {
|
|
|
156
156
|
function validateTextSearchConfig(config) {
|
|
157
157
|
return /^[a-zA-Z0-9_]+$/.test(config);
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* pgvector distance metric → operator allow-list. This is the ONLY mapping
|
|
161
|
+
* from a user-supplied metric token to a SQL operator; any token not present
|
|
162
|
+
* here is rejected, so a user value can never become an arbitrary operator.
|
|
163
|
+
*
|
|
164
|
+
* - `l2` → `<->` (Euclidean / L2 distance)
|
|
165
|
+
* - `cosine` → `<=>` (cosine distance)
|
|
166
|
+
* - `ip` → `<#>` (negative inner product)
|
|
167
|
+
*/
|
|
168
|
+
const VECTOR_METRIC_OPERATORS = {
|
|
169
|
+
l2: '<->',
|
|
170
|
+
cosine: '<=>',
|
|
171
|
+
ip: '<#>',
|
|
172
|
+
};
|
|
173
|
+
/** Comparison keys allowed on a {@link VectorDistanceFilter}. */
|
|
174
|
+
const VECTOR_DISTANCE_COMPARATORS = {
|
|
175
|
+
lt: '<',
|
|
176
|
+
lte: '<=',
|
|
177
|
+
gt: '>',
|
|
178
|
+
gte: '>=',
|
|
179
|
+
};
|
|
180
|
+
/** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
|
|
181
|
+
function isVectorFilter(value) {
|
|
182
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const dist = value.distance;
|
|
186
|
+
return (typeof dist === 'object' &&
|
|
187
|
+
dist !== null &&
|
|
188
|
+
!Array.isArray(dist) &&
|
|
189
|
+
'to' in dist &&
|
|
190
|
+
'metric' in dist);
|
|
191
|
+
}
|
|
192
|
+
/** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
|
|
193
|
+
function isVectorOrderBy(value) {
|
|
194
|
+
return isVectorFilter(value);
|
|
195
|
+
}
|
|
159
196
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
160
197
|
class QueryInterface {
|
|
161
198
|
pool;
|
|
@@ -187,6 +224,14 @@ class QueryInterface {
|
|
|
187
224
|
columnArrayTypeMap;
|
|
188
225
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
189
226
|
deepWithWarned = new Set();
|
|
227
|
+
/**
|
|
228
|
+
* Per-table memo of date columns keyed by their camelCase FIELD name.
|
|
229
|
+
* `meta.dateColumns` is keyed by raw snake_case column name, which matches
|
|
230
|
+
* top-level rows from pg. Nested relation rows arrive from json_build_object
|
|
231
|
+
* with camelCase keys, so they need this camelCase-keyed set to be coerced
|
|
232
|
+
* to Date as well (otherwise nested dates leak through as strings).
|
|
233
|
+
*/
|
|
234
|
+
camelDateFieldCache = new Map();
|
|
190
235
|
/** True when this QI runs inside an active transaction (set via _txScoped option). */
|
|
191
236
|
txScoped;
|
|
192
237
|
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
@@ -529,7 +574,16 @@ class QueryInterface {
|
|
|
529
574
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
530
575
|
const orderFp = args?.orderBy
|
|
531
576
|
? Object.entries(args.orderBy)
|
|
532
|
-
.map(([k, d]) =>
|
|
577
|
+
.map(([k, d]) => {
|
|
578
|
+
// Vector KNN ordering changes the emitted SQL operator by metric and
|
|
579
|
+
// adds a `::vector` param, so the metric + direction must be part of
|
|
580
|
+
// the cache key — otherwise two KNN queries differing only in metric
|
|
581
|
+
// would collide on a single cached SQL string.
|
|
582
|
+
if (isVectorOrderBy(d)) {
|
|
583
|
+
return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
584
|
+
}
|
|
585
|
+
return `${k}:${d}`;
|
|
586
|
+
})
|
|
533
587
|
.join(',')
|
|
534
588
|
: '';
|
|
535
589
|
const cursorFp = args?.cursor
|
|
@@ -589,7 +643,9 @@ class QueryInterface {
|
|
|
589
643
|
}
|
|
590
644
|
}
|
|
591
645
|
if (args?.orderBy) {
|
|
592
|
-
|
|
646
|
+
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
647
|
+
// vector at the correct position (after cursor params, before LIMIT).
|
|
648
|
+
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
|
|
593
649
|
}
|
|
594
650
|
if (effectiveLimit !== undefined) {
|
|
595
651
|
freshParams.push(Number(effectiveLimit));
|
|
@@ -617,11 +673,16 @@ class QueryInterface {
|
|
|
617
673
|
params.push(v);
|
|
618
674
|
}
|
|
619
675
|
}
|
|
620
|
-
// 4.
|
|
676
|
+
// 4. ORDER BY params (vector KNN ordering binds a `$n::vector` query vector).
|
|
677
|
+
// Mirrors buildOrderBy's push order — between cursor and LIMIT.
|
|
678
|
+
if (args?.orderBy) {
|
|
679
|
+
this.collectOrderByParams(args.orderBy, params);
|
|
680
|
+
}
|
|
681
|
+
// 5. LIMIT param
|
|
621
682
|
if (effectiveLimit !== undefined) {
|
|
622
683
|
params.push(Number(effectiveLimit));
|
|
623
684
|
}
|
|
624
|
-
//
|
|
685
|
+
// 6. OFFSET param
|
|
625
686
|
if (args?.offset !== undefined) {
|
|
626
687
|
params.push(Number(args.offset));
|
|
627
688
|
}
|
|
@@ -1312,6 +1373,15 @@ class QueryInterface {
|
|
|
1312
1373
|
}
|
|
1313
1374
|
}
|
|
1314
1375
|
let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
|
|
1376
|
+
// HAVING — filter whole groups by their aggregate values.
|
|
1377
|
+
// Appends to the same `params` array, so placeholders continue from the
|
|
1378
|
+
// WHERE clause's parameter positions (this.p(params.length) below).
|
|
1379
|
+
if (args.having) {
|
|
1380
|
+
const havingClauses = this.buildHavingClauses(args.having, params);
|
|
1381
|
+
if (havingClauses.length > 0) {
|
|
1382
|
+
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1315
1385
|
// ORDER BY
|
|
1316
1386
|
if (args.orderBy) {
|
|
1317
1387
|
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
@@ -1379,6 +1449,117 @@ class QueryInterface {
|
|
|
1379
1449
|
tag: `${this.table}.groupBy`,
|
|
1380
1450
|
};
|
|
1381
1451
|
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Build the SQL fragments for a {@link HavingClause}.
|
|
1454
|
+
*
|
|
1455
|
+
* Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
|
|
1456
|
+
* from a **schema-validated, quoted** column identifier — `this.toColumn()`
|
|
1457
|
+
* throws {@link ValidationError} for unknown fields and `this.q()` quotes via
|
|
1458
|
+
* the dialect, so no unvalidated identifier ever reaches the SQL string. Every
|
|
1459
|
+
* comparison value is pushed onto the shared `params` array and referenced by
|
|
1460
|
+
* a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
|
|
1461
|
+
* interpolation of user values.
|
|
1462
|
+
*/
|
|
1463
|
+
buildHavingClauses(having, params) {
|
|
1464
|
+
const clauses = [];
|
|
1465
|
+
// Maps the per-field aggregate key to its SQL function name. The set of
|
|
1466
|
+
// allowed keys is fixed here — any other key on a field's filter object is
|
|
1467
|
+
// rejected by ValidationError below (never interpolated).
|
|
1468
|
+
const aggFnByKey = {
|
|
1469
|
+
_sum: 'SUM',
|
|
1470
|
+
_avg: 'AVG',
|
|
1471
|
+
_min: 'MIN',
|
|
1472
|
+
_max: 'MAX',
|
|
1473
|
+
_count: 'COUNT',
|
|
1474
|
+
};
|
|
1475
|
+
for (const [key, value] of Object.entries(having)) {
|
|
1476
|
+
if (value === undefined)
|
|
1477
|
+
continue;
|
|
1478
|
+
// Top-level `_count` (no field) → COUNT(*) for the whole group.
|
|
1479
|
+
if (key === '_count') {
|
|
1480
|
+
clauses.push(...this.buildHavingNumericClauses('COUNT(*)', value, params));
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
// Otherwise `key` is a field name mapping to a per-aggregate filter object.
|
|
1484
|
+
if (typeof value !== 'object' || value === null) {
|
|
1485
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid having filter for field "${key}" on table "${this.table}": ` +
|
|
1486
|
+
`expected an aggregate object like { _sum: { gt: 100 } }.`);
|
|
1487
|
+
}
|
|
1488
|
+
// toColumn validates the field against schema metadata (throws
|
|
1489
|
+
// ValidationError on unknown columns) and q() quotes the identifier — no
|
|
1490
|
+
// unvalidated identifier ever reaches the SQL string.
|
|
1491
|
+
const quotedCol = this.q(this.toColumn(key));
|
|
1492
|
+
for (const [aggKey, filter] of Object.entries(value)) {
|
|
1493
|
+
if (filter === undefined)
|
|
1494
|
+
continue;
|
|
1495
|
+
const fn = aggFnByKey[aggKey];
|
|
1496
|
+
if (!fn) {
|
|
1497
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
|
|
1498
|
+
`Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
|
|
1499
|
+
}
|
|
1500
|
+
const expr = `${fn}(${quotedCol})`;
|
|
1501
|
+
clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return clauses;
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Convert a single having filter into one or more parameterized SQL
|
|
1508
|
+
* comparisons against the given aggregate expression. A bare number is
|
|
1509
|
+
* shorthand for equality. Unknown operator keys throw {@link ValidationError}.
|
|
1510
|
+
*/
|
|
1511
|
+
buildHavingNumericClauses(expr, filter, params) {
|
|
1512
|
+
// Bare number → equality.
|
|
1513
|
+
if (typeof filter === 'number') {
|
|
1514
|
+
params.push(filter);
|
|
1515
|
+
return [`${expr} = ${this.p(params.length)}`];
|
|
1516
|
+
}
|
|
1517
|
+
if (typeof filter !== 'object' || filter === null) {
|
|
1518
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${this.table}": expected a number or operator object.`);
|
|
1519
|
+
}
|
|
1520
|
+
const op = filter;
|
|
1521
|
+
const allowedKeys = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn']);
|
|
1522
|
+
for (const k of Object.keys(op)) {
|
|
1523
|
+
if (!allowedKeys.has(k)) {
|
|
1524
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown having operator "${k}" on "${expr}" for table "${this.table}". ` +
|
|
1525
|
+
`Supported: ${[...allowedKeys].join(', ')}.`);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const clauses = [];
|
|
1529
|
+
if (op.equals !== undefined) {
|
|
1530
|
+
params.push(op.equals);
|
|
1531
|
+
clauses.push(`${expr} = ${this.p(params.length)}`);
|
|
1532
|
+
}
|
|
1533
|
+
if (op.not !== undefined) {
|
|
1534
|
+
params.push(op.not);
|
|
1535
|
+
clauses.push(`${expr} != ${this.p(params.length)}`);
|
|
1536
|
+
}
|
|
1537
|
+
if (op.gt !== undefined) {
|
|
1538
|
+
params.push(op.gt);
|
|
1539
|
+
clauses.push(`${expr} > ${this.p(params.length)}`);
|
|
1540
|
+
}
|
|
1541
|
+
if (op.gte !== undefined) {
|
|
1542
|
+
params.push(op.gte);
|
|
1543
|
+
clauses.push(`${expr} >= ${this.p(params.length)}`);
|
|
1544
|
+
}
|
|
1545
|
+
if (op.lt !== undefined) {
|
|
1546
|
+
params.push(op.lt);
|
|
1547
|
+
clauses.push(`${expr} < ${this.p(params.length)}`);
|
|
1548
|
+
}
|
|
1549
|
+
if (op.lte !== undefined) {
|
|
1550
|
+
params.push(op.lte);
|
|
1551
|
+
clauses.push(`${expr} <= ${this.p(params.length)}`);
|
|
1552
|
+
}
|
|
1553
|
+
if (op.in !== undefined) {
|
|
1554
|
+
params.push(op.in);
|
|
1555
|
+
clauses.push(`${expr} = ANY(${this.p(params.length)})`);
|
|
1556
|
+
}
|
|
1557
|
+
if (op.notIn !== undefined) {
|
|
1558
|
+
params.push(op.notIn);
|
|
1559
|
+
clauses.push(`${expr} != ALL(${this.p(params.length)})`);
|
|
1560
|
+
}
|
|
1561
|
+
return clauses;
|
|
1562
|
+
}
|
|
1382
1563
|
// -------------------------------------------------------------------------
|
|
1383
1564
|
// aggregate — standalone aggregation without groupBy
|
|
1384
1565
|
// -------------------------------------------------------------------------
|
|
@@ -1542,12 +1723,24 @@ class QueryInterface {
|
|
|
1542
1723
|
*/
|
|
1543
1724
|
resolveColumns(select, omit) {
|
|
1544
1725
|
if (select) {
|
|
1726
|
+
// An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
|
|
1727
|
+
// style) instead of the object shape. Object.entries() would iterate the
|
|
1728
|
+
// numeric indices and throw a cryptic `Unknown field "0"` — catch it early
|
|
1729
|
+
// with an actionable message.
|
|
1730
|
+
if (Array.isArray(select)) {
|
|
1731
|
+
throw new errors_js_1.ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
|
|
1732
|
+
`(e.g. { id: true, name: true }), not an array.`);
|
|
1733
|
+
}
|
|
1545
1734
|
// Only include columns where value is true
|
|
1546
1735
|
return Object.entries(select)
|
|
1547
1736
|
.filter(([, v]) => v)
|
|
1548
1737
|
.map(([k]) => this.toColumn(k));
|
|
1549
1738
|
}
|
|
1550
1739
|
if (omit) {
|
|
1740
|
+
if (Array.isArray(omit)) {
|
|
1741
|
+
throw new errors_js_1.ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
|
|
1742
|
+
`(e.g. { createdAt: true }), not an array.`);
|
|
1743
|
+
}
|
|
1551
1744
|
// Include all columns except those where value is true
|
|
1552
1745
|
const omitCols = new Set(Object.entries(omit)
|
|
1553
1746
|
.filter(([, v]) => v)
|
|
@@ -1723,6 +1916,17 @@ class QueryInterface {
|
|
|
1723
1916
|
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1724
1917
|
continue;
|
|
1725
1918
|
}
|
|
1919
|
+
// Vector distance filter — metric (operator) and present comparators
|
|
1920
|
+
// change the SQL shape, so both go in the fingerprint.
|
|
1921
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
1922
|
+
const dist = value.distance;
|
|
1923
|
+
const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
|
|
1924
|
+
.filter((c) => dist[c] !== undefined)
|
|
1925
|
+
.sort()
|
|
1926
|
+
.join('|');
|
|
1927
|
+
parts.push(`${key}:vec(${dist.metric},${cmps})`);
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1726
1930
|
// JSON filter
|
|
1727
1931
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1728
1932
|
const jKeys = Object.keys(value).sort();
|
|
@@ -1847,6 +2051,14 @@ class QueryInterface {
|
|
|
1847
2051
|
if (value === null)
|
|
1848
2052
|
continue;
|
|
1849
2053
|
const rawColumn = this.toColumn(key);
|
|
2054
|
+
// Vector distance filter — mirrors buildVectorFilterClauses push order.
|
|
2055
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2056
|
+
// Validate the same way the build path does so the collect path never
|
|
2057
|
+
// diverges (it would throw before any param was pushed).
|
|
2058
|
+
this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
2059
|
+
this.collectVectorFilterParams(key, rawColumn, value, params);
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
1850
2062
|
// JSONB filter
|
|
1851
2063
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1852
2064
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -1943,6 +2155,37 @@ class QueryInterface {
|
|
|
1943
2155
|
params.push(filter.hasSome);
|
|
1944
2156
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
1945
2157
|
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Collect params for an orderBy clause. Only vector KNN ordering pushes a
|
|
2160
|
+
* param (the `$n::vector` query vector); plain direction ordering is
|
|
2161
|
+
* parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
|
|
2162
|
+
* param re-collection stays in lockstep.
|
|
2163
|
+
*/
|
|
2164
|
+
collectOrderByParams(orderBy, params) {
|
|
2165
|
+
for (const [key, dir] of Object.entries(orderBy)) {
|
|
2166
|
+
if (isVectorOrderBy(dir)) {
|
|
2167
|
+
const rawColumn = this.toColumn(key);
|
|
2168
|
+
// Re-run the same validation as buildOrderBy so the collect path can
|
|
2169
|
+
// never push a param that the build path rejected (or vice versa).
|
|
2170
|
+
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2171
|
+
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Collect params for a vector distance WHERE filter. Mirrors
|
|
2177
|
+
* {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
|
|
2178
|
+
* the comparison threshold(s).
|
|
2179
|
+
*/
|
|
2180
|
+
collectVectorFilterParams(field, rawColumn, filter, params) {
|
|
2181
|
+
const dist = filter.distance;
|
|
2182
|
+
this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
2183
|
+
for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
|
|
2184
|
+
const threshold = dist[cmp];
|
|
2185
|
+
if (threshold !== undefined)
|
|
2186
|
+
params.push(threshold);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
1946
2189
|
/**
|
|
1947
2190
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
1948
2191
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -2039,6 +2282,27 @@ class QueryInterface {
|
|
|
2039
2282
|
const targetMeta = this.schema.tables[targetTable];
|
|
2040
2283
|
if (!targetMeta)
|
|
2041
2284
|
return;
|
|
2285
|
+
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2286
|
+
// where params → limit param → nested-with params (always, both paths).
|
|
2287
|
+
if (relDef.type === 'manyToMany') {
|
|
2288
|
+
if (spec.where) {
|
|
2289
|
+
for (const [, v] of Object.entries(spec.where)) {
|
|
2290
|
+
params.push(v);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
if (spec.limit) {
|
|
2294
|
+
params.push(Number(spec.limit));
|
|
2295
|
+
}
|
|
2296
|
+
if (spec.with) {
|
|
2297
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
2298
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2299
|
+
if (!nestedRelDef)
|
|
2300
|
+
continue;
|
|
2301
|
+
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2042
2306
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
2043
2307
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2044
2308
|
if (!willWrap && spec.with) {
|
|
@@ -2055,8 +2319,10 @@ class QueryInterface {
|
|
|
2055
2319
|
params.push(v);
|
|
2056
2320
|
}
|
|
2057
2321
|
}
|
|
2058
|
-
// limit param
|
|
2059
|
-
|
|
2322
|
+
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2323
|
+
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2324
|
+
// pushing one here would orphan a param and desync the collect path.
|
|
2325
|
+
if (relDef.type === 'hasMany' && spec.limit) {
|
|
2060
2326
|
params.push(Number(spec.limit));
|
|
2061
2327
|
}
|
|
2062
2328
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
@@ -2210,6 +2476,12 @@ class QueryInterface {
|
|
|
2210
2476
|
andClauses.push(`${column} IS NULL`);
|
|
2211
2477
|
continue;
|
|
2212
2478
|
}
|
|
2479
|
+
// Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
|
|
2480
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2481
|
+
const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
|
|
2482
|
+
andClauses.push(...vecClauses);
|
|
2483
|
+
continue;
|
|
2484
|
+
}
|
|
2213
2485
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
2214
2486
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2215
2487
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -2258,6 +2530,25 @@ class QueryInterface {
|
|
|
2258
2530
|
andClauses.push(...opClauses);
|
|
2259
2531
|
continue;
|
|
2260
2532
|
}
|
|
2533
|
+
// Strict validation: a plain (non-array, non-Date) object on a non-JSON
|
|
2534
|
+
// column matched no known filter shape — almost always a misspelled
|
|
2535
|
+
// operator (`startWith` for `startsWith`) or a stray nested object.
|
|
2536
|
+
// Silently treating it as `col = $1` returns wrong rows with no error, so
|
|
2537
|
+
// throw with the offending keys and the supported operator list. JSON/JSONB
|
|
2538
|
+
// columns legitimately accept object values for equality, so they fall
|
|
2539
|
+
// through unchanged.
|
|
2540
|
+
if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
2541
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
2542
|
+
if (colType !== 'json' && colType !== 'jsonb') {
|
|
2543
|
+
const badKeys = Object.keys(value);
|
|
2544
|
+
throw new errors_js_1.ValidationError(badKeys.length === 0
|
|
2545
|
+
? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
|
|
2546
|
+
`Provide a value or an operator like { gt: 1 }.`
|
|
2547
|
+
: `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
|
|
2548
|
+
`${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
|
|
2549
|
+
`Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2261
2552
|
// Plain equality
|
|
2262
2553
|
params.push(value);
|
|
2263
2554
|
andClauses.push(`${column} = ${this.p(params.length)}`);
|
|
@@ -2416,8 +2707,17 @@ class QueryInterface {
|
|
|
2416
2707
|
}
|
|
2417
2708
|
return clauses;
|
|
2418
2709
|
}
|
|
2419
|
-
/**
|
|
2420
|
-
|
|
2710
|
+
/**
|
|
2711
|
+
* Build ORDER BY clause from an object.
|
|
2712
|
+
*
|
|
2713
|
+
* Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
|
|
2714
|
+
* columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
|
|
2715
|
+
* Vector ordering binds the query vector as a `$n::vector` param, so a `params`
|
|
2716
|
+
* array MUST be supplied when a vector ordering may be present (top-level
|
|
2717
|
+
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
2718
|
+
* ordering throws — KNN ordering is only supported at the top level.
|
|
2719
|
+
*/
|
|
2720
|
+
buildOrderBy(orderBy, params) {
|
|
2421
2721
|
// Dev-only: validate that orderBy fields exist in the table schema
|
|
2422
2722
|
if (process.env.NODE_ENV !== 'production') {
|
|
2423
2723
|
for (const key of Object.keys(orderBy)) {
|
|
@@ -2432,14 +2732,88 @@ class QueryInterface {
|
|
|
2432
2732
|
return Object.entries(orderBy)
|
|
2433
2733
|
.map(([key, dir]) => {
|
|
2434
2734
|
if (meta && !(key in meta.columnMap)) {
|
|
2435
|
-
throw new errors_js_1.ValidationError(`Unknown
|
|
2735
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
2736
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
2737
|
+
}
|
|
2738
|
+
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
2739
|
+
if (isVectorOrderBy(dir)) {
|
|
2740
|
+
if (!params) {
|
|
2741
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
2742
|
+
}
|
|
2743
|
+
const rawColumn = this.toColumn(key);
|
|
2744
|
+
const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2745
|
+
const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2746
|
+
const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2747
|
+
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
2436
2748
|
}
|
|
2437
2749
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2438
2750
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
2439
2751
|
})
|
|
2440
2752
|
.join(', ');
|
|
2441
2753
|
}
|
|
2754
|
+
// -------------------------------------------------------------------------
|
|
2755
|
+
// pgvector helpers (similarity search)
|
|
2756
|
+
// -------------------------------------------------------------------------
|
|
2757
|
+
/**
|
|
2758
|
+
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
2759
|
+
* fixed allow-list, validating the target column is actually a `vector`
|
|
2760
|
+
* column. Throws {@link ValidationError} for an unknown metric or a
|
|
2761
|
+
* non-vector column — a user-supplied string can never become a SQL operator.
|
|
2762
|
+
*/
|
|
2763
|
+
vectorOperator(field, rawColumn, metric) {
|
|
2764
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
2765
|
+
if (colType !== 'vector') {
|
|
2766
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
|
|
2767
|
+
`(actual type: ${colType}); cannot apply a vector distance operation.`);
|
|
2768
|
+
}
|
|
2769
|
+
const op = VECTOR_METRIC_OPERATORS[metric];
|
|
2770
|
+
if (!op) {
|
|
2771
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
|
|
2772
|
+
`Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
|
|
2773
|
+
}
|
|
2774
|
+
return op;
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* Validate and bind a query vector as a single `$n::vector` parameter.
|
|
2778
|
+
* Every element must be a finite number (no NaN / Infinity / strings) so a
|
|
2779
|
+
* malformed array can never produce a broken `::vector` literal, and the array
|
|
2780
|
+
* is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
|
|
2781
|
+
* placeholder string.
|
|
2782
|
+
*/
|
|
2783
|
+
pushVectorParam(field, _rawColumn, to, params) {
|
|
2784
|
+
if (!Array.isArray(to) || to.length === 0) {
|
|
2785
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
|
|
2786
|
+
}
|
|
2787
|
+
for (const el of to) {
|
|
2788
|
+
if (typeof el !== 'number' || !Number.isFinite(el)) {
|
|
2789
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
|
|
2790
|
+
`got ${JSON.stringify(el)}.`);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
// Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
|
|
2794
|
+
// as finite numbers, so the joined string is safe; it is still passed as a
|
|
2795
|
+
// bound param (never interpolated) and cast with ::vector.
|
|
2796
|
+
params.push(`[${to.join(',')}]`);
|
|
2797
|
+
return `${this.p(params.length)}::vector`;
|
|
2798
|
+
}
|
|
2442
2799
|
/** Parse a flat row: convert snake_case to camelCase + Date coercion */
|
|
2800
|
+
/**
|
|
2801
|
+
* Returns the set of camelCase field names for a table's date columns,
|
|
2802
|
+
* derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
|
|
2803
|
+
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
2804
|
+
* dates the same way top-level rows do.
|
|
2805
|
+
*/
|
|
2806
|
+
getCamelDateFields(table, meta) {
|
|
2807
|
+
let camel = this.camelDateFieldCache.get(table);
|
|
2808
|
+
if (!camel) {
|
|
2809
|
+
camel = new Set();
|
|
2810
|
+
for (const col of meta.dateColumns) {
|
|
2811
|
+
camel.add(meta.reverseColumnMap[col] ?? col);
|
|
2812
|
+
}
|
|
2813
|
+
this.camelDateFieldCache.set(table, camel);
|
|
2814
|
+
}
|
|
2815
|
+
return camel;
|
|
2816
|
+
}
|
|
2443
2817
|
parseRow(row, table) {
|
|
2444
2818
|
const parsed = {};
|
|
2445
2819
|
const meta = this.schema.tables[table];
|
|
@@ -2447,12 +2821,16 @@ class QueryInterface {
|
|
|
2447
2821
|
// Fast path: use pre-computed maps (avoids regex per column per row)
|
|
2448
2822
|
const reverseMap = meta.reverseColumnMap;
|
|
2449
2823
|
const dateCols = meta.dateColumns;
|
|
2824
|
+
// camelCase-keyed date fields, so nested json_build_object rows (whose
|
|
2825
|
+
// keys are already camelCase) get the same Date coercion as top-level rows.
|
|
2826
|
+
const camelDateFields = this.getCamelDateFields(table, meta);
|
|
2450
2827
|
const keys = Object.keys(row);
|
|
2451
2828
|
for (let i = 0; i < keys.length; i++) {
|
|
2452
2829
|
const col = keys[i];
|
|
2453
2830
|
const value = row[col];
|
|
2454
2831
|
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
2455
|
-
|
|
2832
|
+
// Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
|
|
2833
|
+
if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
|
|
2456
2834
|
parsed[field] = new Date(value);
|
|
2457
2835
|
}
|
|
2458
2836
|
else {
|
|
@@ -2496,14 +2874,15 @@ class QueryInterface {
|
|
|
2496
2874
|
if (typeof rawValue === 'string') {
|
|
2497
2875
|
try {
|
|
2498
2876
|
const jsonVal = JSON.parse(rawValue);
|
|
2499
|
-
// After parsing,
|
|
2877
|
+
// After parsing, recurse via parseNestedRow so each item gets date
|
|
2878
|
+
// coercion AND its own sub-relations parsed at arbitrary depth.
|
|
2500
2879
|
if (Array.isArray(jsonVal)) {
|
|
2501
2880
|
parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
|
|
2502
|
-
? this.
|
|
2881
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2503
2882
|
: item);
|
|
2504
2883
|
}
|
|
2505
2884
|
else if (typeof jsonVal === 'object' && jsonVal !== null) {
|
|
2506
|
-
parsed[relName] = this.
|
|
2885
|
+
parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
|
|
2507
2886
|
}
|
|
2508
2887
|
else {
|
|
2509
2888
|
parsed[relName] = jsonVal;
|
|
@@ -2515,10 +2894,12 @@ class QueryInterface {
|
|
|
2515
2894
|
}
|
|
2516
2895
|
}
|
|
2517
2896
|
else if (Array.isArray(rawValue)) {
|
|
2518
|
-
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2897
|
+
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2898
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2899
|
+
: item);
|
|
2519
2900
|
}
|
|
2520
2901
|
else if (typeof rawValue === 'object' && rawValue !== null) {
|
|
2521
|
-
parsed[relName] = this.
|
|
2902
|
+
parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
|
|
2522
2903
|
}
|
|
2523
2904
|
else {
|
|
2524
2905
|
parsed[relName] = rawValue;
|
|
@@ -2719,6 +3100,12 @@ class QueryInterface {
|
|
|
2719
3100
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
2720
3101
|
// so we must NOT build them here (they would push orphaned params).
|
|
2721
3102
|
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
3103
|
+
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3104
|
+
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3105
|
+
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
3106
|
+
if (relDef.type === 'manyToMany') {
|
|
3107
|
+
return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
|
|
3108
|
+
}
|
|
2722
3109
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
2723
3110
|
if (!willWrap && spec !== true && spec.with) {
|
|
2724
3111
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -2775,9 +3162,13 @@ class QueryInterface {
|
|
|
2775
3162
|
whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
2776
3163
|
}
|
|
2777
3164
|
}
|
|
2778
|
-
// LIMIT
|
|
3165
|
+
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
3166
|
+
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
3167
|
+
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
3168
|
+
// which Postgres rejects with "could not determine data type of parameter $N"
|
|
3169
|
+
// (and shifts every later placeholder by one). To-one relations ignore limit.
|
|
2779
3170
|
let limitClause = '';
|
|
2780
|
-
if (spec !== true && spec.limit) {
|
|
3171
|
+
if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
|
|
2781
3172
|
params.push(Number(spec.limit));
|
|
2782
3173
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
2783
3174
|
}
|
|
@@ -2815,6 +3206,146 @@ class QueryInterface {
|
|
|
2815
3206
|
// belongsTo / hasOne — return single object
|
|
2816
3207
|
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
|
|
2817
3208
|
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
3211
|
+
* table through a junction (join) table.
|
|
3212
|
+
*
|
|
3213
|
+
* Shape (no LIMIT/ORDER):
|
|
3214
|
+
* ```sql
|
|
3215
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
3216
|
+
* FROM <target> <talias>
|
|
3217
|
+
* JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
|
|
3218
|
+
* WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
|
|
3219
|
+
* ```
|
|
3220
|
+
*
|
|
3221
|
+
* With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
|
|
3222
|
+
* applies BEFORE aggregation (identical strategy to hasMany).
|
|
3223
|
+
*
|
|
3224
|
+
* Cardinality is always 'many' → empty-array fallback, never NULL.
|
|
3225
|
+
*
|
|
3226
|
+
* IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
|
|
3227
|
+
* {@link collectRelationSubqueryParams} or pipeline batching will desync.
|
|
3228
|
+
*/
|
|
3229
|
+
buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
|
|
3230
|
+
if (!relDef.through) {
|
|
3231
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
|
|
3232
|
+
}
|
|
3233
|
+
const targetTable = relDef.to;
|
|
3234
|
+
const qTarget = this.q(targetTable);
|
|
3235
|
+
const qJunction = this.q(relDef.through.table);
|
|
3236
|
+
const qParent = this.q(parentRef);
|
|
3237
|
+
const jalias = `${talias}j`; // junction alias, distinct from the target alias
|
|
3238
|
+
// JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
|
|
3239
|
+
const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
|
|
3240
|
+
// The target PK is the column(s) the junction's targetKey references. An empty
|
|
3241
|
+
// introspected PK means we cannot know what to JOIN on — fail loudly rather than
|
|
3242
|
+
// silently guessing `id` and generating a wrong JOIN.
|
|
3243
|
+
if (targetMeta.primaryKey.length === 0) {
|
|
3244
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
|
|
3245
|
+
`cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
|
|
3246
|
+
}
|
|
3247
|
+
const targetPk = targetMeta.primaryKey;
|
|
3248
|
+
if (targetKeys.length !== targetPk.length) {
|
|
3249
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
|
|
3250
|
+
`but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
|
|
3251
|
+
}
|
|
3252
|
+
const joinOn = targetKeys
|
|
3253
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
|
|
3254
|
+
.join(' AND ');
|
|
3255
|
+
// Correlation: junction.sourceKey = parent.<referenceKey>.
|
|
3256
|
+
const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
|
|
3257
|
+
const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
|
|
3258
|
+
if (sourceKeys.length !== refKeys.length) {
|
|
3259
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
|
|
3260
|
+
`but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
|
|
3261
|
+
}
|
|
3262
|
+
let whereClause = sourceKeys
|
|
3263
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3264
|
+
.join(' AND ');
|
|
3265
|
+
// ORDER BY on the target rows
|
|
3266
|
+
let orderClause = '';
|
|
3267
|
+
if (spec !== true && spec.orderBy) {
|
|
3268
|
+
const orders = Object.entries(spec.orderBy)
|
|
3269
|
+
.map(([k, dir]) => {
|
|
3270
|
+
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3271
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3272
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3273
|
+
}
|
|
3274
|
+
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3275
|
+
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3276
|
+
})
|
|
3277
|
+
.join(', ');
|
|
3278
|
+
orderClause = ` ORDER BY ${orders}`;
|
|
3279
|
+
}
|
|
3280
|
+
// Additional WHERE filters on the target — properly parameterized.
|
|
3281
|
+
if (spec !== true && spec.where) {
|
|
3282
|
+
for (const [k, v] of Object.entries(spec.where)) {
|
|
3283
|
+
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3284
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3285
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3286
|
+
}
|
|
3287
|
+
params.push(v);
|
|
3288
|
+
whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
// LIMIT
|
|
3292
|
+
let limitClause = '';
|
|
3293
|
+
if (spec !== true && spec.limit) {
|
|
3294
|
+
params.push(Number(spec.limit));
|
|
3295
|
+
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3296
|
+
}
|
|
3297
|
+
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
3298
|
+
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
3299
|
+
// so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
|
|
3300
|
+
if (limitClause || orderClause) {
|
|
3301
|
+
const innerAlias = `${talias}i`;
|
|
3302
|
+
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
|
|
3303
|
+
`${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
3304
|
+
const innerJsonPairs = targetColumns.map((col) => [
|
|
3305
|
+
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
3306
|
+
`${innerAlias}.${this.q(col)}`,
|
|
3307
|
+
]);
|
|
3308
|
+
// Nested relations reference the inner alias.
|
|
3309
|
+
if (spec !== true && spec.with) {
|
|
3310
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3311
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3312
|
+
if (!nestedRelDef) {
|
|
3313
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3314
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3315
|
+
}
|
|
3316
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3317
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3318
|
+
? this.dialect.nullJsonLiteral
|
|
3319
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3320
|
+
innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
|
|
3324
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3325
|
+
}
|
|
3326
|
+
// Simple path: build the json object pairs directly off the target alias,
|
|
3327
|
+
// including any nested relations (correlated to the target alias).
|
|
3328
|
+
const jsonPairs = targetColumns.map((col) => [
|
|
3329
|
+
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
3330
|
+
`${talias}.${this.q(col)}`,
|
|
3331
|
+
]);
|
|
3332
|
+
if (spec !== true && spec.with) {
|
|
3333
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3334
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3335
|
+
if (!nestedRelDef) {
|
|
3336
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3337
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3338
|
+
}
|
|
3339
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3340
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3341
|
+
? this.dialect.nullJsonLiteral
|
|
3342
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3343
|
+
jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
const jsonObj = this.dialect.buildJsonObject(jsonPairs);
|
|
3347
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
|
|
3348
|
+
}
|
|
2818
3349
|
/**
|
|
2819
3350
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
2820
3351
|
* Used to detect JSONB/array columns for specialized operators.
|
|
@@ -2908,6 +3439,39 @@ class QueryInterface {
|
|
|
2908
3439
|
}
|
|
2909
3440
|
return clauses;
|
|
2910
3441
|
}
|
|
3442
|
+
/**
|
|
3443
|
+
* Build SQL clauses for a pgvector distance WHERE filter:
|
|
3444
|
+
*
|
|
3445
|
+
* `"embedding" <-> $1::vector < $2`
|
|
3446
|
+
*
|
|
3447
|
+
* The query vector is bound as a `$n::vector` param (never interpolated), the
|
|
3448
|
+
* metric maps to an operator via a fixed allow-list, and each comparison
|
|
3449
|
+
* threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
|
|
3450
|
+
* per supplied comparator (all ANDed). Param push order matches
|
|
3451
|
+
* {@link collectVectorFilterParams}.
|
|
3452
|
+
*/
|
|
3453
|
+
buildVectorFilterClauses(field, rawColumn, filter, params) {
|
|
3454
|
+
const dist = filter.distance;
|
|
3455
|
+
const operator = this.vectorOperator(field, rawColumn, dist.metric);
|
|
3456
|
+
const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
3457
|
+
const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
|
|
3458
|
+
const clauses = [];
|
|
3459
|
+
for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
|
|
3460
|
+
const threshold = dist[cmp];
|
|
3461
|
+
if (threshold === undefined)
|
|
3462
|
+
continue;
|
|
3463
|
+
if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
|
|
3464
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
|
|
3465
|
+
`got ${JSON.stringify(threshold)}.`);
|
|
3466
|
+
}
|
|
3467
|
+
params.push(threshold);
|
|
3468
|
+
clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
|
|
3469
|
+
}
|
|
3470
|
+
if (clauses.length === 0) {
|
|
3471
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
|
|
3472
|
+
}
|
|
3473
|
+
return clauses;
|
|
3474
|
+
}
|
|
2911
3475
|
/**
|
|
2912
3476
|
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
2913
3477
|
* The config name is validated to prevent injection (only alphanumeric + underscore).
|