turbine-orm 0.27.0 → 0.28.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 +17 -13
- package/dist/cjs/cli/config.js +20 -3
- package/dist/cjs/cli/destructive.js +47 -31
- package/dist/cjs/cli/index.js +273 -71
- package/dist/cjs/cli/mcp.js +788 -0
- package/dist/cjs/cli/migrate.js +95 -20
- package/dist/cjs/cli/studio.js +3 -2
- package/dist/cjs/client.js +267 -34
- package/dist/cjs/dialect.js +2 -0
- package/dist/cjs/generate.js +171 -7
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/introspect.js +177 -4
- package/dist/cjs/query/batched-loader.js +148 -0
- package/dist/cjs/query/builder.js +714 -133
- package/dist/cjs/schema-builder.js +59 -4
- package/dist/cjs/schema-sql.js +315 -6
- package/dist/cjs/seed.js +66 -0
- package/dist/cli/config.d.ts +9 -2
- package/dist/cli/config.js +19 -3
- package/dist/cli/destructive.js +47 -31
- package/dist/cli/index.d.ts +52 -1
- package/dist/cli/index.js +272 -74
- package/dist/cli/mcp.d.ts +17 -0
- package/dist/cli/mcp.js +781 -0
- package/dist/cli/migrate.d.ts +37 -0
- package/dist/cli/migrate.js +92 -20
- package/dist/cli/studio.d.ts +3 -2
- package/dist/cli/studio.js +3 -2
- package/dist/client.d.ts +136 -1
- package/dist/client.js +267 -34
- package/dist/dialect.d.ts +17 -0
- package/dist/dialect.js +2 -0
- package/dist/generate.d.ts +17 -0
- package/dist/generate.js +171 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -0
- package/dist/introspect.d.ts +20 -1
- package/dist/introspect.js +175 -4
- package/dist/query/batched-loader.d.ts +29 -2
- package/dist/query/batched-loader.js +148 -1
- package/dist/query/builder.d.ts +156 -8
- package/dist/query/builder.js +715 -134
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +113 -8
- package/dist/schema-builder.d.ts +73 -8
- package/dist/schema-builder.js +59 -4
- package/dist/schema-sql.d.ts +67 -0
- package/dist/schema-sql.js +310 -6
- package/dist/schema.d.ts +53 -0
- package/dist/seed.d.ts +4 -0
- package/dist/seed.js +63 -0
- package/package.json +2 -3
|
@@ -260,6 +260,21 @@ function isVectorFilter(value) {
|
|
|
260
260
|
function isVectorOrderBy(value) {
|
|
261
261
|
return isVectorFilter(value);
|
|
262
262
|
}
|
|
263
|
+
/** Check if an orderBy value is an explicit `{ sort, nulls? }` spec. */
|
|
264
|
+
function isOrderBySpec(value) {
|
|
265
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && 'sort' in value;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
|
|
269
|
+
* direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
|
|
270
|
+
* path (findMany, groupBy, relation inner subqueries).
|
|
271
|
+
*/
|
|
272
|
+
function normalizeOrderBy(value) {
|
|
273
|
+
if (isOrderBySpec(value)) {
|
|
274
|
+
return { dir: value.sort.toLowerCase() === 'desc' ? 'DESC' : 'ASC', nulls: value.nulls };
|
|
275
|
+
}
|
|
276
|
+
return { dir: String(value).toLowerCase() === 'desc' ? 'DESC' : 'ASC' };
|
|
277
|
+
}
|
|
263
278
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
264
279
|
class QueryInterface {
|
|
265
280
|
pool;
|
|
@@ -279,6 +294,13 @@ class QueryInterface {
|
|
|
279
294
|
relationLoadStrategy;
|
|
280
295
|
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
281
296
|
jsonEncoding;
|
|
297
|
+
/**
|
|
298
|
+
* Client-level automatic WHERE filters keyed by table accessor (soft-delete /
|
|
299
|
+
* multi-tenancy). AND-merged into every query on the keyed table and every
|
|
300
|
+
* relation subquery targeting it. Undefined when none are configured, in
|
|
301
|
+
* which case every path is byte-identical to the pre-0.28 behavior.
|
|
302
|
+
*/
|
|
303
|
+
globalFilters;
|
|
282
304
|
/**
|
|
283
305
|
* Tracks tables that have already triggered an unlimited-query warning so
|
|
284
306
|
* the user is not spammed once per row. Per-instance state — each
|
|
@@ -310,6 +332,15 @@ class QueryInterface {
|
|
|
310
332
|
options;
|
|
311
333
|
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
312
334
|
currentAction = 'raw';
|
|
335
|
+
/**
|
|
336
|
+
* The active query's `skipGlobalFilters` opt-out, set at the top of each
|
|
337
|
+
* `build*` method and read deep in the (synchronous) SQL-build + param-collect
|
|
338
|
+
* tree — so relation subqueries, relation filters, `_count`, and relation
|
|
339
|
+
* `orderBy` all see it without threading it through dozens of signatures.
|
|
340
|
+
* Only load-bearing when {@link globalFilters} is configured; build+collect are
|
|
341
|
+
* synchronous per call, so this transient is never observed across an await.
|
|
342
|
+
*/
|
|
343
|
+
currentSkip;
|
|
313
344
|
constructor(pool, table, schema, middlewares, options) {
|
|
314
345
|
this.pool = pool;
|
|
315
346
|
this.table = table;
|
|
@@ -331,6 +362,10 @@ class QueryInterface {
|
|
|
331
362
|
this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
|
|
332
363
|
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
|
|
333
364
|
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
365
|
+
// Only retain the map when it has at least one entry, so `globalFilters`
|
|
366
|
+
// stays `undefined` (and every merge path a no-op) for the common case.
|
|
367
|
+
this.globalFilters =
|
|
368
|
+
options?.globalFilters && Object.keys(options.globalFilters).length > 0 ? options.globalFilters : undefined;
|
|
334
369
|
this.txScoped = options?._txScoped ?? false;
|
|
335
370
|
this.options = options;
|
|
336
371
|
// Pre-compute column type lookup maps (TASK-26)
|
|
@@ -450,7 +485,7 @@ class QueryInterface {
|
|
|
450
485
|
* and unlimited-warnings silenced — a relation load must fetch every matching
|
|
451
486
|
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
452
487
|
*/
|
|
453
|
-
batchedContext(timeout) {
|
|
488
|
+
batchedContext(timeout, skip) {
|
|
454
489
|
const childOptions = {
|
|
455
490
|
...this.options,
|
|
456
491
|
defaultLimit: undefined,
|
|
@@ -465,6 +500,22 @@ class QueryInterface {
|
|
|
465
500
|
buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
|
|
466
501
|
inClauseParam: (values) => this.inParam(values),
|
|
467
502
|
paramPlaceholder: (index) => this.p(index),
|
|
503
|
+
skipGlobalFilters: skip,
|
|
504
|
+
tableGlobalFilter: (table, alias, precedingParams) => {
|
|
505
|
+
const gf = this.resolveGlobalFilter(table, skip);
|
|
506
|
+
if (!gf)
|
|
507
|
+
return null;
|
|
508
|
+
const meta = this.schema.tables[table];
|
|
509
|
+
if (!meta)
|
|
510
|
+
return null;
|
|
511
|
+
// Seed the param array with `precedingParams` placeholders so
|
|
512
|
+
// buildAliasWhere numbers the gf params after the already-bound ones.
|
|
513
|
+
const seeded = new Array(precedingParams).fill(undefined);
|
|
514
|
+
const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
|
|
515
|
+
if (!clause)
|
|
516
|
+
return null;
|
|
517
|
+
return { clause, params: seeded.slice(precedingParams) };
|
|
518
|
+
},
|
|
468
519
|
};
|
|
469
520
|
}
|
|
470
521
|
/**
|
|
@@ -476,13 +527,18 @@ class QueryInterface {
|
|
|
476
527
|
*/
|
|
477
528
|
async runFindManyBatched(args) {
|
|
478
529
|
const withClause = args.with;
|
|
530
|
+
// Capture the opt-out from the ARGS before any await: this.currentSkip is
|
|
531
|
+
// instance state on a cached accessor, so a concurrent build during the
|
|
532
|
+
// base-query await would overwrite it (tenant query loading relations with
|
|
533
|
+
// another query's skipGlobalFilters).
|
|
534
|
+
const skip = args.skipGlobalFilters;
|
|
479
535
|
const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
|
|
480
536
|
// baseArgs.with is always undefined here; the cast just bridges the R generic.
|
|
481
537
|
const deferred = this.buildFindMany(baseArgs);
|
|
482
538
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
483
539
|
const entities = deferred.transform(result);
|
|
484
540
|
if (entities.length > 0) {
|
|
485
|
-
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), entities, withClause, args.timeout);
|
|
541
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip), entities, withClause, args.timeout);
|
|
486
542
|
}
|
|
487
543
|
(0, batched_loader_js_1.stripFields)(entities, strip);
|
|
488
544
|
return entities;
|
|
@@ -704,18 +760,22 @@ class QueryInterface {
|
|
|
704
760
|
const entity = deferred.transform(result);
|
|
705
761
|
if (!entity)
|
|
706
762
|
return null;
|
|
707
|
-
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
|
|
763
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters), [entity], withClause, args.timeout);
|
|
708
764
|
(0, batched_loader_js_1.stripFields)([entity], proj.strip);
|
|
709
765
|
return entity;
|
|
710
766
|
}
|
|
711
767
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
712
768
|
buildFindUnique(args) {
|
|
769
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
713
770
|
const columnsList = this.resolveColumns(args.select, args.omit);
|
|
714
|
-
|
|
771
|
+
// A global filter turns the where into `{ AND: [...] }`, which the
|
|
772
|
+
// `isSimpleWhere` test below rejects → the general (buildWhereClause) path
|
|
773
|
+
// handles the merge and its params uniformly.
|
|
774
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
715
775
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
716
776
|
const whereFingerprint = this.fingerprintWhere(whereObj);
|
|
717
777
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
718
|
-
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
778
|
+
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
719
779
|
const params = [];
|
|
720
780
|
// Check if all where values are simple (plain equality, no operators/null/OR).
|
|
721
781
|
// Keys are sorted to match fingerprintWhere — insertion order here would let
|
|
@@ -871,24 +931,21 @@ class QueryInterface {
|
|
|
871
931
|
}
|
|
872
932
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
873
933
|
buildFindMany(args) {
|
|
934
|
+
this.currentSkip = args?.skipGlobalFilters;
|
|
874
935
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
875
936
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
876
|
-
|
|
937
|
+
// AND-merge this table's global filter into the user where; `hasWhere` gates
|
|
938
|
+
// the build/collect just like `args?.where` did (a merged filter can make
|
|
939
|
+
// an otherwise-absent where present).
|
|
940
|
+
const effWhere = this.mergeGlobalFilter(args?.where);
|
|
941
|
+
const hasWhere = effWhere !== undefined;
|
|
942
|
+
const whereObj = (effWhere ?? {});
|
|
877
943
|
// Build fingerprint for cache lookup
|
|
878
|
-
const whereFp =
|
|
944
|
+
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
879
945
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
880
946
|
const orderFp = args?.orderBy
|
|
881
947
|
? Object.entries(args.orderBy)
|
|
882
|
-
.map(([k, d]) => {
|
|
883
|
-
// Vector KNN ordering changes the emitted SQL operator by metric and
|
|
884
|
-
// adds a `::vector` param, so the metric + direction must be part of
|
|
885
|
-
// the cache key — otherwise two KNN queries differing only in metric
|
|
886
|
-
// would collide on a single cached SQL string.
|
|
887
|
-
if (isVectorOrderBy(d)) {
|
|
888
|
-
return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
889
|
-
}
|
|
890
|
-
return `${k}:${d}`;
|
|
891
|
-
})
|
|
948
|
+
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
|
|
892
949
|
.join(',')
|
|
893
950
|
: '';
|
|
894
951
|
const cursorFp = args?.cursor
|
|
@@ -901,12 +958,12 @@ class QueryInterface {
|
|
|
901
958
|
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
902
959
|
const limitFp = effectiveLimit !== undefined ? '1' : '0';
|
|
903
960
|
const offsetFp = args?.offset !== undefined ? '1' : '0';
|
|
904
|
-
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}`;
|
|
961
|
+
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
905
962
|
const params = [];
|
|
906
963
|
const entry = this.acquireSql(ck, () => {
|
|
907
964
|
// Fresh build — generates SQL and populates freshParams
|
|
908
965
|
const freshParams = [];
|
|
909
|
-
const { sql: freshWhereSql } =
|
|
966
|
+
const { sql: freshWhereSql } = hasWhere
|
|
910
967
|
? (() => {
|
|
911
968
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
912
969
|
return { sql: clause ? ` WHERE ${clause}` : '' };
|
|
@@ -936,8 +993,11 @@ class QueryInterface {
|
|
|
936
993
|
if (cursorEntries.length > 0) {
|
|
937
994
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
938
995
|
const col = this.toSqlColumn(k);
|
|
939
|
-
|
|
940
|
-
|
|
996
|
+
// orderBy values can be the { sort, nulls } spec form — normalize
|
|
997
|
+
// before comparing, or a desc spec would seek the ascending side.
|
|
998
|
+
const dir = args.orderBy?.[k];
|
|
999
|
+
const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
|
|
1000
|
+
const op = desc ? '<' : '>';
|
|
941
1001
|
freshParams.push(v);
|
|
942
1002
|
return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
|
|
943
1003
|
});
|
|
@@ -984,8 +1044,8 @@ class QueryInterface {
|
|
|
984
1044
|
return sql;
|
|
985
1045
|
});
|
|
986
1046
|
// Collect params in exact build order:
|
|
987
|
-
// 1. WHERE params
|
|
988
|
-
if (
|
|
1047
|
+
// 1. WHERE params (includes the AND-merged global filter, if any)
|
|
1048
|
+
if (hasWhere) {
|
|
989
1049
|
this.collectWhereParams(whereObj, params);
|
|
990
1050
|
}
|
|
991
1051
|
// 2. WITH relation params
|
|
@@ -1197,6 +1257,8 @@ class QueryInterface {
|
|
|
1197
1257
|
});
|
|
1198
1258
|
}
|
|
1199
1259
|
buildCreate(args) {
|
|
1260
|
+
this.assertWritable('create');
|
|
1261
|
+
this.assertNoGeneratedColumns(args.data, 'create');
|
|
1200
1262
|
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
1201
1263
|
const columns = entries.map(([k]) => this.toSqlColumn(k));
|
|
1202
1264
|
const params = entries.map(([, v]) => v);
|
|
@@ -1271,6 +1333,10 @@ class QueryInterface {
|
|
|
1271
1333
|
tag: `${this.table}.createMany`,
|
|
1272
1334
|
};
|
|
1273
1335
|
}
|
|
1336
|
+
this.assertWritable('createMany');
|
|
1337
|
+
for (const row of args.data) {
|
|
1338
|
+
this.assertNoGeneratedColumns(row, 'createMany');
|
|
1339
|
+
}
|
|
1274
1340
|
const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
|
|
1275
1341
|
const columns = keys.map((k) => this.toColumn(k));
|
|
1276
1342
|
const rowValues = args.data.map((row) => {
|
|
@@ -1308,12 +1374,22 @@ class QueryInterface {
|
|
|
1308
1374
|
});
|
|
1309
1375
|
}
|
|
1310
1376
|
buildUpdate(args) {
|
|
1377
|
+
this.assertWritable('update');
|
|
1378
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1311
1379
|
const dataObj = args.data;
|
|
1312
|
-
|
|
1380
|
+
this.assertNoGeneratedColumns(dataObj, 'update');
|
|
1381
|
+
const userWhere = args.where;
|
|
1313
1382
|
const lock = args.optimisticLock;
|
|
1383
|
+
// The empty-`where` guard checks the USER predicate only — a global filter
|
|
1384
|
+
// must never turn an unguarded mass update into an allowed one.
|
|
1385
|
+
const userHasPredicate = !this.userPredicateIsEmpty(userWhere) || !!lock;
|
|
1386
|
+
this.assertMutationHasPredicate('update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
|
|
1387
|
+
// The SQL is built from the global-filter-merged where (soft-delete keeps an
|
|
1388
|
+
// update from touching already-deleted rows).
|
|
1389
|
+
const whereObj = (this.mergeGlobalFilter(userWhere) ?? {});
|
|
1314
1390
|
const setFp = this.fingerprintSet(dataObj);
|
|
1315
1391
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1316
|
-
const ck = lock ? null : `u:${setFp}|${whereFp}`;
|
|
1392
|
+
const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1317
1393
|
const params = [];
|
|
1318
1394
|
const buildSql = () => {
|
|
1319
1395
|
const freshParams = [];
|
|
@@ -1331,7 +1407,6 @@ class QueryInterface {
|
|
|
1331
1407
|
const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
|
|
1332
1408
|
whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
|
|
1333
1409
|
}
|
|
1334
|
-
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
1335
1410
|
// Engines that inject their returning shape MID-statement (SQL Server
|
|
1336
1411
|
// `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
|
|
1337
1412
|
// absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
@@ -1345,9 +1420,6 @@ class QueryInterface {
|
|
|
1345
1420
|
const entry = this.acquireSql(ck, buildSql);
|
|
1346
1421
|
sql = entry.sql;
|
|
1347
1422
|
preparedName = entry.name;
|
|
1348
|
-
if (whereFp === '') {
|
|
1349
|
-
this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
|
|
1350
|
-
}
|
|
1351
1423
|
}
|
|
1352
1424
|
else {
|
|
1353
1425
|
sql = buildSql();
|
|
@@ -1481,27 +1553,24 @@ class QueryInterface {
|
|
|
1481
1553
|
});
|
|
1482
1554
|
}
|
|
1483
1555
|
buildDelete(args) {
|
|
1484
|
-
|
|
1556
|
+
this.assertWritable('delete');
|
|
1557
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1558
|
+
// Guard the USER predicate (a global filter must not satisfy the guard).
|
|
1559
|
+
this.assertMutationHasPredicate('delete', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1560
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1485
1561
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1486
|
-
const ck = `d:${whereFp}`;
|
|
1562
|
+
const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1487
1563
|
const params = [];
|
|
1488
|
-
// We need to check the mutation predicate. Build the whereSql to test it.
|
|
1489
|
-
// On cache hit we still need to validate (the shape may be empty).
|
|
1490
1564
|
const entry = this.acquireSql(ck, () => {
|
|
1491
1565
|
const freshParams = [];
|
|
1492
1566
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1493
1567
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1494
|
-
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
1495
1568
|
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
1496
1569
|
// absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
1497
1570
|
return this.dialect.buildDeleteStatement
|
|
1498
1571
|
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1499
1572
|
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1500
1573
|
});
|
|
1501
|
-
// On cache hit, still validate the predicate
|
|
1502
|
-
if (whereFp === '') {
|
|
1503
|
-
this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
|
|
1504
|
-
}
|
|
1505
1574
|
this.collectWhereParams(whereObj, params);
|
|
1506
1575
|
return {
|
|
1507
1576
|
sql: entry.sql,
|
|
@@ -1541,6 +1610,10 @@ class QueryInterface {
|
|
|
1541
1610
|
});
|
|
1542
1611
|
}
|
|
1543
1612
|
buildUpsert(args) {
|
|
1613
|
+
this.assertWritable('upsert');
|
|
1614
|
+
this.assertNoGeneratedColumns(args.create, 'upsert');
|
|
1615
|
+
this.assertNoGeneratedColumns(args.update, 'upsert');
|
|
1616
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1544
1617
|
// Build the INSERT part from create data
|
|
1545
1618
|
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
1546
1619
|
const columns = createEntries.map(([k]) => this.toSqlColumn(k));
|
|
@@ -1559,12 +1632,23 @@ class QueryInterface {
|
|
|
1559
1632
|
});
|
|
1560
1633
|
const updateParams = updateEntries.map(([, v]) => v);
|
|
1561
1634
|
const params = [...createParams, ...updateParams];
|
|
1635
|
+
// Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
|
|
1636
|
+
// upsert never resurrects a soft-deleted row or writes across tenants. Only
|
|
1637
|
+
// on engines whose upsert can carry a predicate (Postgres); the gf params
|
|
1638
|
+
// continue the placeholder numbering after create+update params.
|
|
1639
|
+
let updateWhere;
|
|
1640
|
+
if (this.dialect.supportsUpsertUpdateWhere) {
|
|
1641
|
+
const gf = this.resolveGlobalFilter(this.table);
|
|
1642
|
+
if (gf)
|
|
1643
|
+
updateWhere = this.buildWhereClause(gf, params) ?? undefined;
|
|
1644
|
+
}
|
|
1562
1645
|
const sql = this.dialect.buildUpsertStatement({
|
|
1563
1646
|
table: this.q(this.table),
|
|
1564
1647
|
insertColumns: columns,
|
|
1565
1648
|
valuePlaceholders: placeholders,
|
|
1566
1649
|
conflictColumns,
|
|
1567
1650
|
updateSetClauses: setClauses,
|
|
1651
|
+
updateWhere,
|
|
1568
1652
|
returning: '*',
|
|
1569
1653
|
});
|
|
1570
1654
|
return {
|
|
@@ -1587,7 +1671,7 @@ class QueryInterface {
|
|
|
1587
1671
|
reselect: this.dialect.resultStrategy === 'reselect'
|
|
1588
1672
|
? async (exec) => {
|
|
1589
1673
|
await exec(sql, params);
|
|
1590
|
-
const sel = this.buildReselectByWhere(args.where);
|
|
1674
|
+
const sel = this.buildReselectByWhere((this.mergeGlobalFilter(args.where) ?? {}));
|
|
1591
1675
|
return exec(sel.sql, sel.params);
|
|
1592
1676
|
}
|
|
1593
1677
|
: undefined,
|
|
@@ -1604,11 +1688,15 @@ class QueryInterface {
|
|
|
1604
1688
|
});
|
|
1605
1689
|
}
|
|
1606
1690
|
buildUpdateMany(args) {
|
|
1691
|
+
this.assertWritable('updateMany');
|
|
1692
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1607
1693
|
const dataObj = args.data;
|
|
1608
|
-
|
|
1694
|
+
this.assertNoGeneratedColumns(dataObj, 'updateMany');
|
|
1695
|
+
this.assertMutationHasPredicate('updateMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1696
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1609
1697
|
const setFp = this.fingerprintSet(dataObj);
|
|
1610
1698
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1611
|
-
const ck = `um:${setFp}|${whereFp}`;
|
|
1699
|
+
const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1612
1700
|
const params = [];
|
|
1613
1701
|
const entry = this.acquireSql(ck, () => {
|
|
1614
1702
|
const freshParams = [];
|
|
@@ -1616,12 +1704,8 @@ class QueryInterface {
|
|
|
1616
1704
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1617
1705
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
1618
1706
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
1619
|
-
this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
|
|
1620
1707
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
1621
1708
|
});
|
|
1622
|
-
if (whereFp === '') {
|
|
1623
|
-
this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
|
|
1624
|
-
}
|
|
1625
1709
|
this.collectSetParams(dataObj, params);
|
|
1626
1710
|
this.collectWhereParams(whereObj, params);
|
|
1627
1711
|
return {
|
|
@@ -1643,20 +1727,19 @@ class QueryInterface {
|
|
|
1643
1727
|
});
|
|
1644
1728
|
}
|
|
1645
1729
|
buildDeleteMany(args) {
|
|
1646
|
-
|
|
1730
|
+
this.assertWritable('deleteMany');
|
|
1731
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1732
|
+
this.assertMutationHasPredicate('deleteMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1733
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1647
1734
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1648
|
-
const ck = `dm:${whereFp}`;
|
|
1735
|
+
const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1649
1736
|
const params = [];
|
|
1650
1737
|
const entry = this.acquireSql(ck, () => {
|
|
1651
1738
|
const freshParams = [];
|
|
1652
1739
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1653
1740
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1654
|
-
this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
|
|
1655
1741
|
return `DELETE FROM ${this.q(this.table)}${whereSql}`;
|
|
1656
1742
|
});
|
|
1657
|
-
if (whereFp === '') {
|
|
1658
|
-
this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
|
|
1659
|
-
}
|
|
1660
1743
|
this.collectWhereParams(whereObj, params);
|
|
1661
1744
|
return {
|
|
1662
1745
|
sql: entry.sql,
|
|
@@ -1677,17 +1760,20 @@ class QueryInterface {
|
|
|
1677
1760
|
});
|
|
1678
1761
|
}
|
|
1679
1762
|
buildCount(args) {
|
|
1680
|
-
|
|
1681
|
-
const
|
|
1682
|
-
const
|
|
1763
|
+
this.currentSkip = args?.skipGlobalFilters;
|
|
1764
|
+
const effWhere = this.mergeGlobalFilter(args?.where);
|
|
1765
|
+
const hasWhere = effWhere !== undefined;
|
|
1766
|
+
const whereObj = (effWhere ?? {});
|
|
1767
|
+
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
1768
|
+
const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1683
1769
|
const params = [];
|
|
1684
1770
|
const entry = this.acquireSql(ck, () => {
|
|
1685
1771
|
const freshParams = [];
|
|
1686
|
-
const clause =
|
|
1772
|
+
const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1687
1773
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1688
1774
|
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1689
1775
|
});
|
|
1690
|
-
if (
|
|
1776
|
+
if (hasWhere) {
|
|
1691
1777
|
this.collectWhereParams(whereObj, params);
|
|
1692
1778
|
}
|
|
1693
1779
|
return {
|
|
@@ -1717,9 +1803,13 @@ class QueryInterface {
|
|
|
1717
1803
|
}
|
|
1718
1804
|
}
|
|
1719
1805
|
}
|
|
1806
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1720
1807
|
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
1721
1808
|
const groupCols = groupColsRaw.map((c) => this.q(c));
|
|
1722
|
-
const
|
|
1809
|
+
const gbWhere = this.mergeGlobalFilter(args.where);
|
|
1810
|
+
const { sql: whereSql, params } = gbWhere
|
|
1811
|
+
? this.buildWhere(gbWhere)
|
|
1812
|
+
: { sql: '', params: [] };
|
|
1723
1813
|
// Build SELECT expressions: group-by columns + aggregate functions
|
|
1724
1814
|
const selectExprs = [...groupCols];
|
|
1725
1815
|
// _count
|
|
@@ -1962,7 +2052,11 @@ class QueryInterface {
|
|
|
1962
2052
|
});
|
|
1963
2053
|
}
|
|
1964
2054
|
buildAggregate(args) {
|
|
1965
|
-
|
|
2055
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
2056
|
+
const aggWhere = this.mergeGlobalFilter(args.where);
|
|
2057
|
+
const { sql: whereSql, params } = aggWhere
|
|
2058
|
+
? this.buildWhere(aggWhere)
|
|
2059
|
+
: { sql: '', params: [] };
|
|
1966
2060
|
const meta = this.schema.tables[this.table];
|
|
1967
2061
|
if (meta) {
|
|
1968
2062
|
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
@@ -2140,6 +2234,36 @@ class QueryInterface {
|
|
|
2140
2234
|
}
|
|
2141
2235
|
return null;
|
|
2142
2236
|
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Reject any write against a view (H4). Views are introspected with
|
|
2239
|
+
* `isView: true` and are read-only in every engine; a write raises a
|
|
2240
|
+
* {@link ValidationError} (E003) rather than emitting SQL Postgres would
|
|
2241
|
+
* reject (or, worse, silently applying to an updatable view).
|
|
2242
|
+
*/
|
|
2243
|
+
assertWritable(operation) {
|
|
2244
|
+
if (this.tableMeta.isView) {
|
|
2245
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot ${operation} "${this.table}": it is a view (read-only). ` +
|
|
2246
|
+
'Views support reads (findMany/findFirst/…) but not writes.');
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
|
|
2251
|
+
* column (H3). Postgres computes these from other columns and errors if you
|
|
2252
|
+
* try to write them; we fail early with a clear {@link ValidationError} (E003)
|
|
2253
|
+
* instead of surfacing a cryptic driver error. Undefined values are ignored
|
|
2254
|
+
* (they're stripped from the statement anyway).
|
|
2255
|
+
*/
|
|
2256
|
+
assertNoGeneratedColumns(data, operation) {
|
|
2257
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2258
|
+
if (value === undefined)
|
|
2259
|
+
continue;
|
|
2260
|
+
const col = this.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === (0, schema_js_1.camelToSnake)(key));
|
|
2261
|
+
if (col?.isGeneratedStored) {
|
|
2262
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot ${operation} "${this.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
|
|
2263
|
+
'column whose value the database computes — remove it from your data.');
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2143
2267
|
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
2144
2268
|
toColumn(field) {
|
|
2145
2269
|
const mapped = this.tableMeta.columnMap[field];
|
|
@@ -2469,16 +2593,7 @@ class QueryInterface {
|
|
|
2469
2593
|
'none' in filterObj ||
|
|
2470
2594
|
'is' in filterObj ||
|
|
2471
2595
|
'isNot' in filterObj) {
|
|
2472
|
-
|
|
2473
|
-
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
2474
|
-
if (filterObj.none !== undefined && filterObj.none !== null)
|
|
2475
|
-
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
2476
|
-
if (filterObj.every !== undefined && filterObj.every !== null)
|
|
2477
|
-
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
2478
|
-
if (filterObj.is !== undefined && filterObj.is !== null)
|
|
2479
|
-
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
2480
|
-
if (filterObj.isNot !== undefined && filterObj.isNot !== null)
|
|
2481
|
-
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
2596
|
+
this.collectRelationFilterParams(relationDef, filterObj, params);
|
|
2482
2597
|
continue;
|
|
2483
2598
|
}
|
|
2484
2599
|
}
|
|
@@ -2526,7 +2641,45 @@ class QueryInterface {
|
|
|
2526
2641
|
params.push(value);
|
|
2527
2642
|
}
|
|
2528
2643
|
}
|
|
2529
|
-
/**
|
|
2644
|
+
/**
|
|
2645
|
+
* Param-collect mirror of {@link buildRelationFilter} for one relation-filter
|
|
2646
|
+
* object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
|
|
2647
|
+
* present branch and in the canonical order some→none→every→is→isNot, the
|
|
2648
|
+
* branch's sub-where params THEN the target table's global-filter params —
|
|
2649
|
+
* exactly the order buildRelationFilter emits. When no global filter applies
|
|
2650
|
+
* the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
|
|
2651
|
+
* Shared by every collect site that mirrors buildRelationFilter
|
|
2652
|
+
* (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
|
|
2653
|
+
*/
|
|
2654
|
+
collectRelationFilterParams(relDef, filterObj, params) {
|
|
2655
|
+
const target = relDef.to;
|
|
2656
|
+
if (filterObj.some !== undefined && filterObj.some !== null) {
|
|
2657
|
+
this.collectRelFilterParams(target, filterObj.some, params);
|
|
2658
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2659
|
+
}
|
|
2660
|
+
if (filterObj.none !== undefined && filterObj.none !== null) {
|
|
2661
|
+
this.collectRelFilterParams(target, filterObj.none, params);
|
|
2662
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2663
|
+
}
|
|
2664
|
+
if (filterObj.every !== undefined && filterObj.every !== null) {
|
|
2665
|
+
// gf is only emitted (build) when the `every` sub-where compiles to a
|
|
2666
|
+
// filter — otherwise `every` is trivially true and no subquery is built.
|
|
2667
|
+
if (this.buildSubWhereForRelation(target, filterObj.every, []) !== null) {
|
|
2668
|
+
this.collectRelFilterParams(target, filterObj.every, params);
|
|
2669
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
if (filterObj.is !== undefined) {
|
|
2673
|
+
if (filterObj.is !== null)
|
|
2674
|
+
this.collectRelFilterParams(target, filterObj.is, params);
|
|
2675
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2676
|
+
}
|
|
2677
|
+
if (filterObj.isNot !== undefined) {
|
|
2678
|
+
if (filterObj.isNot !== null)
|
|
2679
|
+
this.collectRelFilterParams(target, filterObj.isNot, params);
|
|
2680
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2530
2683
|
collectRelFilterParams(targetTable, subWhere, params) {
|
|
2531
2684
|
const meta = this.schema.tables[targetTable];
|
|
2532
2685
|
if (!meta)
|
|
@@ -2554,17 +2707,9 @@ class QueryInterface {
|
|
|
2554
2707
|
if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
2555
2708
|
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
2556
2709
|
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
2557
|
-
//
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
if (norm.none != null)
|
|
2561
|
-
this.collectRelFilterParams(nestedRel.to, norm.none, params);
|
|
2562
|
-
if (norm.every != null)
|
|
2563
|
-
this.collectRelFilterParams(nestedRel.to, norm.every, params);
|
|
2564
|
-
if (norm.is != null)
|
|
2565
|
-
this.collectRelFilterParams(nestedRel.to, norm.is, params);
|
|
2566
|
-
if (norm.isNot != null)
|
|
2567
|
-
this.collectRelFilterParams(nestedRel.to, norm.isNot, params);
|
|
2710
|
+
// Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
|
|
2711
|
+
// params then target global-filter params).
|
|
2712
|
+
this.collectRelationFilterParams(nestedRel, norm, params);
|
|
2568
2713
|
continue;
|
|
2569
2714
|
}
|
|
2570
2715
|
}
|
|
@@ -2644,6 +2789,21 @@ class QueryInterface {
|
|
|
2644
2789
|
// never push a param that the build path rejected (or vice versa).
|
|
2645
2790
|
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2646
2791
|
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2792
|
+
continue;
|
|
2793
|
+
}
|
|
2794
|
+
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2795
|
+
// subquery as `_count` — mirror its global-filter params. To-one relation
|
|
2796
|
+
// orderBy carries the target's global filter once per ordered column.
|
|
2797
|
+
if (this.isRelationOrderByValue(dir)) {
|
|
2798
|
+
const relDef = this.tableMeta.relations[key];
|
|
2799
|
+
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
2800
|
+
this.collectRelationCountParams(relDef, params);
|
|
2801
|
+
}
|
|
2802
|
+
else if (relDef) {
|
|
2803
|
+
for (const _col of Object.keys(dir)) {
|
|
2804
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2647
2807
|
}
|
|
2648
2808
|
}
|
|
2649
2809
|
}
|
|
@@ -2679,6 +2839,19 @@ class QueryInterface {
|
|
|
2679
2839
|
const spec = withClause[relName];
|
|
2680
2840
|
if (!spec)
|
|
2681
2841
|
continue;
|
|
2842
|
+
// Reserved `_count` key — fingerprint by the selected relation set so
|
|
2843
|
+
// `_count: true` and `_count: { posts: true }` never share a cache entry.
|
|
2844
|
+
if (relName === '_count') {
|
|
2845
|
+
const c = spec;
|
|
2846
|
+
parts.push(c === true
|
|
2847
|
+
? '_count(*)'
|
|
2848
|
+
: `_count(${Object.entries(c)
|
|
2849
|
+
.filter(([, v]) => v)
|
|
2850
|
+
.map(([k]) => k)
|
|
2851
|
+
.sort()
|
|
2852
|
+
.join(',')})`);
|
|
2853
|
+
continue;
|
|
2854
|
+
}
|
|
2682
2855
|
const relDef = meta.relations[relName];
|
|
2683
2856
|
if (!relDef) {
|
|
2684
2857
|
parts.push(`unknown:${relName}`);
|
|
@@ -2711,9 +2884,9 @@ class QueryInterface {
|
|
|
2711
2884
|
if (opts.where) {
|
|
2712
2885
|
subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
|
|
2713
2886
|
}
|
|
2714
|
-
// orderBy shape
|
|
2887
|
+
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
2715
2888
|
if (opts.orderBy) {
|
|
2716
|
-
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
|
|
2889
|
+
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
|
|
2717
2890
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2718
2891
|
}
|
|
2719
2892
|
// limit presence
|
|
@@ -2744,6 +2917,15 @@ class QueryInterface {
|
|
|
2744
2917
|
continue;
|
|
2745
2918
|
this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
|
|
2746
2919
|
}
|
|
2920
|
+
// `_count` global-filter params — mirror buildSelectWithRelations, which
|
|
2921
|
+
// appends the count subqueries (and any target-filter params) AFTER every
|
|
2922
|
+
// relation subquery, in resolveCountRelations order.
|
|
2923
|
+
const countSpec = withClause._count;
|
|
2924
|
+
if (countSpec !== undefined) {
|
|
2925
|
+
for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
|
|
2926
|
+
this.collectRelationCountParams(rel, params);
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2747
2929
|
}
|
|
2748
2930
|
/**
|
|
2749
2931
|
* Collect params from a single relation subquery. Mirrors buildRelationSubquery.
|
|
@@ -2761,6 +2943,7 @@ class QueryInterface {
|
|
|
2761
2943
|
if (spec.where) {
|
|
2762
2944
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2763
2945
|
}
|
|
2946
|
+
this.collectTargetGlobalFilterAlias(targetTable, params);
|
|
2764
2947
|
if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
|
|
2765
2948
|
params.push(Number(spec.limit));
|
|
2766
2949
|
}
|
|
@@ -2790,6 +2973,9 @@ class QueryInterface {
|
|
|
2790
2973
|
if (spec.where) {
|
|
2791
2974
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2792
2975
|
}
|
|
2976
|
+
// Global filter on the target — mirrors targetGlobalFilterAlias in
|
|
2977
|
+
// buildRelationSubquery (pushed after spec.where, before limit).
|
|
2978
|
+
this.collectTargetGlobalFilterAlias(targetTable, params);
|
|
2793
2979
|
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2794
2980
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2795
2981
|
// pushing one here would orphan a param and desync the collect path.
|
|
@@ -2859,14 +3045,148 @@ class QueryInterface {
|
|
|
2859
3045
|
return { sql: '', params: [] };
|
|
2860
3046
|
return { sql: ` WHERE ${clause}`, params };
|
|
2861
3047
|
}
|
|
3048
|
+
// -------------------------------------------------------------------------
|
|
3049
|
+
// Global filters (soft-delete / multi-tenancy — WS-G)
|
|
3050
|
+
//
|
|
3051
|
+
// A configured global filter for a table is AND-merged into the compiled WHERE
|
|
3052
|
+
// of every query on that table (via {@link mergeGlobalFilter}, so the merge is
|
|
3053
|
+
// captured in the where fingerprint/collect for free) and into every relation
|
|
3054
|
+
// subquery targeting it (rendered at build time against the subquery's alias/
|
|
3055
|
+
// table by the `*GlobalFilterAlias`/`*GlobalFilterExists` helpers, with the
|
|
3056
|
+
// shape folded into the SQL-cache key via {@link globalFilterCacheSegment}).
|
|
3057
|
+
// Function filters are evaluated per resolve — at query-build time — enabling
|
|
3058
|
+
// per-request tenancy via a closure. They must return a STABLE shape (same
|
|
3059
|
+
// keys/operators); only values may vary between calls.
|
|
3060
|
+
// -------------------------------------------------------------------------
|
|
2862
3061
|
/**
|
|
2863
|
-
*
|
|
2864
|
-
*
|
|
2865
|
-
*
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
3062
|
+
* Resolve the configured global filter for `table`, evaluating a function
|
|
3063
|
+
* filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
|
|
3064
|
+
* no filter applies, the query opted out, or the filter is empty.
|
|
3065
|
+
*/
|
|
3066
|
+
resolveGlobalFilter(table, skip = this.currentSkip) {
|
|
3067
|
+
const filters = this.globalFilters;
|
|
3068
|
+
if (!filters)
|
|
3069
|
+
return null;
|
|
3070
|
+
if (skip === true)
|
|
3071
|
+
return null;
|
|
3072
|
+
if (Array.isArray(skip) && skip.includes(table))
|
|
3073
|
+
return null;
|
|
3074
|
+
const raw = filters[table];
|
|
3075
|
+
if (raw === undefined)
|
|
3076
|
+
return null;
|
|
3077
|
+
const resolved = typeof raw === 'function' ? raw() : raw;
|
|
3078
|
+
if (resolved === null || resolved === undefined)
|
|
3079
|
+
return null;
|
|
3080
|
+
const obj = resolved;
|
|
3081
|
+
// An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
|
|
3082
|
+
// nothing — treat it as absent so it never emits a dangling clause.
|
|
3083
|
+
if (Object.keys(obj).every((k) => obj[k] === undefined))
|
|
3084
|
+
return null;
|
|
3085
|
+
return obj;
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* AND-merge this table's resolved global filter into a user `where`. Either
|
|
3089
|
+
* side may be absent. When no filter applies the user where is returned by
|
|
3090
|
+
* reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
|
|
3091
|
+
*/
|
|
3092
|
+
mergeGlobalFilter(userWhere) {
|
|
3093
|
+
const gf = this.resolveGlobalFilter(this.table);
|
|
3094
|
+
if (!gf)
|
|
3095
|
+
return userWhere;
|
|
3096
|
+
if (userWhere === undefined)
|
|
3097
|
+
return gf;
|
|
3098
|
+
return { AND: [userWhere, gf] };
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* SQL clause for `targetTable`'s global filter rendered against `alias`
|
|
3102
|
+
* (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
|
|
3103
|
+
* `params`; returns `''` when no filter applies. Mirror:
|
|
3104
|
+
* {@link collectTargetGlobalFilterAlias}.
|
|
3105
|
+
*/
|
|
3106
|
+
targetGlobalFilterAlias(targetTable, alias, params) {
|
|
3107
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
3108
|
+
if (!gf)
|
|
3109
|
+
return '';
|
|
3110
|
+
const meta = this.schema.tables[targetTable];
|
|
3111
|
+
if (!meta)
|
|
3112
|
+
return '';
|
|
3113
|
+
return this.buildAliasWhere(targetTable, meta, alias, gf, params) ?? '';
|
|
3114
|
+
}
|
|
3115
|
+
/** Param-collect mirror of {@link targetGlobalFilterAlias}. */
|
|
3116
|
+
collectTargetGlobalFilterAlias(targetTable, params) {
|
|
3117
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
3118
|
+
if (!gf)
|
|
3119
|
+
return;
|
|
3120
|
+
const meta = this.schema.tables[targetTable];
|
|
3121
|
+
if (!meta)
|
|
3122
|
+
return;
|
|
3123
|
+
this.collectAliasWhereParams(targetTable, meta, gf, params);
|
|
3124
|
+
}
|
|
3125
|
+
/**
|
|
3126
|
+
* SQL clause for `targetTable`'s global filter rendered against the bare
|
|
3127
|
+
* (unaliased) table name — the form used inside relation-filter `EXISTS`
|
|
3128
|
+
* subqueries. Pushes its params; `''` when none. Mirror:
|
|
3129
|
+
* {@link collectTargetGlobalFilterExists}.
|
|
3130
|
+
*/
|
|
3131
|
+
targetGlobalFilterExists(targetTable, params) {
|
|
3132
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
3133
|
+
if (!gf)
|
|
3134
|
+
return '';
|
|
3135
|
+
return this.buildSubWhereForRelation(targetTable, gf, params) ?? '';
|
|
3136
|
+
}
|
|
3137
|
+
/** Param-collect mirror of {@link targetGlobalFilterExists}. */
|
|
3138
|
+
collectTargetGlobalFilterExists(targetTable, params) {
|
|
3139
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
3140
|
+
if (!gf)
|
|
3141
|
+
return;
|
|
3142
|
+
this.collectRelFilterParams(targetTable, gf, params);
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3145
|
+
* Value-invariant SQL-cache-key segment for the active global-filter
|
|
3146
|
+
* environment. Relation-subquery / relation-filter / `_count` / relation-
|
|
3147
|
+
* `orderBy` global filters are rendered at build time but their SHAPE is not
|
|
3148
|
+
* otherwise in the where/with fingerprint, so this segment guards the cache:
|
|
3149
|
+
* two different filter shapes never collide on one cached SQL text, while two
|
|
3150
|
+
* function-filter results of the SAME shape (differing only in values) share
|
|
3151
|
+
* the entry and bind their own params. Empty (`''`) when no filter applies, so
|
|
3152
|
+
* cache keys stay byte-identical when the feature is unused.
|
|
3153
|
+
*/
|
|
3154
|
+
globalFilterCacheSegment() {
|
|
3155
|
+
const filters = this.globalFilters;
|
|
3156
|
+
if (!filters)
|
|
3157
|
+
return '';
|
|
3158
|
+
const parts = [];
|
|
3159
|
+
for (const table of Object.keys(filters).sort()) {
|
|
3160
|
+
// Function filters for OTHER tables may be request-scoped closures that
|
|
3161
|
+
// throw outside their own context; a query on an unrelated table must not
|
|
3162
|
+
// break on them. A throwing filter can't have contributed SQL to this
|
|
3163
|
+
// query either (merging it would have thrown first), so a constant
|
|
3164
|
+
// marker keeps the key shape-distinct without evaluating it.
|
|
3165
|
+
let gf;
|
|
3166
|
+
try {
|
|
3167
|
+
gf = this.resolveGlobalFilter(table);
|
|
3168
|
+
}
|
|
3169
|
+
catch {
|
|
3170
|
+
parts.push(`${table}:!`);
|
|
3171
|
+
continue;
|
|
3172
|
+
}
|
|
3173
|
+
if (gf)
|
|
3174
|
+
parts.push(`${table}:${this.fingerprintWhere(gf)}`);
|
|
3175
|
+
}
|
|
3176
|
+
return parts.length ? `|gf=${parts.join(';')}` : '';
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* True when the USER-supplied `where` compiles to no predicate (`{}`,
|
|
3180
|
+
* `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
|
|
3181
|
+
* signal the empty-`where` guard needs — the compiled emptiness, NOT the
|
|
3182
|
+
* fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
|
|
3183
|
+
* any configured global filter, so a global filter never lets an unguarded
|
|
3184
|
+
* mass mutation through.
|
|
2869
3185
|
*/
|
|
3186
|
+
userPredicateIsEmpty(userWhere) {
|
|
3187
|
+
const throwaway = [];
|
|
3188
|
+
return this.buildWhereClause(userWhere, throwaway) === null;
|
|
3189
|
+
}
|
|
2870
3190
|
assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
|
|
2871
3191
|
if (whereSql.length > 0)
|
|
2872
3192
|
return;
|
|
@@ -3038,55 +3358,69 @@ class QueryInterface {
|
|
|
3038
3358
|
// belongsTo: parent.fk = child.pk
|
|
3039
3359
|
correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
|
|
3040
3360
|
}
|
|
3041
|
-
//
|
|
3361
|
+
// The target table's global filter (soft-delete / tenancy) restricts the
|
|
3362
|
+
// DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
|
|
3363
|
+
// ignore filtered-out rows, and `every` quantifies over only the surviving
|
|
3364
|
+
// rows ("every NON-deleted related row matches P"). It is ANDed into the
|
|
3365
|
+
// correlation and its params pushed AFTER the per-branch filter — mirrored
|
|
3366
|
+
// exactly in collectWhereParams' relation-filter branch. `qt` is the bare
|
|
3367
|
+
// target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
|
|
3368
|
+
const gfAnd = () => {
|
|
3369
|
+
const gf = this.targetGlobalFilterExists(targetTable, params);
|
|
3370
|
+
return gf ? ` AND ${gf}` : '';
|
|
3371
|
+
};
|
|
3372
|
+
// "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
3042
3373
|
if (filterObj.some !== undefined) {
|
|
3043
3374
|
const subWhere = filterObj.some;
|
|
3044
3375
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3045
|
-
const
|
|
3046
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3376
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3377
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3047
3378
|
}
|
|
3048
|
-
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
|
|
3379
|
+
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
3049
3380
|
if (filterObj.none !== undefined) {
|
|
3050
3381
|
const subWhere = filterObj.none;
|
|
3051
3382
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3052
|
-
const
|
|
3053
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3383
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3384
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3054
3385
|
}
|
|
3055
|
-
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
|
|
3386
|
+
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
|
|
3056
3387
|
if (filterObj.every !== undefined) {
|
|
3057
3388
|
const subWhere = filterObj.every;
|
|
3058
3389
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3059
3390
|
if (filterClause) {
|
|
3060
|
-
|
|
3391
|
+
// gf params pushed AFTER filter params (collect mirrors this order), but
|
|
3392
|
+
// placed textually inside the domain so it restricts which rows count.
|
|
3393
|
+
const gf = gfAnd();
|
|
3394
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
|
|
3061
3395
|
}
|
|
3062
3396
|
else {
|
|
3063
|
-
// "every" with empty filter = true (all match trivially)
|
|
3397
|
+
// "every" with empty filter = true (all match trivially) — gf irrelevant.
|
|
3064
3398
|
}
|
|
3065
3399
|
}
|
|
3066
3400
|
// "is": EXISTS — for to-one relations (same SQL as "some").
|
|
3067
3401
|
// `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
|
|
3068
3402
|
if (filterObj.is !== undefined) {
|
|
3069
3403
|
if (filterObj.is === null) {
|
|
3070
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3404
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
|
|
3071
3405
|
}
|
|
3072
3406
|
else {
|
|
3073
3407
|
const subWhere = filterObj.is;
|
|
3074
3408
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3075
|
-
const
|
|
3076
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3409
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3410
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3077
3411
|
}
|
|
3078
3412
|
}
|
|
3079
3413
|
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
|
|
3080
3414
|
// `isNot: null` = "a related row exists" → EXISTS.
|
|
3081
3415
|
if (filterObj.isNot !== undefined) {
|
|
3082
3416
|
if (filterObj.isNot === null) {
|
|
3083
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3417
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
|
|
3084
3418
|
}
|
|
3085
3419
|
else {
|
|
3086
3420
|
const subWhere = filterObj.isNot;
|
|
3087
3421
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3088
|
-
const
|
|
3089
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3422
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3423
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3090
3424
|
}
|
|
3091
3425
|
}
|
|
3092
3426
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
@@ -3281,17 +3615,9 @@ class QueryInterface {
|
|
|
3281
3615
|
if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
3282
3616
|
const norm = this.normalizeRelationFilter(aliasRel, value);
|
|
3283
3617
|
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3284
|
-
//
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
if (norm.none != null)
|
|
3288
|
-
this.collectRelFilterParams(aliasRel.to, norm.none, params);
|
|
3289
|
-
if (norm.every != null)
|
|
3290
|
-
this.collectRelFilterParams(aliasRel.to, norm.every, params);
|
|
3291
|
-
if (norm.is != null)
|
|
3292
|
-
this.collectRelFilterParams(aliasRel.to, norm.is, params);
|
|
3293
|
-
if (norm.isNot != null)
|
|
3294
|
-
this.collectRelFilterParams(aliasRel.to, norm.isNot, params);
|
|
3618
|
+
// Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
|
|
3619
|
+
// params then target global-filter params).
|
|
3620
|
+
this.collectRelationFilterParams(aliasRel, norm, params);
|
|
3295
3621
|
continue;
|
|
3296
3622
|
}
|
|
3297
3623
|
}
|
|
@@ -3438,10 +3764,37 @@ class QueryInterface {
|
|
|
3438
3764
|
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
3439
3765
|
* ordering throws — KNN ordering is only supported at the top level.
|
|
3440
3766
|
*/
|
|
3767
|
+
/**
|
|
3768
|
+
* Value-shape fingerprint for a single orderBy entry, so two queries whose
|
|
3769
|
+
* ORDER BY differs only in nulls placement, vector metric, or relation-count
|
|
3770
|
+
* vs relation-column never collide on one cached SQL string. Captures the
|
|
3771
|
+
* SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
|
|
3772
|
+
*/
|
|
3773
|
+
orderByEntryFingerprint(d) {
|
|
3774
|
+
// Vector KNN ordering changes the emitted operator by metric and adds a
|
|
3775
|
+
// `::vector` param, so metric + direction must be part of the cache key.
|
|
3776
|
+
if (isVectorOrderBy(d)) {
|
|
3777
|
+
return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
3778
|
+
}
|
|
3779
|
+
if (isOrderBySpec(d))
|
|
3780
|
+
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3781
|
+
if (d && typeof d === 'object') {
|
|
3782
|
+
// Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
|
|
3783
|
+
return `rel(${Object.entries(d)
|
|
3784
|
+
.map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
|
|
3785
|
+
.sort()
|
|
3786
|
+
.join(',')})`;
|
|
3787
|
+
}
|
|
3788
|
+
return String(d);
|
|
3789
|
+
}
|
|
3441
3790
|
buildOrderBy(orderBy, params) {
|
|
3442
|
-
// Dev-only: validate that orderBy fields exist in the table schema
|
|
3791
|
+
// Dev-only: validate that orderBy fields exist in the table schema. Relation
|
|
3792
|
+
// orderBy keys (object values that are neither a vector nor an OrderBySpec)
|
|
3793
|
+
// are validated in the relation branch below, so skip them here.
|
|
3443
3794
|
if (process.env.NODE_ENV !== 'production') {
|
|
3444
|
-
for (const key of Object.
|
|
3795
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
3796
|
+
if (this.isRelationOrderByValue(value) && this.tableMeta.relations[key])
|
|
3797
|
+
continue;
|
|
3445
3798
|
const snakeKey = (0, schema_js_1.camelToSnake)(key);
|
|
3446
3799
|
if (!this.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in this.tableMeta.columnMap)) {
|
|
3447
3800
|
console.warn(`[turbine] Unknown orderBy field "${key}" for table "${this.tableMeta.name}". ` +
|
|
@@ -3450,28 +3803,217 @@ class QueryInterface {
|
|
|
3450
3803
|
}
|
|
3451
3804
|
}
|
|
3452
3805
|
const meta = this.schema.tables[this.table];
|
|
3806
|
+
let relOrdCounter = 0;
|
|
3453
3807
|
return Object.entries(orderBy)
|
|
3454
|
-
.map(([key,
|
|
3455
|
-
if (meta && !(key in meta.columnMap)) {
|
|
3456
|
-
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3457
|
-
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3458
|
-
}
|
|
3808
|
+
.map(([key, value]) => {
|
|
3459
3809
|
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
3460
|
-
if (isVectorOrderBy(
|
|
3810
|
+
if (isVectorOrderBy(value)) {
|
|
3811
|
+
if (meta && !(key in meta.columnMap)) {
|
|
3812
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3813
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3814
|
+
}
|
|
3461
3815
|
if (!params) {
|
|
3462
3816
|
throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
3463
3817
|
}
|
|
3464
3818
|
const rawColumn = this.toColumn(key);
|
|
3465
|
-
const operator = this.vectorOperator(key, rawColumn,
|
|
3466
|
-
const placeholder = this.pushVectorParam(key, rawColumn,
|
|
3467
|
-
const safeDir =
|
|
3819
|
+
const operator = this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
3820
|
+
const placeholder = this.pushVectorParam(key, rawColumn, value.distance.to, params);
|
|
3821
|
+
const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3468
3822
|
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
3469
3823
|
}
|
|
3470
|
-
|
|
3471
|
-
|
|
3824
|
+
// Relation ordering: an object value that is not a vector or OrderBySpec,
|
|
3825
|
+
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
3826
|
+
// { name: 'asc' } }`).
|
|
3827
|
+
if (this.isRelationOrderByValue(value)) {
|
|
3828
|
+
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
|
|
3829
|
+
}
|
|
3830
|
+
// Scalar column ordering — a plain direction or an OrderBySpec (nulls).
|
|
3831
|
+
if (meta && !(key in meta.columnMap)) {
|
|
3832
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3833
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3834
|
+
}
|
|
3835
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
3836
|
+
return `${this.toSqlColumn(key)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
3472
3837
|
})
|
|
3473
3838
|
.join(', ');
|
|
3474
3839
|
}
|
|
3840
|
+
/**
|
|
3841
|
+
* True when an orderBy value is a relation-ordering object: a plain object
|
|
3842
|
+
* that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
|
|
3843
|
+
* in the orderBy clause is a relation name.
|
|
3844
|
+
*/
|
|
3845
|
+
isRelationOrderByValue(value) {
|
|
3846
|
+
return (typeof value === 'object' &&
|
|
3847
|
+
value !== null &&
|
|
3848
|
+
!Array.isArray(value) &&
|
|
3849
|
+
!isVectorOrderBy(value) &&
|
|
3850
|
+
!isOrderBySpec(value));
|
|
3851
|
+
}
|
|
3852
|
+
/**
|
|
3853
|
+
* Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
|
|
3854
|
+
* Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
|
|
3855
|
+
* other engine a caller asking for explicit nulls placement gets a clear
|
|
3856
|
+
* {@link UnsupportedFeatureError} (E017) instead of broken SQL.
|
|
3857
|
+
*/
|
|
3858
|
+
nullsSuffix(nulls) {
|
|
3859
|
+
if (!nulls)
|
|
3860
|
+
return '';
|
|
3861
|
+
if (this.dialect.name !== 'postgresql' && this.dialect.name !== 'sqlite') {
|
|
3862
|
+
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST/LAST ordering', this.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
|
|
3863
|
+
}
|
|
3864
|
+
return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
|
|
3865
|
+
}
|
|
3866
|
+
/**
|
|
3867
|
+
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
3868
|
+
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
3869
|
+
* to-one relation each entry names a target column and becomes a correlated
|
|
3870
|
+
* scalar subquery (supporting {@link OrderBySpec} nulls placement).
|
|
3871
|
+
*
|
|
3872
|
+
* Validation: relation must exist (E005); to-many only allows `_count`, and
|
|
3873
|
+
* to-one only allows real target columns (E003).
|
|
3874
|
+
*/
|
|
3875
|
+
buildRelationOrderBy(relName, value, alias, params) {
|
|
3876
|
+
const relDef = this.tableMeta.relations[relName];
|
|
3877
|
+
if (!relDef) {
|
|
3878
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
|
|
3879
|
+
`Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
|
|
3880
|
+
}
|
|
3881
|
+
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
3882
|
+
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
3883
|
+
const keys = Object.keys(value);
|
|
3884
|
+
if (keys.length !== 1 || keys[0] !== '_count') {
|
|
3885
|
+
throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
|
|
3886
|
+
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
3887
|
+
}
|
|
3888
|
+
const { dir } = normalizeOrderBy(value._count);
|
|
3889
|
+
return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
|
|
3890
|
+
}
|
|
3891
|
+
// To-one: each entry orders by a correlated scalar subquery on a target column.
|
|
3892
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
3893
|
+
if (!targetMeta)
|
|
3894
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
3895
|
+
const qTarget = this.q(relDef.to);
|
|
3896
|
+
const qParent = this.q(this.table);
|
|
3897
|
+
// belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
|
|
3898
|
+
const correlation = relDef.type === 'belongsTo'
|
|
3899
|
+
? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
3900
|
+
: this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3901
|
+
const entries = Object.entries(value);
|
|
3902
|
+
if (entries.length === 0) {
|
|
3903
|
+
throw new errors_js_1.ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
|
|
3904
|
+
}
|
|
3905
|
+
return entries
|
|
3906
|
+
.map(([col, dirValue]) => {
|
|
3907
|
+
const snakeCol = (0, schema_js_1.camelToSnake)(col);
|
|
3908
|
+
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
3909
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
3910
|
+
}
|
|
3911
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
3912
|
+
// Target's global filter applies here too — otherwise ordering keys off
|
|
3913
|
+
// a soft-deleted / other-tenant related row's value (matches the with
|
|
3914
|
+
// subquery semantics for belongsTo/hasOne).
|
|
3915
|
+
let where = correlation;
|
|
3916
|
+
if (params) {
|
|
3917
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
3918
|
+
if (gf)
|
|
3919
|
+
where += ` AND ${gf}`;
|
|
3920
|
+
}
|
|
3921
|
+
return `(SELECT ${alias}.${this.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${this.limitOneClause()}) ${dir}${this.nullsSuffix(nulls)}`;
|
|
3922
|
+
})
|
|
3923
|
+
.join(', ');
|
|
3924
|
+
}
|
|
3925
|
+
/**
|
|
3926
|
+
* Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
|
|
3927
|
+
* relation, correlated to `parentRef`. hasMany counts child rows via the FK;
|
|
3928
|
+
* manyToMany counts junction rows via the source key. Shared by the `_count`
|
|
3929
|
+
* `with` key and to-many relation orderBy.
|
|
3930
|
+
*
|
|
3931
|
+
* When `params` is supplied and the target has a global filter, it is
|
|
3932
|
+
* AND-merged so the count only sees surviving rows (a soft-deleted child is
|
|
3933
|
+
* not counted): hasMany filters the counted rows directly; manyToMany adds an
|
|
3934
|
+
* `EXISTS` on the target through the junction (the junction rows themselves
|
|
3935
|
+
* carry no filter). Params are mirrored by {@link collectRelationCountParams}.
|
|
3936
|
+
*/
|
|
3937
|
+
buildRelationCountExpr(relDef, parentRef, alias, params) {
|
|
3938
|
+
const qParent = this.q(parentRef);
|
|
3939
|
+
const count = this.castAgg('COUNT(*)', 'int');
|
|
3940
|
+
if (relDef.type === 'manyToMany') {
|
|
3941
|
+
if (!relDef.through) {
|
|
3942
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
|
|
3943
|
+
}
|
|
3944
|
+
const qJ = this.q(relDef.through.table);
|
|
3945
|
+
const jalias = `${alias}j`;
|
|
3946
|
+
const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
|
|
3947
|
+
const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
|
|
3948
|
+
let where = sourceKeys
|
|
3949
|
+
.map((jc, i) => `${jalias}.${this.q(jc)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3950
|
+
.join(' AND ');
|
|
3951
|
+
if (params) {
|
|
3952
|
+
const targetExists = this.manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params);
|
|
3953
|
+
if (targetExists)
|
|
3954
|
+
where += ` AND ${targetExists}`;
|
|
3955
|
+
}
|
|
3956
|
+
return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
|
|
3957
|
+
}
|
|
3958
|
+
// hasMany: child FK correlates to the parent reference key.
|
|
3959
|
+
const qTarget = this.q(relDef.to);
|
|
3960
|
+
let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3961
|
+
if (params) {
|
|
3962
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
3963
|
+
if (gf)
|
|
3964
|
+
where += ` AND ${gf}`;
|
|
3965
|
+
}
|
|
3966
|
+
return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
|
|
3967
|
+
}
|
|
3968
|
+
/**
|
|
3969
|
+
* `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
|
|
3970
|
+
* a manyToMany `_count` to targets that survive their global filter. `''` when
|
|
3971
|
+
* the target has no filter. Pushes gf params; mirror:
|
|
3972
|
+
* {@link collectManyToManyTargetGlobalFilter}.
|
|
3973
|
+
*/
|
|
3974
|
+
manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params) {
|
|
3975
|
+
const gf = this.resolveGlobalFilter(relDef.to);
|
|
3976
|
+
if (!gf || !relDef.through)
|
|
3977
|
+
return '';
|
|
3978
|
+
const tMeta = this.schema.tables[relDef.to];
|
|
3979
|
+
if (!tMeta || tMeta.primaryKey.length === 0)
|
|
3980
|
+
return '';
|
|
3981
|
+
const talias = `${alias}t`;
|
|
3982
|
+
const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
|
|
3983
|
+
const pk = tMeta.primaryKey;
|
|
3984
|
+
if (targetKeys.length !== pk.length)
|
|
3985
|
+
return '';
|
|
3986
|
+
const join = targetKeys.map((jc, i) => `${talias}.${this.q(pk[i])} = ${jalias}.${this.q(jc)}`).join(' AND ');
|
|
3987
|
+
const gfClause = this.buildAliasWhere(relDef.to, tMeta, talias, gf, params);
|
|
3988
|
+
const gfAnd = gfClause ? ` AND ${gfClause}` : '';
|
|
3989
|
+
return `EXISTS (SELECT 1 FROM ${this.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
|
|
3990
|
+
}
|
|
3991
|
+
/** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
|
|
3992
|
+
collectManyToManyTargetGlobalFilter(relDef, params) {
|
|
3993
|
+
const gf = this.resolveGlobalFilter(relDef.to);
|
|
3994
|
+
if (!gf || !relDef.through)
|
|
3995
|
+
return;
|
|
3996
|
+
const tMeta = this.schema.tables[relDef.to];
|
|
3997
|
+
if (!tMeta || tMeta.primaryKey.length === 0)
|
|
3998
|
+
return;
|
|
3999
|
+
const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
|
|
4000
|
+
if (targetKeys.length !== tMeta.primaryKey.length)
|
|
4001
|
+
return;
|
|
4002
|
+
this.collectAliasWhereParams(relDef.to, tMeta, gf, params);
|
|
4003
|
+
}
|
|
4004
|
+
/**
|
|
4005
|
+
* Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
|
|
4006
|
+
* params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
|
|
4007
|
+
* when a filter applies — no-op otherwise.
|
|
4008
|
+
*/
|
|
4009
|
+
collectRelationCountParams(relDef, params) {
|
|
4010
|
+
if (relDef.type === 'manyToMany') {
|
|
4011
|
+
this.collectManyToManyTargetGlobalFilter(relDef, params);
|
|
4012
|
+
}
|
|
4013
|
+
else {
|
|
4014
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
3475
4017
|
// -------------------------------------------------------------------------
|
|
3476
4018
|
// pgvector helpers (similarity search)
|
|
3477
4019
|
// -------------------------------------------------------------------------
|
|
@@ -3600,6 +4142,19 @@ class QueryInterface {
|
|
|
3600
4142
|
const meta = this.schema.tables[table];
|
|
3601
4143
|
if (!meta)
|
|
3602
4144
|
return parsed;
|
|
4145
|
+
// Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
|
|
4146
|
+
// parseRow copies these unknown columns through under their raw key.
|
|
4147
|
+
let countObj;
|
|
4148
|
+
for (const key of Object.keys(parsed)) {
|
|
4149
|
+
if (key.startsWith('_count__')) {
|
|
4150
|
+
if (countObj === undefined)
|
|
4151
|
+
countObj = {};
|
|
4152
|
+
countObj[key.slice('_count__'.length)] = Number(parsed[key]);
|
|
4153
|
+
delete parsed[key];
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
if (countObj)
|
|
4157
|
+
parsed._count = countObj;
|
|
3603
4158
|
for (const [relName, relDef] of Object.entries(meta.relations)) {
|
|
3604
4159
|
const rawValue = row[relName];
|
|
3605
4160
|
if (rawValue === undefined)
|
|
@@ -3866,6 +4421,9 @@ class QueryInterface {
|
|
|
3866
4421
|
const relationSelects = [];
|
|
3867
4422
|
const aliasCounter = { n: 0 };
|
|
3868
4423
|
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
4424
|
+
// `_count` is a reserved key handled after the relation subqueries.
|
|
4425
|
+
if (relName === '_count')
|
|
4426
|
+
continue;
|
|
3869
4427
|
const relDef = meta.relations[relName];
|
|
3870
4428
|
if (!relDef) {
|
|
3871
4429
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3875,6 +4433,18 @@ class QueryInterface {
|
|
|
3875
4433
|
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
3876
4434
|
relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
|
|
3877
4435
|
}
|
|
4436
|
+
// Reserved `_count` key → one correlated COUNT(*) scalar subquery per
|
|
4437
|
+
// selected to-many relation, aliased `_count__<rel>`. Appended after the
|
|
4438
|
+
// relation subqueries; the only params they can push come from a global
|
|
4439
|
+
// filter on the counted target (mirrored at the tail of collectWithParams).
|
|
4440
|
+
// Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
|
|
4441
|
+
const countSpec = withClause._count;
|
|
4442
|
+
if (countSpec !== undefined) {
|
|
4443
|
+
for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
|
|
4444
|
+
const expr = this.buildRelationCountExpr(rel, table, `t${aliasCounter.n++}`, params);
|
|
4445
|
+
relationSelects.push(`${expr} AS ${this.q(`_count__${rel.name}`)}`);
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
3878
4448
|
return [baseCols, ...relationSelects].join(', ');
|
|
3879
4449
|
}
|
|
3880
4450
|
/**
|
|
@@ -4075,13 +4645,13 @@ class QueryInterface {
|
|
|
4075
4645
|
let orderClause = '';
|
|
4076
4646
|
if (relOrderEntries.length > 0) {
|
|
4077
4647
|
const orders = relOrderEntries
|
|
4078
|
-
.map(([k,
|
|
4648
|
+
.map(([k, dirValue]) => {
|
|
4079
4649
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
4080
4650
|
if (!targetMeta.allColumns.includes(col)) {
|
|
4081
4651
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4082
4652
|
}
|
|
4083
|
-
const
|
|
4084
|
-
return `${alias}.${this.q(col)} ${
|
|
4653
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4654
|
+
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4085
4655
|
})
|
|
4086
4656
|
.join(', ');
|
|
4087
4657
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -4107,6 +4677,12 @@ class QueryInterface {
|
|
|
4107
4677
|
if (extra)
|
|
4108
4678
|
whereClause += ` AND ${extra}`;
|
|
4109
4679
|
}
|
|
4680
|
+
// Global filter on the target table (soft-delete / tenancy) — AND-merged so
|
|
4681
|
+
// a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
|
|
4682
|
+
// mirrored by collectRelationSubqueryParams.
|
|
4683
|
+
const gfExtra = this.targetGlobalFilterAlias(targetTable, alias, params);
|
|
4684
|
+
if (gfExtra)
|
|
4685
|
+
whereClause += ` AND ${gfExtra}`;
|
|
4110
4686
|
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
4111
4687
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
4112
4688
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
@@ -4217,13 +4793,13 @@ class QueryInterface {
|
|
|
4217
4793
|
let orderClause = '';
|
|
4218
4794
|
if (relOrderEntries.length > 0) {
|
|
4219
4795
|
const orders = relOrderEntries
|
|
4220
|
-
.map(([k,
|
|
4796
|
+
.map(([k, dirValue]) => {
|
|
4221
4797
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
4222
4798
|
if (!targetMeta.allColumns.includes(col)) {
|
|
4223
4799
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4224
4800
|
}
|
|
4225
|
-
const
|
|
4226
|
-
return `${talias}.${this.q(col)} ${
|
|
4801
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4802
|
+
return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4227
4803
|
})
|
|
4228
4804
|
.join(', ');
|
|
4229
4805
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -4235,6 +4811,11 @@ class QueryInterface {
|
|
|
4235
4811
|
if (extra)
|
|
4236
4812
|
whereClause += ` AND ${extra}`;
|
|
4237
4813
|
}
|
|
4814
|
+
// Global filter on the target table (mirrors collectRelationSubqueryParams'
|
|
4815
|
+
// m2m branch: after spec.where, before limit).
|
|
4816
|
+
const gfExtra = this.targetGlobalFilterAlias(targetTable, talias, params);
|
|
4817
|
+
if (gfExtra)
|
|
4818
|
+
whereClause += ` AND ${gfExtra}`;
|
|
4238
4819
|
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
4239
4820
|
let limitClause = '';
|
|
4240
4821
|
if (spec !== true && spec.limit !== undefined) {
|