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
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
|
// -------------------------------------------------------------------------
|
|
@@ -1506,12 +1687,24 @@ export class QueryInterface {
|
|
|
1506
1687
|
*/
|
|
1507
1688
|
resolveColumns(select, omit) {
|
|
1508
1689
|
if (select) {
|
|
1690
|
+
// An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
|
|
1691
|
+
// style) instead of the object shape. Object.entries() would iterate the
|
|
1692
|
+
// numeric indices and throw a cryptic `Unknown field "0"` — catch it early
|
|
1693
|
+
// with an actionable message.
|
|
1694
|
+
if (Array.isArray(select)) {
|
|
1695
|
+
throw new ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
|
|
1696
|
+
`(e.g. { id: true, name: true }), not an array.`);
|
|
1697
|
+
}
|
|
1509
1698
|
// Only include columns where value is true
|
|
1510
1699
|
return Object.entries(select)
|
|
1511
1700
|
.filter(([, v]) => v)
|
|
1512
1701
|
.map(([k]) => this.toColumn(k));
|
|
1513
1702
|
}
|
|
1514
1703
|
if (omit) {
|
|
1704
|
+
if (Array.isArray(omit)) {
|
|
1705
|
+
throw new ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
|
|
1706
|
+
`(e.g. { createdAt: true }), not an array.`);
|
|
1707
|
+
}
|
|
1515
1708
|
// Include all columns except those where value is true
|
|
1516
1709
|
const omitCols = new Set(Object.entries(omit)
|
|
1517
1710
|
.filter(([, v]) => v)
|
|
@@ -1687,6 +1880,17 @@ export class QueryInterface {
|
|
|
1687
1880
|
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1688
1881
|
continue;
|
|
1689
1882
|
}
|
|
1883
|
+
// Vector distance filter — metric (operator) and present comparators
|
|
1884
|
+
// change the SQL shape, so both go in the fingerprint.
|
|
1885
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
1886
|
+
const dist = value.distance;
|
|
1887
|
+
const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
|
|
1888
|
+
.filter((c) => dist[c] !== undefined)
|
|
1889
|
+
.sort()
|
|
1890
|
+
.join('|');
|
|
1891
|
+
parts.push(`${key}:vec(${dist.metric},${cmps})`);
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1690
1894
|
// JSON filter
|
|
1691
1895
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1692
1896
|
const jKeys = Object.keys(value).sort();
|
|
@@ -1811,6 +2015,14 @@ export class QueryInterface {
|
|
|
1811
2015
|
if (value === null)
|
|
1812
2016
|
continue;
|
|
1813
2017
|
const rawColumn = this.toColumn(key);
|
|
2018
|
+
// Vector distance filter — mirrors buildVectorFilterClauses push order.
|
|
2019
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2020
|
+
// Validate the same way the build path does so the collect path never
|
|
2021
|
+
// diverges (it would throw before any param was pushed).
|
|
2022
|
+
this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
2023
|
+
this.collectVectorFilterParams(key, rawColumn, value, params);
|
|
2024
|
+
continue;
|
|
2025
|
+
}
|
|
1814
2026
|
// JSONB filter
|
|
1815
2027
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1816
2028
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -1907,6 +2119,37 @@ export class QueryInterface {
|
|
|
1907
2119
|
params.push(filter.hasSome);
|
|
1908
2120
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
1909
2121
|
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Collect params for an orderBy clause. Only vector KNN ordering pushes a
|
|
2124
|
+
* param (the `$n::vector` query vector); plain direction ordering is
|
|
2125
|
+
* parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
|
|
2126
|
+
* param re-collection stays in lockstep.
|
|
2127
|
+
*/
|
|
2128
|
+
collectOrderByParams(orderBy, params) {
|
|
2129
|
+
for (const [key, dir] of Object.entries(orderBy)) {
|
|
2130
|
+
if (isVectorOrderBy(dir)) {
|
|
2131
|
+
const rawColumn = this.toColumn(key);
|
|
2132
|
+
// Re-run the same validation as buildOrderBy so the collect path can
|
|
2133
|
+
// never push a param that the build path rejected (or vice versa).
|
|
2134
|
+
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2135
|
+
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Collect params for a vector distance WHERE filter. Mirrors
|
|
2141
|
+
* {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
|
|
2142
|
+
* the comparison threshold(s).
|
|
2143
|
+
*/
|
|
2144
|
+
collectVectorFilterParams(field, rawColumn, filter, params) {
|
|
2145
|
+
const dist = filter.distance;
|
|
2146
|
+
this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
2147
|
+
for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
|
|
2148
|
+
const threshold = dist[cmp];
|
|
2149
|
+
if (threshold !== undefined)
|
|
2150
|
+
params.push(threshold);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
1910
2153
|
/**
|
|
1911
2154
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
1912
2155
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -2003,6 +2246,27 @@ export class QueryInterface {
|
|
|
2003
2246
|
const targetMeta = this.schema.tables[targetTable];
|
|
2004
2247
|
if (!targetMeta)
|
|
2005
2248
|
return;
|
|
2249
|
+
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2250
|
+
// where params → limit param → nested-with params (always, both paths).
|
|
2251
|
+
if (relDef.type === 'manyToMany') {
|
|
2252
|
+
if (spec.where) {
|
|
2253
|
+
for (const [, v] of Object.entries(spec.where)) {
|
|
2254
|
+
params.push(v);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
if (spec.limit) {
|
|
2258
|
+
params.push(Number(spec.limit));
|
|
2259
|
+
}
|
|
2260
|
+
if (spec.with) {
|
|
2261
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
2262
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2263
|
+
if (!nestedRelDef)
|
|
2264
|
+
continue;
|
|
2265
|
+
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2006
2270
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
2007
2271
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2008
2272
|
if (!willWrap && spec.with) {
|
|
@@ -2019,8 +2283,10 @@ export class QueryInterface {
|
|
|
2019
2283
|
params.push(v);
|
|
2020
2284
|
}
|
|
2021
2285
|
}
|
|
2022
|
-
// limit param
|
|
2023
|
-
|
|
2286
|
+
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2287
|
+
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2288
|
+
// pushing one here would orphan a param and desync the collect path.
|
|
2289
|
+
if (relDef.type === 'hasMany' && spec.limit) {
|
|
2024
2290
|
params.push(Number(spec.limit));
|
|
2025
2291
|
}
|
|
2026
2292
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
@@ -2174,6 +2440,12 @@ export class QueryInterface {
|
|
|
2174
2440
|
andClauses.push(`${column} IS NULL`);
|
|
2175
2441
|
continue;
|
|
2176
2442
|
}
|
|
2443
|
+
// Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
|
|
2444
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2445
|
+
const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
|
|
2446
|
+
andClauses.push(...vecClauses);
|
|
2447
|
+
continue;
|
|
2448
|
+
}
|
|
2177
2449
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
2178
2450
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2179
2451
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -2222,6 +2494,25 @@ export class QueryInterface {
|
|
|
2222
2494
|
andClauses.push(...opClauses);
|
|
2223
2495
|
continue;
|
|
2224
2496
|
}
|
|
2497
|
+
// Strict validation: a plain (non-array, non-Date) object on a non-JSON
|
|
2498
|
+
// column matched no known filter shape — almost always a misspelled
|
|
2499
|
+
// operator (`startWith` for `startsWith`) or a stray nested object.
|
|
2500
|
+
// Silently treating it as `col = $1` returns wrong rows with no error, so
|
|
2501
|
+
// throw with the offending keys and the supported operator list. JSON/JSONB
|
|
2502
|
+
// columns legitimately accept object values for equality, so they fall
|
|
2503
|
+
// through unchanged.
|
|
2504
|
+
if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
2505
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
2506
|
+
if (colType !== 'json' && colType !== 'jsonb') {
|
|
2507
|
+
const badKeys = Object.keys(value);
|
|
2508
|
+
throw new ValidationError(badKeys.length === 0
|
|
2509
|
+
? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
|
|
2510
|
+
`Provide a value or an operator like { gt: 1 }.`
|
|
2511
|
+
: `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
|
|
2512
|
+
`${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
|
|
2513
|
+
`Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2225
2516
|
// Plain equality
|
|
2226
2517
|
params.push(value);
|
|
2227
2518
|
andClauses.push(`${column} = ${this.p(params.length)}`);
|
|
@@ -2380,8 +2671,17 @@ export class QueryInterface {
|
|
|
2380
2671
|
}
|
|
2381
2672
|
return clauses;
|
|
2382
2673
|
}
|
|
2383
|
-
/**
|
|
2384
|
-
|
|
2674
|
+
/**
|
|
2675
|
+
* Build ORDER BY clause from an object.
|
|
2676
|
+
*
|
|
2677
|
+
* Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
|
|
2678
|
+
* columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
|
|
2679
|
+
* Vector ordering binds the query vector as a `$n::vector` param, so a `params`
|
|
2680
|
+
* array MUST be supplied when a vector ordering may be present (top-level
|
|
2681
|
+
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
2682
|
+
* ordering throws — KNN ordering is only supported at the top level.
|
|
2683
|
+
*/
|
|
2684
|
+
buildOrderBy(orderBy, params) {
|
|
2385
2685
|
// Dev-only: validate that orderBy fields exist in the table schema
|
|
2386
2686
|
if (process.env.NODE_ENV !== 'production') {
|
|
2387
2687
|
for (const key of Object.keys(orderBy)) {
|
|
@@ -2396,14 +2696,88 @@ export class QueryInterface {
|
|
|
2396
2696
|
return Object.entries(orderBy)
|
|
2397
2697
|
.map(([key, dir]) => {
|
|
2398
2698
|
if (meta && !(key in meta.columnMap)) {
|
|
2399
|
-
throw new ValidationError(`Unknown
|
|
2699
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
2700
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
2701
|
+
}
|
|
2702
|
+
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
2703
|
+
if (isVectorOrderBy(dir)) {
|
|
2704
|
+
if (!params) {
|
|
2705
|
+
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
2706
|
+
}
|
|
2707
|
+
const rawColumn = this.toColumn(key);
|
|
2708
|
+
const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2709
|
+
const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2710
|
+
const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2711
|
+
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
2400
2712
|
}
|
|
2401
2713
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2402
2714
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
2403
2715
|
})
|
|
2404
2716
|
.join(', ');
|
|
2405
2717
|
}
|
|
2718
|
+
// -------------------------------------------------------------------------
|
|
2719
|
+
// pgvector helpers (similarity search)
|
|
2720
|
+
// -------------------------------------------------------------------------
|
|
2721
|
+
/**
|
|
2722
|
+
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
2723
|
+
* fixed allow-list, validating the target column is actually a `vector`
|
|
2724
|
+
* column. Throws {@link ValidationError} for an unknown metric or a
|
|
2725
|
+
* non-vector column — a user-supplied string can never become a SQL operator.
|
|
2726
|
+
*/
|
|
2727
|
+
vectorOperator(field, rawColumn, metric) {
|
|
2728
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
2729
|
+
if (colType !== 'vector') {
|
|
2730
|
+
throw new ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
|
|
2731
|
+
`(actual type: ${colType}); cannot apply a vector distance operation.`);
|
|
2732
|
+
}
|
|
2733
|
+
const op = VECTOR_METRIC_OPERATORS[metric];
|
|
2734
|
+
if (!op) {
|
|
2735
|
+
throw new ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
|
|
2736
|
+
`Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
|
|
2737
|
+
}
|
|
2738
|
+
return op;
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Validate and bind a query vector as a single `$n::vector` parameter.
|
|
2742
|
+
* Every element must be a finite number (no NaN / Infinity / strings) so a
|
|
2743
|
+
* malformed array can never produce a broken `::vector` literal, and the array
|
|
2744
|
+
* is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
|
|
2745
|
+
* placeholder string.
|
|
2746
|
+
*/
|
|
2747
|
+
pushVectorParam(field, _rawColumn, to, params) {
|
|
2748
|
+
if (!Array.isArray(to) || to.length === 0) {
|
|
2749
|
+
throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
|
|
2750
|
+
}
|
|
2751
|
+
for (const el of to) {
|
|
2752
|
+
if (typeof el !== 'number' || !Number.isFinite(el)) {
|
|
2753
|
+
throw new ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
|
|
2754
|
+
`got ${JSON.stringify(el)}.`);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
// Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
|
|
2758
|
+
// as finite numbers, so the joined string is safe; it is still passed as a
|
|
2759
|
+
// bound param (never interpolated) and cast with ::vector.
|
|
2760
|
+
params.push(`[${to.join(',')}]`);
|
|
2761
|
+
return `${this.p(params.length)}::vector`;
|
|
2762
|
+
}
|
|
2406
2763
|
/** Parse a flat row: convert snake_case to camelCase + Date coercion */
|
|
2764
|
+
/**
|
|
2765
|
+
* Returns the set of camelCase field names for a table's date columns,
|
|
2766
|
+
* derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
|
|
2767
|
+
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
2768
|
+
* dates the same way top-level rows do.
|
|
2769
|
+
*/
|
|
2770
|
+
getCamelDateFields(table, meta) {
|
|
2771
|
+
let camel = this.camelDateFieldCache.get(table);
|
|
2772
|
+
if (!camel) {
|
|
2773
|
+
camel = new Set();
|
|
2774
|
+
for (const col of meta.dateColumns) {
|
|
2775
|
+
camel.add(meta.reverseColumnMap[col] ?? col);
|
|
2776
|
+
}
|
|
2777
|
+
this.camelDateFieldCache.set(table, camel);
|
|
2778
|
+
}
|
|
2779
|
+
return camel;
|
|
2780
|
+
}
|
|
2407
2781
|
parseRow(row, table) {
|
|
2408
2782
|
const parsed = {};
|
|
2409
2783
|
const meta = this.schema.tables[table];
|
|
@@ -2411,12 +2785,16 @@ export class QueryInterface {
|
|
|
2411
2785
|
// Fast path: use pre-computed maps (avoids regex per column per row)
|
|
2412
2786
|
const reverseMap = meta.reverseColumnMap;
|
|
2413
2787
|
const dateCols = meta.dateColumns;
|
|
2788
|
+
// camelCase-keyed date fields, so nested json_build_object rows (whose
|
|
2789
|
+
// keys are already camelCase) get the same Date coercion as top-level rows.
|
|
2790
|
+
const camelDateFields = this.getCamelDateFields(table, meta);
|
|
2414
2791
|
const keys = Object.keys(row);
|
|
2415
2792
|
for (let i = 0; i < keys.length; i++) {
|
|
2416
2793
|
const col = keys[i];
|
|
2417
2794
|
const value = row[col];
|
|
2418
2795
|
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
2419
|
-
|
|
2796
|
+
// Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
|
|
2797
|
+
if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
|
|
2420
2798
|
parsed[field] = new Date(value);
|
|
2421
2799
|
}
|
|
2422
2800
|
else {
|
|
@@ -2460,14 +2838,15 @@ export class QueryInterface {
|
|
|
2460
2838
|
if (typeof rawValue === 'string') {
|
|
2461
2839
|
try {
|
|
2462
2840
|
const jsonVal = JSON.parse(rawValue);
|
|
2463
|
-
// After parsing,
|
|
2841
|
+
// After parsing, recurse via parseNestedRow so each item gets date
|
|
2842
|
+
// coercion AND its own sub-relations parsed at arbitrary depth.
|
|
2464
2843
|
if (Array.isArray(jsonVal)) {
|
|
2465
2844
|
parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
|
|
2466
|
-
? this.
|
|
2845
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2467
2846
|
: item);
|
|
2468
2847
|
}
|
|
2469
2848
|
else if (typeof jsonVal === 'object' && jsonVal !== null) {
|
|
2470
|
-
parsed[relName] = this.
|
|
2849
|
+
parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
|
|
2471
2850
|
}
|
|
2472
2851
|
else {
|
|
2473
2852
|
parsed[relName] = jsonVal;
|
|
@@ -2479,10 +2858,12 @@ export class QueryInterface {
|
|
|
2479
2858
|
}
|
|
2480
2859
|
}
|
|
2481
2860
|
else if (Array.isArray(rawValue)) {
|
|
2482
|
-
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2861
|
+
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2862
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2863
|
+
: item);
|
|
2483
2864
|
}
|
|
2484
2865
|
else if (typeof rawValue === 'object' && rawValue !== null) {
|
|
2485
|
-
parsed[relName] = this.
|
|
2866
|
+
parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
|
|
2486
2867
|
}
|
|
2487
2868
|
else {
|
|
2488
2869
|
parsed[relName] = rawValue;
|
|
@@ -2683,6 +3064,12 @@ export class QueryInterface {
|
|
|
2683
3064
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
2684
3065
|
// so we must NOT build them here (they would push orphaned params).
|
|
2685
3066
|
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
3067
|
+
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3068
|
+
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3069
|
+
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
3070
|
+
if (relDef.type === 'manyToMany') {
|
|
3071
|
+
return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
|
|
3072
|
+
}
|
|
2686
3073
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
2687
3074
|
if (!willWrap && spec !== true && spec.with) {
|
|
2688
3075
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -2739,9 +3126,13 @@ export class QueryInterface {
|
|
|
2739
3126
|
whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
2740
3127
|
}
|
|
2741
3128
|
}
|
|
2742
|
-
// LIMIT
|
|
3129
|
+
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
3130
|
+
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
3131
|
+
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
3132
|
+
// which Postgres rejects with "could not determine data type of parameter $N"
|
|
3133
|
+
// (and shifts every later placeholder by one). To-one relations ignore limit.
|
|
2743
3134
|
let limitClause = '';
|
|
2744
|
-
if (spec !== true && spec.limit) {
|
|
3135
|
+
if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
|
|
2745
3136
|
params.push(Number(spec.limit));
|
|
2746
3137
|
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
2747
3138
|
}
|
|
@@ -2779,6 +3170,146 @@ export class QueryInterface {
|
|
|
2779
3170
|
// belongsTo / hasOne — return single object
|
|
2780
3171
|
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
|
|
2781
3172
|
}
|
|
3173
|
+
/**
|
|
3174
|
+
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
3175
|
+
* table through a junction (join) table.
|
|
3176
|
+
*
|
|
3177
|
+
* Shape (no LIMIT/ORDER):
|
|
3178
|
+
* ```sql
|
|
3179
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
3180
|
+
* FROM <target> <talias>
|
|
3181
|
+
* JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
|
|
3182
|
+
* WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
|
|
3183
|
+
* ```
|
|
3184
|
+
*
|
|
3185
|
+
* With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
|
|
3186
|
+
* applies BEFORE aggregation (identical strategy to hasMany).
|
|
3187
|
+
*
|
|
3188
|
+
* Cardinality is always 'many' → empty-array fallback, never NULL.
|
|
3189
|
+
*
|
|
3190
|
+
* IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
|
|
3191
|
+
* {@link collectRelationSubqueryParams} or pipeline batching will desync.
|
|
3192
|
+
*/
|
|
3193
|
+
buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
|
|
3194
|
+
if (!relDef.through) {
|
|
3195
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
|
|
3196
|
+
}
|
|
3197
|
+
const targetTable = relDef.to;
|
|
3198
|
+
const qTarget = this.q(targetTable);
|
|
3199
|
+
const qJunction = this.q(relDef.through.table);
|
|
3200
|
+
const qParent = this.q(parentRef);
|
|
3201
|
+
const jalias = `${talias}j`; // junction alias, distinct from the target alias
|
|
3202
|
+
// JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
|
|
3203
|
+
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
3204
|
+
// The target PK is the column(s) the junction's targetKey references. An empty
|
|
3205
|
+
// introspected PK means we cannot know what to JOIN on — fail loudly rather than
|
|
3206
|
+
// silently guessing `id` and generating a wrong JOIN.
|
|
3207
|
+
if (targetMeta.primaryKey.length === 0) {
|
|
3208
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
|
|
3209
|
+
`cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
|
|
3210
|
+
}
|
|
3211
|
+
const targetPk = targetMeta.primaryKey;
|
|
3212
|
+
if (targetKeys.length !== targetPk.length) {
|
|
3213
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
|
|
3214
|
+
`but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
|
|
3215
|
+
}
|
|
3216
|
+
const joinOn = targetKeys
|
|
3217
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
|
|
3218
|
+
.join(' AND ');
|
|
3219
|
+
// Correlation: junction.sourceKey = parent.<referenceKey>.
|
|
3220
|
+
const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
|
|
3221
|
+
const refKeys = normalizeKeyColumns(relDef.referenceKey);
|
|
3222
|
+
if (sourceKeys.length !== refKeys.length) {
|
|
3223
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
|
|
3224
|
+
`but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
|
|
3225
|
+
}
|
|
3226
|
+
let whereClause = sourceKeys
|
|
3227
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3228
|
+
.join(' AND ');
|
|
3229
|
+
// ORDER BY on the target rows
|
|
3230
|
+
let orderClause = '';
|
|
3231
|
+
if (spec !== true && spec.orderBy) {
|
|
3232
|
+
const orders = Object.entries(spec.orderBy)
|
|
3233
|
+
.map(([k, dir]) => {
|
|
3234
|
+
const col = camelToSnake(k);
|
|
3235
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3236
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3237
|
+
}
|
|
3238
|
+
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3239
|
+
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3240
|
+
})
|
|
3241
|
+
.join(', ');
|
|
3242
|
+
orderClause = ` ORDER BY ${orders}`;
|
|
3243
|
+
}
|
|
3244
|
+
// Additional WHERE filters on the target — properly parameterized.
|
|
3245
|
+
if (spec !== true && spec.where) {
|
|
3246
|
+
for (const [k, v] of Object.entries(spec.where)) {
|
|
3247
|
+
const col = camelToSnake(k);
|
|
3248
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3249
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3250
|
+
}
|
|
3251
|
+
params.push(v);
|
|
3252
|
+
whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
// LIMIT
|
|
3256
|
+
let limitClause = '';
|
|
3257
|
+
if (spec !== true && spec.limit) {
|
|
3258
|
+
params.push(Number(spec.limit));
|
|
3259
|
+
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3260
|
+
}
|
|
3261
|
+
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
3262
|
+
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
3263
|
+
// so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
|
|
3264
|
+
if (limitClause || orderClause) {
|
|
3265
|
+
const innerAlias = `${talias}i`;
|
|
3266
|
+
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
|
|
3267
|
+
`${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
3268
|
+
const innerJsonPairs = targetColumns.map((col) => [
|
|
3269
|
+
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
3270
|
+
`${innerAlias}.${this.q(col)}`,
|
|
3271
|
+
]);
|
|
3272
|
+
// Nested relations reference the inner alias.
|
|
3273
|
+
if (spec !== true && spec.with) {
|
|
3274
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3275
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3276
|
+
if (!nestedRelDef) {
|
|
3277
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3278
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3279
|
+
}
|
|
3280
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3281
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3282
|
+
? this.dialect.nullJsonLiteral
|
|
3283
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3284
|
+
innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
|
|
3288
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3289
|
+
}
|
|
3290
|
+
// Simple path: build the json object pairs directly off the target alias,
|
|
3291
|
+
// including any nested relations (correlated to the target alias).
|
|
3292
|
+
const jsonPairs = targetColumns.map((col) => [
|
|
3293
|
+
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
3294
|
+
`${talias}.${this.q(col)}`,
|
|
3295
|
+
]);
|
|
3296
|
+
if (spec !== true && spec.with) {
|
|
3297
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3298
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3299
|
+
if (!nestedRelDef) {
|
|
3300
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3301
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3302
|
+
}
|
|
3303
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3304
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3305
|
+
? this.dialect.nullJsonLiteral
|
|
3306
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3307
|
+
jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
const jsonObj = this.dialect.buildJsonObject(jsonPairs);
|
|
3311
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
|
|
3312
|
+
}
|
|
2782
3313
|
/**
|
|
2783
3314
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
2784
3315
|
* Used to detect JSONB/array columns for specialized operators.
|
|
@@ -2872,6 +3403,39 @@ export class QueryInterface {
|
|
|
2872
3403
|
}
|
|
2873
3404
|
return clauses;
|
|
2874
3405
|
}
|
|
3406
|
+
/**
|
|
3407
|
+
* Build SQL clauses for a pgvector distance WHERE filter:
|
|
3408
|
+
*
|
|
3409
|
+
* `"embedding" <-> $1::vector < $2`
|
|
3410
|
+
*
|
|
3411
|
+
* The query vector is bound as a `$n::vector` param (never interpolated), the
|
|
3412
|
+
* metric maps to an operator via a fixed allow-list, and each comparison
|
|
3413
|
+
* threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
|
|
3414
|
+
* per supplied comparator (all ANDed). Param push order matches
|
|
3415
|
+
* {@link collectVectorFilterParams}.
|
|
3416
|
+
*/
|
|
3417
|
+
buildVectorFilterClauses(field, rawColumn, filter, params) {
|
|
3418
|
+
const dist = filter.distance;
|
|
3419
|
+
const operator = this.vectorOperator(field, rawColumn, dist.metric);
|
|
3420
|
+
const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
3421
|
+
const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
|
|
3422
|
+
const clauses = [];
|
|
3423
|
+
for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
|
|
3424
|
+
const threshold = dist[cmp];
|
|
3425
|
+
if (threshold === undefined)
|
|
3426
|
+
continue;
|
|
3427
|
+
if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
|
|
3428
|
+
throw new ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
|
|
3429
|
+
`got ${JSON.stringify(threshold)}.`);
|
|
3430
|
+
}
|
|
3431
|
+
params.push(threshold);
|
|
3432
|
+
clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
|
|
3433
|
+
}
|
|
3434
|
+
if (clauses.length === 0) {
|
|
3435
|
+
throw new ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
|
|
3436
|
+
}
|
|
3437
|
+
return clauses;
|
|
3438
|
+
}
|
|
2875
3439
|
/**
|
|
2876
3440
|
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
2877
3441
|
* The config name is validated to prevent injection (only alphanumeric + underscore).
|