turbine-orm 0.15.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/index.js +64 -0
- package/dist/cjs/cli/observe-ui.js +182 -0
- package/dist/cjs/cli/observe.js +242 -0
- package/dist/cjs/cli/studio.js +5 -1
- package/dist/cjs/client.js +218 -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 +164 -10
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +604 -25
- 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/index.d.ts +1 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/observe-ui.d.ts +2 -0
- package/dist/cli/observe-ui.js +180 -0
- package/dist/cli/observe.d.ts +20 -0
- package/dist/cli/observe.js +237 -0
- package/dist/cli/studio.js +5 -1
- package/dist/client.d.ts +129 -2
- package/dist/client.js +220 -2
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.d.ts +2 -2
- package/dist/nested-write.js +164 -10
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +121 -1
- package/dist/query/builder.js +605 -26
- package/dist/query/index.d.ts +2 -2
- 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 +18 -16
|
@@ -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,10 +224,20 @@ 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. */
|
|
193
238
|
options;
|
|
239
|
+
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
240
|
+
currentAction = 'raw';
|
|
194
241
|
constructor(pool, table, schema, middlewares, options) {
|
|
195
242
|
this.pool = pool;
|
|
196
243
|
this.table = table;
|
|
@@ -272,12 +319,25 @@ class QueryInterface {
|
|
|
272
319
|
resetUnlimitedWarnings() {
|
|
273
320
|
this.warnedTables.clear();
|
|
274
321
|
}
|
|
322
|
+
emitQueryEvent(sql, params, duration, action, rows, error) {
|
|
323
|
+
const onQuery = this.options?._onQuery;
|
|
324
|
+
if (!onQuery)
|
|
325
|
+
return;
|
|
326
|
+
try {
|
|
327
|
+
onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Listener errors must never crash a query
|
|
331
|
+
}
|
|
332
|
+
}
|
|
275
333
|
/**
|
|
276
334
|
* Execute a pool.query with an optional timeout.
|
|
277
335
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
278
336
|
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
279
337
|
*/
|
|
280
338
|
async queryWithTimeout(sql, params, timeout, preparedName) {
|
|
339
|
+
const start = performance.now();
|
|
340
|
+
const action = this.currentAction;
|
|
281
341
|
// Build the query argument — use object form with `name` for prepared
|
|
282
342
|
// statements, or the plain (text, values) form otherwise.
|
|
283
343
|
const usePrepared = preparedName && this.preparedStatementsEnabled;
|
|
@@ -286,10 +346,14 @@ class QueryInterface {
|
|
|
286
346
|
: this.pool.query(sql, params);
|
|
287
347
|
if (!timeout) {
|
|
288
348
|
try {
|
|
289
|
-
|
|
349
|
+
const result = await exec;
|
|
350
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
351
|
+
return result;
|
|
290
352
|
}
|
|
291
353
|
catch (err) {
|
|
292
|
-
|
|
354
|
+
const wrapped = (0, errors_js_1.wrapPgError)(err);
|
|
355
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
356
|
+
throw wrapped;
|
|
293
357
|
}
|
|
294
358
|
}
|
|
295
359
|
let timer;
|
|
@@ -297,10 +361,14 @@ class QueryInterface {
|
|
|
297
361
|
timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
|
|
298
362
|
});
|
|
299
363
|
try {
|
|
300
|
-
|
|
364
|
+
const result = await Promise.race([exec, timeoutPromise]);
|
|
365
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
366
|
+
return result;
|
|
301
367
|
}
|
|
302
368
|
catch (err) {
|
|
303
|
-
|
|
369
|
+
const wrapped = (0, errors_js_1.wrapPgError)(err);
|
|
370
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
371
|
+
throw wrapped;
|
|
304
372
|
}
|
|
305
373
|
finally {
|
|
306
374
|
clearTimeout(timer);
|
|
@@ -316,6 +384,7 @@ class QueryInterface {
|
|
|
316
384
|
* To intercept queries before SQL generation, use the raw() method instead.
|
|
317
385
|
*/
|
|
318
386
|
async executeWithMiddleware(action, args, executor) {
|
|
387
|
+
this.currentAction = action;
|
|
319
388
|
if (this.middlewares.length === 0) {
|
|
320
389
|
return executor();
|
|
321
390
|
}
|
|
@@ -335,7 +404,6 @@ class QueryInterface {
|
|
|
335
404
|
// -------------------------------------------------------------------------
|
|
336
405
|
// findUnique
|
|
337
406
|
// -------------------------------------------------------------------------
|
|
338
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
339
407
|
async findUnique(args) {
|
|
340
408
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
341
409
|
const deferred = this.buildFindUnique(args);
|
|
@@ -439,7 +507,6 @@ class QueryInterface {
|
|
|
439
507
|
// -------------------------------------------------------------------------
|
|
440
508
|
// findMany
|
|
441
509
|
// -------------------------------------------------------------------------
|
|
442
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
443
510
|
async findMany(args) {
|
|
444
511
|
this.maybeWarnUnlimited(args);
|
|
445
512
|
// Dev-only: warn on deeply nested with clauses
|
|
@@ -507,7 +574,16 @@ class QueryInterface {
|
|
|
507
574
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
508
575
|
const orderFp = args?.orderBy
|
|
509
576
|
? Object.entries(args.orderBy)
|
|
510
|
-
.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
|
+
})
|
|
511
587
|
.join(',')
|
|
512
588
|
: '';
|
|
513
589
|
const cursorFp = args?.cursor
|
|
@@ -567,7 +643,9 @@ class QueryInterface {
|
|
|
567
643
|
}
|
|
568
644
|
}
|
|
569
645
|
if (args?.orderBy) {
|
|
570
|
-
|
|
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)}`;
|
|
571
649
|
}
|
|
572
650
|
if (effectiveLimit !== undefined) {
|
|
573
651
|
freshParams.push(Number(effectiveLimit));
|
|
@@ -595,11 +673,16 @@ class QueryInterface {
|
|
|
595
673
|
params.push(v);
|
|
596
674
|
}
|
|
597
675
|
}
|
|
598
|
-
// 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
|
|
599
682
|
if (effectiveLimit !== undefined) {
|
|
600
683
|
params.push(Number(effectiveLimit));
|
|
601
684
|
}
|
|
602
|
-
//
|
|
685
|
+
// 6. OFFSET param
|
|
603
686
|
if (args?.offset !== undefined) {
|
|
604
687
|
params.push(Number(args.offset));
|
|
605
688
|
}
|
|
@@ -641,7 +724,6 @@ class QueryInterface {
|
|
|
641
724
|
* }
|
|
642
725
|
* ```
|
|
643
726
|
*/
|
|
644
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
645
727
|
async *findManyStream(args) {
|
|
646
728
|
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
647
729
|
const hasRelations = !!args?.with;
|
|
@@ -650,6 +732,7 @@ class QueryInterface {
|
|
|
650
732
|
...args,
|
|
651
733
|
limit: batchSize + 1,
|
|
652
734
|
});
|
|
735
|
+
this.currentAction = 'findManyStream';
|
|
653
736
|
const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
|
|
654
737
|
if (speculativeResult.rows.length <= batchSize) {
|
|
655
738
|
// Small drain — yield all rows and return, no cursor needed
|
|
@@ -698,7 +781,6 @@ class QueryInterface {
|
|
|
698
781
|
// -------------------------------------------------------------------------
|
|
699
782
|
// findFirst — like findMany but returns a single row or null
|
|
700
783
|
// -------------------------------------------------------------------------
|
|
701
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
702
784
|
async findFirst(args) {
|
|
703
785
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
704
786
|
const deferred = this.buildFindFirst(args);
|
|
@@ -724,7 +806,6 @@ class QueryInterface {
|
|
|
724
806
|
// -------------------------------------------------------------------------
|
|
725
807
|
// findFirstOrThrow — like findFirst but throws if no record found
|
|
726
808
|
// -------------------------------------------------------------------------
|
|
727
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
728
809
|
async findFirstOrThrow(args) {
|
|
729
810
|
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
730
811
|
const deferred = this.buildFindFirstOrThrow(args);
|
|
@@ -755,7 +836,6 @@ class QueryInterface {
|
|
|
755
836
|
// -------------------------------------------------------------------------
|
|
756
837
|
// findUniqueOrThrow — like findUnique but throws if no record found
|
|
757
838
|
// -------------------------------------------------------------------------
|
|
758
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
759
839
|
async findUniqueOrThrow(args) {
|
|
760
840
|
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
761
841
|
const deferred = this.buildFindUniqueOrThrow(args);
|
|
@@ -1293,6 +1373,15 @@ class QueryInterface {
|
|
|
1293
1373
|
}
|
|
1294
1374
|
}
|
|
1295
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
|
+
}
|
|
1296
1385
|
// ORDER BY
|
|
1297
1386
|
if (args.orderBy) {
|
|
1298
1387
|
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
@@ -1360,6 +1449,117 @@ class QueryInterface {
|
|
|
1360
1449
|
tag: `${this.table}.groupBy`,
|
|
1361
1450
|
};
|
|
1362
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
|
+
}
|
|
1363
1563
|
// -------------------------------------------------------------------------
|
|
1364
1564
|
// aggregate — standalone aggregation without groupBy
|
|
1365
1565
|
// -------------------------------------------------------------------------
|
|
@@ -1669,7 +1869,11 @@ class QueryInterface {
|
|
|
1669
1869
|
const relDef = this.tableMeta.relations[key];
|
|
1670
1870
|
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1671
1871
|
const filterObj = value;
|
|
1672
|
-
if ('some' in filterObj ||
|
|
1872
|
+
if ('some' in filterObj ||
|
|
1873
|
+
'every' in filterObj ||
|
|
1874
|
+
'none' in filterObj ||
|
|
1875
|
+
'is' in filterObj ||
|
|
1876
|
+
'isNot' in filterObj) {
|
|
1673
1877
|
const relParts = [];
|
|
1674
1878
|
if (filterObj.some !== undefined)
|
|
1675
1879
|
relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
|
|
@@ -1677,6 +1881,10 @@ class QueryInterface {
|
|
|
1677
1881
|
relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
|
|
1678
1882
|
if (filterObj.none !== undefined)
|
|
1679
1883
|
relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
|
|
1884
|
+
if (filterObj.is !== undefined)
|
|
1885
|
+
relParts.push(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
|
|
1886
|
+
if (filterObj.isNot !== undefined)
|
|
1887
|
+
relParts.push(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
|
|
1680
1888
|
parts.push(`${key}:{${relParts.join(',')}}`);
|
|
1681
1889
|
continue;
|
|
1682
1890
|
}
|
|
@@ -1696,6 +1904,17 @@ class QueryInterface {
|
|
|
1696
1904
|
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1697
1905
|
continue;
|
|
1698
1906
|
}
|
|
1907
|
+
// Vector distance filter — metric (operator) and present comparators
|
|
1908
|
+
// change the SQL shape, so both go in the fingerprint.
|
|
1909
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
1910
|
+
const dist = value.distance;
|
|
1911
|
+
const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
|
|
1912
|
+
.filter((c) => dist[c] !== undefined)
|
|
1913
|
+
.sort()
|
|
1914
|
+
.join('|');
|
|
1915
|
+
parts.push(`${key}:vec(${dist.metric},${cmps})`);
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1699
1918
|
// JSON filter
|
|
1700
1919
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1701
1920
|
const jKeys = Object.keys(value).sort();
|
|
@@ -1798,13 +2017,21 @@ class QueryInterface {
|
|
|
1798
2017
|
const relationDef = this.tableMeta.relations[key];
|
|
1799
2018
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1800
2019
|
const filterObj = value;
|
|
1801
|
-
if ('some' in filterObj ||
|
|
2020
|
+
if ('some' in filterObj ||
|
|
2021
|
+
'every' in filterObj ||
|
|
2022
|
+
'none' in filterObj ||
|
|
2023
|
+
'is' in filterObj ||
|
|
2024
|
+
'isNot' in filterObj) {
|
|
1802
2025
|
if (filterObj.some !== undefined)
|
|
1803
2026
|
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
1804
2027
|
if (filterObj.none !== undefined)
|
|
1805
2028
|
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
1806
2029
|
if (filterObj.every !== undefined)
|
|
1807
2030
|
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
2031
|
+
if (filterObj.is !== undefined)
|
|
2032
|
+
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
2033
|
+
if (filterObj.isNot !== undefined)
|
|
2034
|
+
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
1808
2035
|
continue;
|
|
1809
2036
|
}
|
|
1810
2037
|
}
|
|
@@ -1812,6 +2039,14 @@ class QueryInterface {
|
|
|
1812
2039
|
if (value === null)
|
|
1813
2040
|
continue;
|
|
1814
2041
|
const rawColumn = this.toColumn(key);
|
|
2042
|
+
// Vector distance filter — mirrors buildVectorFilterClauses push order.
|
|
2043
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2044
|
+
// Validate the same way the build path does so the collect path never
|
|
2045
|
+
// diverges (it would throw before any param was pushed).
|
|
2046
|
+
this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
2047
|
+
this.collectVectorFilterParams(key, rawColumn, value, params);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
1815
2050
|
// JSONB filter
|
|
1816
2051
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1817
2052
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -1908,6 +2143,37 @@ class QueryInterface {
|
|
|
1908
2143
|
params.push(filter.hasSome);
|
|
1909
2144
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
1910
2145
|
}
|
|
2146
|
+
/**
|
|
2147
|
+
* Collect params for an orderBy clause. Only vector KNN ordering pushes a
|
|
2148
|
+
* param (the `$n::vector` query vector); plain direction ordering is
|
|
2149
|
+
* parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
|
|
2150
|
+
* param re-collection stays in lockstep.
|
|
2151
|
+
*/
|
|
2152
|
+
collectOrderByParams(orderBy, params) {
|
|
2153
|
+
for (const [key, dir] of Object.entries(orderBy)) {
|
|
2154
|
+
if (isVectorOrderBy(dir)) {
|
|
2155
|
+
const rawColumn = this.toColumn(key);
|
|
2156
|
+
// Re-run the same validation as buildOrderBy so the collect path can
|
|
2157
|
+
// never push a param that the build path rejected (or vice versa).
|
|
2158
|
+
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2159
|
+
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Collect params for a vector distance WHERE filter. Mirrors
|
|
2165
|
+
* {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
|
|
2166
|
+
* the comparison threshold(s).
|
|
2167
|
+
*/
|
|
2168
|
+
collectVectorFilterParams(field, rawColumn, filter, params) {
|
|
2169
|
+
const dist = filter.distance;
|
|
2170
|
+
this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
2171
|
+
for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
|
|
2172
|
+
const threshold = dist[cmp];
|
|
2173
|
+
if (threshold !== undefined)
|
|
2174
|
+
params.push(threshold);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
1911
2177
|
/**
|
|
1912
2178
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
1913
2179
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -2004,6 +2270,27 @@ class QueryInterface {
|
|
|
2004
2270
|
const targetMeta = this.schema.tables[targetTable];
|
|
2005
2271
|
if (!targetMeta)
|
|
2006
2272
|
return;
|
|
2273
|
+
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2274
|
+
// where params → limit param → nested-with params (always, both paths).
|
|
2275
|
+
if (relDef.type === 'manyToMany') {
|
|
2276
|
+
if (spec.where) {
|
|
2277
|
+
for (const [, v] of Object.entries(spec.where)) {
|
|
2278
|
+
params.push(v);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
if (spec.limit) {
|
|
2282
|
+
params.push(Number(spec.limit));
|
|
2283
|
+
}
|
|
2284
|
+
if (spec.with) {
|
|
2285
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
2286
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2287
|
+
if (!nestedRelDef)
|
|
2288
|
+
continue;
|
|
2289
|
+
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2007
2294
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
2008
2295
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2009
2296
|
if (!willWrap && spec.with) {
|
|
@@ -2157,7 +2444,11 @@ class QueryInterface {
|
|
|
2157
2444
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2158
2445
|
const filterObj = value;
|
|
2159
2446
|
// Check if this is a relation filter (has some/every/none keys)
|
|
2160
|
-
if ('some' in filterObj ||
|
|
2447
|
+
if ('some' in filterObj ||
|
|
2448
|
+
'every' in filterObj ||
|
|
2449
|
+
'none' in filterObj ||
|
|
2450
|
+
'is' in filterObj ||
|
|
2451
|
+
'isNot' in filterObj) {
|
|
2161
2452
|
const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
|
|
2162
2453
|
if (relClause)
|
|
2163
2454
|
andClauses.push(relClause);
|
|
@@ -2171,6 +2462,12 @@ class QueryInterface {
|
|
|
2171
2462
|
andClauses.push(`${column} IS NULL`);
|
|
2172
2463
|
continue;
|
|
2173
2464
|
}
|
|
2465
|
+
// Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
|
|
2466
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2467
|
+
const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
|
|
2468
|
+
andClauses.push(...vecClauses);
|
|
2469
|
+
continue;
|
|
2470
|
+
}
|
|
2174
2471
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
2175
2472
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2176
2473
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -2274,6 +2571,20 @@ class QueryInterface {
|
|
|
2274
2571
|
// "every" with empty filter = true (all match trivially)
|
|
2275
2572
|
}
|
|
2276
2573
|
}
|
|
2574
|
+
// "is": EXISTS — for to-one relations (same SQL as "some")
|
|
2575
|
+
if (filterObj.is !== undefined) {
|
|
2576
|
+
const subWhere = filterObj.is;
|
|
2577
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2578
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2579
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2580
|
+
}
|
|
2581
|
+
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
|
|
2582
|
+
if (filterObj.isNot !== undefined) {
|
|
2583
|
+
const subWhere = filterObj.isNot;
|
|
2584
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2585
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2586
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2587
|
+
}
|
|
2277
2588
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2278
2589
|
}
|
|
2279
2590
|
/**
|
|
@@ -2363,8 +2674,17 @@ class QueryInterface {
|
|
|
2363
2674
|
}
|
|
2364
2675
|
return clauses;
|
|
2365
2676
|
}
|
|
2366
|
-
/**
|
|
2367
|
-
|
|
2677
|
+
/**
|
|
2678
|
+
* Build ORDER BY clause from an object.
|
|
2679
|
+
*
|
|
2680
|
+
* Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
|
|
2681
|
+
* columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
|
|
2682
|
+
* Vector ordering binds the query vector as a `$n::vector` param, so a `params`
|
|
2683
|
+
* array MUST be supplied when a vector ordering may be present (top-level
|
|
2684
|
+
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
2685
|
+
* ordering throws — KNN ordering is only supported at the top level.
|
|
2686
|
+
*/
|
|
2687
|
+
buildOrderBy(orderBy, params) {
|
|
2368
2688
|
// Dev-only: validate that orderBy fields exist in the table schema
|
|
2369
2689
|
if (process.env.NODE_ENV !== 'production') {
|
|
2370
2690
|
for (const key of Object.keys(orderBy)) {
|
|
@@ -2381,12 +2701,85 @@ class QueryInterface {
|
|
|
2381
2701
|
if (meta && !(key in meta.columnMap)) {
|
|
2382
2702
|
throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
|
|
2383
2703
|
}
|
|
2704
|
+
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
2705
|
+
if (isVectorOrderBy(dir)) {
|
|
2706
|
+
if (!params) {
|
|
2707
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
2708
|
+
}
|
|
2709
|
+
const rawColumn = this.toColumn(key);
|
|
2710
|
+
const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2711
|
+
const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2712
|
+
const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2713
|
+
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
2714
|
+
}
|
|
2384
2715
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2385
2716
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
2386
2717
|
})
|
|
2387
2718
|
.join(', ');
|
|
2388
2719
|
}
|
|
2720
|
+
// -------------------------------------------------------------------------
|
|
2721
|
+
// pgvector helpers (similarity search)
|
|
2722
|
+
// -------------------------------------------------------------------------
|
|
2723
|
+
/**
|
|
2724
|
+
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
2725
|
+
* fixed allow-list, validating the target column is actually a `vector`
|
|
2726
|
+
* column. Throws {@link ValidationError} for an unknown metric or a
|
|
2727
|
+
* non-vector column — a user-supplied string can never become a SQL operator.
|
|
2728
|
+
*/
|
|
2729
|
+
vectorOperator(field, rawColumn, metric) {
|
|
2730
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
2731
|
+
if (colType !== 'vector') {
|
|
2732
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
|
|
2733
|
+
`(actual type: ${colType}); cannot apply a vector distance operation.`);
|
|
2734
|
+
}
|
|
2735
|
+
const op = VECTOR_METRIC_OPERATORS[metric];
|
|
2736
|
+
if (!op) {
|
|
2737
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
|
|
2738
|
+
`Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
|
|
2739
|
+
}
|
|
2740
|
+
return op;
|
|
2741
|
+
}
|
|
2742
|
+
/**
|
|
2743
|
+
* Validate and bind a query vector as a single `$n::vector` parameter.
|
|
2744
|
+
* Every element must be a finite number (no NaN / Infinity / strings) so a
|
|
2745
|
+
* malformed array can never produce a broken `::vector` literal, and the array
|
|
2746
|
+
* is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
|
|
2747
|
+
* placeholder string.
|
|
2748
|
+
*/
|
|
2749
|
+
pushVectorParam(field, _rawColumn, to, params) {
|
|
2750
|
+
if (!Array.isArray(to) || to.length === 0) {
|
|
2751
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
|
|
2752
|
+
}
|
|
2753
|
+
for (const el of to) {
|
|
2754
|
+
if (typeof el !== 'number' || !Number.isFinite(el)) {
|
|
2755
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
|
|
2756
|
+
`got ${JSON.stringify(el)}.`);
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
// Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
|
|
2760
|
+
// as finite numbers, so the joined string is safe; it is still passed as a
|
|
2761
|
+
// bound param (never interpolated) and cast with ::vector.
|
|
2762
|
+
params.push(`[${to.join(',')}]`);
|
|
2763
|
+
return `${this.p(params.length)}::vector`;
|
|
2764
|
+
}
|
|
2389
2765
|
/** Parse a flat row: convert snake_case to camelCase + Date coercion */
|
|
2766
|
+
/**
|
|
2767
|
+
* Returns the set of camelCase field names for a table's date columns,
|
|
2768
|
+
* derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
|
|
2769
|
+
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
2770
|
+
* dates the same way top-level rows do.
|
|
2771
|
+
*/
|
|
2772
|
+
getCamelDateFields(table, meta) {
|
|
2773
|
+
let camel = this.camelDateFieldCache.get(table);
|
|
2774
|
+
if (!camel) {
|
|
2775
|
+
camel = new Set();
|
|
2776
|
+
for (const col of meta.dateColumns) {
|
|
2777
|
+
camel.add(meta.reverseColumnMap[col] ?? col);
|
|
2778
|
+
}
|
|
2779
|
+
this.camelDateFieldCache.set(table, camel);
|
|
2780
|
+
}
|
|
2781
|
+
return camel;
|
|
2782
|
+
}
|
|
2390
2783
|
parseRow(row, table) {
|
|
2391
2784
|
const parsed = {};
|
|
2392
2785
|
const meta = this.schema.tables[table];
|
|
@@ -2394,12 +2787,16 @@ class QueryInterface {
|
|
|
2394
2787
|
// Fast path: use pre-computed maps (avoids regex per column per row)
|
|
2395
2788
|
const reverseMap = meta.reverseColumnMap;
|
|
2396
2789
|
const dateCols = meta.dateColumns;
|
|
2790
|
+
// camelCase-keyed date fields, so nested json_build_object rows (whose
|
|
2791
|
+
// keys are already camelCase) get the same Date coercion as top-level rows.
|
|
2792
|
+
const camelDateFields = this.getCamelDateFields(table, meta);
|
|
2397
2793
|
const keys = Object.keys(row);
|
|
2398
2794
|
for (let i = 0; i < keys.length; i++) {
|
|
2399
2795
|
const col = keys[i];
|
|
2400
2796
|
const value = row[col];
|
|
2401
2797
|
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
2402
|
-
|
|
2798
|
+
// Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
|
|
2799
|
+
if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
|
|
2403
2800
|
parsed[field] = new Date(value);
|
|
2404
2801
|
}
|
|
2405
2802
|
else {
|
|
@@ -2443,14 +2840,15 @@ class QueryInterface {
|
|
|
2443
2840
|
if (typeof rawValue === 'string') {
|
|
2444
2841
|
try {
|
|
2445
2842
|
const jsonVal = JSON.parse(rawValue);
|
|
2446
|
-
// After parsing,
|
|
2843
|
+
// After parsing, recurse via parseNestedRow so each item gets date
|
|
2844
|
+
// coercion AND its own sub-relations parsed at arbitrary depth.
|
|
2447
2845
|
if (Array.isArray(jsonVal)) {
|
|
2448
2846
|
parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
|
|
2449
|
-
? this.
|
|
2847
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2450
2848
|
: item);
|
|
2451
2849
|
}
|
|
2452
2850
|
else if (typeof jsonVal === 'object' && jsonVal !== null) {
|
|
2453
|
-
parsed[relName] = this.
|
|
2851
|
+
parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
|
|
2454
2852
|
}
|
|
2455
2853
|
else {
|
|
2456
2854
|
parsed[relName] = jsonVal;
|
|
@@ -2462,10 +2860,12 @@ class QueryInterface {
|
|
|
2462
2860
|
}
|
|
2463
2861
|
}
|
|
2464
2862
|
else if (Array.isArray(rawValue)) {
|
|
2465
|
-
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2863
|
+
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2864
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2865
|
+
: item);
|
|
2466
2866
|
}
|
|
2467
2867
|
else if (typeof rawValue === 'object' && rawValue !== null) {
|
|
2468
|
-
parsed[relName] = this.
|
|
2868
|
+
parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
|
|
2469
2869
|
}
|
|
2470
2870
|
else {
|
|
2471
2871
|
parsed[relName] = rawValue;
|
|
@@ -2666,6 +3066,12 @@ class QueryInterface {
|
|
|
2666
3066
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
2667
3067
|
// so we must NOT build them here (they would push orphaned params).
|
|
2668
3068
|
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
3069
|
+
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3070
|
+
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3071
|
+
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
3072
|
+
if (relDef.type === 'manyToMany') {
|
|
3073
|
+
return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
|
|
3074
|
+
}
|
|
2669
3075
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
2670
3076
|
if (!willWrap && spec !== true && spec.with) {
|
|
2671
3077
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -2762,6 +3168,146 @@ class QueryInterface {
|
|
|
2762
3168
|
// belongsTo / hasOne — return single object
|
|
2763
3169
|
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
|
|
2764
3170
|
}
|
|
3171
|
+
/**
|
|
3172
|
+
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
3173
|
+
* table through a junction (join) table.
|
|
3174
|
+
*
|
|
3175
|
+
* Shape (no LIMIT/ORDER):
|
|
3176
|
+
* ```sql
|
|
3177
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
3178
|
+
* FROM <target> <talias>
|
|
3179
|
+
* JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
|
|
3180
|
+
* WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
|
|
3181
|
+
* ```
|
|
3182
|
+
*
|
|
3183
|
+
* With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
|
|
3184
|
+
* applies BEFORE aggregation (identical strategy to hasMany).
|
|
3185
|
+
*
|
|
3186
|
+
* Cardinality is always 'many' → empty-array fallback, never NULL.
|
|
3187
|
+
*
|
|
3188
|
+
* IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
|
|
3189
|
+
* {@link collectRelationSubqueryParams} or pipeline batching will desync.
|
|
3190
|
+
*/
|
|
3191
|
+
buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
|
|
3192
|
+
if (!relDef.through) {
|
|
3193
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
|
|
3194
|
+
}
|
|
3195
|
+
const targetTable = relDef.to;
|
|
3196
|
+
const qTarget = this.q(targetTable);
|
|
3197
|
+
const qJunction = this.q(relDef.through.table);
|
|
3198
|
+
const qParent = this.q(parentRef);
|
|
3199
|
+
const jalias = `${talias}j`; // junction alias, distinct from the target alias
|
|
3200
|
+
// JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
|
|
3201
|
+
const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
|
|
3202
|
+
// The target PK is the column(s) the junction's targetKey references. An empty
|
|
3203
|
+
// introspected PK means we cannot know what to JOIN on — fail loudly rather than
|
|
3204
|
+
// silently guessing `id` and generating a wrong JOIN.
|
|
3205
|
+
if (targetMeta.primaryKey.length === 0) {
|
|
3206
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
|
|
3207
|
+
`cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
|
|
3208
|
+
}
|
|
3209
|
+
const targetPk = targetMeta.primaryKey;
|
|
3210
|
+
if (targetKeys.length !== targetPk.length) {
|
|
3211
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
|
|
3212
|
+
`but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
|
|
3213
|
+
}
|
|
3214
|
+
const joinOn = targetKeys
|
|
3215
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
|
|
3216
|
+
.join(' AND ');
|
|
3217
|
+
// Correlation: junction.sourceKey = parent.<referenceKey>.
|
|
3218
|
+
const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
|
|
3219
|
+
const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
|
|
3220
|
+
if (sourceKeys.length !== refKeys.length) {
|
|
3221
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
|
|
3222
|
+
`but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
|
|
3223
|
+
}
|
|
3224
|
+
let whereClause = sourceKeys
|
|
3225
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3226
|
+
.join(' AND ');
|
|
3227
|
+
// ORDER BY on the target rows
|
|
3228
|
+
let orderClause = '';
|
|
3229
|
+
if (spec !== true && spec.orderBy) {
|
|
3230
|
+
const orders = Object.entries(spec.orderBy)
|
|
3231
|
+
.map(([k, dir]) => {
|
|
3232
|
+
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3233
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3234
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3235
|
+
}
|
|
3236
|
+
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3237
|
+
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3238
|
+
})
|
|
3239
|
+
.join(', ');
|
|
3240
|
+
orderClause = ` ORDER BY ${orders}`;
|
|
3241
|
+
}
|
|
3242
|
+
// Additional WHERE filters on the target — properly parameterized.
|
|
3243
|
+
if (spec !== true && spec.where) {
|
|
3244
|
+
for (const [k, v] of Object.entries(spec.where)) {
|
|
3245
|
+
const col = (0, schema_js_1.camelToSnake)(k);
|
|
3246
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3247
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3248
|
+
}
|
|
3249
|
+
params.push(v);
|
|
3250
|
+
whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
// LIMIT
|
|
3254
|
+
let limitClause = '';
|
|
3255
|
+
if (spec !== true && spec.limit) {
|
|
3256
|
+
params.push(Number(spec.limit));
|
|
3257
|
+
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3258
|
+
}
|
|
3259
|
+
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
3260
|
+
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
3261
|
+
// so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
|
|
3262
|
+
if (limitClause || orderClause) {
|
|
3263
|
+
const innerAlias = `${talias}i`;
|
|
3264
|
+
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
|
|
3265
|
+
`${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
3266
|
+
const innerJsonPairs = targetColumns.map((col) => [
|
|
3267
|
+
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
3268
|
+
`${innerAlias}.${this.q(col)}`,
|
|
3269
|
+
]);
|
|
3270
|
+
// Nested relations reference the inner alias.
|
|
3271
|
+
if (spec !== true && spec.with) {
|
|
3272
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3273
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3274
|
+
if (!nestedRelDef) {
|
|
3275
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3276
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3277
|
+
}
|
|
3278
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3279
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3280
|
+
? this.dialect.nullJsonLiteral
|
|
3281
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3282
|
+
innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
|
|
3286
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3287
|
+
}
|
|
3288
|
+
// Simple path: build the json object pairs directly off the target alias,
|
|
3289
|
+
// including any nested relations (correlated to the target alias).
|
|
3290
|
+
const jsonPairs = targetColumns.map((col) => [
|
|
3291
|
+
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
3292
|
+
`${talias}.${this.q(col)}`,
|
|
3293
|
+
]);
|
|
3294
|
+
if (spec !== true && spec.with) {
|
|
3295
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3296
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3297
|
+
if (!nestedRelDef) {
|
|
3298
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3299
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3300
|
+
}
|
|
3301
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3302
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3303
|
+
? this.dialect.nullJsonLiteral
|
|
3304
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3305
|
+
jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
const jsonObj = this.dialect.buildJsonObject(jsonPairs);
|
|
3309
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
|
|
3310
|
+
}
|
|
2765
3311
|
/**
|
|
2766
3312
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
2767
3313
|
* Used to detect JSONB/array columns for specialized operators.
|
|
@@ -2855,6 +3401,39 @@ class QueryInterface {
|
|
|
2855
3401
|
}
|
|
2856
3402
|
return clauses;
|
|
2857
3403
|
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Build SQL clauses for a pgvector distance WHERE filter:
|
|
3406
|
+
*
|
|
3407
|
+
* `"embedding" <-> $1::vector < $2`
|
|
3408
|
+
*
|
|
3409
|
+
* The query vector is bound as a `$n::vector` param (never interpolated), the
|
|
3410
|
+
* metric maps to an operator via a fixed allow-list, and each comparison
|
|
3411
|
+
* threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
|
|
3412
|
+
* per supplied comparator (all ANDed). Param push order matches
|
|
3413
|
+
* {@link collectVectorFilterParams}.
|
|
3414
|
+
*/
|
|
3415
|
+
buildVectorFilterClauses(field, rawColumn, filter, params) {
|
|
3416
|
+
const dist = filter.distance;
|
|
3417
|
+
const operator = this.vectorOperator(field, rawColumn, dist.metric);
|
|
3418
|
+
const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
3419
|
+
const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
|
|
3420
|
+
const clauses = [];
|
|
3421
|
+
for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
|
|
3422
|
+
const threshold = dist[cmp];
|
|
3423
|
+
if (threshold === undefined)
|
|
3424
|
+
continue;
|
|
3425
|
+
if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
|
|
3426
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
|
|
3427
|
+
`got ${JSON.stringify(threshold)}.`);
|
|
3428
|
+
}
|
|
3429
|
+
params.push(threshold);
|
|
3430
|
+
clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
|
|
3431
|
+
}
|
|
3432
|
+
if (clauses.length === 0) {
|
|
3433
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
|
|
3434
|
+
}
|
|
3435
|
+
return clauses;
|
|
3436
|
+
}
|
|
2858
3437
|
/**
|
|
2859
3438
|
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
2860
3439
|
* The config name is validated to prevent injection (only alphanumeric + underscore).
|