turbine-orm 0.27.1 → 0.28.1
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 +19 -15
- package/dist/cjs/cli/config.js +20 -3
- 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/errors.js +15 -1
- package/dist/cjs/generate.js +171 -7
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/introspect.js +177 -4
- package/dist/cjs/powdb.js +1 -1
- package/dist/cjs/powql.js +1 -1
- package/dist/cjs/query/batched-loader.js +148 -0
- package/dist/cjs/query/builder.js +763 -401
- package/dist/cjs/query/deferred.js +7 -0
- package/dist/cjs/query/filters.js +251 -0
- 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/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/errors.js +15 -1
- 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/powdb.d.ts +1 -1
- package/dist/powdb.js +1 -1
- package/dist/powql.d.ts +1 -1
- package/dist/powql.js +1 -1
- package/dist/query/batched-loader.d.ts +29 -2
- package/dist/query/batched-loader.js +148 -1
- package/dist/query/builder.d.ts +151 -122
- package/dist/query/builder.js +701 -339
- package/dist/query/deferred.d.ts +130 -0
- package/dist/query/deferred.js +6 -0
- package/dist/query/filters.d.ts +120 -0
- package/dist/query/filters.js +232 -0
- 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 +4 -5
package/dist/query/builder.js
CHANGED
|
@@ -15,215 +15,11 @@ 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
|
+
import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
19
20
|
import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
// Internal detection helpers — used by QueryInterface
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
/** Check if a value is a where operator object (has at least one known operator key) */
|
|
24
|
-
function isWhereOperator(value) {
|
|
25
|
-
if (value === null ||
|
|
26
|
-
value === undefined ||
|
|
27
|
-
typeof value !== 'object' ||
|
|
28
|
-
Array.isArray(value) ||
|
|
29
|
-
value instanceof Date) {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
const keys = Object.keys(value);
|
|
33
|
-
return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* True for a *plain object literal* that reached an equality fallthrough
|
|
37
|
-
* without matching any known filter shape — the misspelled-operator case.
|
|
38
|
-
* Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
|
|
39
|
-
* bind values and return false, as do arrays and Dates.
|
|
40
|
-
*/
|
|
41
|
-
function isUnmatchedPlainObject(value) {
|
|
42
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
|
|
43
|
-
return false;
|
|
44
|
-
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
|
|
45
|
-
return false;
|
|
46
|
-
const proto = Object.getPrototypeOf(value);
|
|
47
|
-
return proto === Object.prototype || proto === null;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
|
|
51
|
-
* `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
|
|
52
|
-
* param pushed), so null-ness is part of the shape — without it a cache entry
|
|
53
|
-
* warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
|
|
54
|
-
*/
|
|
55
|
-
function fingerprintOperatorShape(value) {
|
|
56
|
-
const obj = value;
|
|
57
|
-
const opKeys = Object.keys(obj)
|
|
58
|
-
.filter((k) => k !== 'mode')
|
|
59
|
-
.map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
|
|
60
|
-
.sort();
|
|
61
|
-
const modeStr = value.mode === 'insensitive' ? ':i' : '';
|
|
62
|
-
return `op(${opKeys.join(',')}${modeStr})`;
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Guard for the value of an `equals` operator reaching the plain-equality
|
|
66
|
-
* operator path. A plain object literal can only legitimately be an equality
|
|
67
|
-
* value on a json/jsonb column — and those route to the JSONB filter branch
|
|
68
|
-
* BEFORE the operator branch, so any plain object that reaches here is a
|
|
69
|
-
* mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
|
|
70
|
-
* SQL-build path and the cache-hit param-collect path so a warmed cache can
|
|
71
|
-
* never skip the check.
|
|
72
|
-
*/
|
|
73
|
-
function assertBindableEqualsOperand(value, column) {
|
|
74
|
-
if (!isUnmatchedPlainObject(value))
|
|
75
|
-
return;
|
|
76
|
-
throw new ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
|
|
77
|
-
`objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
|
|
78
|
-
`where 'equals' is the JSONB containment filter.`);
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Object keys in sorted order, mirroring the canonical order used by every
|
|
82
|
-
* cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
|
|
83
|
-
* enumerate object keys in this exact order: fingerprints sort keys, so two
|
|
84
|
-
* where clauses with the same fields in different insertion order share one
|
|
85
|
-
* cache entry — if build/collect iterated insertion order, the cached SQL's
|
|
86
|
-
* `$N` placeholders would bind the wrong values (cross-tenant-leak class).
|
|
87
|
-
* Array order (OR/AND members) is positional and is never sorted.
|
|
88
|
-
*/
|
|
89
|
-
function sortedKeys(obj) {
|
|
90
|
-
return Object.keys(obj).sort();
|
|
91
|
-
}
|
|
92
|
-
/** {@link sortedKeys}, but yielding `[key, value]` pairs. */
|
|
93
|
-
function sortedEntries(obj) {
|
|
94
|
-
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
95
|
-
}
|
|
96
|
-
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
97
21
|
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
98
22
|
const unindexedRelationWarned = new Set();
|
|
99
|
-
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
100
|
-
/** Known JSONB operator keys */
|
|
101
|
-
const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
102
|
-
/**
|
|
103
|
-
* JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
|
|
104
|
-
* appear in any other where-filter shape, so the presence of one of these is
|
|
105
|
-
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
106
|
-
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
107
|
-
* `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
|
|
108
|
-
* set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
|
|
109
|
-
* so it must fall through instead of throwing.
|
|
110
|
-
*/
|
|
111
|
-
const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
|
|
112
|
-
/** Check if a value is a JSONB filter object */
|
|
113
|
-
function isJsonFilter(value) {
|
|
114
|
-
if (value === null ||
|
|
115
|
-
value === undefined ||
|
|
116
|
-
typeof value !== 'object' ||
|
|
117
|
-
Array.isArray(value) ||
|
|
118
|
-
value instanceof Date) {
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
const keys = Object.keys(value);
|
|
122
|
-
return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Returns the first JSON-unique key found in `value`, or `null` if none.
|
|
126
|
-
* Used to drive the strict-validation error message.
|
|
127
|
-
*/
|
|
128
|
-
function findJsonUniqueKey(value) {
|
|
129
|
-
for (const k of Object.keys(value)) {
|
|
130
|
-
if (JSONB_UNIQUE_KEYS.has(k))
|
|
131
|
-
return k;
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
/** Known Array operator keys */
|
|
136
|
-
const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
137
|
-
/**
|
|
138
|
-
* Array operator keys that are *unique* to {@link ArrayFilter}. None of the
|
|
139
|
-
* array operators currently overlap with `WhereOperator` or `JsonFilter`, so
|
|
140
|
-
* this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
|
|
141
|
-
* constant so a future overlap (e.g. a `contains` for arrays) is easy to
|
|
142
|
-
* carve out.
|
|
143
|
-
*/
|
|
144
|
-
const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
145
|
-
/** Check if a value is an Array filter object */
|
|
146
|
-
function isArrayFilter(value) {
|
|
147
|
-
if (value === null ||
|
|
148
|
-
value === undefined ||
|
|
149
|
-
typeof value !== 'object' ||
|
|
150
|
-
Array.isArray(value) ||
|
|
151
|
-
value instanceof Date) {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
const keys = Object.keys(value);
|
|
155
|
-
return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Returns the first array-unique key found in `value`, or `null` if none.
|
|
159
|
-
* Used to drive the strict-validation error message.
|
|
160
|
-
*/
|
|
161
|
-
function findArrayUniqueKey(value) {
|
|
162
|
-
for (const k of Object.keys(value)) {
|
|
163
|
-
if (ARRAY_UNIQUE_KEYS.has(k))
|
|
164
|
-
return k;
|
|
165
|
-
}
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
/** Known text search operator keys */
|
|
169
|
-
const TEXT_SEARCH_KEYS = new Set(['search', 'config']);
|
|
170
|
-
/** Check if a value is a TextSearchFilter object */
|
|
171
|
-
function isTextSearchFilter(value) {
|
|
172
|
-
if (value === null ||
|
|
173
|
-
value === undefined ||
|
|
174
|
-
typeof value !== 'object' ||
|
|
175
|
-
Array.isArray(value) ||
|
|
176
|
-
value instanceof Date) {
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
const keys = Object.keys(value);
|
|
180
|
-
// Must have 'search' key and only known text search keys
|
|
181
|
-
return keys.includes('search') && keys.every((k) => TEXT_SEARCH_KEYS.has(k));
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Validate a text search config name. Only alphanumeric characters and
|
|
185
|
-
* underscores are allowed to prevent SQL injection via the config parameter.
|
|
186
|
-
*/
|
|
187
|
-
function validateTextSearchConfig(config) {
|
|
188
|
-
return /^[a-zA-Z0-9_]+$/.test(config);
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* pgvector distance metric → operator allow-list. This is the ONLY mapping
|
|
192
|
-
* from a user-supplied metric token to a SQL operator; any token not present
|
|
193
|
-
* here is rejected, so a user value can never become an arbitrary operator.
|
|
194
|
-
*
|
|
195
|
-
* - `l2` → `<->` (Euclidean / L2 distance)
|
|
196
|
-
* - `cosine` → `<=>` (cosine distance)
|
|
197
|
-
* - `ip` → `<#>` (negative inner product)
|
|
198
|
-
*/
|
|
199
|
-
const VECTOR_METRIC_OPERATORS = {
|
|
200
|
-
l2: '<->',
|
|
201
|
-
cosine: '<=>',
|
|
202
|
-
ip: '<#>',
|
|
203
|
-
};
|
|
204
|
-
/** Comparison keys allowed on a {@link VectorDistanceFilter}. */
|
|
205
|
-
const VECTOR_DISTANCE_COMPARATORS = {
|
|
206
|
-
lt: '<',
|
|
207
|
-
lte: '<=',
|
|
208
|
-
gt: '>',
|
|
209
|
-
gte: '>=',
|
|
210
|
-
};
|
|
211
|
-
/** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
|
|
212
|
-
function isVectorFilter(value) {
|
|
213
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
216
|
-
const dist = value.distance;
|
|
217
|
-
return (typeof dist === 'object' &&
|
|
218
|
-
dist !== null &&
|
|
219
|
-
!Array.isArray(dist) &&
|
|
220
|
-
'to' in dist &&
|
|
221
|
-
'metric' in dist);
|
|
222
|
-
}
|
|
223
|
-
/** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
|
|
224
|
-
function isVectorOrderBy(value) {
|
|
225
|
-
return isVectorFilter(value);
|
|
226
|
-
}
|
|
227
23
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
228
24
|
export class QueryInterface {
|
|
229
25
|
pool;
|
|
@@ -243,6 +39,13 @@ export class QueryInterface {
|
|
|
243
39
|
relationLoadStrategy;
|
|
244
40
|
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
245
41
|
jsonEncoding;
|
|
42
|
+
/**
|
|
43
|
+
* Client-level automatic WHERE filters keyed by table accessor (soft-delete /
|
|
44
|
+
* multi-tenancy). AND-merged into every query on the keyed table and every
|
|
45
|
+
* relation subquery targeting it. Undefined when none are configured, in
|
|
46
|
+
* which case every path is byte-identical to the pre-0.28 behavior.
|
|
47
|
+
*/
|
|
48
|
+
globalFilters;
|
|
246
49
|
/**
|
|
247
50
|
* Tracks tables that have already triggered an unlimited-query warning so
|
|
248
51
|
* the user is not spammed once per row. Per-instance state — each
|
|
@@ -274,6 +77,15 @@ export class QueryInterface {
|
|
|
274
77
|
options;
|
|
275
78
|
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
276
79
|
currentAction = 'raw';
|
|
80
|
+
/**
|
|
81
|
+
* The active query's `skipGlobalFilters` opt-out, set at the top of each
|
|
82
|
+
* `build*` method and read deep in the (synchronous) SQL-build + param-collect
|
|
83
|
+
* tree — so relation subqueries, relation filters, `_count`, and relation
|
|
84
|
+
* `orderBy` all see it without threading it through dozens of signatures.
|
|
85
|
+
* Only load-bearing when {@link globalFilters} is configured; build+collect are
|
|
86
|
+
* synchronous per call, so this transient is never observed across an await.
|
|
87
|
+
*/
|
|
88
|
+
currentSkip;
|
|
277
89
|
constructor(pool, table, schema, middlewares, options) {
|
|
278
90
|
this.pool = pool;
|
|
279
91
|
this.table = table;
|
|
@@ -295,6 +107,10 @@ export class QueryInterface {
|
|
|
295
107
|
this.dialect = options?.dialect ?? postgresDialect;
|
|
296
108
|
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
|
|
297
109
|
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
110
|
+
// Only retain the map when it has at least one entry, so `globalFilters`
|
|
111
|
+
// stays `undefined` (and every merge path a no-op) for the common case.
|
|
112
|
+
this.globalFilters =
|
|
113
|
+
options?.globalFilters && Object.keys(options.globalFilters).length > 0 ? options.globalFilters : undefined;
|
|
298
114
|
this.txScoped = options?._txScoped ?? false;
|
|
299
115
|
this.options = options;
|
|
300
116
|
// Pre-compute column type lookup maps (TASK-26)
|
|
@@ -414,7 +230,7 @@ export class QueryInterface {
|
|
|
414
230
|
* and unlimited-warnings silenced — a relation load must fetch every matching
|
|
415
231
|
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
416
232
|
*/
|
|
417
|
-
batchedContext(timeout) {
|
|
233
|
+
batchedContext(timeout, skip) {
|
|
418
234
|
const childOptions = {
|
|
419
235
|
...this.options,
|
|
420
236
|
defaultLimit: undefined,
|
|
@@ -429,6 +245,22 @@ export class QueryInterface {
|
|
|
429
245
|
buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
|
|
430
246
|
inClauseParam: (values) => this.inParam(values),
|
|
431
247
|
paramPlaceholder: (index) => this.p(index),
|
|
248
|
+
skipGlobalFilters: skip,
|
|
249
|
+
tableGlobalFilter: (table, alias, precedingParams) => {
|
|
250
|
+
const gf = this.resolveGlobalFilter(table, skip);
|
|
251
|
+
if (!gf)
|
|
252
|
+
return null;
|
|
253
|
+
const meta = this.schema.tables[table];
|
|
254
|
+
if (!meta)
|
|
255
|
+
return null;
|
|
256
|
+
// Seed the param array with `precedingParams` placeholders so
|
|
257
|
+
// buildAliasWhere numbers the gf params after the already-bound ones.
|
|
258
|
+
const seeded = new Array(precedingParams).fill(undefined);
|
|
259
|
+
const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
|
|
260
|
+
if (!clause)
|
|
261
|
+
return null;
|
|
262
|
+
return { clause, params: seeded.slice(precedingParams) };
|
|
263
|
+
},
|
|
432
264
|
};
|
|
433
265
|
}
|
|
434
266
|
/**
|
|
@@ -440,13 +272,18 @@ export class QueryInterface {
|
|
|
440
272
|
*/
|
|
441
273
|
async runFindManyBatched(args) {
|
|
442
274
|
const withClause = args.with;
|
|
275
|
+
// Capture the opt-out from the ARGS before any await: this.currentSkip is
|
|
276
|
+
// instance state on a cached accessor, so a concurrent build during the
|
|
277
|
+
// base-query await would overwrite it (tenant query loading relations with
|
|
278
|
+
// another query's skipGlobalFilters).
|
|
279
|
+
const skip = args.skipGlobalFilters;
|
|
443
280
|
const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
|
|
444
281
|
// baseArgs.with is always undefined here; the cast just bridges the R generic.
|
|
445
282
|
const deferred = this.buildFindMany(baseArgs);
|
|
446
283
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
447
284
|
const entities = deferred.transform(result);
|
|
448
285
|
if (entities.length > 0) {
|
|
449
|
-
await loadRelationsBatched(this.batchedContext(args.timeout), entities, withClause, args.timeout);
|
|
286
|
+
await loadRelationsBatched(this.batchedContext(args.timeout, skip), entities, withClause, args.timeout);
|
|
450
287
|
}
|
|
451
288
|
stripFields(entities, strip);
|
|
452
289
|
return entities;
|
|
@@ -668,18 +505,22 @@ export class QueryInterface {
|
|
|
668
505
|
const entity = deferred.transform(result);
|
|
669
506
|
if (!entity)
|
|
670
507
|
return null;
|
|
671
|
-
await loadRelationsBatched(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
|
|
508
|
+
await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters), [entity], withClause, args.timeout);
|
|
672
509
|
stripFields([entity], proj.strip);
|
|
673
510
|
return entity;
|
|
674
511
|
}
|
|
675
512
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
676
513
|
buildFindUnique(args) {
|
|
514
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
677
515
|
const columnsList = this.resolveColumns(args.select, args.omit);
|
|
678
|
-
|
|
516
|
+
// A global filter turns the where into `{ AND: [...] }`, which the
|
|
517
|
+
// `isSimpleWhere` test below rejects → the general (buildWhereClause) path
|
|
518
|
+
// handles the merge and its params uniformly.
|
|
519
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
679
520
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
680
521
|
const whereFingerprint = this.fingerprintWhere(whereObj);
|
|
681
522
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
682
|
-
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
523
|
+
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
683
524
|
const params = [];
|
|
684
525
|
// Check if all where values are simple (plain equality, no operators/null/OR).
|
|
685
526
|
// Keys are sorted to match fingerprintWhere — insertion order here would let
|
|
@@ -835,24 +676,21 @@ export class QueryInterface {
|
|
|
835
676
|
}
|
|
836
677
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
837
678
|
buildFindMany(args) {
|
|
679
|
+
this.currentSkip = args?.skipGlobalFilters;
|
|
838
680
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
839
681
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
840
|
-
|
|
682
|
+
// AND-merge this table's global filter into the user where; `hasWhere` gates
|
|
683
|
+
// the build/collect just like `args?.where` did (a merged filter can make
|
|
684
|
+
// an otherwise-absent where present).
|
|
685
|
+
const effWhere = this.mergeGlobalFilter(args?.where);
|
|
686
|
+
const hasWhere = effWhere !== undefined;
|
|
687
|
+
const whereObj = (effWhere ?? {});
|
|
841
688
|
// Build fingerprint for cache lookup
|
|
842
|
-
const whereFp =
|
|
689
|
+
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
843
690
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
844
691
|
const orderFp = args?.orderBy
|
|
845
692
|
? 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
|
-
})
|
|
693
|
+
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
|
|
856
694
|
.join(',')
|
|
857
695
|
: '';
|
|
858
696
|
const cursorFp = args?.cursor
|
|
@@ -865,12 +703,12 @@ export class QueryInterface {
|
|
|
865
703
|
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
866
704
|
const limitFp = effectiveLimit !== undefined ? '1' : '0';
|
|
867
705
|
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}`;
|
|
706
|
+
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
869
707
|
const params = [];
|
|
870
708
|
const entry = this.acquireSql(ck, () => {
|
|
871
709
|
// Fresh build — generates SQL and populates freshParams
|
|
872
710
|
const freshParams = [];
|
|
873
|
-
const { sql: freshWhereSql } =
|
|
711
|
+
const { sql: freshWhereSql } = hasWhere
|
|
874
712
|
? (() => {
|
|
875
713
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
876
714
|
return { sql: clause ? ` WHERE ${clause}` : '' };
|
|
@@ -900,8 +738,11 @@ export class QueryInterface {
|
|
|
900
738
|
if (cursorEntries.length > 0) {
|
|
901
739
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
902
740
|
const col = this.toSqlColumn(k);
|
|
903
|
-
|
|
904
|
-
|
|
741
|
+
// orderBy values can be the { sort, nulls } spec form — normalize
|
|
742
|
+
// before comparing, or a desc spec would seek the ascending side.
|
|
743
|
+
const dir = args.orderBy?.[k];
|
|
744
|
+
const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
|
|
745
|
+
const op = desc ? '<' : '>';
|
|
905
746
|
freshParams.push(v);
|
|
906
747
|
return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
|
|
907
748
|
});
|
|
@@ -948,8 +789,8 @@ export class QueryInterface {
|
|
|
948
789
|
return sql;
|
|
949
790
|
});
|
|
950
791
|
// Collect params in exact build order:
|
|
951
|
-
// 1. WHERE params
|
|
952
|
-
if (
|
|
792
|
+
// 1. WHERE params (includes the AND-merged global filter, if any)
|
|
793
|
+
if (hasWhere) {
|
|
953
794
|
this.collectWhereParams(whereObj, params);
|
|
954
795
|
}
|
|
955
796
|
// 2. WITH relation params
|
|
@@ -1161,6 +1002,8 @@ export class QueryInterface {
|
|
|
1161
1002
|
});
|
|
1162
1003
|
}
|
|
1163
1004
|
buildCreate(args) {
|
|
1005
|
+
this.assertWritable('create');
|
|
1006
|
+
this.assertNoGeneratedColumns(args.data, 'create');
|
|
1164
1007
|
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
1165
1008
|
const columns = entries.map(([k]) => this.toSqlColumn(k));
|
|
1166
1009
|
const params = entries.map(([, v]) => v);
|
|
@@ -1235,6 +1078,10 @@ export class QueryInterface {
|
|
|
1235
1078
|
tag: `${this.table}.createMany`,
|
|
1236
1079
|
};
|
|
1237
1080
|
}
|
|
1081
|
+
this.assertWritable('createMany');
|
|
1082
|
+
for (const row of args.data) {
|
|
1083
|
+
this.assertNoGeneratedColumns(row, 'createMany');
|
|
1084
|
+
}
|
|
1238
1085
|
const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
|
|
1239
1086
|
const columns = keys.map((k) => this.toColumn(k));
|
|
1240
1087
|
const rowValues = args.data.map((row) => {
|
|
@@ -1272,12 +1119,22 @@ export class QueryInterface {
|
|
|
1272
1119
|
});
|
|
1273
1120
|
}
|
|
1274
1121
|
buildUpdate(args) {
|
|
1122
|
+
this.assertWritable('update');
|
|
1123
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1275
1124
|
const dataObj = args.data;
|
|
1276
|
-
|
|
1125
|
+
this.assertNoGeneratedColumns(dataObj, 'update');
|
|
1126
|
+
const userWhere = args.where;
|
|
1277
1127
|
const lock = args.optimisticLock;
|
|
1128
|
+
// The empty-`where` guard checks the USER predicate only — a global filter
|
|
1129
|
+
// must never turn an unguarded mass update into an allowed one.
|
|
1130
|
+
const userHasPredicate = !this.userPredicateIsEmpty(userWhere) || !!lock;
|
|
1131
|
+
this.assertMutationHasPredicate('update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
|
|
1132
|
+
// The SQL is built from the global-filter-merged where (soft-delete keeps an
|
|
1133
|
+
// update from touching already-deleted rows).
|
|
1134
|
+
const whereObj = (this.mergeGlobalFilter(userWhere) ?? {});
|
|
1278
1135
|
const setFp = this.fingerprintSet(dataObj);
|
|
1279
1136
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1280
|
-
const ck = lock ? null : `u:${setFp}|${whereFp}`;
|
|
1137
|
+
const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1281
1138
|
const params = [];
|
|
1282
1139
|
const buildSql = () => {
|
|
1283
1140
|
const freshParams = [];
|
|
@@ -1295,7 +1152,6 @@ export class QueryInterface {
|
|
|
1295
1152
|
const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
|
|
1296
1153
|
whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
|
|
1297
1154
|
}
|
|
1298
|
-
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
1299
1155
|
// Engines that inject their returning shape MID-statement (SQL Server
|
|
1300
1156
|
// `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
|
|
1301
1157
|
// absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
@@ -1309,9 +1165,6 @@ export class QueryInterface {
|
|
|
1309
1165
|
const entry = this.acquireSql(ck, buildSql);
|
|
1310
1166
|
sql = entry.sql;
|
|
1311
1167
|
preparedName = entry.name;
|
|
1312
|
-
if (whereFp === '') {
|
|
1313
|
-
this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
|
|
1314
|
-
}
|
|
1315
1168
|
}
|
|
1316
1169
|
else {
|
|
1317
1170
|
sql = buildSql();
|
|
@@ -1445,27 +1298,24 @@ export class QueryInterface {
|
|
|
1445
1298
|
});
|
|
1446
1299
|
}
|
|
1447
1300
|
buildDelete(args) {
|
|
1448
|
-
|
|
1301
|
+
this.assertWritable('delete');
|
|
1302
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1303
|
+
// Guard the USER predicate (a global filter must not satisfy the guard).
|
|
1304
|
+
this.assertMutationHasPredicate('delete', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1305
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1449
1306
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1450
|
-
const ck = `d:${whereFp}`;
|
|
1307
|
+
const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1451
1308
|
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
1309
|
const entry = this.acquireSql(ck, () => {
|
|
1455
1310
|
const freshParams = [];
|
|
1456
1311
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1457
1312
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1458
|
-
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
1459
1313
|
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
1460
1314
|
// absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
1461
1315
|
return this.dialect.buildDeleteStatement
|
|
1462
1316
|
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1463
1317
|
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1464
1318
|
});
|
|
1465
|
-
// On cache hit, still validate the predicate
|
|
1466
|
-
if (whereFp === '') {
|
|
1467
|
-
this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
|
|
1468
|
-
}
|
|
1469
1319
|
this.collectWhereParams(whereObj, params);
|
|
1470
1320
|
return {
|
|
1471
1321
|
sql: entry.sql,
|
|
@@ -1505,6 +1355,10 @@ export class QueryInterface {
|
|
|
1505
1355
|
});
|
|
1506
1356
|
}
|
|
1507
1357
|
buildUpsert(args) {
|
|
1358
|
+
this.assertWritable('upsert');
|
|
1359
|
+
this.assertNoGeneratedColumns(args.create, 'upsert');
|
|
1360
|
+
this.assertNoGeneratedColumns(args.update, 'upsert');
|
|
1361
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1508
1362
|
// Build the INSERT part from create data
|
|
1509
1363
|
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
1510
1364
|
const columns = createEntries.map(([k]) => this.toSqlColumn(k));
|
|
@@ -1523,12 +1377,23 @@ export class QueryInterface {
|
|
|
1523
1377
|
});
|
|
1524
1378
|
const updateParams = updateEntries.map(([, v]) => v);
|
|
1525
1379
|
const params = [...createParams, ...updateParams];
|
|
1380
|
+
// Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
|
|
1381
|
+
// upsert never resurrects a soft-deleted row or writes across tenants. Only
|
|
1382
|
+
// on engines whose upsert can carry a predicate (Postgres); the gf params
|
|
1383
|
+
// continue the placeholder numbering after create+update params.
|
|
1384
|
+
let updateWhere;
|
|
1385
|
+
if (this.dialect.supportsUpsertUpdateWhere) {
|
|
1386
|
+
const gf = this.resolveGlobalFilter(this.table);
|
|
1387
|
+
if (gf)
|
|
1388
|
+
updateWhere = this.buildWhereClause(gf, params) ?? undefined;
|
|
1389
|
+
}
|
|
1526
1390
|
const sql = this.dialect.buildUpsertStatement({
|
|
1527
1391
|
table: this.q(this.table),
|
|
1528
1392
|
insertColumns: columns,
|
|
1529
1393
|
valuePlaceholders: placeholders,
|
|
1530
1394
|
conflictColumns,
|
|
1531
1395
|
updateSetClauses: setClauses,
|
|
1396
|
+
updateWhere,
|
|
1532
1397
|
returning: '*',
|
|
1533
1398
|
});
|
|
1534
1399
|
return {
|
|
@@ -1551,7 +1416,7 @@ export class QueryInterface {
|
|
|
1551
1416
|
reselect: this.dialect.resultStrategy === 'reselect'
|
|
1552
1417
|
? async (exec) => {
|
|
1553
1418
|
await exec(sql, params);
|
|
1554
|
-
const sel = this.buildReselectByWhere(args.where);
|
|
1419
|
+
const sel = this.buildReselectByWhere((this.mergeGlobalFilter(args.where) ?? {}));
|
|
1555
1420
|
return exec(sel.sql, sel.params);
|
|
1556
1421
|
}
|
|
1557
1422
|
: undefined,
|
|
@@ -1568,11 +1433,15 @@ export class QueryInterface {
|
|
|
1568
1433
|
});
|
|
1569
1434
|
}
|
|
1570
1435
|
buildUpdateMany(args) {
|
|
1436
|
+
this.assertWritable('updateMany');
|
|
1437
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1571
1438
|
const dataObj = args.data;
|
|
1572
|
-
|
|
1439
|
+
this.assertNoGeneratedColumns(dataObj, 'updateMany');
|
|
1440
|
+
this.assertMutationHasPredicate('updateMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1441
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1573
1442
|
const setFp = this.fingerprintSet(dataObj);
|
|
1574
1443
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1575
|
-
const ck = `um:${setFp}|${whereFp}`;
|
|
1444
|
+
const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1576
1445
|
const params = [];
|
|
1577
1446
|
const entry = this.acquireSql(ck, () => {
|
|
1578
1447
|
const freshParams = [];
|
|
@@ -1580,12 +1449,8 @@ export class QueryInterface {
|
|
|
1580
1449
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1581
1450
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
1582
1451
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
1583
|
-
this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
|
|
1584
1452
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
1585
1453
|
});
|
|
1586
|
-
if (whereFp === '') {
|
|
1587
|
-
this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
|
|
1588
|
-
}
|
|
1589
1454
|
this.collectSetParams(dataObj, params);
|
|
1590
1455
|
this.collectWhereParams(whereObj, params);
|
|
1591
1456
|
return {
|
|
@@ -1607,20 +1472,19 @@ export class QueryInterface {
|
|
|
1607
1472
|
});
|
|
1608
1473
|
}
|
|
1609
1474
|
buildDeleteMany(args) {
|
|
1610
|
-
|
|
1475
|
+
this.assertWritable('deleteMany');
|
|
1476
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1477
|
+
this.assertMutationHasPredicate('deleteMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1478
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1611
1479
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1612
|
-
const ck = `dm:${whereFp}`;
|
|
1480
|
+
const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1613
1481
|
const params = [];
|
|
1614
1482
|
const entry = this.acquireSql(ck, () => {
|
|
1615
1483
|
const freshParams = [];
|
|
1616
1484
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1617
1485
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1618
|
-
this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
|
|
1619
1486
|
return `DELETE FROM ${this.q(this.table)}${whereSql}`;
|
|
1620
1487
|
});
|
|
1621
|
-
if (whereFp === '') {
|
|
1622
|
-
this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
|
|
1623
|
-
}
|
|
1624
1488
|
this.collectWhereParams(whereObj, params);
|
|
1625
1489
|
return {
|
|
1626
1490
|
sql: entry.sql,
|
|
@@ -1641,17 +1505,20 @@ export class QueryInterface {
|
|
|
1641
1505
|
});
|
|
1642
1506
|
}
|
|
1643
1507
|
buildCount(args) {
|
|
1644
|
-
|
|
1645
|
-
const
|
|
1646
|
-
const
|
|
1508
|
+
this.currentSkip = args?.skipGlobalFilters;
|
|
1509
|
+
const effWhere = this.mergeGlobalFilter(args?.where);
|
|
1510
|
+
const hasWhere = effWhere !== undefined;
|
|
1511
|
+
const whereObj = (effWhere ?? {});
|
|
1512
|
+
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
1513
|
+
const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1647
1514
|
const params = [];
|
|
1648
1515
|
const entry = this.acquireSql(ck, () => {
|
|
1649
1516
|
const freshParams = [];
|
|
1650
|
-
const clause =
|
|
1517
|
+
const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1651
1518
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1652
1519
|
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1653
1520
|
});
|
|
1654
|
-
if (
|
|
1521
|
+
if (hasWhere) {
|
|
1655
1522
|
this.collectWhereParams(whereObj, params);
|
|
1656
1523
|
}
|
|
1657
1524
|
return {
|
|
@@ -1681,9 +1548,13 @@ export class QueryInterface {
|
|
|
1681
1548
|
}
|
|
1682
1549
|
}
|
|
1683
1550
|
}
|
|
1551
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1684
1552
|
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
1685
1553
|
const groupCols = groupColsRaw.map((c) => this.q(c));
|
|
1686
|
-
const
|
|
1554
|
+
const gbWhere = this.mergeGlobalFilter(args.where);
|
|
1555
|
+
const { sql: whereSql, params } = gbWhere
|
|
1556
|
+
? this.buildWhere(gbWhere)
|
|
1557
|
+
: { sql: '', params: [] };
|
|
1687
1558
|
// Build SELECT expressions: group-by columns + aggregate functions
|
|
1688
1559
|
const selectExprs = [...groupCols];
|
|
1689
1560
|
// _count
|
|
@@ -1926,7 +1797,11 @@ export class QueryInterface {
|
|
|
1926
1797
|
});
|
|
1927
1798
|
}
|
|
1928
1799
|
buildAggregate(args) {
|
|
1929
|
-
|
|
1800
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1801
|
+
const aggWhere = this.mergeGlobalFilter(args.where);
|
|
1802
|
+
const { sql: whereSql, params } = aggWhere
|
|
1803
|
+
? this.buildWhere(aggWhere)
|
|
1804
|
+
: { sql: '', params: [] };
|
|
1930
1805
|
const meta = this.schema.tables[this.table];
|
|
1931
1806
|
if (meta) {
|
|
1932
1807
|
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
@@ -2104,6 +1979,36 @@ export class QueryInterface {
|
|
|
2104
1979
|
}
|
|
2105
1980
|
return null;
|
|
2106
1981
|
}
|
|
1982
|
+
/**
|
|
1983
|
+
* Reject any write against a view (H4). Views are introspected with
|
|
1984
|
+
* `isView: true` and are read-only in every engine; a write raises a
|
|
1985
|
+
* {@link ValidationError} (E003) rather than emitting SQL Postgres would
|
|
1986
|
+
* reject (or, worse, silently applying to an updatable view).
|
|
1987
|
+
*/
|
|
1988
|
+
assertWritable(operation) {
|
|
1989
|
+
if (this.tableMeta.isView) {
|
|
1990
|
+
throw new ValidationError(`[turbine] Cannot ${operation} "${this.table}": it is a view (read-only). ` +
|
|
1991
|
+
'Views support reads (findMany/findFirst/…) but not writes.');
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
|
|
1996
|
+
* column (H3). Postgres computes these from other columns and errors if you
|
|
1997
|
+
* try to write them; we fail early with a clear {@link ValidationError} (E003)
|
|
1998
|
+
* instead of surfacing a cryptic driver error. Undefined values are ignored
|
|
1999
|
+
* (they're stripped from the statement anyway).
|
|
2000
|
+
*/
|
|
2001
|
+
assertNoGeneratedColumns(data, operation) {
|
|
2002
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2003
|
+
if (value === undefined)
|
|
2004
|
+
continue;
|
|
2005
|
+
const col = this.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
|
|
2006
|
+
if (col?.isGeneratedStored) {
|
|
2007
|
+
throw new ValidationError(`[turbine] Cannot ${operation} "${this.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
|
|
2008
|
+
'column whose value the database computes — remove it from your data.');
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2107
2012
|
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
2108
2013
|
toColumn(field) {
|
|
2109
2014
|
const mapped = this.tableMeta.columnMap[field];
|
|
@@ -2433,16 +2338,7 @@ export class QueryInterface {
|
|
|
2433
2338
|
'none' in filterObj ||
|
|
2434
2339
|
'is' in filterObj ||
|
|
2435
2340
|
'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);
|
|
2341
|
+
this.collectRelationFilterParams(relationDef, filterObj, params);
|
|
2446
2342
|
continue;
|
|
2447
2343
|
}
|
|
2448
2344
|
}
|
|
@@ -2490,7 +2386,45 @@ export class QueryInterface {
|
|
|
2490
2386
|
params.push(value);
|
|
2491
2387
|
}
|
|
2492
2388
|
}
|
|
2493
|
-
/**
|
|
2389
|
+
/**
|
|
2390
|
+
* Param-collect mirror of {@link buildRelationFilter} for one relation-filter
|
|
2391
|
+
* object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
|
|
2392
|
+
* present branch and in the canonical order some→none→every→is→isNot, the
|
|
2393
|
+
* branch's sub-where params THEN the target table's global-filter params —
|
|
2394
|
+
* exactly the order buildRelationFilter emits. When no global filter applies
|
|
2395
|
+
* the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
|
|
2396
|
+
* Shared by every collect site that mirrors buildRelationFilter
|
|
2397
|
+
* (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
|
|
2398
|
+
*/
|
|
2399
|
+
collectRelationFilterParams(relDef, filterObj, params) {
|
|
2400
|
+
const target = relDef.to;
|
|
2401
|
+
if (filterObj.some !== undefined && filterObj.some !== null) {
|
|
2402
|
+
this.collectRelFilterParams(target, filterObj.some, params);
|
|
2403
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2404
|
+
}
|
|
2405
|
+
if (filterObj.none !== undefined && filterObj.none !== null) {
|
|
2406
|
+
this.collectRelFilterParams(target, filterObj.none, params);
|
|
2407
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2408
|
+
}
|
|
2409
|
+
if (filterObj.every !== undefined && filterObj.every !== null) {
|
|
2410
|
+
// gf is only emitted (build) when the `every` sub-where compiles to a
|
|
2411
|
+
// filter — otherwise `every` is trivially true and no subquery is built.
|
|
2412
|
+
if (this.buildSubWhereForRelation(target, filterObj.every, []) !== null) {
|
|
2413
|
+
this.collectRelFilterParams(target, filterObj.every, params);
|
|
2414
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
if (filterObj.is !== undefined) {
|
|
2418
|
+
if (filterObj.is !== null)
|
|
2419
|
+
this.collectRelFilterParams(target, filterObj.is, params);
|
|
2420
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2421
|
+
}
|
|
2422
|
+
if (filterObj.isNot !== undefined) {
|
|
2423
|
+
if (filterObj.isNot !== null)
|
|
2424
|
+
this.collectRelFilterParams(target, filterObj.isNot, params);
|
|
2425
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2494
2428
|
collectRelFilterParams(targetTable, subWhere, params) {
|
|
2495
2429
|
const meta = this.schema.tables[targetTable];
|
|
2496
2430
|
if (!meta)
|
|
@@ -2518,17 +2452,9 @@ export class QueryInterface {
|
|
|
2518
2452
|
if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
2519
2453
|
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
2520
2454
|
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);
|
|
2455
|
+
// Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
|
|
2456
|
+
// params then target global-filter params).
|
|
2457
|
+
this.collectRelationFilterParams(nestedRel, norm, params);
|
|
2532
2458
|
continue;
|
|
2533
2459
|
}
|
|
2534
2460
|
}
|
|
@@ -2608,6 +2534,21 @@ export class QueryInterface {
|
|
|
2608
2534
|
// never push a param that the build path rejected (or vice versa).
|
|
2609
2535
|
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2610
2536
|
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2540
|
+
// subquery as `_count` — mirror its global-filter params. To-one relation
|
|
2541
|
+
// orderBy carries the target's global filter once per ordered column.
|
|
2542
|
+
if (this.isRelationOrderByValue(dir)) {
|
|
2543
|
+
const relDef = this.tableMeta.relations[key];
|
|
2544
|
+
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
2545
|
+
this.collectRelationCountParams(relDef, params);
|
|
2546
|
+
}
|
|
2547
|
+
else if (relDef) {
|
|
2548
|
+
for (const _col of Object.keys(dir)) {
|
|
2549
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2611
2552
|
}
|
|
2612
2553
|
}
|
|
2613
2554
|
}
|
|
@@ -2643,6 +2584,19 @@ export class QueryInterface {
|
|
|
2643
2584
|
const spec = withClause[relName];
|
|
2644
2585
|
if (!spec)
|
|
2645
2586
|
continue;
|
|
2587
|
+
// Reserved `_count` key — fingerprint by the selected relation set so
|
|
2588
|
+
// `_count: true` and `_count: { posts: true }` never share a cache entry.
|
|
2589
|
+
if (relName === '_count') {
|
|
2590
|
+
const c = spec;
|
|
2591
|
+
parts.push(c === true
|
|
2592
|
+
? '_count(*)'
|
|
2593
|
+
: `_count(${Object.entries(c)
|
|
2594
|
+
.filter(([, v]) => v)
|
|
2595
|
+
.map(([k]) => k)
|
|
2596
|
+
.sort()
|
|
2597
|
+
.join(',')})`);
|
|
2598
|
+
continue;
|
|
2599
|
+
}
|
|
2646
2600
|
const relDef = meta.relations[relName];
|
|
2647
2601
|
if (!relDef) {
|
|
2648
2602
|
parts.push(`unknown:${relName}`);
|
|
@@ -2675,9 +2629,9 @@ export class QueryInterface {
|
|
|
2675
2629
|
if (opts.where) {
|
|
2676
2630
|
subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
|
|
2677
2631
|
}
|
|
2678
|
-
// orderBy shape
|
|
2632
|
+
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
2679
2633
|
if (opts.orderBy) {
|
|
2680
|
-
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
|
|
2634
|
+
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
|
|
2681
2635
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2682
2636
|
}
|
|
2683
2637
|
// limit presence
|
|
@@ -2708,6 +2662,15 @@ export class QueryInterface {
|
|
|
2708
2662
|
continue;
|
|
2709
2663
|
this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
|
|
2710
2664
|
}
|
|
2665
|
+
// `_count` global-filter params — mirror buildSelectWithRelations, which
|
|
2666
|
+
// appends the count subqueries (and any target-filter params) AFTER every
|
|
2667
|
+
// relation subquery, in resolveCountRelations order.
|
|
2668
|
+
const countSpec = withClause._count;
|
|
2669
|
+
if (countSpec !== undefined) {
|
|
2670
|
+
for (const rel of resolveCountRelations(meta, countSpec)) {
|
|
2671
|
+
this.collectRelationCountParams(rel, params);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2711
2674
|
}
|
|
2712
2675
|
/**
|
|
2713
2676
|
* Collect params from a single relation subquery. Mirrors buildRelationSubquery.
|
|
@@ -2725,6 +2688,7 @@ export class QueryInterface {
|
|
|
2725
2688
|
if (spec.where) {
|
|
2726
2689
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2727
2690
|
}
|
|
2691
|
+
this.collectTargetGlobalFilterAlias(targetTable, params);
|
|
2728
2692
|
if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
|
|
2729
2693
|
params.push(Number(spec.limit));
|
|
2730
2694
|
}
|
|
@@ -2754,6 +2718,9 @@ export class QueryInterface {
|
|
|
2754
2718
|
if (spec.where) {
|
|
2755
2719
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2756
2720
|
}
|
|
2721
|
+
// Global filter on the target — mirrors targetGlobalFilterAlias in
|
|
2722
|
+
// buildRelationSubquery (pushed after spec.where, before limit).
|
|
2723
|
+
this.collectTargetGlobalFilterAlias(targetTable, params);
|
|
2757
2724
|
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2758
2725
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2759
2726
|
// pushing one here would orphan a param and desync the collect path.
|
|
@@ -2823,14 +2790,148 @@ export class QueryInterface {
|
|
|
2823
2790
|
return { sql: '', params: [] };
|
|
2824
2791
|
return { sql: ` WHERE ${clause}`, params };
|
|
2825
2792
|
}
|
|
2793
|
+
// -------------------------------------------------------------------------
|
|
2794
|
+
// Global filters (soft-delete / multi-tenancy — WS-G)
|
|
2795
|
+
//
|
|
2796
|
+
// A configured global filter for a table is AND-merged into the compiled WHERE
|
|
2797
|
+
// of every query on that table (via {@link mergeGlobalFilter}, so the merge is
|
|
2798
|
+
// captured in the where fingerprint/collect for free) and into every relation
|
|
2799
|
+
// subquery targeting it (rendered at build time against the subquery's alias/
|
|
2800
|
+
// table by the `*GlobalFilterAlias`/`*GlobalFilterExists` helpers, with the
|
|
2801
|
+
// shape folded into the SQL-cache key via {@link globalFilterCacheSegment}).
|
|
2802
|
+
// Function filters are evaluated per resolve — at query-build time — enabling
|
|
2803
|
+
// per-request tenancy via a closure. They must return a STABLE shape (same
|
|
2804
|
+
// keys/operators); only values may vary between calls.
|
|
2805
|
+
// -------------------------------------------------------------------------
|
|
2806
|
+
/**
|
|
2807
|
+
* Resolve the configured global filter for `table`, evaluating a function
|
|
2808
|
+
* filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
|
|
2809
|
+
* no filter applies, the query opted out, or the filter is empty.
|
|
2810
|
+
*/
|
|
2811
|
+
resolveGlobalFilter(table, skip = this.currentSkip) {
|
|
2812
|
+
const filters = this.globalFilters;
|
|
2813
|
+
if (!filters)
|
|
2814
|
+
return null;
|
|
2815
|
+
if (skip === true)
|
|
2816
|
+
return null;
|
|
2817
|
+
if (Array.isArray(skip) && skip.includes(table))
|
|
2818
|
+
return null;
|
|
2819
|
+
const raw = filters[table];
|
|
2820
|
+
if (raw === undefined)
|
|
2821
|
+
return null;
|
|
2822
|
+
const resolved = typeof raw === 'function' ? raw() : raw;
|
|
2823
|
+
if (resolved === null || resolved === undefined)
|
|
2824
|
+
return null;
|
|
2825
|
+
const obj = resolved;
|
|
2826
|
+
// An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
|
|
2827
|
+
// nothing — treat it as absent so it never emits a dangling clause.
|
|
2828
|
+
if (Object.keys(obj).every((k) => obj[k] === undefined))
|
|
2829
|
+
return null;
|
|
2830
|
+
return obj;
|
|
2831
|
+
}
|
|
2826
2832
|
/**
|
|
2827
|
-
*
|
|
2828
|
-
*
|
|
2829
|
-
*
|
|
2830
|
-
* mutation with no filter — a common footgun when a caller's filter
|
|
2831
|
-
* value accidentally resolves to `undefined`. This guard throws
|
|
2832
|
-
* `ValidationError` in that case unless `allowFullTableScan: true`.
|
|
2833
|
+
* AND-merge this table's resolved global filter into a user `where`. Either
|
|
2834
|
+
* side may be absent. When no filter applies the user where is returned by
|
|
2835
|
+
* reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
|
|
2833
2836
|
*/
|
|
2837
|
+
mergeGlobalFilter(userWhere) {
|
|
2838
|
+
const gf = this.resolveGlobalFilter(this.table);
|
|
2839
|
+
if (!gf)
|
|
2840
|
+
return userWhere;
|
|
2841
|
+
if (userWhere === undefined)
|
|
2842
|
+
return gf;
|
|
2843
|
+
return { AND: [userWhere, gf] };
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* SQL clause for `targetTable`'s global filter rendered against `alias`
|
|
2847
|
+
* (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
|
|
2848
|
+
* `params`; returns `''` when no filter applies. Mirror:
|
|
2849
|
+
* {@link collectTargetGlobalFilterAlias}.
|
|
2850
|
+
*/
|
|
2851
|
+
targetGlobalFilterAlias(targetTable, alias, params) {
|
|
2852
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
2853
|
+
if (!gf)
|
|
2854
|
+
return '';
|
|
2855
|
+
const meta = this.schema.tables[targetTable];
|
|
2856
|
+
if (!meta)
|
|
2857
|
+
return '';
|
|
2858
|
+
return this.buildAliasWhere(targetTable, meta, alias, gf, params) ?? '';
|
|
2859
|
+
}
|
|
2860
|
+
/** Param-collect mirror of {@link targetGlobalFilterAlias}. */
|
|
2861
|
+
collectTargetGlobalFilterAlias(targetTable, params) {
|
|
2862
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
2863
|
+
if (!gf)
|
|
2864
|
+
return;
|
|
2865
|
+
const meta = this.schema.tables[targetTable];
|
|
2866
|
+
if (!meta)
|
|
2867
|
+
return;
|
|
2868
|
+
this.collectAliasWhereParams(targetTable, meta, gf, params);
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* SQL clause for `targetTable`'s global filter rendered against the bare
|
|
2872
|
+
* (unaliased) table name — the form used inside relation-filter `EXISTS`
|
|
2873
|
+
* subqueries. Pushes its params; `''` when none. Mirror:
|
|
2874
|
+
* {@link collectTargetGlobalFilterExists}.
|
|
2875
|
+
*/
|
|
2876
|
+
targetGlobalFilterExists(targetTable, params) {
|
|
2877
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
2878
|
+
if (!gf)
|
|
2879
|
+
return '';
|
|
2880
|
+
return this.buildSubWhereForRelation(targetTable, gf, params) ?? '';
|
|
2881
|
+
}
|
|
2882
|
+
/** Param-collect mirror of {@link targetGlobalFilterExists}. */
|
|
2883
|
+
collectTargetGlobalFilterExists(targetTable, params) {
|
|
2884
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
2885
|
+
if (!gf)
|
|
2886
|
+
return;
|
|
2887
|
+
this.collectRelFilterParams(targetTable, gf, params);
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Value-invariant SQL-cache-key segment for the active global-filter
|
|
2891
|
+
* environment. Relation-subquery / relation-filter / `_count` / relation-
|
|
2892
|
+
* `orderBy` global filters are rendered at build time but their SHAPE is not
|
|
2893
|
+
* otherwise in the where/with fingerprint, so this segment guards the cache:
|
|
2894
|
+
* two different filter shapes never collide on one cached SQL text, while two
|
|
2895
|
+
* function-filter results of the SAME shape (differing only in values) share
|
|
2896
|
+
* the entry and bind their own params. Empty (`''`) when no filter applies, so
|
|
2897
|
+
* cache keys stay byte-identical when the feature is unused.
|
|
2898
|
+
*/
|
|
2899
|
+
globalFilterCacheSegment() {
|
|
2900
|
+
const filters = this.globalFilters;
|
|
2901
|
+
if (!filters)
|
|
2902
|
+
return '';
|
|
2903
|
+
const parts = [];
|
|
2904
|
+
for (const table of Object.keys(filters).sort()) {
|
|
2905
|
+
// Function filters for OTHER tables may be request-scoped closures that
|
|
2906
|
+
// throw outside their own context; a query on an unrelated table must not
|
|
2907
|
+
// break on them. A throwing filter can't have contributed SQL to this
|
|
2908
|
+
// query either (merging it would have thrown first), so a constant
|
|
2909
|
+
// marker keeps the key shape-distinct without evaluating it.
|
|
2910
|
+
let gf;
|
|
2911
|
+
try {
|
|
2912
|
+
gf = this.resolveGlobalFilter(table);
|
|
2913
|
+
}
|
|
2914
|
+
catch {
|
|
2915
|
+
parts.push(`${table}:!`);
|
|
2916
|
+
continue;
|
|
2917
|
+
}
|
|
2918
|
+
if (gf)
|
|
2919
|
+
parts.push(`${table}:${this.fingerprintWhere(gf)}`);
|
|
2920
|
+
}
|
|
2921
|
+
return parts.length ? `|gf=${parts.join(';')}` : '';
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* True when the USER-supplied `where` compiles to no predicate (`{}`,
|
|
2925
|
+
* `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
|
|
2926
|
+
* signal the empty-`where` guard needs — the compiled emptiness, NOT the
|
|
2927
|
+
* fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
|
|
2928
|
+
* any configured global filter, so a global filter never lets an unguarded
|
|
2929
|
+
* mass mutation through.
|
|
2930
|
+
*/
|
|
2931
|
+
userPredicateIsEmpty(userWhere) {
|
|
2932
|
+
const throwaway = [];
|
|
2933
|
+
return this.buildWhereClause(userWhere, throwaway) === null;
|
|
2934
|
+
}
|
|
2834
2935
|
assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
|
|
2835
2936
|
if (whereSql.length > 0)
|
|
2836
2937
|
return;
|
|
@@ -3002,55 +3103,69 @@ export class QueryInterface {
|
|
|
3002
3103
|
// belongsTo: parent.fk = child.pk
|
|
3003
3104
|
correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
|
|
3004
3105
|
}
|
|
3005
|
-
//
|
|
3106
|
+
// The target table's global filter (soft-delete / tenancy) restricts the
|
|
3107
|
+
// DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
|
|
3108
|
+
// ignore filtered-out rows, and `every` quantifies over only the surviving
|
|
3109
|
+
// rows ("every NON-deleted related row matches P"). It is ANDed into the
|
|
3110
|
+
// correlation and its params pushed AFTER the per-branch filter — mirrored
|
|
3111
|
+
// exactly in collectWhereParams' relation-filter branch. `qt` is the bare
|
|
3112
|
+
// target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
|
|
3113
|
+
const gfAnd = () => {
|
|
3114
|
+
const gf = this.targetGlobalFilterExists(targetTable, params);
|
|
3115
|
+
return gf ? ` AND ${gf}` : '';
|
|
3116
|
+
};
|
|
3117
|
+
// "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
3006
3118
|
if (filterObj.some !== undefined) {
|
|
3007
3119
|
const subWhere = filterObj.some;
|
|
3008
3120
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3009
|
-
const
|
|
3010
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3121
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3122
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3011
3123
|
}
|
|
3012
|
-
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
|
|
3124
|
+
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
3013
3125
|
if (filterObj.none !== undefined) {
|
|
3014
3126
|
const subWhere = filterObj.none;
|
|
3015
3127
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3016
|
-
const
|
|
3017
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3128
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3129
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3018
3130
|
}
|
|
3019
|
-
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
|
|
3131
|
+
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
|
|
3020
3132
|
if (filterObj.every !== undefined) {
|
|
3021
3133
|
const subWhere = filterObj.every;
|
|
3022
3134
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3023
3135
|
if (filterClause) {
|
|
3024
|
-
|
|
3136
|
+
// gf params pushed AFTER filter params (collect mirrors this order), but
|
|
3137
|
+
// placed textually inside the domain so it restricts which rows count.
|
|
3138
|
+
const gf = gfAnd();
|
|
3139
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
|
|
3025
3140
|
}
|
|
3026
3141
|
else {
|
|
3027
|
-
// "every" with empty filter = true (all match trivially)
|
|
3142
|
+
// "every" with empty filter = true (all match trivially) — gf irrelevant.
|
|
3028
3143
|
}
|
|
3029
3144
|
}
|
|
3030
3145
|
// "is": EXISTS — for to-one relations (same SQL as "some").
|
|
3031
3146
|
// `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
|
|
3032
3147
|
if (filterObj.is !== undefined) {
|
|
3033
3148
|
if (filterObj.is === null) {
|
|
3034
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3149
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
|
|
3035
3150
|
}
|
|
3036
3151
|
else {
|
|
3037
3152
|
const subWhere = filterObj.is;
|
|
3038
3153
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3039
|
-
const
|
|
3040
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3154
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3155
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3041
3156
|
}
|
|
3042
3157
|
}
|
|
3043
3158
|
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
|
|
3044
3159
|
// `isNot: null` = "a related row exists" → EXISTS.
|
|
3045
3160
|
if (filterObj.isNot !== undefined) {
|
|
3046
3161
|
if (filterObj.isNot === null) {
|
|
3047
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3162
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
|
|
3048
3163
|
}
|
|
3049
3164
|
else {
|
|
3050
3165
|
const subWhere = filterObj.isNot;
|
|
3051
3166
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3052
|
-
const
|
|
3053
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3167
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3168
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3054
3169
|
}
|
|
3055
3170
|
}
|
|
3056
3171
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
@@ -3245,17 +3360,9 @@ export class QueryInterface {
|
|
|
3245
3360
|
if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
3246
3361
|
const norm = this.normalizeRelationFilter(aliasRel, value);
|
|
3247
3362
|
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);
|
|
3363
|
+
// Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
|
|
3364
|
+
// params then target global-filter params).
|
|
3365
|
+
this.collectRelationFilterParams(aliasRel, norm, params);
|
|
3259
3366
|
continue;
|
|
3260
3367
|
}
|
|
3261
3368
|
}
|
|
@@ -3402,10 +3509,37 @@ export class QueryInterface {
|
|
|
3402
3509
|
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
3403
3510
|
* ordering throws — KNN ordering is only supported at the top level.
|
|
3404
3511
|
*/
|
|
3512
|
+
/**
|
|
3513
|
+
* Value-shape fingerprint for a single orderBy entry, so two queries whose
|
|
3514
|
+
* ORDER BY differs only in nulls placement, vector metric, or relation-count
|
|
3515
|
+
* vs relation-column never collide on one cached SQL string. Captures the
|
|
3516
|
+
* SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
|
|
3517
|
+
*/
|
|
3518
|
+
orderByEntryFingerprint(d) {
|
|
3519
|
+
// Vector KNN ordering changes the emitted operator by metric and adds a
|
|
3520
|
+
// `::vector` param, so metric + direction must be part of the cache key.
|
|
3521
|
+
if (isVectorOrderBy(d)) {
|
|
3522
|
+
return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
3523
|
+
}
|
|
3524
|
+
if (isOrderBySpec(d))
|
|
3525
|
+
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3526
|
+
if (d && typeof d === 'object') {
|
|
3527
|
+
// Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
|
|
3528
|
+
return `rel(${Object.entries(d)
|
|
3529
|
+
.map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
|
|
3530
|
+
.sort()
|
|
3531
|
+
.join(',')})`;
|
|
3532
|
+
}
|
|
3533
|
+
return String(d);
|
|
3534
|
+
}
|
|
3405
3535
|
buildOrderBy(orderBy, params) {
|
|
3406
|
-
// Dev-only: validate that orderBy fields exist in the table schema
|
|
3536
|
+
// Dev-only: validate that orderBy fields exist in the table schema. Relation
|
|
3537
|
+
// orderBy keys (object values that are neither a vector nor an OrderBySpec)
|
|
3538
|
+
// are validated in the relation branch below, so skip them here.
|
|
3407
3539
|
if (process.env.NODE_ENV !== 'production') {
|
|
3408
|
-
for (const key of Object.
|
|
3540
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
3541
|
+
if (this.isRelationOrderByValue(value) && this.tableMeta.relations[key])
|
|
3542
|
+
continue;
|
|
3409
3543
|
const snakeKey = camelToSnake(key);
|
|
3410
3544
|
if (!this.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in this.tableMeta.columnMap)) {
|
|
3411
3545
|
console.warn(`[turbine] Unknown orderBy field "${key}" for table "${this.tableMeta.name}". ` +
|
|
@@ -3414,28 +3548,217 @@ export class QueryInterface {
|
|
|
3414
3548
|
}
|
|
3415
3549
|
}
|
|
3416
3550
|
const meta = this.schema.tables[this.table];
|
|
3551
|
+
let relOrdCounter = 0;
|
|
3417
3552
|
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
|
-
}
|
|
3553
|
+
.map(([key, value]) => {
|
|
3423
3554
|
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
3424
|
-
if (isVectorOrderBy(
|
|
3555
|
+
if (isVectorOrderBy(value)) {
|
|
3556
|
+
if (meta && !(key in meta.columnMap)) {
|
|
3557
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3558
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3559
|
+
}
|
|
3425
3560
|
if (!params) {
|
|
3426
3561
|
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
3427
3562
|
}
|
|
3428
3563
|
const rawColumn = this.toColumn(key);
|
|
3429
|
-
const operator = this.vectorOperator(key, rawColumn,
|
|
3430
|
-
const placeholder = this.pushVectorParam(key, rawColumn,
|
|
3431
|
-
const safeDir =
|
|
3564
|
+
const operator = this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
3565
|
+
const placeholder = this.pushVectorParam(key, rawColumn, value.distance.to, params);
|
|
3566
|
+
const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3432
3567
|
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
3433
3568
|
}
|
|
3434
|
-
|
|
3435
|
-
|
|
3569
|
+
// Relation ordering: an object value that is not a vector or OrderBySpec,
|
|
3570
|
+
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
3571
|
+
// { name: 'asc' } }`).
|
|
3572
|
+
if (this.isRelationOrderByValue(value)) {
|
|
3573
|
+
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
|
|
3574
|
+
}
|
|
3575
|
+
// Scalar column ordering — a plain direction or an OrderBySpec (nulls).
|
|
3576
|
+
if (meta && !(key in meta.columnMap)) {
|
|
3577
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3578
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3579
|
+
}
|
|
3580
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
3581
|
+
return `${this.toSqlColumn(key)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
3582
|
+
})
|
|
3583
|
+
.join(', ');
|
|
3584
|
+
}
|
|
3585
|
+
/**
|
|
3586
|
+
* True when an orderBy value is a relation-ordering object: a plain object
|
|
3587
|
+
* that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
|
|
3588
|
+
* in the orderBy clause is a relation name.
|
|
3589
|
+
*/
|
|
3590
|
+
isRelationOrderByValue(value) {
|
|
3591
|
+
return (typeof value === 'object' &&
|
|
3592
|
+
value !== null &&
|
|
3593
|
+
!Array.isArray(value) &&
|
|
3594
|
+
!isVectorOrderBy(value) &&
|
|
3595
|
+
!isOrderBySpec(value));
|
|
3596
|
+
}
|
|
3597
|
+
/**
|
|
3598
|
+
* Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
|
|
3599
|
+
* Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
|
|
3600
|
+
* other engine a caller asking for explicit nulls placement gets a clear
|
|
3601
|
+
* {@link UnsupportedFeatureError} (E017) instead of broken SQL.
|
|
3602
|
+
*/
|
|
3603
|
+
nullsSuffix(nulls) {
|
|
3604
|
+
if (!nulls)
|
|
3605
|
+
return '';
|
|
3606
|
+
if (this.dialect.name !== 'postgresql' && this.dialect.name !== 'sqlite') {
|
|
3607
|
+
throw new UnsupportedFeatureError('NULLS FIRST/LAST ordering', this.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
|
|
3608
|
+
}
|
|
3609
|
+
return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
|
|
3610
|
+
}
|
|
3611
|
+
/**
|
|
3612
|
+
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
3613
|
+
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
3614
|
+
* to-one relation each entry names a target column and becomes a correlated
|
|
3615
|
+
* scalar subquery (supporting {@link OrderBySpec} nulls placement).
|
|
3616
|
+
*
|
|
3617
|
+
* Validation: relation must exist (E005); to-many only allows `_count`, and
|
|
3618
|
+
* to-one only allows real target columns (E003).
|
|
3619
|
+
*/
|
|
3620
|
+
buildRelationOrderBy(relName, value, alias, params) {
|
|
3621
|
+
const relDef = this.tableMeta.relations[relName];
|
|
3622
|
+
if (!relDef) {
|
|
3623
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
|
|
3624
|
+
`Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
|
|
3625
|
+
}
|
|
3626
|
+
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
3627
|
+
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
3628
|
+
const keys = Object.keys(value);
|
|
3629
|
+
if (keys.length !== 1 || keys[0] !== '_count') {
|
|
3630
|
+
throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
|
|
3631
|
+
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
3632
|
+
}
|
|
3633
|
+
const { dir } = normalizeOrderBy(value._count);
|
|
3634
|
+
return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
|
|
3635
|
+
}
|
|
3636
|
+
// To-one: each entry orders by a correlated scalar subquery on a target column.
|
|
3637
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
3638
|
+
if (!targetMeta)
|
|
3639
|
+
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
3640
|
+
const qTarget = this.q(relDef.to);
|
|
3641
|
+
const qParent = this.q(this.table);
|
|
3642
|
+
// belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
|
|
3643
|
+
const correlation = relDef.type === 'belongsTo'
|
|
3644
|
+
? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
3645
|
+
: this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3646
|
+
const entries = Object.entries(value);
|
|
3647
|
+
if (entries.length === 0) {
|
|
3648
|
+
throw new ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
|
|
3649
|
+
}
|
|
3650
|
+
return entries
|
|
3651
|
+
.map(([col, dirValue]) => {
|
|
3652
|
+
const snakeCol = camelToSnake(col);
|
|
3653
|
+
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
3654
|
+
throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
3655
|
+
}
|
|
3656
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
3657
|
+
// Target's global filter applies here too — otherwise ordering keys off
|
|
3658
|
+
// a soft-deleted / other-tenant related row's value (matches the with
|
|
3659
|
+
// subquery semantics for belongsTo/hasOne).
|
|
3660
|
+
let where = correlation;
|
|
3661
|
+
if (params) {
|
|
3662
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
3663
|
+
if (gf)
|
|
3664
|
+
where += ` AND ${gf}`;
|
|
3665
|
+
}
|
|
3666
|
+
return `(SELECT ${alias}.${this.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${this.limitOneClause()}) ${dir}${this.nullsSuffix(nulls)}`;
|
|
3436
3667
|
})
|
|
3437
3668
|
.join(', ');
|
|
3438
3669
|
}
|
|
3670
|
+
/**
|
|
3671
|
+
* Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
|
|
3672
|
+
* relation, correlated to `parentRef`. hasMany counts child rows via the FK;
|
|
3673
|
+
* manyToMany counts junction rows via the source key. Shared by the `_count`
|
|
3674
|
+
* `with` key and to-many relation orderBy.
|
|
3675
|
+
*
|
|
3676
|
+
* When `params` is supplied and the target has a global filter, it is
|
|
3677
|
+
* AND-merged so the count only sees surviving rows (a soft-deleted child is
|
|
3678
|
+
* not counted): hasMany filters the counted rows directly; manyToMany adds an
|
|
3679
|
+
* `EXISTS` on the target through the junction (the junction rows themselves
|
|
3680
|
+
* carry no filter). Params are mirrored by {@link collectRelationCountParams}.
|
|
3681
|
+
*/
|
|
3682
|
+
buildRelationCountExpr(relDef, parentRef, alias, params) {
|
|
3683
|
+
const qParent = this.q(parentRef);
|
|
3684
|
+
const count = this.castAgg('COUNT(*)', 'int');
|
|
3685
|
+
if (relDef.type === 'manyToMany') {
|
|
3686
|
+
if (!relDef.through) {
|
|
3687
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
|
|
3688
|
+
}
|
|
3689
|
+
const qJ = this.q(relDef.through.table);
|
|
3690
|
+
const jalias = `${alias}j`;
|
|
3691
|
+
const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
|
|
3692
|
+
const refKeys = normalizeKeyColumns(relDef.referenceKey);
|
|
3693
|
+
let where = sourceKeys
|
|
3694
|
+
.map((jc, i) => `${jalias}.${this.q(jc)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3695
|
+
.join(' AND ');
|
|
3696
|
+
if (params) {
|
|
3697
|
+
const targetExists = this.manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params);
|
|
3698
|
+
if (targetExists)
|
|
3699
|
+
where += ` AND ${targetExists}`;
|
|
3700
|
+
}
|
|
3701
|
+
return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
|
|
3702
|
+
}
|
|
3703
|
+
// hasMany: child FK correlates to the parent reference key.
|
|
3704
|
+
const qTarget = this.q(relDef.to);
|
|
3705
|
+
let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3706
|
+
if (params) {
|
|
3707
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
3708
|
+
if (gf)
|
|
3709
|
+
where += ` AND ${gf}`;
|
|
3710
|
+
}
|
|
3711
|
+
return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
|
|
3712
|
+
}
|
|
3713
|
+
/**
|
|
3714
|
+
* `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
|
|
3715
|
+
* a manyToMany `_count` to targets that survive their global filter. `''` when
|
|
3716
|
+
* the target has no filter. Pushes gf params; mirror:
|
|
3717
|
+
* {@link collectManyToManyTargetGlobalFilter}.
|
|
3718
|
+
*/
|
|
3719
|
+
manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params) {
|
|
3720
|
+
const gf = this.resolveGlobalFilter(relDef.to);
|
|
3721
|
+
if (!gf || !relDef.through)
|
|
3722
|
+
return '';
|
|
3723
|
+
const tMeta = this.schema.tables[relDef.to];
|
|
3724
|
+
if (!tMeta || tMeta.primaryKey.length === 0)
|
|
3725
|
+
return '';
|
|
3726
|
+
const talias = `${alias}t`;
|
|
3727
|
+
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
3728
|
+
const pk = tMeta.primaryKey;
|
|
3729
|
+
if (targetKeys.length !== pk.length)
|
|
3730
|
+
return '';
|
|
3731
|
+
const join = targetKeys.map((jc, i) => `${talias}.${this.q(pk[i])} = ${jalias}.${this.q(jc)}`).join(' AND ');
|
|
3732
|
+
const gfClause = this.buildAliasWhere(relDef.to, tMeta, talias, gf, params);
|
|
3733
|
+
const gfAnd = gfClause ? ` AND ${gfClause}` : '';
|
|
3734
|
+
return `EXISTS (SELECT 1 FROM ${this.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
|
|
3735
|
+
}
|
|
3736
|
+
/** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
|
|
3737
|
+
collectManyToManyTargetGlobalFilter(relDef, params) {
|
|
3738
|
+
const gf = this.resolveGlobalFilter(relDef.to);
|
|
3739
|
+
if (!gf || !relDef.through)
|
|
3740
|
+
return;
|
|
3741
|
+
const tMeta = this.schema.tables[relDef.to];
|
|
3742
|
+
if (!tMeta || tMeta.primaryKey.length === 0)
|
|
3743
|
+
return;
|
|
3744
|
+
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
3745
|
+
if (targetKeys.length !== tMeta.primaryKey.length)
|
|
3746
|
+
return;
|
|
3747
|
+
this.collectAliasWhereParams(relDef.to, tMeta, gf, params);
|
|
3748
|
+
}
|
|
3749
|
+
/**
|
|
3750
|
+
* Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
|
|
3751
|
+
* params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
|
|
3752
|
+
* when a filter applies — no-op otherwise.
|
|
3753
|
+
*/
|
|
3754
|
+
collectRelationCountParams(relDef, params) {
|
|
3755
|
+
if (relDef.type === 'manyToMany') {
|
|
3756
|
+
this.collectManyToManyTargetGlobalFilter(relDef, params);
|
|
3757
|
+
}
|
|
3758
|
+
else {
|
|
3759
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3439
3762
|
// -------------------------------------------------------------------------
|
|
3440
3763
|
// pgvector helpers (similarity search)
|
|
3441
3764
|
// -------------------------------------------------------------------------
|
|
@@ -3564,6 +3887,19 @@ export class QueryInterface {
|
|
|
3564
3887
|
const meta = this.schema.tables[table];
|
|
3565
3888
|
if (!meta)
|
|
3566
3889
|
return parsed;
|
|
3890
|
+
// Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
|
|
3891
|
+
// parseRow copies these unknown columns through under their raw key.
|
|
3892
|
+
let countObj;
|
|
3893
|
+
for (const key of Object.keys(parsed)) {
|
|
3894
|
+
if (key.startsWith('_count__')) {
|
|
3895
|
+
if (countObj === undefined)
|
|
3896
|
+
countObj = {};
|
|
3897
|
+
countObj[key.slice('_count__'.length)] = Number(parsed[key]);
|
|
3898
|
+
delete parsed[key];
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
if (countObj)
|
|
3902
|
+
parsed._count = countObj;
|
|
3567
3903
|
for (const [relName, relDef] of Object.entries(meta.relations)) {
|
|
3568
3904
|
const rawValue = row[relName];
|
|
3569
3905
|
if (rawValue === undefined)
|
|
@@ -3830,6 +4166,9 @@ export class QueryInterface {
|
|
|
3830
4166
|
const relationSelects = [];
|
|
3831
4167
|
const aliasCounter = { n: 0 };
|
|
3832
4168
|
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
4169
|
+
// `_count` is a reserved key handled after the relation subqueries.
|
|
4170
|
+
if (relName === '_count')
|
|
4171
|
+
continue;
|
|
3833
4172
|
const relDef = meta.relations[relName];
|
|
3834
4173
|
if (!relDef) {
|
|
3835
4174
|
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3839,6 +4178,18 @@ export class QueryInterface {
|
|
|
3839
4178
|
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
3840
4179
|
relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
|
|
3841
4180
|
}
|
|
4181
|
+
// Reserved `_count` key → one correlated COUNT(*) scalar subquery per
|
|
4182
|
+
// selected to-many relation, aliased `_count__<rel>`. Appended after the
|
|
4183
|
+
// relation subqueries; the only params they can push come from a global
|
|
4184
|
+
// filter on the counted target (mirrored at the tail of collectWithParams).
|
|
4185
|
+
// Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
|
|
4186
|
+
const countSpec = withClause._count;
|
|
4187
|
+
if (countSpec !== undefined) {
|
|
4188
|
+
for (const rel of resolveCountRelations(meta, countSpec)) {
|
|
4189
|
+
const expr = this.buildRelationCountExpr(rel, table, `t${aliasCounter.n++}`, params);
|
|
4190
|
+
relationSelects.push(`${expr} AS ${this.q(`_count__${rel.name}`)}`);
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
3842
4193
|
return [baseCols, ...relationSelects].join(', ');
|
|
3843
4194
|
}
|
|
3844
4195
|
/**
|
|
@@ -4039,13 +4390,13 @@ export class QueryInterface {
|
|
|
4039
4390
|
let orderClause = '';
|
|
4040
4391
|
if (relOrderEntries.length > 0) {
|
|
4041
4392
|
const orders = relOrderEntries
|
|
4042
|
-
.map(([k,
|
|
4393
|
+
.map(([k, dirValue]) => {
|
|
4043
4394
|
const col = camelToSnake(k);
|
|
4044
4395
|
if (!targetMeta.allColumns.includes(col)) {
|
|
4045
4396
|
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4046
4397
|
}
|
|
4047
|
-
const
|
|
4048
|
-
return `${alias}.${this.q(col)} ${
|
|
4398
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4399
|
+
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4049
4400
|
})
|
|
4050
4401
|
.join(', ');
|
|
4051
4402
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -4071,6 +4422,12 @@ export class QueryInterface {
|
|
|
4071
4422
|
if (extra)
|
|
4072
4423
|
whereClause += ` AND ${extra}`;
|
|
4073
4424
|
}
|
|
4425
|
+
// Global filter on the target table (soft-delete / tenancy) — AND-merged so
|
|
4426
|
+
// a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
|
|
4427
|
+
// mirrored by collectRelationSubqueryParams.
|
|
4428
|
+
const gfExtra = this.targetGlobalFilterAlias(targetTable, alias, params);
|
|
4429
|
+
if (gfExtra)
|
|
4430
|
+
whereClause += ` AND ${gfExtra}`;
|
|
4074
4431
|
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
4075
4432
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
4076
4433
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
@@ -4181,13 +4538,13 @@ export class QueryInterface {
|
|
|
4181
4538
|
let orderClause = '';
|
|
4182
4539
|
if (relOrderEntries.length > 0) {
|
|
4183
4540
|
const orders = relOrderEntries
|
|
4184
|
-
.map(([k,
|
|
4541
|
+
.map(([k, dirValue]) => {
|
|
4185
4542
|
const col = camelToSnake(k);
|
|
4186
4543
|
if (!targetMeta.allColumns.includes(col)) {
|
|
4187
4544
|
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4188
4545
|
}
|
|
4189
|
-
const
|
|
4190
|
-
return `${talias}.${this.q(col)} ${
|
|
4546
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4547
|
+
return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4191
4548
|
})
|
|
4192
4549
|
.join(', ');
|
|
4193
4550
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -4199,6 +4556,11 @@ export class QueryInterface {
|
|
|
4199
4556
|
if (extra)
|
|
4200
4557
|
whereClause += ` AND ${extra}`;
|
|
4201
4558
|
}
|
|
4559
|
+
// Global filter on the target table (mirrors collectRelationSubqueryParams'
|
|
4560
|
+
// m2m branch: after spec.where, before limit).
|
|
4561
|
+
const gfExtra = this.targetGlobalFilterAlias(targetTable, talias, params);
|
|
4562
|
+
if (gfExtra)
|
|
4563
|
+
whereClause += ` AND ${gfExtra}`;
|
|
4202
4564
|
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
4203
4565
|
let limitClause = '';
|
|
4204
4566
|
if (spec !== true && spec.limit !== undefined) {
|