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
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,10 +188,20 @@ 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. */
|
|
157
202
|
options;
|
|
203
|
+
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
204
|
+
currentAction = 'raw';
|
|
158
205
|
constructor(pool, table, schema, middlewares, options) {
|
|
159
206
|
this.pool = pool;
|
|
160
207
|
this.table = table;
|
|
@@ -236,12 +283,25 @@ export class QueryInterface {
|
|
|
236
283
|
resetUnlimitedWarnings() {
|
|
237
284
|
this.warnedTables.clear();
|
|
238
285
|
}
|
|
286
|
+
emitQueryEvent(sql, params, duration, action, rows, error) {
|
|
287
|
+
const onQuery = this.options?._onQuery;
|
|
288
|
+
if (!onQuery)
|
|
289
|
+
return;
|
|
290
|
+
try {
|
|
291
|
+
onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Listener errors must never crash a query
|
|
295
|
+
}
|
|
296
|
+
}
|
|
239
297
|
/**
|
|
240
298
|
* Execute a pool.query with an optional timeout.
|
|
241
299
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
242
300
|
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
243
301
|
*/
|
|
244
302
|
async queryWithTimeout(sql, params, timeout, preparedName) {
|
|
303
|
+
const start = performance.now();
|
|
304
|
+
const action = this.currentAction;
|
|
245
305
|
// Build the query argument — use object form with `name` for prepared
|
|
246
306
|
// statements, or the plain (text, values) form otherwise.
|
|
247
307
|
const usePrepared = preparedName && this.preparedStatementsEnabled;
|
|
@@ -250,10 +310,14 @@ export class QueryInterface {
|
|
|
250
310
|
: this.pool.query(sql, params);
|
|
251
311
|
if (!timeout) {
|
|
252
312
|
try {
|
|
253
|
-
|
|
313
|
+
const result = await exec;
|
|
314
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
315
|
+
return result;
|
|
254
316
|
}
|
|
255
317
|
catch (err) {
|
|
256
|
-
|
|
318
|
+
const wrapped = wrapPgError(err);
|
|
319
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
320
|
+
throw wrapped;
|
|
257
321
|
}
|
|
258
322
|
}
|
|
259
323
|
let timer;
|
|
@@ -261,10 +325,14 @@ export class QueryInterface {
|
|
|
261
325
|
timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
|
|
262
326
|
});
|
|
263
327
|
try {
|
|
264
|
-
|
|
328
|
+
const result = await Promise.race([exec, timeoutPromise]);
|
|
329
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
330
|
+
return result;
|
|
265
331
|
}
|
|
266
332
|
catch (err) {
|
|
267
|
-
|
|
333
|
+
const wrapped = wrapPgError(err);
|
|
334
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
335
|
+
throw wrapped;
|
|
268
336
|
}
|
|
269
337
|
finally {
|
|
270
338
|
clearTimeout(timer);
|
|
@@ -280,6 +348,7 @@ export class QueryInterface {
|
|
|
280
348
|
* To intercept queries before SQL generation, use the raw() method instead.
|
|
281
349
|
*/
|
|
282
350
|
async executeWithMiddleware(action, args, executor) {
|
|
351
|
+
this.currentAction = action;
|
|
283
352
|
if (this.middlewares.length === 0) {
|
|
284
353
|
return executor();
|
|
285
354
|
}
|
|
@@ -299,7 +368,6 @@ export class QueryInterface {
|
|
|
299
368
|
// -------------------------------------------------------------------------
|
|
300
369
|
// findUnique
|
|
301
370
|
// -------------------------------------------------------------------------
|
|
302
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
303
371
|
async findUnique(args) {
|
|
304
372
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
305
373
|
const deferred = this.buildFindUnique(args);
|
|
@@ -403,7 +471,6 @@ export class QueryInterface {
|
|
|
403
471
|
// -------------------------------------------------------------------------
|
|
404
472
|
// findMany
|
|
405
473
|
// -------------------------------------------------------------------------
|
|
406
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
407
474
|
async findMany(args) {
|
|
408
475
|
this.maybeWarnUnlimited(args);
|
|
409
476
|
// Dev-only: warn on deeply nested with clauses
|
|
@@ -471,7 +538,16 @@ export class QueryInterface {
|
|
|
471
538
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
472
539
|
const orderFp = args?.orderBy
|
|
473
540
|
? Object.entries(args.orderBy)
|
|
474
|
-
.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
|
+
})
|
|
475
551
|
.join(',')
|
|
476
552
|
: '';
|
|
477
553
|
const cursorFp = args?.cursor
|
|
@@ -531,7 +607,9 @@ export class QueryInterface {
|
|
|
531
607
|
}
|
|
532
608
|
}
|
|
533
609
|
if (args?.orderBy) {
|
|
534
|
-
|
|
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)}`;
|
|
535
613
|
}
|
|
536
614
|
if (effectiveLimit !== undefined) {
|
|
537
615
|
freshParams.push(Number(effectiveLimit));
|
|
@@ -559,11 +637,16 @@ export class QueryInterface {
|
|
|
559
637
|
params.push(v);
|
|
560
638
|
}
|
|
561
639
|
}
|
|
562
|
-
// 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
|
|
563
646
|
if (effectiveLimit !== undefined) {
|
|
564
647
|
params.push(Number(effectiveLimit));
|
|
565
648
|
}
|
|
566
|
-
//
|
|
649
|
+
// 6. OFFSET param
|
|
567
650
|
if (args?.offset !== undefined) {
|
|
568
651
|
params.push(Number(args.offset));
|
|
569
652
|
}
|
|
@@ -605,7 +688,6 @@ export class QueryInterface {
|
|
|
605
688
|
* }
|
|
606
689
|
* ```
|
|
607
690
|
*/
|
|
608
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
609
691
|
async *findManyStream(args) {
|
|
610
692
|
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
611
693
|
const hasRelations = !!args?.with;
|
|
@@ -614,6 +696,7 @@ export class QueryInterface {
|
|
|
614
696
|
...args,
|
|
615
697
|
limit: batchSize + 1,
|
|
616
698
|
});
|
|
699
|
+
this.currentAction = 'findManyStream';
|
|
617
700
|
const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
|
|
618
701
|
if (speculativeResult.rows.length <= batchSize) {
|
|
619
702
|
// Small drain — yield all rows and return, no cursor needed
|
|
@@ -662,7 +745,6 @@ export class QueryInterface {
|
|
|
662
745
|
// -------------------------------------------------------------------------
|
|
663
746
|
// findFirst — like findMany but returns a single row or null
|
|
664
747
|
// -------------------------------------------------------------------------
|
|
665
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
666
748
|
async findFirst(args) {
|
|
667
749
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
668
750
|
const deferred = this.buildFindFirst(args);
|
|
@@ -688,7 +770,6 @@ export class QueryInterface {
|
|
|
688
770
|
// -------------------------------------------------------------------------
|
|
689
771
|
// findFirstOrThrow — like findFirst but throws if no record found
|
|
690
772
|
// -------------------------------------------------------------------------
|
|
691
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
692
773
|
async findFirstOrThrow(args) {
|
|
693
774
|
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
694
775
|
const deferred = this.buildFindFirstOrThrow(args);
|
|
@@ -719,7 +800,6 @@ export class QueryInterface {
|
|
|
719
800
|
// -------------------------------------------------------------------------
|
|
720
801
|
// findUniqueOrThrow — like findUnique but throws if no record found
|
|
721
802
|
// -------------------------------------------------------------------------
|
|
722
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
723
803
|
async findUniqueOrThrow(args) {
|
|
724
804
|
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
725
805
|
const deferred = this.buildFindUniqueOrThrow(args);
|
|
@@ -1257,6 +1337,15 @@ export class QueryInterface {
|
|
|
1257
1337
|
}
|
|
1258
1338
|
}
|
|
1259
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
|
+
}
|
|
1260
1349
|
// ORDER BY
|
|
1261
1350
|
if (args.orderBy) {
|
|
1262
1351
|
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
@@ -1324,6 +1413,117 @@ export class QueryInterface {
|
|
|
1324
1413
|
tag: `${this.table}.groupBy`,
|
|
1325
1414
|
};
|
|
1326
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
|
+
}
|
|
1327
1527
|
// -------------------------------------------------------------------------
|
|
1328
1528
|
// aggregate — standalone aggregation without groupBy
|
|
1329
1529
|
// -------------------------------------------------------------------------
|
|
@@ -1633,7 +1833,11 @@ export class QueryInterface {
|
|
|
1633
1833
|
const relDef = this.tableMeta.relations[key];
|
|
1634
1834
|
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1635
1835
|
const filterObj = value;
|
|
1636
|
-
if ('some' in filterObj ||
|
|
1836
|
+
if ('some' in filterObj ||
|
|
1837
|
+
'every' in filterObj ||
|
|
1838
|
+
'none' in filterObj ||
|
|
1839
|
+
'is' in filterObj ||
|
|
1840
|
+
'isNot' in filterObj) {
|
|
1637
1841
|
const relParts = [];
|
|
1638
1842
|
if (filterObj.some !== undefined)
|
|
1639
1843
|
relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
|
|
@@ -1641,6 +1845,10 @@ export class QueryInterface {
|
|
|
1641
1845
|
relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
|
|
1642
1846
|
if (filterObj.none !== undefined)
|
|
1643
1847
|
relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
|
|
1848
|
+
if (filterObj.is !== undefined)
|
|
1849
|
+
relParts.push(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
|
|
1850
|
+
if (filterObj.isNot !== undefined)
|
|
1851
|
+
relParts.push(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
|
|
1644
1852
|
parts.push(`${key}:{${relParts.join(',')}}`);
|
|
1645
1853
|
continue;
|
|
1646
1854
|
}
|
|
@@ -1660,6 +1868,17 @@ export class QueryInterface {
|
|
|
1660
1868
|
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1661
1869
|
continue;
|
|
1662
1870
|
}
|
|
1871
|
+
// Vector distance filter — metric (operator) and present comparators
|
|
1872
|
+
// change the SQL shape, so both go in the fingerprint.
|
|
1873
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
1874
|
+
const dist = value.distance;
|
|
1875
|
+
const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
|
|
1876
|
+
.filter((c) => dist[c] !== undefined)
|
|
1877
|
+
.sort()
|
|
1878
|
+
.join('|');
|
|
1879
|
+
parts.push(`${key}:vec(${dist.metric},${cmps})`);
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1663
1882
|
// JSON filter
|
|
1664
1883
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1665
1884
|
const jKeys = Object.keys(value).sort();
|
|
@@ -1762,13 +1981,21 @@ export class QueryInterface {
|
|
|
1762
1981
|
const relationDef = this.tableMeta.relations[key];
|
|
1763
1982
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1764
1983
|
const filterObj = value;
|
|
1765
|
-
if ('some' in filterObj ||
|
|
1984
|
+
if ('some' in filterObj ||
|
|
1985
|
+
'every' in filterObj ||
|
|
1986
|
+
'none' in filterObj ||
|
|
1987
|
+
'is' in filterObj ||
|
|
1988
|
+
'isNot' in filterObj) {
|
|
1766
1989
|
if (filterObj.some !== undefined)
|
|
1767
1990
|
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
1768
1991
|
if (filterObj.none !== undefined)
|
|
1769
1992
|
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
1770
1993
|
if (filterObj.every !== undefined)
|
|
1771
1994
|
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
1995
|
+
if (filterObj.is !== undefined)
|
|
1996
|
+
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
1997
|
+
if (filterObj.isNot !== undefined)
|
|
1998
|
+
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
1772
1999
|
continue;
|
|
1773
2000
|
}
|
|
1774
2001
|
}
|
|
@@ -1776,6 +2003,14 @@ export class QueryInterface {
|
|
|
1776
2003
|
if (value === null)
|
|
1777
2004
|
continue;
|
|
1778
2005
|
const rawColumn = this.toColumn(key);
|
|
2006
|
+
// Vector distance filter — mirrors buildVectorFilterClauses push order.
|
|
2007
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2008
|
+
// Validate the same way the build path does so the collect path never
|
|
2009
|
+
// diverges (it would throw before any param was pushed).
|
|
2010
|
+
this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
2011
|
+
this.collectVectorFilterParams(key, rawColumn, value, params);
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
1779
2014
|
// JSONB filter
|
|
1780
2015
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
1781
2016
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -1872,6 +2107,37 @@ export class QueryInterface {
|
|
|
1872
2107
|
params.push(filter.hasSome);
|
|
1873
2108
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
1874
2109
|
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Collect params for an orderBy clause. Only vector KNN ordering pushes a
|
|
2112
|
+
* param (the `$n::vector` query vector); plain direction ordering is
|
|
2113
|
+
* parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
|
|
2114
|
+
* param re-collection stays in lockstep.
|
|
2115
|
+
*/
|
|
2116
|
+
collectOrderByParams(orderBy, params) {
|
|
2117
|
+
for (const [key, dir] of Object.entries(orderBy)) {
|
|
2118
|
+
if (isVectorOrderBy(dir)) {
|
|
2119
|
+
const rawColumn = this.toColumn(key);
|
|
2120
|
+
// Re-run the same validation as buildOrderBy so the collect path can
|
|
2121
|
+
// never push a param that the build path rejected (or vice versa).
|
|
2122
|
+
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2123
|
+
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Collect params for a vector distance WHERE filter. Mirrors
|
|
2129
|
+
* {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
|
|
2130
|
+
* the comparison threshold(s).
|
|
2131
|
+
*/
|
|
2132
|
+
collectVectorFilterParams(field, rawColumn, filter, params) {
|
|
2133
|
+
const dist = filter.distance;
|
|
2134
|
+
this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
2135
|
+
for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
|
|
2136
|
+
const threshold = dist[cmp];
|
|
2137
|
+
if (threshold !== undefined)
|
|
2138
|
+
params.push(threshold);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
1875
2141
|
/**
|
|
1876
2142
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
1877
2143
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -1968,6 +2234,27 @@ export class QueryInterface {
|
|
|
1968
2234
|
const targetMeta = this.schema.tables[targetTable];
|
|
1969
2235
|
if (!targetMeta)
|
|
1970
2236
|
return;
|
|
2237
|
+
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2238
|
+
// where params → limit param → nested-with params (always, both paths).
|
|
2239
|
+
if (relDef.type === 'manyToMany') {
|
|
2240
|
+
if (spec.where) {
|
|
2241
|
+
for (const [, v] of Object.entries(spec.where)) {
|
|
2242
|
+
params.push(v);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
if (spec.limit) {
|
|
2246
|
+
params.push(Number(spec.limit));
|
|
2247
|
+
}
|
|
2248
|
+
if (spec.with) {
|
|
2249
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
2250
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2251
|
+
if (!nestedRelDef)
|
|
2252
|
+
continue;
|
|
2253
|
+
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
1971
2258
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
1972
2259
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
1973
2260
|
if (!willWrap && spec.with) {
|
|
@@ -2121,7 +2408,11 @@ export class QueryInterface {
|
|
|
2121
2408
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2122
2409
|
const filterObj = value;
|
|
2123
2410
|
// Check if this is a relation filter (has some/every/none keys)
|
|
2124
|
-
if ('some' in filterObj ||
|
|
2411
|
+
if ('some' in filterObj ||
|
|
2412
|
+
'every' in filterObj ||
|
|
2413
|
+
'none' in filterObj ||
|
|
2414
|
+
'is' in filterObj ||
|
|
2415
|
+
'isNot' in filterObj) {
|
|
2125
2416
|
const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
|
|
2126
2417
|
if (relClause)
|
|
2127
2418
|
andClauses.push(relClause);
|
|
@@ -2135,6 +2426,12 @@ export class QueryInterface {
|
|
|
2135
2426
|
andClauses.push(`${column} IS NULL`);
|
|
2136
2427
|
continue;
|
|
2137
2428
|
}
|
|
2429
|
+
// Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
|
|
2430
|
+
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2431
|
+
const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
|
|
2432
|
+
andClauses.push(...vecClauses);
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2138
2435
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
2139
2436
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2140
2437
|
const colType = this.getColumnPgType(rawColumn);
|
|
@@ -2238,6 +2535,20 @@ export class QueryInterface {
|
|
|
2238
2535
|
// "every" with empty filter = true (all match trivially)
|
|
2239
2536
|
}
|
|
2240
2537
|
}
|
|
2538
|
+
// "is": EXISTS — for to-one relations (same SQL as "some")
|
|
2539
|
+
if (filterObj.is !== undefined) {
|
|
2540
|
+
const subWhere = filterObj.is;
|
|
2541
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2542
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2543
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2544
|
+
}
|
|
2545
|
+
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
|
|
2546
|
+
if (filterObj.isNot !== undefined) {
|
|
2547
|
+
const subWhere = filterObj.isNot;
|
|
2548
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2549
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2550
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2551
|
+
}
|
|
2241
2552
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2242
2553
|
}
|
|
2243
2554
|
/**
|
|
@@ -2327,8 +2638,17 @@ export class QueryInterface {
|
|
|
2327
2638
|
}
|
|
2328
2639
|
return clauses;
|
|
2329
2640
|
}
|
|
2330
|
-
/**
|
|
2331
|
-
|
|
2641
|
+
/**
|
|
2642
|
+
* Build ORDER BY clause from an object.
|
|
2643
|
+
*
|
|
2644
|
+
* Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
|
|
2645
|
+
* columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
|
|
2646
|
+
* Vector ordering binds the query vector as a `$n::vector` param, so a `params`
|
|
2647
|
+
* array MUST be supplied when a vector ordering may be present (top-level
|
|
2648
|
+
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
2649
|
+
* ordering throws — KNN ordering is only supported at the top level.
|
|
2650
|
+
*/
|
|
2651
|
+
buildOrderBy(orderBy, params) {
|
|
2332
2652
|
// Dev-only: validate that orderBy fields exist in the table schema
|
|
2333
2653
|
if (process.env.NODE_ENV !== 'production') {
|
|
2334
2654
|
for (const key of Object.keys(orderBy)) {
|
|
@@ -2345,12 +2665,85 @@ export class QueryInterface {
|
|
|
2345
2665
|
if (meta && !(key in meta.columnMap)) {
|
|
2346
2666
|
throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
|
|
2347
2667
|
}
|
|
2668
|
+
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
2669
|
+
if (isVectorOrderBy(dir)) {
|
|
2670
|
+
if (!params) {
|
|
2671
|
+
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
2672
|
+
}
|
|
2673
|
+
const rawColumn = this.toColumn(key);
|
|
2674
|
+
const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2675
|
+
const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2676
|
+
const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2677
|
+
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
2678
|
+
}
|
|
2348
2679
|
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
2349
2680
|
return `${this.toSqlColumn(key)} ${safeDir}`;
|
|
2350
2681
|
})
|
|
2351
2682
|
.join(', ');
|
|
2352
2683
|
}
|
|
2684
|
+
// -------------------------------------------------------------------------
|
|
2685
|
+
// pgvector helpers (similarity search)
|
|
2686
|
+
// -------------------------------------------------------------------------
|
|
2687
|
+
/**
|
|
2688
|
+
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
2689
|
+
* fixed allow-list, validating the target column is actually a `vector`
|
|
2690
|
+
* column. Throws {@link ValidationError} for an unknown metric or a
|
|
2691
|
+
* non-vector column — a user-supplied string can never become a SQL operator.
|
|
2692
|
+
*/
|
|
2693
|
+
vectorOperator(field, rawColumn, metric) {
|
|
2694
|
+
const colType = this.getColumnPgType(rawColumn);
|
|
2695
|
+
if (colType !== 'vector') {
|
|
2696
|
+
throw new ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
|
|
2697
|
+
`(actual type: ${colType}); cannot apply a vector distance operation.`);
|
|
2698
|
+
}
|
|
2699
|
+
const op = VECTOR_METRIC_OPERATORS[metric];
|
|
2700
|
+
if (!op) {
|
|
2701
|
+
throw new ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
|
|
2702
|
+
`Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
|
|
2703
|
+
}
|
|
2704
|
+
return op;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Validate and bind a query vector as a single `$n::vector` parameter.
|
|
2708
|
+
* Every element must be a finite number (no NaN / Infinity / strings) so a
|
|
2709
|
+
* malformed array can never produce a broken `::vector` literal, and the array
|
|
2710
|
+
* is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
|
|
2711
|
+
* placeholder string.
|
|
2712
|
+
*/
|
|
2713
|
+
pushVectorParam(field, _rawColumn, to, params) {
|
|
2714
|
+
if (!Array.isArray(to) || to.length === 0) {
|
|
2715
|
+
throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
|
|
2716
|
+
}
|
|
2717
|
+
for (const el of to) {
|
|
2718
|
+
if (typeof el !== 'number' || !Number.isFinite(el)) {
|
|
2719
|
+
throw new ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
|
|
2720
|
+
`got ${JSON.stringify(el)}.`);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
// Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
|
|
2724
|
+
// as finite numbers, so the joined string is safe; it is still passed as a
|
|
2725
|
+
// bound param (never interpolated) and cast with ::vector.
|
|
2726
|
+
params.push(`[${to.join(',')}]`);
|
|
2727
|
+
return `${this.p(params.length)}::vector`;
|
|
2728
|
+
}
|
|
2353
2729
|
/** Parse a flat row: convert snake_case to camelCase + Date coercion */
|
|
2730
|
+
/**
|
|
2731
|
+
* Returns the set of camelCase field names for a table's date columns,
|
|
2732
|
+
* derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
|
|
2733
|
+
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
2734
|
+
* dates the same way top-level rows do.
|
|
2735
|
+
*/
|
|
2736
|
+
getCamelDateFields(table, meta) {
|
|
2737
|
+
let camel = this.camelDateFieldCache.get(table);
|
|
2738
|
+
if (!camel) {
|
|
2739
|
+
camel = new Set();
|
|
2740
|
+
for (const col of meta.dateColumns) {
|
|
2741
|
+
camel.add(meta.reverseColumnMap[col] ?? col);
|
|
2742
|
+
}
|
|
2743
|
+
this.camelDateFieldCache.set(table, camel);
|
|
2744
|
+
}
|
|
2745
|
+
return camel;
|
|
2746
|
+
}
|
|
2354
2747
|
parseRow(row, table) {
|
|
2355
2748
|
const parsed = {};
|
|
2356
2749
|
const meta = this.schema.tables[table];
|
|
@@ -2358,12 +2751,16 @@ export class QueryInterface {
|
|
|
2358
2751
|
// Fast path: use pre-computed maps (avoids regex per column per row)
|
|
2359
2752
|
const reverseMap = meta.reverseColumnMap;
|
|
2360
2753
|
const dateCols = meta.dateColumns;
|
|
2754
|
+
// camelCase-keyed date fields, so nested json_build_object rows (whose
|
|
2755
|
+
// keys are already camelCase) get the same Date coercion as top-level rows.
|
|
2756
|
+
const camelDateFields = this.getCamelDateFields(table, meta);
|
|
2361
2757
|
const keys = Object.keys(row);
|
|
2362
2758
|
for (let i = 0; i < keys.length; i++) {
|
|
2363
2759
|
const col = keys[i];
|
|
2364
2760
|
const value = row[col];
|
|
2365
2761
|
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
2366
|
-
|
|
2762
|
+
// Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
|
|
2763
|
+
if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
|
|
2367
2764
|
parsed[field] = new Date(value);
|
|
2368
2765
|
}
|
|
2369
2766
|
else {
|
|
@@ -2407,14 +2804,15 @@ export class QueryInterface {
|
|
|
2407
2804
|
if (typeof rawValue === 'string') {
|
|
2408
2805
|
try {
|
|
2409
2806
|
const jsonVal = JSON.parse(rawValue);
|
|
2410
|
-
// After parsing,
|
|
2807
|
+
// After parsing, recurse via parseNestedRow so each item gets date
|
|
2808
|
+
// coercion AND its own sub-relations parsed at arbitrary depth.
|
|
2411
2809
|
if (Array.isArray(jsonVal)) {
|
|
2412
2810
|
parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
|
|
2413
|
-
? this.
|
|
2811
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2414
2812
|
: item);
|
|
2415
2813
|
}
|
|
2416
2814
|
else if (typeof jsonVal === 'object' && jsonVal !== null) {
|
|
2417
|
-
parsed[relName] = this.
|
|
2815
|
+
parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
|
|
2418
2816
|
}
|
|
2419
2817
|
else {
|
|
2420
2818
|
parsed[relName] = jsonVal;
|
|
@@ -2426,10 +2824,12 @@ export class QueryInterface {
|
|
|
2426
2824
|
}
|
|
2427
2825
|
}
|
|
2428
2826
|
else if (Array.isArray(rawValue)) {
|
|
2429
|
-
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2827
|
+
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
2828
|
+
? this.parseNestedRow(item, relDef.to)
|
|
2829
|
+
: item);
|
|
2430
2830
|
}
|
|
2431
2831
|
else if (typeof rawValue === 'object' && rawValue !== null) {
|
|
2432
|
-
parsed[relName] = this.
|
|
2832
|
+
parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
|
|
2433
2833
|
}
|
|
2434
2834
|
else {
|
|
2435
2835
|
parsed[relName] = rawValue;
|
|
@@ -2630,6 +3030,12 @@ export class QueryInterface {
|
|
|
2630
3030
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
2631
3031
|
// so we must NOT build them here (they would push orphaned params).
|
|
2632
3032
|
const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
|
|
3033
|
+
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
3034
|
+
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
3035
|
+
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
3036
|
+
if (relDef.type === 'manyToMany') {
|
|
3037
|
+
return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
|
|
3038
|
+
}
|
|
2633
3039
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
2634
3040
|
if (!willWrap && spec !== true && spec.with) {
|
|
2635
3041
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -2726,6 +3132,146 @@ export class QueryInterface {
|
|
|
2726
3132
|
// belongsTo / hasOne — return single object
|
|
2727
3133
|
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
|
|
2728
3134
|
}
|
|
3135
|
+
/**
|
|
3136
|
+
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
3137
|
+
* table through a junction (join) table.
|
|
3138
|
+
*
|
|
3139
|
+
* Shape (no LIMIT/ORDER):
|
|
3140
|
+
* ```sql
|
|
3141
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
3142
|
+
* FROM <target> <talias>
|
|
3143
|
+
* JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
|
|
3144
|
+
* WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
|
|
3145
|
+
* ```
|
|
3146
|
+
*
|
|
3147
|
+
* With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
|
|
3148
|
+
* applies BEFORE aggregation (identical strategy to hasMany).
|
|
3149
|
+
*
|
|
3150
|
+
* Cardinality is always 'many' → empty-array fallback, never NULL.
|
|
3151
|
+
*
|
|
3152
|
+
* IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
|
|
3153
|
+
* {@link collectRelationSubqueryParams} or pipeline batching will desync.
|
|
3154
|
+
*/
|
|
3155
|
+
buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
|
|
3156
|
+
if (!relDef.through) {
|
|
3157
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
|
|
3158
|
+
}
|
|
3159
|
+
const targetTable = relDef.to;
|
|
3160
|
+
const qTarget = this.q(targetTable);
|
|
3161
|
+
const qJunction = this.q(relDef.through.table);
|
|
3162
|
+
const qParent = this.q(parentRef);
|
|
3163
|
+
const jalias = `${talias}j`; // junction alias, distinct from the target alias
|
|
3164
|
+
// JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
|
|
3165
|
+
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
3166
|
+
// The target PK is the column(s) the junction's targetKey references. An empty
|
|
3167
|
+
// introspected PK means we cannot know what to JOIN on — fail loudly rather than
|
|
3168
|
+
// silently guessing `id` and generating a wrong JOIN.
|
|
3169
|
+
if (targetMeta.primaryKey.length === 0) {
|
|
3170
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
|
|
3171
|
+
`cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
|
|
3172
|
+
}
|
|
3173
|
+
const targetPk = targetMeta.primaryKey;
|
|
3174
|
+
if (targetKeys.length !== targetPk.length) {
|
|
3175
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
|
|
3176
|
+
`but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
|
|
3177
|
+
}
|
|
3178
|
+
const joinOn = targetKeys
|
|
3179
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
|
|
3180
|
+
.join(' AND ');
|
|
3181
|
+
// Correlation: junction.sourceKey = parent.<referenceKey>.
|
|
3182
|
+
const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
|
|
3183
|
+
const refKeys = normalizeKeyColumns(relDef.referenceKey);
|
|
3184
|
+
if (sourceKeys.length !== refKeys.length) {
|
|
3185
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
|
|
3186
|
+
`but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
|
|
3187
|
+
}
|
|
3188
|
+
let whereClause = sourceKeys
|
|
3189
|
+
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3190
|
+
.join(' AND ');
|
|
3191
|
+
// ORDER BY on the target rows
|
|
3192
|
+
let orderClause = '';
|
|
3193
|
+
if (spec !== true && spec.orderBy) {
|
|
3194
|
+
const orders = Object.entries(spec.orderBy)
|
|
3195
|
+
.map(([k, dir]) => {
|
|
3196
|
+
const col = camelToSnake(k);
|
|
3197
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3198
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
3199
|
+
}
|
|
3200
|
+
const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3201
|
+
return `${talias}.${this.q(col)} ${safeDir}`;
|
|
3202
|
+
})
|
|
3203
|
+
.join(', ');
|
|
3204
|
+
orderClause = ` ORDER BY ${orders}`;
|
|
3205
|
+
}
|
|
3206
|
+
// Additional WHERE filters on the target — properly parameterized.
|
|
3207
|
+
if (spec !== true && spec.where) {
|
|
3208
|
+
for (const [k, v] of Object.entries(spec.where)) {
|
|
3209
|
+
const col = camelToSnake(k);
|
|
3210
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
3211
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
|
|
3212
|
+
}
|
|
3213
|
+
params.push(v);
|
|
3214
|
+
whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
// LIMIT
|
|
3218
|
+
let limitClause = '';
|
|
3219
|
+
if (spec !== true && spec.limit) {
|
|
3220
|
+
params.push(Number(spec.limit));
|
|
3221
|
+
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3222
|
+
}
|
|
3223
|
+
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
3224
|
+
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
3225
|
+
// so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
|
|
3226
|
+
if (limitClause || orderClause) {
|
|
3227
|
+
const innerAlias = `${talias}i`;
|
|
3228
|
+
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
|
|
3229
|
+
`${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
3230
|
+
const innerJsonPairs = targetColumns.map((col) => [
|
|
3231
|
+
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
3232
|
+
`${innerAlias}.${this.q(col)}`,
|
|
3233
|
+
]);
|
|
3234
|
+
// Nested relations reference the inner alias.
|
|
3235
|
+
if (spec !== true && spec.with) {
|
|
3236
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3237
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3238
|
+
if (!nestedRelDef) {
|
|
3239
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3240
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3241
|
+
}
|
|
3242
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3243
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3244
|
+
? this.dialect.nullJsonLiteral
|
|
3245
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3246
|
+
innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
|
|
3250
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3251
|
+
}
|
|
3252
|
+
// Simple path: build the json object pairs directly off the target alias,
|
|
3253
|
+
// including any nested relations (correlated to the target alias).
|
|
3254
|
+
const jsonPairs = targetColumns.map((col) => [
|
|
3255
|
+
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
3256
|
+
`${talias}.${this.q(col)}`,
|
|
3257
|
+
]);
|
|
3258
|
+
if (spec !== true && spec.with) {
|
|
3259
|
+
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
3260
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3261
|
+
if (!nestedRelDef) {
|
|
3262
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
3263
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
3264
|
+
}
|
|
3265
|
+
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3266
|
+
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3267
|
+
? this.dialect.nullJsonLiteral
|
|
3268
|
+
: this.dialect.emptyJsonArrayLiteral;
|
|
3269
|
+
jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
const jsonObj = this.dialect.buildJsonObject(jsonPairs);
|
|
3273
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
|
|
3274
|
+
}
|
|
2729
3275
|
/**
|
|
2730
3276
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
2731
3277
|
* Used to detect JSONB/array columns for specialized operators.
|
|
@@ -2819,6 +3365,39 @@ export class QueryInterface {
|
|
|
2819
3365
|
}
|
|
2820
3366
|
return clauses;
|
|
2821
3367
|
}
|
|
3368
|
+
/**
|
|
3369
|
+
* Build SQL clauses for a pgvector distance WHERE filter:
|
|
3370
|
+
*
|
|
3371
|
+
* `"embedding" <-> $1::vector < $2`
|
|
3372
|
+
*
|
|
3373
|
+
* The query vector is bound as a `$n::vector` param (never interpolated), the
|
|
3374
|
+
* metric maps to an operator via a fixed allow-list, and each comparison
|
|
3375
|
+
* threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
|
|
3376
|
+
* per supplied comparator (all ANDed). Param push order matches
|
|
3377
|
+
* {@link collectVectorFilterParams}.
|
|
3378
|
+
*/
|
|
3379
|
+
buildVectorFilterClauses(field, rawColumn, filter, params) {
|
|
3380
|
+
const dist = filter.distance;
|
|
3381
|
+
const operator = this.vectorOperator(field, rawColumn, dist.metric);
|
|
3382
|
+
const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
3383
|
+
const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
|
|
3384
|
+
const clauses = [];
|
|
3385
|
+
for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
|
|
3386
|
+
const threshold = dist[cmp];
|
|
3387
|
+
if (threshold === undefined)
|
|
3388
|
+
continue;
|
|
3389
|
+
if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
|
|
3390
|
+
throw new ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
|
|
3391
|
+
`got ${JSON.stringify(threshold)}.`);
|
|
3392
|
+
}
|
|
3393
|
+
params.push(threshold);
|
|
3394
|
+
clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
|
|
3395
|
+
}
|
|
3396
|
+
if (clauses.length === 0) {
|
|
3397
|
+
throw new ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
|
|
3398
|
+
}
|
|
3399
|
+
return clauses;
|
|
3400
|
+
}
|
|
2822
3401
|
/**
|
|
2823
3402
|
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
2824
3403
|
* The config name is validated to prevent injection (only alphanumeric + underscore).
|