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
package/dist/query/builder.js
CHANGED
|
@@ -15,7 +15,7 @@ import { CircularRelationError, NotFoundError, OptimisticLockError, RelationErro
|
|
|
15
15
|
import { missingIndexForRelation } from '../index-advisor.js';
|
|
16
16
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
17
17
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
18
|
-
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, stripFields, } from './batched-loader.js';
|
|
18
|
+
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
19
19
|
import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// Internal detection helpers — used by QueryInterface
|
|
@@ -224,6 +224,21 @@ function isVectorFilter(value) {
|
|
|
224
224
|
function isVectorOrderBy(value) {
|
|
225
225
|
return isVectorFilter(value);
|
|
226
226
|
}
|
|
227
|
+
/** Check if an orderBy value is an explicit `{ sort, nulls? }` spec. */
|
|
228
|
+
function isOrderBySpec(value) {
|
|
229
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && 'sort' in value;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
|
|
233
|
+
* direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
|
|
234
|
+
* path (findMany, groupBy, relation inner subqueries).
|
|
235
|
+
*/
|
|
236
|
+
function normalizeOrderBy(value) {
|
|
237
|
+
if (isOrderBySpec(value)) {
|
|
238
|
+
return { dir: value.sort.toLowerCase() === 'desc' ? 'DESC' : 'ASC', nulls: value.nulls };
|
|
239
|
+
}
|
|
240
|
+
return { dir: String(value).toLowerCase() === 'desc' ? 'DESC' : 'ASC' };
|
|
241
|
+
}
|
|
227
242
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
228
243
|
export class QueryInterface {
|
|
229
244
|
pool;
|
|
@@ -243,6 +258,13 @@ export class QueryInterface {
|
|
|
243
258
|
relationLoadStrategy;
|
|
244
259
|
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
245
260
|
jsonEncoding;
|
|
261
|
+
/**
|
|
262
|
+
* Client-level automatic WHERE filters keyed by table accessor (soft-delete /
|
|
263
|
+
* multi-tenancy). AND-merged into every query on the keyed table and every
|
|
264
|
+
* relation subquery targeting it. Undefined when none are configured, in
|
|
265
|
+
* which case every path is byte-identical to the pre-0.28 behavior.
|
|
266
|
+
*/
|
|
267
|
+
globalFilters;
|
|
246
268
|
/**
|
|
247
269
|
* Tracks tables that have already triggered an unlimited-query warning so
|
|
248
270
|
* the user is not spammed once per row. Per-instance state — each
|
|
@@ -274,6 +296,15 @@ export class QueryInterface {
|
|
|
274
296
|
options;
|
|
275
297
|
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
276
298
|
currentAction = 'raw';
|
|
299
|
+
/**
|
|
300
|
+
* The active query's `skipGlobalFilters` opt-out, set at the top of each
|
|
301
|
+
* `build*` method and read deep in the (synchronous) SQL-build + param-collect
|
|
302
|
+
* tree — so relation subqueries, relation filters, `_count`, and relation
|
|
303
|
+
* `orderBy` all see it without threading it through dozens of signatures.
|
|
304
|
+
* Only load-bearing when {@link globalFilters} is configured; build+collect are
|
|
305
|
+
* synchronous per call, so this transient is never observed across an await.
|
|
306
|
+
*/
|
|
307
|
+
currentSkip;
|
|
277
308
|
constructor(pool, table, schema, middlewares, options) {
|
|
278
309
|
this.pool = pool;
|
|
279
310
|
this.table = table;
|
|
@@ -295,6 +326,10 @@ export class QueryInterface {
|
|
|
295
326
|
this.dialect = options?.dialect ?? postgresDialect;
|
|
296
327
|
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
|
|
297
328
|
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
329
|
+
// Only retain the map when it has at least one entry, so `globalFilters`
|
|
330
|
+
// stays `undefined` (and every merge path a no-op) for the common case.
|
|
331
|
+
this.globalFilters =
|
|
332
|
+
options?.globalFilters && Object.keys(options.globalFilters).length > 0 ? options.globalFilters : undefined;
|
|
298
333
|
this.txScoped = options?._txScoped ?? false;
|
|
299
334
|
this.options = options;
|
|
300
335
|
// Pre-compute column type lookup maps (TASK-26)
|
|
@@ -414,7 +449,7 @@ export class QueryInterface {
|
|
|
414
449
|
* and unlimited-warnings silenced — a relation load must fetch every matching
|
|
415
450
|
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
416
451
|
*/
|
|
417
|
-
batchedContext(timeout) {
|
|
452
|
+
batchedContext(timeout, skip) {
|
|
418
453
|
const childOptions = {
|
|
419
454
|
...this.options,
|
|
420
455
|
defaultLimit: undefined,
|
|
@@ -429,6 +464,22 @@ export class QueryInterface {
|
|
|
429
464
|
buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
|
|
430
465
|
inClauseParam: (values) => this.inParam(values),
|
|
431
466
|
paramPlaceholder: (index) => this.p(index),
|
|
467
|
+
skipGlobalFilters: skip,
|
|
468
|
+
tableGlobalFilter: (table, alias, precedingParams) => {
|
|
469
|
+
const gf = this.resolveGlobalFilter(table, skip);
|
|
470
|
+
if (!gf)
|
|
471
|
+
return null;
|
|
472
|
+
const meta = this.schema.tables[table];
|
|
473
|
+
if (!meta)
|
|
474
|
+
return null;
|
|
475
|
+
// Seed the param array with `precedingParams` placeholders so
|
|
476
|
+
// buildAliasWhere numbers the gf params after the already-bound ones.
|
|
477
|
+
const seeded = new Array(precedingParams).fill(undefined);
|
|
478
|
+
const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
|
|
479
|
+
if (!clause)
|
|
480
|
+
return null;
|
|
481
|
+
return { clause, params: seeded.slice(precedingParams) };
|
|
482
|
+
},
|
|
432
483
|
};
|
|
433
484
|
}
|
|
434
485
|
/**
|
|
@@ -440,13 +491,18 @@ export class QueryInterface {
|
|
|
440
491
|
*/
|
|
441
492
|
async runFindManyBatched(args) {
|
|
442
493
|
const withClause = args.with;
|
|
494
|
+
// Capture the opt-out from the ARGS before any await: this.currentSkip is
|
|
495
|
+
// instance state on a cached accessor, so a concurrent build during the
|
|
496
|
+
// base-query await would overwrite it (tenant query loading relations with
|
|
497
|
+
// another query's skipGlobalFilters).
|
|
498
|
+
const skip = args.skipGlobalFilters;
|
|
443
499
|
const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
|
|
444
500
|
// baseArgs.with is always undefined here; the cast just bridges the R generic.
|
|
445
501
|
const deferred = this.buildFindMany(baseArgs);
|
|
446
502
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
447
503
|
const entities = deferred.transform(result);
|
|
448
504
|
if (entities.length > 0) {
|
|
449
|
-
await loadRelationsBatched(this.batchedContext(args.timeout), entities, withClause, args.timeout);
|
|
505
|
+
await loadRelationsBatched(this.batchedContext(args.timeout, skip), entities, withClause, args.timeout);
|
|
450
506
|
}
|
|
451
507
|
stripFields(entities, strip);
|
|
452
508
|
return entities;
|
|
@@ -668,18 +724,22 @@ export class QueryInterface {
|
|
|
668
724
|
const entity = deferred.transform(result);
|
|
669
725
|
if (!entity)
|
|
670
726
|
return null;
|
|
671
|
-
await loadRelationsBatched(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
|
|
727
|
+
await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters), [entity], withClause, args.timeout);
|
|
672
728
|
stripFields([entity], proj.strip);
|
|
673
729
|
return entity;
|
|
674
730
|
}
|
|
675
731
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
676
732
|
buildFindUnique(args) {
|
|
733
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
677
734
|
const columnsList = this.resolveColumns(args.select, args.omit);
|
|
678
|
-
|
|
735
|
+
// A global filter turns the where into `{ AND: [...] }`, which the
|
|
736
|
+
// `isSimpleWhere` test below rejects → the general (buildWhereClause) path
|
|
737
|
+
// handles the merge and its params uniformly.
|
|
738
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
679
739
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
680
740
|
const whereFingerprint = this.fingerprintWhere(whereObj);
|
|
681
741
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
682
|
-
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
742
|
+
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
683
743
|
const params = [];
|
|
684
744
|
// Check if all where values are simple (plain equality, no operators/null/OR).
|
|
685
745
|
// Keys are sorted to match fingerprintWhere — insertion order here would let
|
|
@@ -835,24 +895,21 @@ export class QueryInterface {
|
|
|
835
895
|
}
|
|
836
896
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
837
897
|
buildFindMany(args) {
|
|
898
|
+
this.currentSkip = args?.skipGlobalFilters;
|
|
838
899
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
839
900
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
840
|
-
|
|
901
|
+
// AND-merge this table's global filter into the user where; `hasWhere` gates
|
|
902
|
+
// the build/collect just like `args?.where` did (a merged filter can make
|
|
903
|
+
// an otherwise-absent where present).
|
|
904
|
+
const effWhere = this.mergeGlobalFilter(args?.where);
|
|
905
|
+
const hasWhere = effWhere !== undefined;
|
|
906
|
+
const whereObj = (effWhere ?? {});
|
|
841
907
|
// Build fingerprint for cache lookup
|
|
842
|
-
const whereFp =
|
|
908
|
+
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
843
909
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
844
910
|
const orderFp = args?.orderBy
|
|
845
911
|
? Object.entries(args.orderBy)
|
|
846
|
-
.map(([k, d]) => {
|
|
847
|
-
// Vector KNN ordering changes the emitted SQL operator by metric and
|
|
848
|
-
// adds a `::vector` param, so the metric + direction must be part of
|
|
849
|
-
// the cache key — otherwise two KNN queries differing only in metric
|
|
850
|
-
// would collide on a single cached SQL string.
|
|
851
|
-
if (isVectorOrderBy(d)) {
|
|
852
|
-
return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
853
|
-
}
|
|
854
|
-
return `${k}:${d}`;
|
|
855
|
-
})
|
|
912
|
+
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
|
|
856
913
|
.join(',')
|
|
857
914
|
: '';
|
|
858
915
|
const cursorFp = args?.cursor
|
|
@@ -865,12 +922,12 @@ export class QueryInterface {
|
|
|
865
922
|
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
866
923
|
const limitFp = effectiveLimit !== undefined ? '1' : '0';
|
|
867
924
|
const offsetFp = args?.offset !== undefined ? '1' : '0';
|
|
868
|
-
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}`;
|
|
925
|
+
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
869
926
|
const params = [];
|
|
870
927
|
const entry = this.acquireSql(ck, () => {
|
|
871
928
|
// Fresh build — generates SQL and populates freshParams
|
|
872
929
|
const freshParams = [];
|
|
873
|
-
const { sql: freshWhereSql } =
|
|
930
|
+
const { sql: freshWhereSql } = hasWhere
|
|
874
931
|
? (() => {
|
|
875
932
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
876
933
|
return { sql: clause ? ` WHERE ${clause}` : '' };
|
|
@@ -900,8 +957,11 @@ export class QueryInterface {
|
|
|
900
957
|
if (cursorEntries.length > 0) {
|
|
901
958
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
902
959
|
const col = this.toSqlColumn(k);
|
|
903
|
-
|
|
904
|
-
|
|
960
|
+
// orderBy values can be the { sort, nulls } spec form — normalize
|
|
961
|
+
// before comparing, or a desc spec would seek the ascending side.
|
|
962
|
+
const dir = args.orderBy?.[k];
|
|
963
|
+
const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
|
|
964
|
+
const op = desc ? '<' : '>';
|
|
905
965
|
freshParams.push(v);
|
|
906
966
|
return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
|
|
907
967
|
});
|
|
@@ -948,8 +1008,8 @@ export class QueryInterface {
|
|
|
948
1008
|
return sql;
|
|
949
1009
|
});
|
|
950
1010
|
// Collect params in exact build order:
|
|
951
|
-
// 1. WHERE params
|
|
952
|
-
if (
|
|
1011
|
+
// 1. WHERE params (includes the AND-merged global filter, if any)
|
|
1012
|
+
if (hasWhere) {
|
|
953
1013
|
this.collectWhereParams(whereObj, params);
|
|
954
1014
|
}
|
|
955
1015
|
// 2. WITH relation params
|
|
@@ -1161,6 +1221,8 @@ export class QueryInterface {
|
|
|
1161
1221
|
});
|
|
1162
1222
|
}
|
|
1163
1223
|
buildCreate(args) {
|
|
1224
|
+
this.assertWritable('create');
|
|
1225
|
+
this.assertNoGeneratedColumns(args.data, 'create');
|
|
1164
1226
|
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
1165
1227
|
const columns = entries.map(([k]) => this.toSqlColumn(k));
|
|
1166
1228
|
const params = entries.map(([, v]) => v);
|
|
@@ -1235,6 +1297,10 @@ export class QueryInterface {
|
|
|
1235
1297
|
tag: `${this.table}.createMany`,
|
|
1236
1298
|
};
|
|
1237
1299
|
}
|
|
1300
|
+
this.assertWritable('createMany');
|
|
1301
|
+
for (const row of args.data) {
|
|
1302
|
+
this.assertNoGeneratedColumns(row, 'createMany');
|
|
1303
|
+
}
|
|
1238
1304
|
const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
|
|
1239
1305
|
const columns = keys.map((k) => this.toColumn(k));
|
|
1240
1306
|
const rowValues = args.data.map((row) => {
|
|
@@ -1272,12 +1338,22 @@ export class QueryInterface {
|
|
|
1272
1338
|
});
|
|
1273
1339
|
}
|
|
1274
1340
|
buildUpdate(args) {
|
|
1341
|
+
this.assertWritable('update');
|
|
1342
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1275
1343
|
const dataObj = args.data;
|
|
1276
|
-
|
|
1344
|
+
this.assertNoGeneratedColumns(dataObj, 'update');
|
|
1345
|
+
const userWhere = args.where;
|
|
1277
1346
|
const lock = args.optimisticLock;
|
|
1347
|
+
// The empty-`where` guard checks the USER predicate only — a global filter
|
|
1348
|
+
// must never turn an unguarded mass update into an allowed one.
|
|
1349
|
+
const userHasPredicate = !this.userPredicateIsEmpty(userWhere) || !!lock;
|
|
1350
|
+
this.assertMutationHasPredicate('update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
|
|
1351
|
+
// The SQL is built from the global-filter-merged where (soft-delete keeps an
|
|
1352
|
+
// update from touching already-deleted rows).
|
|
1353
|
+
const whereObj = (this.mergeGlobalFilter(userWhere) ?? {});
|
|
1278
1354
|
const setFp = this.fingerprintSet(dataObj);
|
|
1279
1355
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1280
|
-
const ck = lock ? null : `u:${setFp}|${whereFp}`;
|
|
1356
|
+
const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1281
1357
|
const params = [];
|
|
1282
1358
|
const buildSql = () => {
|
|
1283
1359
|
const freshParams = [];
|
|
@@ -1295,7 +1371,6 @@ export class QueryInterface {
|
|
|
1295
1371
|
const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
|
|
1296
1372
|
whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
|
|
1297
1373
|
}
|
|
1298
|
-
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
1299
1374
|
// Engines that inject their returning shape MID-statement (SQL Server
|
|
1300
1375
|
// `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
|
|
1301
1376
|
// absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
@@ -1309,9 +1384,6 @@ export class QueryInterface {
|
|
|
1309
1384
|
const entry = this.acquireSql(ck, buildSql);
|
|
1310
1385
|
sql = entry.sql;
|
|
1311
1386
|
preparedName = entry.name;
|
|
1312
|
-
if (whereFp === '') {
|
|
1313
|
-
this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
|
|
1314
|
-
}
|
|
1315
1387
|
}
|
|
1316
1388
|
else {
|
|
1317
1389
|
sql = buildSql();
|
|
@@ -1445,27 +1517,24 @@ export class QueryInterface {
|
|
|
1445
1517
|
});
|
|
1446
1518
|
}
|
|
1447
1519
|
buildDelete(args) {
|
|
1448
|
-
|
|
1520
|
+
this.assertWritable('delete');
|
|
1521
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1522
|
+
// Guard the USER predicate (a global filter must not satisfy the guard).
|
|
1523
|
+
this.assertMutationHasPredicate('delete', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1524
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1449
1525
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1450
|
-
const ck = `d:${whereFp}`;
|
|
1526
|
+
const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1451
1527
|
const params = [];
|
|
1452
|
-
// We need to check the mutation predicate. Build the whereSql to test it.
|
|
1453
|
-
// On cache hit we still need to validate (the shape may be empty).
|
|
1454
1528
|
const entry = this.acquireSql(ck, () => {
|
|
1455
1529
|
const freshParams = [];
|
|
1456
1530
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1457
1531
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1458
|
-
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
1459
1532
|
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
1460
1533
|
// absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
1461
1534
|
return this.dialect.buildDeleteStatement
|
|
1462
1535
|
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1463
1536
|
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1464
1537
|
});
|
|
1465
|
-
// On cache hit, still validate the predicate
|
|
1466
|
-
if (whereFp === '') {
|
|
1467
|
-
this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
|
|
1468
|
-
}
|
|
1469
1538
|
this.collectWhereParams(whereObj, params);
|
|
1470
1539
|
return {
|
|
1471
1540
|
sql: entry.sql,
|
|
@@ -1505,6 +1574,10 @@ export class QueryInterface {
|
|
|
1505
1574
|
});
|
|
1506
1575
|
}
|
|
1507
1576
|
buildUpsert(args) {
|
|
1577
|
+
this.assertWritable('upsert');
|
|
1578
|
+
this.assertNoGeneratedColumns(args.create, 'upsert');
|
|
1579
|
+
this.assertNoGeneratedColumns(args.update, 'upsert');
|
|
1580
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1508
1581
|
// Build the INSERT part from create data
|
|
1509
1582
|
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
1510
1583
|
const columns = createEntries.map(([k]) => this.toSqlColumn(k));
|
|
@@ -1523,12 +1596,23 @@ export class QueryInterface {
|
|
|
1523
1596
|
});
|
|
1524
1597
|
const updateParams = updateEntries.map(([, v]) => v);
|
|
1525
1598
|
const params = [...createParams, ...updateParams];
|
|
1599
|
+
// Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
|
|
1600
|
+
// upsert never resurrects a soft-deleted row or writes across tenants. Only
|
|
1601
|
+
// on engines whose upsert can carry a predicate (Postgres); the gf params
|
|
1602
|
+
// continue the placeholder numbering after create+update params.
|
|
1603
|
+
let updateWhere;
|
|
1604
|
+
if (this.dialect.supportsUpsertUpdateWhere) {
|
|
1605
|
+
const gf = this.resolveGlobalFilter(this.table);
|
|
1606
|
+
if (gf)
|
|
1607
|
+
updateWhere = this.buildWhereClause(gf, params) ?? undefined;
|
|
1608
|
+
}
|
|
1526
1609
|
const sql = this.dialect.buildUpsertStatement({
|
|
1527
1610
|
table: this.q(this.table),
|
|
1528
1611
|
insertColumns: columns,
|
|
1529
1612
|
valuePlaceholders: placeholders,
|
|
1530
1613
|
conflictColumns,
|
|
1531
1614
|
updateSetClauses: setClauses,
|
|
1615
|
+
updateWhere,
|
|
1532
1616
|
returning: '*',
|
|
1533
1617
|
});
|
|
1534
1618
|
return {
|
|
@@ -1551,7 +1635,7 @@ export class QueryInterface {
|
|
|
1551
1635
|
reselect: this.dialect.resultStrategy === 'reselect'
|
|
1552
1636
|
? async (exec) => {
|
|
1553
1637
|
await exec(sql, params);
|
|
1554
|
-
const sel = this.buildReselectByWhere(args.where);
|
|
1638
|
+
const sel = this.buildReselectByWhere((this.mergeGlobalFilter(args.where) ?? {}));
|
|
1555
1639
|
return exec(sel.sql, sel.params);
|
|
1556
1640
|
}
|
|
1557
1641
|
: undefined,
|
|
@@ -1568,11 +1652,15 @@ export class QueryInterface {
|
|
|
1568
1652
|
});
|
|
1569
1653
|
}
|
|
1570
1654
|
buildUpdateMany(args) {
|
|
1655
|
+
this.assertWritable('updateMany');
|
|
1656
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1571
1657
|
const dataObj = args.data;
|
|
1572
|
-
|
|
1658
|
+
this.assertNoGeneratedColumns(dataObj, 'updateMany');
|
|
1659
|
+
this.assertMutationHasPredicate('updateMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1660
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1573
1661
|
const setFp = this.fingerprintSet(dataObj);
|
|
1574
1662
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1575
|
-
const ck = `um:${setFp}|${whereFp}`;
|
|
1663
|
+
const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1576
1664
|
const params = [];
|
|
1577
1665
|
const entry = this.acquireSql(ck, () => {
|
|
1578
1666
|
const freshParams = [];
|
|
@@ -1580,12 +1668,8 @@ export class QueryInterface {
|
|
|
1580
1668
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1581
1669
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
1582
1670
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
1583
|
-
this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
|
|
1584
1671
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
1585
1672
|
});
|
|
1586
|
-
if (whereFp === '') {
|
|
1587
|
-
this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
|
|
1588
|
-
}
|
|
1589
1673
|
this.collectSetParams(dataObj, params);
|
|
1590
1674
|
this.collectWhereParams(whereObj, params);
|
|
1591
1675
|
return {
|
|
@@ -1607,20 +1691,19 @@ export class QueryInterface {
|
|
|
1607
1691
|
});
|
|
1608
1692
|
}
|
|
1609
1693
|
buildDeleteMany(args) {
|
|
1610
|
-
|
|
1694
|
+
this.assertWritable('deleteMany');
|
|
1695
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1696
|
+
this.assertMutationHasPredicate('deleteMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1697
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1611
1698
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1612
|
-
const ck = `dm:${whereFp}`;
|
|
1699
|
+
const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1613
1700
|
const params = [];
|
|
1614
1701
|
const entry = this.acquireSql(ck, () => {
|
|
1615
1702
|
const freshParams = [];
|
|
1616
1703
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1617
1704
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1618
|
-
this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
|
|
1619
1705
|
return `DELETE FROM ${this.q(this.table)}${whereSql}`;
|
|
1620
1706
|
});
|
|
1621
|
-
if (whereFp === '') {
|
|
1622
|
-
this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
|
|
1623
|
-
}
|
|
1624
1707
|
this.collectWhereParams(whereObj, params);
|
|
1625
1708
|
return {
|
|
1626
1709
|
sql: entry.sql,
|
|
@@ -1641,17 +1724,20 @@ export class QueryInterface {
|
|
|
1641
1724
|
});
|
|
1642
1725
|
}
|
|
1643
1726
|
buildCount(args) {
|
|
1644
|
-
|
|
1645
|
-
const
|
|
1646
|
-
const
|
|
1727
|
+
this.currentSkip = args?.skipGlobalFilters;
|
|
1728
|
+
const effWhere = this.mergeGlobalFilter(args?.where);
|
|
1729
|
+
const hasWhere = effWhere !== undefined;
|
|
1730
|
+
const whereObj = (effWhere ?? {});
|
|
1731
|
+
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
1732
|
+
const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1647
1733
|
const params = [];
|
|
1648
1734
|
const entry = this.acquireSql(ck, () => {
|
|
1649
1735
|
const freshParams = [];
|
|
1650
|
-
const clause =
|
|
1736
|
+
const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1651
1737
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1652
1738
|
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1653
1739
|
});
|
|
1654
|
-
if (
|
|
1740
|
+
if (hasWhere) {
|
|
1655
1741
|
this.collectWhereParams(whereObj, params);
|
|
1656
1742
|
}
|
|
1657
1743
|
return {
|
|
@@ -1681,9 +1767,13 @@ export class QueryInterface {
|
|
|
1681
1767
|
}
|
|
1682
1768
|
}
|
|
1683
1769
|
}
|
|
1770
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1684
1771
|
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
1685
1772
|
const groupCols = groupColsRaw.map((c) => this.q(c));
|
|
1686
|
-
const
|
|
1773
|
+
const gbWhere = this.mergeGlobalFilter(args.where);
|
|
1774
|
+
const { sql: whereSql, params } = gbWhere
|
|
1775
|
+
? this.buildWhere(gbWhere)
|
|
1776
|
+
: { sql: '', params: [] };
|
|
1687
1777
|
// Build SELECT expressions: group-by columns + aggregate functions
|
|
1688
1778
|
const selectExprs = [...groupCols];
|
|
1689
1779
|
// _count
|
|
@@ -1926,7 +2016,11 @@ export class QueryInterface {
|
|
|
1926
2016
|
});
|
|
1927
2017
|
}
|
|
1928
2018
|
buildAggregate(args) {
|
|
1929
|
-
|
|
2019
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
2020
|
+
const aggWhere = this.mergeGlobalFilter(args.where);
|
|
2021
|
+
const { sql: whereSql, params } = aggWhere
|
|
2022
|
+
? this.buildWhere(aggWhere)
|
|
2023
|
+
: { sql: '', params: [] };
|
|
1930
2024
|
const meta = this.schema.tables[this.table];
|
|
1931
2025
|
if (meta) {
|
|
1932
2026
|
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
@@ -2104,6 +2198,36 @@ export class QueryInterface {
|
|
|
2104
2198
|
}
|
|
2105
2199
|
return null;
|
|
2106
2200
|
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Reject any write against a view (H4). Views are introspected with
|
|
2203
|
+
* `isView: true` and are read-only in every engine; a write raises a
|
|
2204
|
+
* {@link ValidationError} (E003) rather than emitting SQL Postgres would
|
|
2205
|
+
* reject (or, worse, silently applying to an updatable view).
|
|
2206
|
+
*/
|
|
2207
|
+
assertWritable(operation) {
|
|
2208
|
+
if (this.tableMeta.isView) {
|
|
2209
|
+
throw new ValidationError(`[turbine] Cannot ${operation} "${this.table}": it is a view (read-only). ` +
|
|
2210
|
+
'Views support reads (findMany/findFirst/…) but not writes.');
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
|
|
2215
|
+
* column (H3). Postgres computes these from other columns and errors if you
|
|
2216
|
+
* try to write them; we fail early with a clear {@link ValidationError} (E003)
|
|
2217
|
+
* instead of surfacing a cryptic driver error. Undefined values are ignored
|
|
2218
|
+
* (they're stripped from the statement anyway).
|
|
2219
|
+
*/
|
|
2220
|
+
assertNoGeneratedColumns(data, operation) {
|
|
2221
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2222
|
+
if (value === undefined)
|
|
2223
|
+
continue;
|
|
2224
|
+
const col = this.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
|
|
2225
|
+
if (col?.isGeneratedStored) {
|
|
2226
|
+
throw new ValidationError(`[turbine] Cannot ${operation} "${this.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
|
|
2227
|
+
'column whose value the database computes — remove it from your data.');
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2107
2231
|
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
2108
2232
|
toColumn(field) {
|
|
2109
2233
|
const mapped = this.tableMeta.columnMap[field];
|
|
@@ -2433,16 +2557,7 @@ export class QueryInterface {
|
|
|
2433
2557
|
'none' in filterObj ||
|
|
2434
2558
|
'is' in filterObj ||
|
|
2435
2559
|
'isNot' in filterObj) {
|
|
2436
|
-
|
|
2437
|
-
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
2438
|
-
if (filterObj.none !== undefined && filterObj.none !== null)
|
|
2439
|
-
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
2440
|
-
if (filterObj.every !== undefined && filterObj.every !== null)
|
|
2441
|
-
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
2442
|
-
if (filterObj.is !== undefined && filterObj.is !== null)
|
|
2443
|
-
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
2444
|
-
if (filterObj.isNot !== undefined && filterObj.isNot !== null)
|
|
2445
|
-
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
2560
|
+
this.collectRelationFilterParams(relationDef, filterObj, params);
|
|
2446
2561
|
continue;
|
|
2447
2562
|
}
|
|
2448
2563
|
}
|
|
@@ -2490,7 +2605,45 @@ export class QueryInterface {
|
|
|
2490
2605
|
params.push(value);
|
|
2491
2606
|
}
|
|
2492
2607
|
}
|
|
2493
|
-
/**
|
|
2608
|
+
/**
|
|
2609
|
+
* Param-collect mirror of {@link buildRelationFilter} for one relation-filter
|
|
2610
|
+
* object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
|
|
2611
|
+
* present branch and in the canonical order some→none→every→is→isNot, the
|
|
2612
|
+
* branch's sub-where params THEN the target table's global-filter params —
|
|
2613
|
+
* exactly the order buildRelationFilter emits. When no global filter applies
|
|
2614
|
+
* the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
|
|
2615
|
+
* Shared by every collect site that mirrors buildRelationFilter
|
|
2616
|
+
* (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
|
|
2617
|
+
*/
|
|
2618
|
+
collectRelationFilterParams(relDef, filterObj, params) {
|
|
2619
|
+
const target = relDef.to;
|
|
2620
|
+
if (filterObj.some !== undefined && filterObj.some !== null) {
|
|
2621
|
+
this.collectRelFilterParams(target, filterObj.some, params);
|
|
2622
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2623
|
+
}
|
|
2624
|
+
if (filterObj.none !== undefined && filterObj.none !== null) {
|
|
2625
|
+
this.collectRelFilterParams(target, filterObj.none, params);
|
|
2626
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2627
|
+
}
|
|
2628
|
+
if (filterObj.every !== undefined && filterObj.every !== null) {
|
|
2629
|
+
// gf is only emitted (build) when the `every` sub-where compiles to a
|
|
2630
|
+
// filter — otherwise `every` is trivially true and no subquery is built.
|
|
2631
|
+
if (this.buildSubWhereForRelation(target, filterObj.every, []) !== null) {
|
|
2632
|
+
this.collectRelFilterParams(target, filterObj.every, params);
|
|
2633
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
if (filterObj.is !== undefined) {
|
|
2637
|
+
if (filterObj.is !== null)
|
|
2638
|
+
this.collectRelFilterParams(target, filterObj.is, params);
|
|
2639
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2640
|
+
}
|
|
2641
|
+
if (filterObj.isNot !== undefined) {
|
|
2642
|
+
if (filterObj.isNot !== null)
|
|
2643
|
+
this.collectRelFilterParams(target, filterObj.isNot, params);
|
|
2644
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2494
2647
|
collectRelFilterParams(targetTable, subWhere, params) {
|
|
2495
2648
|
const meta = this.schema.tables[targetTable];
|
|
2496
2649
|
if (!meta)
|
|
@@ -2518,17 +2671,9 @@ export class QueryInterface {
|
|
|
2518
2671
|
if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
2519
2672
|
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
2520
2673
|
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
2521
|
-
//
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
if (norm.none != null)
|
|
2525
|
-
this.collectRelFilterParams(nestedRel.to, norm.none, params);
|
|
2526
|
-
if (norm.every != null)
|
|
2527
|
-
this.collectRelFilterParams(nestedRel.to, norm.every, params);
|
|
2528
|
-
if (norm.is != null)
|
|
2529
|
-
this.collectRelFilterParams(nestedRel.to, norm.is, params);
|
|
2530
|
-
if (norm.isNot != null)
|
|
2531
|
-
this.collectRelFilterParams(nestedRel.to, norm.isNot, params);
|
|
2674
|
+
// Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
|
|
2675
|
+
// params then target global-filter params).
|
|
2676
|
+
this.collectRelationFilterParams(nestedRel, norm, params);
|
|
2532
2677
|
continue;
|
|
2533
2678
|
}
|
|
2534
2679
|
}
|
|
@@ -2608,6 +2753,21 @@ export class QueryInterface {
|
|
|
2608
2753
|
// never push a param that the build path rejected (or vice versa).
|
|
2609
2754
|
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2610
2755
|
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2756
|
+
continue;
|
|
2757
|
+
}
|
|
2758
|
+
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2759
|
+
// subquery as `_count` — mirror its global-filter params. To-one relation
|
|
2760
|
+
// orderBy carries the target's global filter once per ordered column.
|
|
2761
|
+
if (this.isRelationOrderByValue(dir)) {
|
|
2762
|
+
const relDef = this.tableMeta.relations[key];
|
|
2763
|
+
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
2764
|
+
this.collectRelationCountParams(relDef, params);
|
|
2765
|
+
}
|
|
2766
|
+
else if (relDef) {
|
|
2767
|
+
for (const _col of Object.keys(dir)) {
|
|
2768
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2611
2771
|
}
|
|
2612
2772
|
}
|
|
2613
2773
|
}
|
|
@@ -2643,6 +2803,19 @@ export class QueryInterface {
|
|
|
2643
2803
|
const spec = withClause[relName];
|
|
2644
2804
|
if (!spec)
|
|
2645
2805
|
continue;
|
|
2806
|
+
// Reserved `_count` key — fingerprint by the selected relation set so
|
|
2807
|
+
// `_count: true` and `_count: { posts: true }` never share a cache entry.
|
|
2808
|
+
if (relName === '_count') {
|
|
2809
|
+
const c = spec;
|
|
2810
|
+
parts.push(c === true
|
|
2811
|
+
? '_count(*)'
|
|
2812
|
+
: `_count(${Object.entries(c)
|
|
2813
|
+
.filter(([, v]) => v)
|
|
2814
|
+
.map(([k]) => k)
|
|
2815
|
+
.sort()
|
|
2816
|
+
.join(',')})`);
|
|
2817
|
+
continue;
|
|
2818
|
+
}
|
|
2646
2819
|
const relDef = meta.relations[relName];
|
|
2647
2820
|
if (!relDef) {
|
|
2648
2821
|
parts.push(`unknown:${relName}`);
|
|
@@ -2675,9 +2848,9 @@ export class QueryInterface {
|
|
|
2675
2848
|
if (opts.where) {
|
|
2676
2849
|
subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
|
|
2677
2850
|
}
|
|
2678
|
-
// orderBy shape
|
|
2851
|
+
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
2679
2852
|
if (opts.orderBy) {
|
|
2680
|
-
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
|
|
2853
|
+
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
|
|
2681
2854
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2682
2855
|
}
|
|
2683
2856
|
// limit presence
|
|
@@ -2708,6 +2881,15 @@ export class QueryInterface {
|
|
|
2708
2881
|
continue;
|
|
2709
2882
|
this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
|
|
2710
2883
|
}
|
|
2884
|
+
// `_count` global-filter params — mirror buildSelectWithRelations, which
|
|
2885
|
+
// appends the count subqueries (and any target-filter params) AFTER every
|
|
2886
|
+
// relation subquery, in resolveCountRelations order.
|
|
2887
|
+
const countSpec = withClause._count;
|
|
2888
|
+
if (countSpec !== undefined) {
|
|
2889
|
+
for (const rel of resolveCountRelations(meta, countSpec)) {
|
|
2890
|
+
this.collectRelationCountParams(rel, params);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2711
2893
|
}
|
|
2712
2894
|
/**
|
|
2713
2895
|
* Collect params from a single relation subquery. Mirrors buildRelationSubquery.
|
|
@@ -2725,6 +2907,7 @@ export class QueryInterface {
|
|
|
2725
2907
|
if (spec.where) {
|
|
2726
2908
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2727
2909
|
}
|
|
2910
|
+
this.collectTargetGlobalFilterAlias(targetTable, params);
|
|
2728
2911
|
if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
|
|
2729
2912
|
params.push(Number(spec.limit));
|
|
2730
2913
|
}
|
|
@@ -2754,6 +2937,9 @@ export class QueryInterface {
|
|
|
2754
2937
|
if (spec.where) {
|
|
2755
2938
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2756
2939
|
}
|
|
2940
|
+
// Global filter on the target — mirrors targetGlobalFilterAlias in
|
|
2941
|
+
// buildRelationSubquery (pushed after spec.where, before limit).
|
|
2942
|
+
this.collectTargetGlobalFilterAlias(targetTable, params);
|
|
2757
2943
|
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2758
2944
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2759
2945
|
// pushing one here would orphan a param and desync the collect path.
|
|
@@ -2823,14 +3009,148 @@ export class QueryInterface {
|
|
|
2823
3009
|
return { sql: '', params: [] };
|
|
2824
3010
|
return { sql: ` WHERE ${clause}`, params };
|
|
2825
3011
|
}
|
|
3012
|
+
// -------------------------------------------------------------------------
|
|
3013
|
+
// Global filters (soft-delete / multi-tenancy — WS-G)
|
|
3014
|
+
//
|
|
3015
|
+
// A configured global filter for a table is AND-merged into the compiled WHERE
|
|
3016
|
+
// of every query on that table (via {@link mergeGlobalFilter}, so the merge is
|
|
3017
|
+
// captured in the where fingerprint/collect for free) and into every relation
|
|
3018
|
+
// subquery targeting it (rendered at build time against the subquery's alias/
|
|
3019
|
+
// table by the `*GlobalFilterAlias`/`*GlobalFilterExists` helpers, with the
|
|
3020
|
+
// shape folded into the SQL-cache key via {@link globalFilterCacheSegment}).
|
|
3021
|
+
// Function filters are evaluated per resolve — at query-build time — enabling
|
|
3022
|
+
// per-request tenancy via a closure. They must return a STABLE shape (same
|
|
3023
|
+
// keys/operators); only values may vary between calls.
|
|
3024
|
+
// -------------------------------------------------------------------------
|
|
2826
3025
|
/**
|
|
2827
|
-
*
|
|
2828
|
-
*
|
|
2829
|
-
*
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
3026
|
+
* Resolve the configured global filter for `table`, evaluating a function
|
|
3027
|
+
* filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
|
|
3028
|
+
* no filter applies, the query opted out, or the filter is empty.
|
|
3029
|
+
*/
|
|
3030
|
+
resolveGlobalFilter(table, skip = this.currentSkip) {
|
|
3031
|
+
const filters = this.globalFilters;
|
|
3032
|
+
if (!filters)
|
|
3033
|
+
return null;
|
|
3034
|
+
if (skip === true)
|
|
3035
|
+
return null;
|
|
3036
|
+
if (Array.isArray(skip) && skip.includes(table))
|
|
3037
|
+
return null;
|
|
3038
|
+
const raw = filters[table];
|
|
3039
|
+
if (raw === undefined)
|
|
3040
|
+
return null;
|
|
3041
|
+
const resolved = typeof raw === 'function' ? raw() : raw;
|
|
3042
|
+
if (resolved === null || resolved === undefined)
|
|
3043
|
+
return null;
|
|
3044
|
+
const obj = resolved;
|
|
3045
|
+
// An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
|
|
3046
|
+
// nothing — treat it as absent so it never emits a dangling clause.
|
|
3047
|
+
if (Object.keys(obj).every((k) => obj[k] === undefined))
|
|
3048
|
+
return null;
|
|
3049
|
+
return obj;
|
|
3050
|
+
}
|
|
3051
|
+
/**
|
|
3052
|
+
* AND-merge this table's resolved global filter into a user `where`. Either
|
|
3053
|
+
* side may be absent. When no filter applies the user where is returned by
|
|
3054
|
+
* reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
|
|
3055
|
+
*/
|
|
3056
|
+
mergeGlobalFilter(userWhere) {
|
|
3057
|
+
const gf = this.resolveGlobalFilter(this.table);
|
|
3058
|
+
if (!gf)
|
|
3059
|
+
return userWhere;
|
|
3060
|
+
if (userWhere === undefined)
|
|
3061
|
+
return gf;
|
|
3062
|
+
return { AND: [userWhere, gf] };
|
|
3063
|
+
}
|
|
3064
|
+
/**
|
|
3065
|
+
* SQL clause for `targetTable`'s global filter rendered against `alias`
|
|
3066
|
+
* (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
|
|
3067
|
+
* `params`; returns `''` when no filter applies. Mirror:
|
|
3068
|
+
* {@link collectTargetGlobalFilterAlias}.
|
|
3069
|
+
*/
|
|
3070
|
+
targetGlobalFilterAlias(targetTable, alias, params) {
|
|
3071
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
3072
|
+
if (!gf)
|
|
3073
|
+
return '';
|
|
3074
|
+
const meta = this.schema.tables[targetTable];
|
|
3075
|
+
if (!meta)
|
|
3076
|
+
return '';
|
|
3077
|
+
return this.buildAliasWhere(targetTable, meta, alias, gf, params) ?? '';
|
|
3078
|
+
}
|
|
3079
|
+
/** Param-collect mirror of {@link targetGlobalFilterAlias}. */
|
|
3080
|
+
collectTargetGlobalFilterAlias(targetTable, params) {
|
|
3081
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
3082
|
+
if (!gf)
|
|
3083
|
+
return;
|
|
3084
|
+
const meta = this.schema.tables[targetTable];
|
|
3085
|
+
if (!meta)
|
|
3086
|
+
return;
|
|
3087
|
+
this.collectAliasWhereParams(targetTable, meta, gf, params);
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* SQL clause for `targetTable`'s global filter rendered against the bare
|
|
3091
|
+
* (unaliased) table name — the form used inside relation-filter `EXISTS`
|
|
3092
|
+
* subqueries. Pushes its params; `''` when none. Mirror:
|
|
3093
|
+
* {@link collectTargetGlobalFilterExists}.
|
|
2833
3094
|
*/
|
|
3095
|
+
targetGlobalFilterExists(targetTable, params) {
|
|
3096
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
3097
|
+
if (!gf)
|
|
3098
|
+
return '';
|
|
3099
|
+
return this.buildSubWhereForRelation(targetTable, gf, params) ?? '';
|
|
3100
|
+
}
|
|
3101
|
+
/** Param-collect mirror of {@link targetGlobalFilterExists}. */
|
|
3102
|
+
collectTargetGlobalFilterExists(targetTable, params) {
|
|
3103
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
3104
|
+
if (!gf)
|
|
3105
|
+
return;
|
|
3106
|
+
this.collectRelFilterParams(targetTable, gf, params);
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* Value-invariant SQL-cache-key segment for the active global-filter
|
|
3110
|
+
* environment. Relation-subquery / relation-filter / `_count` / relation-
|
|
3111
|
+
* `orderBy` global filters are rendered at build time but their SHAPE is not
|
|
3112
|
+
* otherwise in the where/with fingerprint, so this segment guards the cache:
|
|
3113
|
+
* two different filter shapes never collide on one cached SQL text, while two
|
|
3114
|
+
* function-filter results of the SAME shape (differing only in values) share
|
|
3115
|
+
* the entry and bind their own params. Empty (`''`) when no filter applies, so
|
|
3116
|
+
* cache keys stay byte-identical when the feature is unused.
|
|
3117
|
+
*/
|
|
3118
|
+
globalFilterCacheSegment() {
|
|
3119
|
+
const filters = this.globalFilters;
|
|
3120
|
+
if (!filters)
|
|
3121
|
+
return '';
|
|
3122
|
+
const parts = [];
|
|
3123
|
+
for (const table of Object.keys(filters).sort()) {
|
|
3124
|
+
// Function filters for OTHER tables may be request-scoped closures that
|
|
3125
|
+
// throw outside their own context; a query on an unrelated table must not
|
|
3126
|
+
// break on them. A throwing filter can't have contributed SQL to this
|
|
3127
|
+
// query either (merging it would have thrown first), so a constant
|
|
3128
|
+
// marker keeps the key shape-distinct without evaluating it.
|
|
3129
|
+
let gf;
|
|
3130
|
+
try {
|
|
3131
|
+
gf = this.resolveGlobalFilter(table);
|
|
3132
|
+
}
|
|
3133
|
+
catch {
|
|
3134
|
+
parts.push(`${table}:!`);
|
|
3135
|
+
continue;
|
|
3136
|
+
}
|
|
3137
|
+
if (gf)
|
|
3138
|
+
parts.push(`${table}:${this.fingerprintWhere(gf)}`);
|
|
3139
|
+
}
|
|
3140
|
+
return parts.length ? `|gf=${parts.join(';')}` : '';
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* True when the USER-supplied `where` compiles to no predicate (`{}`,
|
|
3144
|
+
* `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
|
|
3145
|
+
* signal the empty-`where` guard needs — the compiled emptiness, NOT the
|
|
3146
|
+
* fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
|
|
3147
|
+
* any configured global filter, so a global filter never lets an unguarded
|
|
3148
|
+
* mass mutation through.
|
|
3149
|
+
*/
|
|
3150
|
+
userPredicateIsEmpty(userWhere) {
|
|
3151
|
+
const throwaway = [];
|
|
3152
|
+
return this.buildWhereClause(userWhere, throwaway) === null;
|
|
3153
|
+
}
|
|
2834
3154
|
assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
|
|
2835
3155
|
if (whereSql.length > 0)
|
|
2836
3156
|
return;
|
|
@@ -3002,55 +3322,69 @@ export class QueryInterface {
|
|
|
3002
3322
|
// belongsTo: parent.fk = child.pk
|
|
3003
3323
|
correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
|
|
3004
3324
|
}
|
|
3005
|
-
//
|
|
3325
|
+
// The target table's global filter (soft-delete / tenancy) restricts the
|
|
3326
|
+
// DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
|
|
3327
|
+
// ignore filtered-out rows, and `every` quantifies over only the surviving
|
|
3328
|
+
// rows ("every NON-deleted related row matches P"). It is ANDed into the
|
|
3329
|
+
// correlation and its params pushed AFTER the per-branch filter — mirrored
|
|
3330
|
+
// exactly in collectWhereParams' relation-filter branch. `qt` is the bare
|
|
3331
|
+
// target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
|
|
3332
|
+
const gfAnd = () => {
|
|
3333
|
+
const gf = this.targetGlobalFilterExists(targetTable, params);
|
|
3334
|
+
return gf ? ` AND ${gf}` : '';
|
|
3335
|
+
};
|
|
3336
|
+
// "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
3006
3337
|
if (filterObj.some !== undefined) {
|
|
3007
3338
|
const subWhere = filterObj.some;
|
|
3008
3339
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3009
|
-
const
|
|
3010
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3340
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3341
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3011
3342
|
}
|
|
3012
|
-
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
|
|
3343
|
+
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
3013
3344
|
if (filterObj.none !== undefined) {
|
|
3014
3345
|
const subWhere = filterObj.none;
|
|
3015
3346
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3016
|
-
const
|
|
3017
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3347
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3348
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3018
3349
|
}
|
|
3019
|
-
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
|
|
3350
|
+
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
|
|
3020
3351
|
if (filterObj.every !== undefined) {
|
|
3021
3352
|
const subWhere = filterObj.every;
|
|
3022
3353
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3023
3354
|
if (filterClause) {
|
|
3024
|
-
|
|
3355
|
+
// gf params pushed AFTER filter params (collect mirrors this order), but
|
|
3356
|
+
// placed textually inside the domain so it restricts which rows count.
|
|
3357
|
+
const gf = gfAnd();
|
|
3358
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
|
|
3025
3359
|
}
|
|
3026
3360
|
else {
|
|
3027
|
-
// "every" with empty filter = true (all match trivially)
|
|
3361
|
+
// "every" with empty filter = true (all match trivially) — gf irrelevant.
|
|
3028
3362
|
}
|
|
3029
3363
|
}
|
|
3030
3364
|
// "is": EXISTS — for to-one relations (same SQL as "some").
|
|
3031
3365
|
// `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
|
|
3032
3366
|
if (filterObj.is !== undefined) {
|
|
3033
3367
|
if (filterObj.is === null) {
|
|
3034
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3368
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
|
|
3035
3369
|
}
|
|
3036
3370
|
else {
|
|
3037
3371
|
const subWhere = filterObj.is;
|
|
3038
3372
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3039
|
-
const
|
|
3040
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3373
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3374
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3041
3375
|
}
|
|
3042
3376
|
}
|
|
3043
3377
|
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
|
|
3044
3378
|
// `isNot: null` = "a related row exists" → EXISTS.
|
|
3045
3379
|
if (filterObj.isNot !== undefined) {
|
|
3046
3380
|
if (filterObj.isNot === null) {
|
|
3047
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3381
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
|
|
3048
3382
|
}
|
|
3049
3383
|
else {
|
|
3050
3384
|
const subWhere = filterObj.isNot;
|
|
3051
3385
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3052
|
-
const
|
|
3053
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3386
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3387
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3054
3388
|
}
|
|
3055
3389
|
}
|
|
3056
3390
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
@@ -3245,17 +3579,9 @@ export class QueryInterface {
|
|
|
3245
3579
|
if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
3246
3580
|
const norm = this.normalizeRelationFilter(aliasRel, value);
|
|
3247
3581
|
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3248
|
-
//
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
if (norm.none != null)
|
|
3252
|
-
this.collectRelFilterParams(aliasRel.to, norm.none, params);
|
|
3253
|
-
if (norm.every != null)
|
|
3254
|
-
this.collectRelFilterParams(aliasRel.to, norm.every, params);
|
|
3255
|
-
if (norm.is != null)
|
|
3256
|
-
this.collectRelFilterParams(aliasRel.to, norm.is, params);
|
|
3257
|
-
if (norm.isNot != null)
|
|
3258
|
-
this.collectRelFilterParams(aliasRel.to, norm.isNot, params);
|
|
3582
|
+
// Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
|
|
3583
|
+
// params then target global-filter params).
|
|
3584
|
+
this.collectRelationFilterParams(aliasRel, norm, params);
|
|
3259
3585
|
continue;
|
|
3260
3586
|
}
|
|
3261
3587
|
}
|
|
@@ -3402,10 +3728,37 @@ export class QueryInterface {
|
|
|
3402
3728
|
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
3403
3729
|
* ordering throws — KNN ordering is only supported at the top level.
|
|
3404
3730
|
*/
|
|
3731
|
+
/**
|
|
3732
|
+
* Value-shape fingerprint for a single orderBy entry, so two queries whose
|
|
3733
|
+
* ORDER BY differs only in nulls placement, vector metric, or relation-count
|
|
3734
|
+
* vs relation-column never collide on one cached SQL string. Captures the
|
|
3735
|
+
* SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
|
|
3736
|
+
*/
|
|
3737
|
+
orderByEntryFingerprint(d) {
|
|
3738
|
+
// Vector KNN ordering changes the emitted operator by metric and adds a
|
|
3739
|
+
// `::vector` param, so metric + direction must be part of the cache key.
|
|
3740
|
+
if (isVectorOrderBy(d)) {
|
|
3741
|
+
return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
3742
|
+
}
|
|
3743
|
+
if (isOrderBySpec(d))
|
|
3744
|
+
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3745
|
+
if (d && typeof d === 'object') {
|
|
3746
|
+
// Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
|
|
3747
|
+
return `rel(${Object.entries(d)
|
|
3748
|
+
.map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
|
|
3749
|
+
.sort()
|
|
3750
|
+
.join(',')})`;
|
|
3751
|
+
}
|
|
3752
|
+
return String(d);
|
|
3753
|
+
}
|
|
3405
3754
|
buildOrderBy(orderBy, params) {
|
|
3406
|
-
// Dev-only: validate that orderBy fields exist in the table schema
|
|
3755
|
+
// Dev-only: validate that orderBy fields exist in the table schema. Relation
|
|
3756
|
+
// orderBy keys (object values that are neither a vector nor an OrderBySpec)
|
|
3757
|
+
// are validated in the relation branch below, so skip them here.
|
|
3407
3758
|
if (process.env.NODE_ENV !== 'production') {
|
|
3408
|
-
for (const key of Object.
|
|
3759
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
3760
|
+
if (this.isRelationOrderByValue(value) && this.tableMeta.relations[key])
|
|
3761
|
+
continue;
|
|
3409
3762
|
const snakeKey = camelToSnake(key);
|
|
3410
3763
|
if (!this.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in this.tableMeta.columnMap)) {
|
|
3411
3764
|
console.warn(`[turbine] Unknown orderBy field "${key}" for table "${this.tableMeta.name}". ` +
|
|
@@ -3414,28 +3767,217 @@ export class QueryInterface {
|
|
|
3414
3767
|
}
|
|
3415
3768
|
}
|
|
3416
3769
|
const meta = this.schema.tables[this.table];
|
|
3770
|
+
let relOrdCounter = 0;
|
|
3417
3771
|
return Object.entries(orderBy)
|
|
3418
|
-
.map(([key,
|
|
3419
|
-
if (meta && !(key in meta.columnMap)) {
|
|
3420
|
-
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3421
|
-
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3422
|
-
}
|
|
3772
|
+
.map(([key, value]) => {
|
|
3423
3773
|
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
3424
|
-
if (isVectorOrderBy(
|
|
3774
|
+
if (isVectorOrderBy(value)) {
|
|
3775
|
+
if (meta && !(key in meta.columnMap)) {
|
|
3776
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3777
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3778
|
+
}
|
|
3425
3779
|
if (!params) {
|
|
3426
3780
|
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
3427
3781
|
}
|
|
3428
3782
|
const rawColumn = this.toColumn(key);
|
|
3429
|
-
const operator = this.vectorOperator(key, rawColumn,
|
|
3430
|
-
const placeholder = this.pushVectorParam(key, rawColumn,
|
|
3431
|
-
const safeDir =
|
|
3783
|
+
const operator = this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
3784
|
+
const placeholder = this.pushVectorParam(key, rawColumn, value.distance.to, params);
|
|
3785
|
+
const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3432
3786
|
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
3433
3787
|
}
|
|
3434
|
-
|
|
3435
|
-
|
|
3788
|
+
// Relation ordering: an object value that is not a vector or OrderBySpec,
|
|
3789
|
+
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
3790
|
+
// { name: 'asc' } }`).
|
|
3791
|
+
if (this.isRelationOrderByValue(value)) {
|
|
3792
|
+
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
|
|
3793
|
+
}
|
|
3794
|
+
// Scalar column ordering — a plain direction or an OrderBySpec (nulls).
|
|
3795
|
+
if (meta && !(key in meta.columnMap)) {
|
|
3796
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3797
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3798
|
+
}
|
|
3799
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
3800
|
+
return `${this.toSqlColumn(key)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
3436
3801
|
})
|
|
3437
3802
|
.join(', ');
|
|
3438
3803
|
}
|
|
3804
|
+
/**
|
|
3805
|
+
* True when an orderBy value is a relation-ordering object: a plain object
|
|
3806
|
+
* that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
|
|
3807
|
+
* in the orderBy clause is a relation name.
|
|
3808
|
+
*/
|
|
3809
|
+
isRelationOrderByValue(value) {
|
|
3810
|
+
return (typeof value === 'object' &&
|
|
3811
|
+
value !== null &&
|
|
3812
|
+
!Array.isArray(value) &&
|
|
3813
|
+
!isVectorOrderBy(value) &&
|
|
3814
|
+
!isOrderBySpec(value));
|
|
3815
|
+
}
|
|
3816
|
+
/**
|
|
3817
|
+
* Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
|
|
3818
|
+
* Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
|
|
3819
|
+
* other engine a caller asking for explicit nulls placement gets a clear
|
|
3820
|
+
* {@link UnsupportedFeatureError} (E017) instead of broken SQL.
|
|
3821
|
+
*/
|
|
3822
|
+
nullsSuffix(nulls) {
|
|
3823
|
+
if (!nulls)
|
|
3824
|
+
return '';
|
|
3825
|
+
if (this.dialect.name !== 'postgresql' && this.dialect.name !== 'sqlite') {
|
|
3826
|
+
throw new UnsupportedFeatureError('NULLS FIRST/LAST ordering', this.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
|
|
3827
|
+
}
|
|
3828
|
+
return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
|
|
3829
|
+
}
|
|
3830
|
+
/**
|
|
3831
|
+
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
3832
|
+
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
3833
|
+
* to-one relation each entry names a target column and becomes a correlated
|
|
3834
|
+
* scalar subquery (supporting {@link OrderBySpec} nulls placement).
|
|
3835
|
+
*
|
|
3836
|
+
* Validation: relation must exist (E005); to-many only allows `_count`, and
|
|
3837
|
+
* to-one only allows real target columns (E003).
|
|
3838
|
+
*/
|
|
3839
|
+
buildRelationOrderBy(relName, value, alias, params) {
|
|
3840
|
+
const relDef = this.tableMeta.relations[relName];
|
|
3841
|
+
if (!relDef) {
|
|
3842
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
|
|
3843
|
+
`Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
|
|
3844
|
+
}
|
|
3845
|
+
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
3846
|
+
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
3847
|
+
const keys = Object.keys(value);
|
|
3848
|
+
if (keys.length !== 1 || keys[0] !== '_count') {
|
|
3849
|
+
throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
|
|
3850
|
+
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
3851
|
+
}
|
|
3852
|
+
const { dir } = normalizeOrderBy(value._count);
|
|
3853
|
+
return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
|
|
3854
|
+
}
|
|
3855
|
+
// To-one: each entry orders by a correlated scalar subquery on a target column.
|
|
3856
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
3857
|
+
if (!targetMeta)
|
|
3858
|
+
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
3859
|
+
const qTarget = this.q(relDef.to);
|
|
3860
|
+
const qParent = this.q(this.table);
|
|
3861
|
+
// belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
|
|
3862
|
+
const correlation = relDef.type === 'belongsTo'
|
|
3863
|
+
? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
3864
|
+
: this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3865
|
+
const entries = Object.entries(value);
|
|
3866
|
+
if (entries.length === 0) {
|
|
3867
|
+
throw new ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
|
|
3868
|
+
}
|
|
3869
|
+
return entries
|
|
3870
|
+
.map(([col, dirValue]) => {
|
|
3871
|
+
const snakeCol = camelToSnake(col);
|
|
3872
|
+
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
3873
|
+
throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
3874
|
+
}
|
|
3875
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
3876
|
+
// Target's global filter applies here too — otherwise ordering keys off
|
|
3877
|
+
// a soft-deleted / other-tenant related row's value (matches the with
|
|
3878
|
+
// subquery semantics for belongsTo/hasOne).
|
|
3879
|
+
let where = correlation;
|
|
3880
|
+
if (params) {
|
|
3881
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
3882
|
+
if (gf)
|
|
3883
|
+
where += ` AND ${gf}`;
|
|
3884
|
+
}
|
|
3885
|
+
return `(SELECT ${alias}.${this.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${this.limitOneClause()}) ${dir}${this.nullsSuffix(nulls)}`;
|
|
3886
|
+
})
|
|
3887
|
+
.join(', ');
|
|
3888
|
+
}
|
|
3889
|
+
/**
|
|
3890
|
+
* Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
|
|
3891
|
+
* relation, correlated to `parentRef`. hasMany counts child rows via the FK;
|
|
3892
|
+
* manyToMany counts junction rows via the source key. Shared by the `_count`
|
|
3893
|
+
* `with` key and to-many relation orderBy.
|
|
3894
|
+
*
|
|
3895
|
+
* When `params` is supplied and the target has a global filter, it is
|
|
3896
|
+
* AND-merged so the count only sees surviving rows (a soft-deleted child is
|
|
3897
|
+
* not counted): hasMany filters the counted rows directly; manyToMany adds an
|
|
3898
|
+
* `EXISTS` on the target through the junction (the junction rows themselves
|
|
3899
|
+
* carry no filter). Params are mirrored by {@link collectRelationCountParams}.
|
|
3900
|
+
*/
|
|
3901
|
+
buildRelationCountExpr(relDef, parentRef, alias, params) {
|
|
3902
|
+
const qParent = this.q(parentRef);
|
|
3903
|
+
const count = this.castAgg('COUNT(*)', 'int');
|
|
3904
|
+
if (relDef.type === 'manyToMany') {
|
|
3905
|
+
if (!relDef.through) {
|
|
3906
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
|
|
3907
|
+
}
|
|
3908
|
+
const qJ = this.q(relDef.through.table);
|
|
3909
|
+
const jalias = `${alias}j`;
|
|
3910
|
+
const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
|
|
3911
|
+
const refKeys = normalizeKeyColumns(relDef.referenceKey);
|
|
3912
|
+
let where = sourceKeys
|
|
3913
|
+
.map((jc, i) => `${jalias}.${this.q(jc)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3914
|
+
.join(' AND ');
|
|
3915
|
+
if (params) {
|
|
3916
|
+
const targetExists = this.manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params);
|
|
3917
|
+
if (targetExists)
|
|
3918
|
+
where += ` AND ${targetExists}`;
|
|
3919
|
+
}
|
|
3920
|
+
return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
|
|
3921
|
+
}
|
|
3922
|
+
// hasMany: child FK correlates to the parent reference key.
|
|
3923
|
+
const qTarget = this.q(relDef.to);
|
|
3924
|
+
let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3925
|
+
if (params) {
|
|
3926
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
3927
|
+
if (gf)
|
|
3928
|
+
where += ` AND ${gf}`;
|
|
3929
|
+
}
|
|
3930
|
+
return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
|
|
3931
|
+
}
|
|
3932
|
+
/**
|
|
3933
|
+
* `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
|
|
3934
|
+
* a manyToMany `_count` to targets that survive their global filter. `''` when
|
|
3935
|
+
* the target has no filter. Pushes gf params; mirror:
|
|
3936
|
+
* {@link collectManyToManyTargetGlobalFilter}.
|
|
3937
|
+
*/
|
|
3938
|
+
manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params) {
|
|
3939
|
+
const gf = this.resolveGlobalFilter(relDef.to);
|
|
3940
|
+
if (!gf || !relDef.through)
|
|
3941
|
+
return '';
|
|
3942
|
+
const tMeta = this.schema.tables[relDef.to];
|
|
3943
|
+
if (!tMeta || tMeta.primaryKey.length === 0)
|
|
3944
|
+
return '';
|
|
3945
|
+
const talias = `${alias}t`;
|
|
3946
|
+
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
3947
|
+
const pk = tMeta.primaryKey;
|
|
3948
|
+
if (targetKeys.length !== pk.length)
|
|
3949
|
+
return '';
|
|
3950
|
+
const join = targetKeys.map((jc, i) => `${talias}.${this.q(pk[i])} = ${jalias}.${this.q(jc)}`).join(' AND ');
|
|
3951
|
+
const gfClause = this.buildAliasWhere(relDef.to, tMeta, talias, gf, params);
|
|
3952
|
+
const gfAnd = gfClause ? ` AND ${gfClause}` : '';
|
|
3953
|
+
return `EXISTS (SELECT 1 FROM ${this.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
|
|
3954
|
+
}
|
|
3955
|
+
/** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
|
|
3956
|
+
collectManyToManyTargetGlobalFilter(relDef, params) {
|
|
3957
|
+
const gf = this.resolveGlobalFilter(relDef.to);
|
|
3958
|
+
if (!gf || !relDef.through)
|
|
3959
|
+
return;
|
|
3960
|
+
const tMeta = this.schema.tables[relDef.to];
|
|
3961
|
+
if (!tMeta || tMeta.primaryKey.length === 0)
|
|
3962
|
+
return;
|
|
3963
|
+
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
3964
|
+
if (targetKeys.length !== tMeta.primaryKey.length)
|
|
3965
|
+
return;
|
|
3966
|
+
this.collectAliasWhereParams(relDef.to, tMeta, gf, params);
|
|
3967
|
+
}
|
|
3968
|
+
/**
|
|
3969
|
+
* Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
|
|
3970
|
+
* params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
|
|
3971
|
+
* when a filter applies — no-op otherwise.
|
|
3972
|
+
*/
|
|
3973
|
+
collectRelationCountParams(relDef, params) {
|
|
3974
|
+
if (relDef.type === 'manyToMany') {
|
|
3975
|
+
this.collectManyToManyTargetGlobalFilter(relDef, params);
|
|
3976
|
+
}
|
|
3977
|
+
else {
|
|
3978
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3439
3981
|
// -------------------------------------------------------------------------
|
|
3440
3982
|
// pgvector helpers (similarity search)
|
|
3441
3983
|
// -------------------------------------------------------------------------
|
|
@@ -3564,6 +4106,19 @@ export class QueryInterface {
|
|
|
3564
4106
|
const meta = this.schema.tables[table];
|
|
3565
4107
|
if (!meta)
|
|
3566
4108
|
return parsed;
|
|
4109
|
+
// Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
|
|
4110
|
+
// parseRow copies these unknown columns through under their raw key.
|
|
4111
|
+
let countObj;
|
|
4112
|
+
for (const key of Object.keys(parsed)) {
|
|
4113
|
+
if (key.startsWith('_count__')) {
|
|
4114
|
+
if (countObj === undefined)
|
|
4115
|
+
countObj = {};
|
|
4116
|
+
countObj[key.slice('_count__'.length)] = Number(parsed[key]);
|
|
4117
|
+
delete parsed[key];
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
if (countObj)
|
|
4121
|
+
parsed._count = countObj;
|
|
3567
4122
|
for (const [relName, relDef] of Object.entries(meta.relations)) {
|
|
3568
4123
|
const rawValue = row[relName];
|
|
3569
4124
|
if (rawValue === undefined)
|
|
@@ -3830,6 +4385,9 @@ export class QueryInterface {
|
|
|
3830
4385
|
const relationSelects = [];
|
|
3831
4386
|
const aliasCounter = { n: 0 };
|
|
3832
4387
|
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
4388
|
+
// `_count` is a reserved key handled after the relation subqueries.
|
|
4389
|
+
if (relName === '_count')
|
|
4390
|
+
continue;
|
|
3833
4391
|
const relDef = meta.relations[relName];
|
|
3834
4392
|
if (!relDef) {
|
|
3835
4393
|
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3839,6 +4397,18 @@ export class QueryInterface {
|
|
|
3839
4397
|
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
3840
4398
|
relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
|
|
3841
4399
|
}
|
|
4400
|
+
// Reserved `_count` key → one correlated COUNT(*) scalar subquery per
|
|
4401
|
+
// selected to-many relation, aliased `_count__<rel>`. Appended after the
|
|
4402
|
+
// relation subqueries; the only params they can push come from a global
|
|
4403
|
+
// filter on the counted target (mirrored at the tail of collectWithParams).
|
|
4404
|
+
// Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
|
|
4405
|
+
const countSpec = withClause._count;
|
|
4406
|
+
if (countSpec !== undefined) {
|
|
4407
|
+
for (const rel of resolveCountRelations(meta, countSpec)) {
|
|
4408
|
+
const expr = this.buildRelationCountExpr(rel, table, `t${aliasCounter.n++}`, params);
|
|
4409
|
+
relationSelects.push(`${expr} AS ${this.q(`_count__${rel.name}`)}`);
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
3842
4412
|
return [baseCols, ...relationSelects].join(', ');
|
|
3843
4413
|
}
|
|
3844
4414
|
/**
|
|
@@ -4039,13 +4609,13 @@ export class QueryInterface {
|
|
|
4039
4609
|
let orderClause = '';
|
|
4040
4610
|
if (relOrderEntries.length > 0) {
|
|
4041
4611
|
const orders = relOrderEntries
|
|
4042
|
-
.map(([k,
|
|
4612
|
+
.map(([k, dirValue]) => {
|
|
4043
4613
|
const col = camelToSnake(k);
|
|
4044
4614
|
if (!targetMeta.allColumns.includes(col)) {
|
|
4045
4615
|
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4046
4616
|
}
|
|
4047
|
-
const
|
|
4048
|
-
return `${alias}.${this.q(col)} ${
|
|
4617
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4618
|
+
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4049
4619
|
})
|
|
4050
4620
|
.join(', ');
|
|
4051
4621
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -4071,6 +4641,12 @@ export class QueryInterface {
|
|
|
4071
4641
|
if (extra)
|
|
4072
4642
|
whereClause += ` AND ${extra}`;
|
|
4073
4643
|
}
|
|
4644
|
+
// Global filter on the target table (soft-delete / tenancy) — AND-merged so
|
|
4645
|
+
// a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
|
|
4646
|
+
// mirrored by collectRelationSubqueryParams.
|
|
4647
|
+
const gfExtra = this.targetGlobalFilterAlias(targetTable, alias, params);
|
|
4648
|
+
if (gfExtra)
|
|
4649
|
+
whereClause += ` AND ${gfExtra}`;
|
|
4074
4650
|
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
4075
4651
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
4076
4652
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
@@ -4181,13 +4757,13 @@ export class QueryInterface {
|
|
|
4181
4757
|
let orderClause = '';
|
|
4182
4758
|
if (relOrderEntries.length > 0) {
|
|
4183
4759
|
const orders = relOrderEntries
|
|
4184
|
-
.map(([k,
|
|
4760
|
+
.map(([k, dirValue]) => {
|
|
4185
4761
|
const col = camelToSnake(k);
|
|
4186
4762
|
if (!targetMeta.allColumns.includes(col)) {
|
|
4187
4763
|
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4188
4764
|
}
|
|
4189
|
-
const
|
|
4190
|
-
return `${talias}.${this.q(col)} ${
|
|
4765
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4766
|
+
return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4191
4767
|
})
|
|
4192
4768
|
.join(', ');
|
|
4193
4769
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -4199,6 +4775,11 @@ export class QueryInterface {
|
|
|
4199
4775
|
if (extra)
|
|
4200
4776
|
whereClause += ` AND ${extra}`;
|
|
4201
4777
|
}
|
|
4778
|
+
// Global filter on the target table (mirrors collectRelationSubqueryParams'
|
|
4779
|
+
// m2m branch: after spec.where, before limit).
|
|
4780
|
+
const gfExtra = this.targetGlobalFilterAlias(targetTable, talias, params);
|
|
4781
|
+
if (gfExtra)
|
|
4782
|
+
whereClause += ` AND ${gfExtra}`;
|
|
4202
4783
|
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
4203
4784
|
let limitClause = '';
|
|
4204
4785
|
if (spec !== true && spec.limit !== undefined) {
|