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
|
@@ -52,214 +52,10 @@ const index_advisor_js_1 = require("../index-advisor.js");
|
|
|
52
52
|
const nested_write_js_1 = require("../nested-write.js");
|
|
53
53
|
const schema_js_1 = require("../schema.js");
|
|
54
54
|
const batched_loader_js_1 = require("./batched-loader.js");
|
|
55
|
+
const filters_js_1 = require("./filters.js");
|
|
55
56
|
const utils_js_1 = require("./utils.js");
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Internal detection helpers — used by QueryInterface
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
/** Check if a value is a where operator object (has at least one known operator key) */
|
|
60
|
-
function isWhereOperator(value) {
|
|
61
|
-
if (value === null ||
|
|
62
|
-
value === undefined ||
|
|
63
|
-
typeof value !== 'object' ||
|
|
64
|
-
Array.isArray(value) ||
|
|
65
|
-
value instanceof Date) {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
const keys = Object.keys(value);
|
|
69
|
-
return keys.length > 0 && keys.every((k) => utils_js_1.OPERATOR_KEYS.has(k));
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* True for a *plain object literal* that reached an equality fallthrough
|
|
73
|
-
* without matching any known filter shape — the misspelled-operator case.
|
|
74
|
-
* Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
|
|
75
|
-
* bind values and return false, as do arrays and Dates.
|
|
76
|
-
*/
|
|
77
|
-
function isUnmatchedPlainObject(value) {
|
|
78
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
|
|
79
|
-
return false;
|
|
80
|
-
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
|
|
81
|
-
return false;
|
|
82
|
-
const proto = Object.getPrototypeOf(value);
|
|
83
|
-
return proto === Object.prototype || proto === null;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
|
|
87
|
-
* `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
|
|
88
|
-
* param pushed), so null-ness is part of the shape — without it a cache entry
|
|
89
|
-
* warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
|
|
90
|
-
*/
|
|
91
|
-
function fingerprintOperatorShape(value) {
|
|
92
|
-
const obj = value;
|
|
93
|
-
const opKeys = Object.keys(obj)
|
|
94
|
-
.filter((k) => k !== 'mode')
|
|
95
|
-
.map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
|
|
96
|
-
.sort();
|
|
97
|
-
const modeStr = value.mode === 'insensitive' ? ':i' : '';
|
|
98
|
-
return `op(${opKeys.join(',')}${modeStr})`;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Guard for the value of an `equals` operator reaching the plain-equality
|
|
102
|
-
* operator path. A plain object literal can only legitimately be an equality
|
|
103
|
-
* value on a json/jsonb column — and those route to the JSONB filter branch
|
|
104
|
-
* BEFORE the operator branch, so any plain object that reaches here is a
|
|
105
|
-
* mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
|
|
106
|
-
* SQL-build path and the cache-hit param-collect path so a warmed cache can
|
|
107
|
-
* never skip the check.
|
|
108
|
-
*/
|
|
109
|
-
function assertBindableEqualsOperand(value, column) {
|
|
110
|
-
if (!isUnmatchedPlainObject(value))
|
|
111
|
-
return;
|
|
112
|
-
throw new errors_js_1.ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
|
|
113
|
-
`objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
|
|
114
|
-
`where 'equals' is the JSONB containment filter.`);
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Object keys in sorted order, mirroring the canonical order used by every
|
|
118
|
-
* cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
|
|
119
|
-
* enumerate object keys in this exact order: fingerprints sort keys, so two
|
|
120
|
-
* where clauses with the same fields in different insertion order share one
|
|
121
|
-
* cache entry — if build/collect iterated insertion order, the cached SQL's
|
|
122
|
-
* `$N` placeholders would bind the wrong values (cross-tenant-leak class).
|
|
123
|
-
* Array order (OR/AND members) is positional and is never sorted.
|
|
124
|
-
*/
|
|
125
|
-
function sortedKeys(obj) {
|
|
126
|
-
return Object.keys(obj).sort();
|
|
127
|
-
}
|
|
128
|
-
/** {@link sortedKeys}, but yielding `[key, value]` pairs. */
|
|
129
|
-
function sortedEntries(obj) {
|
|
130
|
-
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
131
|
-
}
|
|
132
|
-
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
133
57
|
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
134
58
|
const unindexedRelationWarned = new Set();
|
|
135
|
-
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
136
|
-
/** Known JSONB operator keys */
|
|
137
|
-
const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
138
|
-
/**
|
|
139
|
-
* JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
|
|
140
|
-
* appear in any other where-filter shape, so the presence of one of these is
|
|
141
|
-
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
142
|
-
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
143
|
-
* `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
|
|
144
|
-
* set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
|
|
145
|
-
* so it must fall through instead of throwing.
|
|
146
|
-
*/
|
|
147
|
-
const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
|
|
148
|
-
/** Check if a value is a JSONB filter object */
|
|
149
|
-
function isJsonFilter(value) {
|
|
150
|
-
if (value === null ||
|
|
151
|
-
value === undefined ||
|
|
152
|
-
typeof value !== 'object' ||
|
|
153
|
-
Array.isArray(value) ||
|
|
154
|
-
value instanceof Date) {
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
const keys = Object.keys(value);
|
|
158
|
-
return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Returns the first JSON-unique key found in `value`, or `null` if none.
|
|
162
|
-
* Used to drive the strict-validation error message.
|
|
163
|
-
*/
|
|
164
|
-
function findJsonUniqueKey(value) {
|
|
165
|
-
for (const k of Object.keys(value)) {
|
|
166
|
-
if (JSONB_UNIQUE_KEYS.has(k))
|
|
167
|
-
return k;
|
|
168
|
-
}
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
/** Known Array operator keys */
|
|
172
|
-
const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
173
|
-
/**
|
|
174
|
-
* Array operator keys that are *unique* to {@link ArrayFilter}. None of the
|
|
175
|
-
* array operators currently overlap with `WhereOperator` or `JsonFilter`, so
|
|
176
|
-
* this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
|
|
177
|
-
* constant so a future overlap (e.g. a `contains` for arrays) is easy to
|
|
178
|
-
* carve out.
|
|
179
|
-
*/
|
|
180
|
-
const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
181
|
-
/** Check if a value is an Array filter object */
|
|
182
|
-
function isArrayFilter(value) {
|
|
183
|
-
if (value === null ||
|
|
184
|
-
value === undefined ||
|
|
185
|
-
typeof value !== 'object' ||
|
|
186
|
-
Array.isArray(value) ||
|
|
187
|
-
value instanceof Date) {
|
|
188
|
-
return false;
|
|
189
|
-
}
|
|
190
|
-
const keys = Object.keys(value);
|
|
191
|
-
return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Returns the first array-unique key found in `value`, or `null` if none.
|
|
195
|
-
* Used to drive the strict-validation error message.
|
|
196
|
-
*/
|
|
197
|
-
function findArrayUniqueKey(value) {
|
|
198
|
-
for (const k of Object.keys(value)) {
|
|
199
|
-
if (ARRAY_UNIQUE_KEYS.has(k))
|
|
200
|
-
return k;
|
|
201
|
-
}
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
/** Known text search operator keys */
|
|
205
|
-
const TEXT_SEARCH_KEYS = new Set(['search', 'config']);
|
|
206
|
-
/** Check if a value is a TextSearchFilter object */
|
|
207
|
-
function isTextSearchFilter(value) {
|
|
208
|
-
if (value === null ||
|
|
209
|
-
value === undefined ||
|
|
210
|
-
typeof value !== 'object' ||
|
|
211
|
-
Array.isArray(value) ||
|
|
212
|
-
value instanceof Date) {
|
|
213
|
-
return false;
|
|
214
|
-
}
|
|
215
|
-
const keys = Object.keys(value);
|
|
216
|
-
// Must have 'search' key and only known text search keys
|
|
217
|
-
return keys.includes('search') && keys.every((k) => TEXT_SEARCH_KEYS.has(k));
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* Validate a text search config name. Only alphanumeric characters and
|
|
221
|
-
* underscores are allowed to prevent SQL injection via the config parameter.
|
|
222
|
-
*/
|
|
223
|
-
function validateTextSearchConfig(config) {
|
|
224
|
-
return /^[a-zA-Z0-9_]+$/.test(config);
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* pgvector distance metric → operator allow-list. This is the ONLY mapping
|
|
228
|
-
* from a user-supplied metric token to a SQL operator; any token not present
|
|
229
|
-
* here is rejected, so a user value can never become an arbitrary operator.
|
|
230
|
-
*
|
|
231
|
-
* - `l2` → `<->` (Euclidean / L2 distance)
|
|
232
|
-
* - `cosine` → `<=>` (cosine distance)
|
|
233
|
-
* - `ip` → `<#>` (negative inner product)
|
|
234
|
-
*/
|
|
235
|
-
const VECTOR_METRIC_OPERATORS = {
|
|
236
|
-
l2: '<->',
|
|
237
|
-
cosine: '<=>',
|
|
238
|
-
ip: '<#>',
|
|
239
|
-
};
|
|
240
|
-
/** Comparison keys allowed on a {@link VectorDistanceFilter}. */
|
|
241
|
-
const VECTOR_DISTANCE_COMPARATORS = {
|
|
242
|
-
lt: '<',
|
|
243
|
-
lte: '<=',
|
|
244
|
-
gt: '>',
|
|
245
|
-
gte: '>=',
|
|
246
|
-
};
|
|
247
|
-
/** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
|
|
248
|
-
function isVectorFilter(value) {
|
|
249
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
|
250
|
-
return false;
|
|
251
|
-
}
|
|
252
|
-
const dist = value.distance;
|
|
253
|
-
return (typeof dist === 'object' &&
|
|
254
|
-
dist !== null &&
|
|
255
|
-
!Array.isArray(dist) &&
|
|
256
|
-
'to' in dist &&
|
|
257
|
-
'metric' in dist);
|
|
258
|
-
}
|
|
259
|
-
/** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
|
|
260
|
-
function isVectorOrderBy(value) {
|
|
261
|
-
return isVectorFilter(value);
|
|
262
|
-
}
|
|
263
59
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
264
60
|
class QueryInterface {
|
|
265
61
|
pool;
|
|
@@ -279,6 +75,13 @@ class QueryInterface {
|
|
|
279
75
|
relationLoadStrategy;
|
|
280
76
|
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
281
77
|
jsonEncoding;
|
|
78
|
+
/**
|
|
79
|
+
* Client-level automatic WHERE filters keyed by table accessor (soft-delete /
|
|
80
|
+
* multi-tenancy). AND-merged into every query on the keyed table and every
|
|
81
|
+
* relation subquery targeting it. Undefined when none are configured, in
|
|
82
|
+
* which case every path is byte-identical to the pre-0.28 behavior.
|
|
83
|
+
*/
|
|
84
|
+
globalFilters;
|
|
282
85
|
/**
|
|
283
86
|
* Tracks tables that have already triggered an unlimited-query warning so
|
|
284
87
|
* the user is not spammed once per row. Per-instance state — each
|
|
@@ -310,6 +113,15 @@ class QueryInterface {
|
|
|
310
113
|
options;
|
|
311
114
|
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
312
115
|
currentAction = 'raw';
|
|
116
|
+
/**
|
|
117
|
+
* The active query's `skipGlobalFilters` opt-out, set at the top of each
|
|
118
|
+
* `build*` method and read deep in the (synchronous) SQL-build + param-collect
|
|
119
|
+
* tree — so relation subqueries, relation filters, `_count`, and relation
|
|
120
|
+
* `orderBy` all see it without threading it through dozens of signatures.
|
|
121
|
+
* Only load-bearing when {@link globalFilters} is configured; build+collect are
|
|
122
|
+
* synchronous per call, so this transient is never observed across an await.
|
|
123
|
+
*/
|
|
124
|
+
currentSkip;
|
|
313
125
|
constructor(pool, table, schema, middlewares, options) {
|
|
314
126
|
this.pool = pool;
|
|
315
127
|
this.table = table;
|
|
@@ -331,6 +143,10 @@ class QueryInterface {
|
|
|
331
143
|
this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
|
|
332
144
|
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
|
|
333
145
|
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
146
|
+
// Only retain the map when it has at least one entry, so `globalFilters`
|
|
147
|
+
// stays `undefined` (and every merge path a no-op) for the common case.
|
|
148
|
+
this.globalFilters =
|
|
149
|
+
options?.globalFilters && Object.keys(options.globalFilters).length > 0 ? options.globalFilters : undefined;
|
|
334
150
|
this.txScoped = options?._txScoped ?? false;
|
|
335
151
|
this.options = options;
|
|
336
152
|
// Pre-compute column type lookup maps (TASK-26)
|
|
@@ -450,7 +266,7 @@ class QueryInterface {
|
|
|
450
266
|
* and unlimited-warnings silenced — a relation load must fetch every matching
|
|
451
267
|
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
452
268
|
*/
|
|
453
|
-
batchedContext(timeout) {
|
|
269
|
+
batchedContext(timeout, skip) {
|
|
454
270
|
const childOptions = {
|
|
455
271
|
...this.options,
|
|
456
272
|
defaultLimit: undefined,
|
|
@@ -465,6 +281,22 @@ class QueryInterface {
|
|
|
465
281
|
buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
|
|
466
282
|
inClauseParam: (values) => this.inParam(values),
|
|
467
283
|
paramPlaceholder: (index) => this.p(index),
|
|
284
|
+
skipGlobalFilters: skip,
|
|
285
|
+
tableGlobalFilter: (table, alias, precedingParams) => {
|
|
286
|
+
const gf = this.resolveGlobalFilter(table, skip);
|
|
287
|
+
if (!gf)
|
|
288
|
+
return null;
|
|
289
|
+
const meta = this.schema.tables[table];
|
|
290
|
+
if (!meta)
|
|
291
|
+
return null;
|
|
292
|
+
// Seed the param array with `precedingParams` placeholders so
|
|
293
|
+
// buildAliasWhere numbers the gf params after the already-bound ones.
|
|
294
|
+
const seeded = new Array(precedingParams).fill(undefined);
|
|
295
|
+
const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
|
|
296
|
+
if (!clause)
|
|
297
|
+
return null;
|
|
298
|
+
return { clause, params: seeded.slice(precedingParams) };
|
|
299
|
+
},
|
|
468
300
|
};
|
|
469
301
|
}
|
|
470
302
|
/**
|
|
@@ -476,13 +308,18 @@ class QueryInterface {
|
|
|
476
308
|
*/
|
|
477
309
|
async runFindManyBatched(args) {
|
|
478
310
|
const withClause = args.with;
|
|
311
|
+
// Capture the opt-out from the ARGS before any await: this.currentSkip is
|
|
312
|
+
// instance state on a cached accessor, so a concurrent build during the
|
|
313
|
+
// base-query await would overwrite it (tenant query loading relations with
|
|
314
|
+
// another query's skipGlobalFilters).
|
|
315
|
+
const skip = args.skipGlobalFilters;
|
|
479
316
|
const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
|
|
480
317
|
// baseArgs.with is always undefined here; the cast just bridges the R generic.
|
|
481
318
|
const deferred = this.buildFindMany(baseArgs);
|
|
482
319
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
483
320
|
const entities = deferred.transform(result);
|
|
484
321
|
if (entities.length > 0) {
|
|
485
|
-
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), entities, withClause, args.timeout);
|
|
322
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip), entities, withClause, args.timeout);
|
|
486
323
|
}
|
|
487
324
|
(0, batched_loader_js_1.stripFields)(entities, strip);
|
|
488
325
|
return entities;
|
|
@@ -704,18 +541,22 @@ class QueryInterface {
|
|
|
704
541
|
const entity = deferred.transform(result);
|
|
705
542
|
if (!entity)
|
|
706
543
|
return null;
|
|
707
|
-
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
|
|
544
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters), [entity], withClause, args.timeout);
|
|
708
545
|
(0, batched_loader_js_1.stripFields)([entity], proj.strip);
|
|
709
546
|
return entity;
|
|
710
547
|
}
|
|
711
548
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
712
549
|
buildFindUnique(args) {
|
|
550
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
713
551
|
const columnsList = this.resolveColumns(args.select, args.omit);
|
|
714
|
-
|
|
552
|
+
// A global filter turns the where into `{ AND: [...] }`, which the
|
|
553
|
+
// `isSimpleWhere` test below rejects → the general (buildWhereClause) path
|
|
554
|
+
// handles the merge and its params uniformly.
|
|
555
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
715
556
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
716
557
|
const whereFingerprint = this.fingerprintWhere(whereObj);
|
|
717
558
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
718
|
-
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
559
|
+
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
719
560
|
const params = [];
|
|
720
561
|
// Check if all where values are simple (plain equality, no operators/null/OR).
|
|
721
562
|
// Keys are sorted to match fingerprintWhere — insertion order here would let
|
|
@@ -728,7 +569,7 @@ class QueryInterface {
|
|
|
728
569
|
!whereObj.NOT &&
|
|
729
570
|
whereKeys.every((k) => {
|
|
730
571
|
const v = whereObj[k];
|
|
731
|
-
return v !== null && !isWhereOperator(v) && !this.tableMeta.relations[k];
|
|
572
|
+
return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !this.tableMeta.relations[k];
|
|
732
573
|
});
|
|
733
574
|
// Simple path: plain equality, no operators/null/OR
|
|
734
575
|
if (!args.with && isSimpleWhere) {
|
|
@@ -871,24 +712,21 @@ class QueryInterface {
|
|
|
871
712
|
}
|
|
872
713
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
873
714
|
buildFindMany(args) {
|
|
715
|
+
this.currentSkip = args?.skipGlobalFilters;
|
|
874
716
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
875
717
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
876
|
-
|
|
718
|
+
// AND-merge this table's global filter into the user where; `hasWhere` gates
|
|
719
|
+
// the build/collect just like `args?.where` did (a merged filter can make
|
|
720
|
+
// an otherwise-absent where present).
|
|
721
|
+
const effWhere = this.mergeGlobalFilter(args?.where);
|
|
722
|
+
const hasWhere = effWhere !== undefined;
|
|
723
|
+
const whereObj = (effWhere ?? {});
|
|
877
724
|
// Build fingerprint for cache lookup
|
|
878
|
-
const whereFp =
|
|
725
|
+
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
879
726
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
880
727
|
const orderFp = args?.orderBy
|
|
881
728
|
? Object.entries(args.orderBy)
|
|
882
|
-
.map(([k, d]) => {
|
|
883
|
-
// Vector KNN ordering changes the emitted SQL operator by metric and
|
|
884
|
-
// adds a `::vector` param, so the metric + direction must be part of
|
|
885
|
-
// the cache key — otherwise two KNN queries differing only in metric
|
|
886
|
-
// would collide on a single cached SQL string.
|
|
887
|
-
if (isVectorOrderBy(d)) {
|
|
888
|
-
return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
889
|
-
}
|
|
890
|
-
return `${k}:${d}`;
|
|
891
|
-
})
|
|
729
|
+
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
|
|
892
730
|
.join(',')
|
|
893
731
|
: '';
|
|
894
732
|
const cursorFp = args?.cursor
|
|
@@ -901,12 +739,12 @@ class QueryInterface {
|
|
|
901
739
|
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
902
740
|
const limitFp = effectiveLimit !== undefined ? '1' : '0';
|
|
903
741
|
const offsetFp = args?.offset !== undefined ? '1' : '0';
|
|
904
|
-
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}`;
|
|
742
|
+
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
905
743
|
const params = [];
|
|
906
744
|
const entry = this.acquireSql(ck, () => {
|
|
907
745
|
// Fresh build — generates SQL and populates freshParams
|
|
908
746
|
const freshParams = [];
|
|
909
|
-
const { sql: freshWhereSql } =
|
|
747
|
+
const { sql: freshWhereSql } = hasWhere
|
|
910
748
|
? (() => {
|
|
911
749
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
912
750
|
return { sql: clause ? ` WHERE ${clause}` : '' };
|
|
@@ -932,12 +770,15 @@ class QueryInterface {
|
|
|
932
770
|
let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
|
|
933
771
|
if (args?.cursor) {
|
|
934
772
|
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
935
|
-
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
773
|
+
const cursorEntries = (0, filters_js_1.sortedEntries)(args.cursor).filter(([, v]) => v !== undefined);
|
|
936
774
|
if (cursorEntries.length > 0) {
|
|
937
775
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
938
776
|
const col = this.toSqlColumn(k);
|
|
939
|
-
|
|
940
|
-
|
|
777
|
+
// orderBy values can be the { sort, nulls } spec form — normalize
|
|
778
|
+
// before comparing, or a desc spec would seek the ascending side.
|
|
779
|
+
const dir = args.orderBy?.[k];
|
|
780
|
+
const desc = (0, filters_js_1.isOrderBySpec)(dir) ? dir.sort === 'desc' : dir === 'desc';
|
|
781
|
+
const op = desc ? '<' : '>';
|
|
941
782
|
freshParams.push(v);
|
|
942
783
|
return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
|
|
943
784
|
});
|
|
@@ -956,7 +797,7 @@ class QueryInterface {
|
|
|
956
797
|
// order") need two levels: inner DISTINCT ON ordered by the distinct
|
|
957
798
|
// columns then the user's order (picks the right representative row),
|
|
958
799
|
// outer re-ordered by the user's order alone.
|
|
959
|
-
if (Object.values(args.orderBy).some((d) => isVectorOrderBy(d))) {
|
|
800
|
+
if (Object.values(args.orderBy).some((d) => (0, filters_js_1.isVectorOrderBy)(d))) {
|
|
960
801
|
throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
|
|
961
802
|
}
|
|
962
803
|
const userOrder = this.buildOrderBy(args.orderBy, freshParams);
|
|
@@ -984,8 +825,8 @@ class QueryInterface {
|
|
|
984
825
|
return sql;
|
|
985
826
|
});
|
|
986
827
|
// Collect params in exact build order:
|
|
987
|
-
// 1. WHERE params
|
|
988
|
-
if (
|
|
828
|
+
// 1. WHERE params (includes the AND-merged global filter, if any)
|
|
829
|
+
if (hasWhere) {
|
|
989
830
|
this.collectWhereParams(whereObj, params);
|
|
990
831
|
}
|
|
991
832
|
// 2. WITH relation params
|
|
@@ -994,7 +835,7 @@ class QueryInterface {
|
|
|
994
835
|
}
|
|
995
836
|
// 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
|
|
996
837
|
if (args?.cursor) {
|
|
997
|
-
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
838
|
+
const cursorEntries = (0, filters_js_1.sortedEntries)(args.cursor).filter(([, v]) => v !== undefined);
|
|
998
839
|
for (const [, v] of cursorEntries) {
|
|
999
840
|
params.push(v);
|
|
1000
841
|
}
|
|
@@ -1197,6 +1038,8 @@ class QueryInterface {
|
|
|
1197
1038
|
});
|
|
1198
1039
|
}
|
|
1199
1040
|
buildCreate(args) {
|
|
1041
|
+
this.assertWritable('create');
|
|
1042
|
+
this.assertNoGeneratedColumns(args.data, 'create');
|
|
1200
1043
|
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
1201
1044
|
const columns = entries.map(([k]) => this.toSqlColumn(k));
|
|
1202
1045
|
const params = entries.map(([, v]) => v);
|
|
@@ -1271,6 +1114,10 @@ class QueryInterface {
|
|
|
1271
1114
|
tag: `${this.table}.createMany`,
|
|
1272
1115
|
};
|
|
1273
1116
|
}
|
|
1117
|
+
this.assertWritable('createMany');
|
|
1118
|
+
for (const row of args.data) {
|
|
1119
|
+
this.assertNoGeneratedColumns(row, 'createMany');
|
|
1120
|
+
}
|
|
1274
1121
|
const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
|
|
1275
1122
|
const columns = keys.map((k) => this.toColumn(k));
|
|
1276
1123
|
const rowValues = args.data.map((row) => {
|
|
@@ -1308,12 +1155,22 @@ class QueryInterface {
|
|
|
1308
1155
|
});
|
|
1309
1156
|
}
|
|
1310
1157
|
buildUpdate(args) {
|
|
1158
|
+
this.assertWritable('update');
|
|
1159
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1311
1160
|
const dataObj = args.data;
|
|
1312
|
-
|
|
1161
|
+
this.assertNoGeneratedColumns(dataObj, 'update');
|
|
1162
|
+
const userWhere = args.where;
|
|
1313
1163
|
const lock = args.optimisticLock;
|
|
1164
|
+
// The empty-`where` guard checks the USER predicate only — a global filter
|
|
1165
|
+
// must never turn an unguarded mass update into an allowed one.
|
|
1166
|
+
const userHasPredicate = !this.userPredicateIsEmpty(userWhere) || !!lock;
|
|
1167
|
+
this.assertMutationHasPredicate('update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
|
|
1168
|
+
// The SQL is built from the global-filter-merged where (soft-delete keeps an
|
|
1169
|
+
// update from touching already-deleted rows).
|
|
1170
|
+
const whereObj = (this.mergeGlobalFilter(userWhere) ?? {});
|
|
1314
1171
|
const setFp = this.fingerprintSet(dataObj);
|
|
1315
1172
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1316
|
-
const ck = lock ? null : `u:${setFp}|${whereFp}`;
|
|
1173
|
+
const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1317
1174
|
const params = [];
|
|
1318
1175
|
const buildSql = () => {
|
|
1319
1176
|
const freshParams = [];
|
|
@@ -1331,7 +1188,6 @@ class QueryInterface {
|
|
|
1331
1188
|
const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
|
|
1332
1189
|
whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
|
|
1333
1190
|
}
|
|
1334
|
-
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
1335
1191
|
// Engines that inject their returning shape MID-statement (SQL Server
|
|
1336
1192
|
// `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
|
|
1337
1193
|
// absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
@@ -1345,9 +1201,6 @@ class QueryInterface {
|
|
|
1345
1201
|
const entry = this.acquireSql(ck, buildSql);
|
|
1346
1202
|
sql = entry.sql;
|
|
1347
1203
|
preparedName = entry.name;
|
|
1348
|
-
if (whereFp === '') {
|
|
1349
|
-
this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
|
|
1350
|
-
}
|
|
1351
1204
|
}
|
|
1352
1205
|
else {
|
|
1353
1206
|
sql = buildSql();
|
|
@@ -1481,27 +1334,24 @@ class QueryInterface {
|
|
|
1481
1334
|
});
|
|
1482
1335
|
}
|
|
1483
1336
|
buildDelete(args) {
|
|
1484
|
-
|
|
1337
|
+
this.assertWritable('delete');
|
|
1338
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1339
|
+
// Guard the USER predicate (a global filter must not satisfy the guard).
|
|
1340
|
+
this.assertMutationHasPredicate('delete', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1341
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1485
1342
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1486
|
-
const ck = `d:${whereFp}`;
|
|
1343
|
+
const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1487
1344
|
const params = [];
|
|
1488
|
-
// We need to check the mutation predicate. Build the whereSql to test it.
|
|
1489
|
-
// On cache hit we still need to validate (the shape may be empty).
|
|
1490
1345
|
const entry = this.acquireSql(ck, () => {
|
|
1491
1346
|
const freshParams = [];
|
|
1492
1347
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1493
1348
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1494
|
-
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
1495
1349
|
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
1496
1350
|
// absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
1497
1351
|
return this.dialect.buildDeleteStatement
|
|
1498
1352
|
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1499
1353
|
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1500
1354
|
});
|
|
1501
|
-
// On cache hit, still validate the predicate
|
|
1502
|
-
if (whereFp === '') {
|
|
1503
|
-
this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
|
|
1504
|
-
}
|
|
1505
1355
|
this.collectWhereParams(whereObj, params);
|
|
1506
1356
|
return {
|
|
1507
1357
|
sql: entry.sql,
|
|
@@ -1541,6 +1391,10 @@ class QueryInterface {
|
|
|
1541
1391
|
});
|
|
1542
1392
|
}
|
|
1543
1393
|
buildUpsert(args) {
|
|
1394
|
+
this.assertWritable('upsert');
|
|
1395
|
+
this.assertNoGeneratedColumns(args.create, 'upsert');
|
|
1396
|
+
this.assertNoGeneratedColumns(args.update, 'upsert');
|
|
1397
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1544
1398
|
// Build the INSERT part from create data
|
|
1545
1399
|
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
1546
1400
|
const columns = createEntries.map(([k]) => this.toSqlColumn(k));
|
|
@@ -1559,12 +1413,23 @@ class QueryInterface {
|
|
|
1559
1413
|
});
|
|
1560
1414
|
const updateParams = updateEntries.map(([, v]) => v);
|
|
1561
1415
|
const params = [...createParams, ...updateParams];
|
|
1416
|
+
// Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
|
|
1417
|
+
// upsert never resurrects a soft-deleted row or writes across tenants. Only
|
|
1418
|
+
// on engines whose upsert can carry a predicate (Postgres); the gf params
|
|
1419
|
+
// continue the placeholder numbering after create+update params.
|
|
1420
|
+
let updateWhere;
|
|
1421
|
+
if (this.dialect.supportsUpsertUpdateWhere) {
|
|
1422
|
+
const gf = this.resolveGlobalFilter(this.table);
|
|
1423
|
+
if (gf)
|
|
1424
|
+
updateWhere = this.buildWhereClause(gf, params) ?? undefined;
|
|
1425
|
+
}
|
|
1562
1426
|
const sql = this.dialect.buildUpsertStatement({
|
|
1563
1427
|
table: this.q(this.table),
|
|
1564
1428
|
insertColumns: columns,
|
|
1565
1429
|
valuePlaceholders: placeholders,
|
|
1566
1430
|
conflictColumns,
|
|
1567
1431
|
updateSetClauses: setClauses,
|
|
1432
|
+
updateWhere,
|
|
1568
1433
|
returning: '*',
|
|
1569
1434
|
});
|
|
1570
1435
|
return {
|
|
@@ -1587,7 +1452,7 @@ class QueryInterface {
|
|
|
1587
1452
|
reselect: this.dialect.resultStrategy === 'reselect'
|
|
1588
1453
|
? async (exec) => {
|
|
1589
1454
|
await exec(sql, params);
|
|
1590
|
-
const sel = this.buildReselectByWhere(args.where);
|
|
1455
|
+
const sel = this.buildReselectByWhere((this.mergeGlobalFilter(args.where) ?? {}));
|
|
1591
1456
|
return exec(sel.sql, sel.params);
|
|
1592
1457
|
}
|
|
1593
1458
|
: undefined,
|
|
@@ -1604,11 +1469,15 @@ class QueryInterface {
|
|
|
1604
1469
|
});
|
|
1605
1470
|
}
|
|
1606
1471
|
buildUpdateMany(args) {
|
|
1472
|
+
this.assertWritable('updateMany');
|
|
1473
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1607
1474
|
const dataObj = args.data;
|
|
1608
|
-
|
|
1475
|
+
this.assertNoGeneratedColumns(dataObj, 'updateMany');
|
|
1476
|
+
this.assertMutationHasPredicate('updateMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1477
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1609
1478
|
const setFp = this.fingerprintSet(dataObj);
|
|
1610
1479
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1611
|
-
const ck = `um:${setFp}|${whereFp}`;
|
|
1480
|
+
const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1612
1481
|
const params = [];
|
|
1613
1482
|
const entry = this.acquireSql(ck, () => {
|
|
1614
1483
|
const freshParams = [];
|
|
@@ -1616,12 +1485,8 @@ class QueryInterface {
|
|
|
1616
1485
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1617
1486
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
1618
1487
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
1619
|
-
this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
|
|
1620
1488
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
1621
1489
|
});
|
|
1622
|
-
if (whereFp === '') {
|
|
1623
|
-
this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
|
|
1624
|
-
}
|
|
1625
1490
|
this.collectSetParams(dataObj, params);
|
|
1626
1491
|
this.collectWhereParams(whereObj, params);
|
|
1627
1492
|
return {
|
|
@@ -1643,20 +1508,19 @@ class QueryInterface {
|
|
|
1643
1508
|
});
|
|
1644
1509
|
}
|
|
1645
1510
|
buildDeleteMany(args) {
|
|
1646
|
-
|
|
1511
|
+
this.assertWritable('deleteMany');
|
|
1512
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1513
|
+
this.assertMutationHasPredicate('deleteMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
1514
|
+
const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
|
|
1647
1515
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1648
|
-
const ck = `dm:${whereFp}`;
|
|
1516
|
+
const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1649
1517
|
const params = [];
|
|
1650
1518
|
const entry = this.acquireSql(ck, () => {
|
|
1651
1519
|
const freshParams = [];
|
|
1652
1520
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1653
1521
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1654
|
-
this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
|
|
1655
1522
|
return `DELETE FROM ${this.q(this.table)}${whereSql}`;
|
|
1656
1523
|
});
|
|
1657
|
-
if (whereFp === '') {
|
|
1658
|
-
this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
|
|
1659
|
-
}
|
|
1660
1524
|
this.collectWhereParams(whereObj, params);
|
|
1661
1525
|
return {
|
|
1662
1526
|
sql: entry.sql,
|
|
@@ -1677,17 +1541,20 @@ class QueryInterface {
|
|
|
1677
1541
|
});
|
|
1678
1542
|
}
|
|
1679
1543
|
buildCount(args) {
|
|
1680
|
-
|
|
1681
|
-
const
|
|
1682
|
-
const
|
|
1544
|
+
this.currentSkip = args?.skipGlobalFilters;
|
|
1545
|
+
const effWhere = this.mergeGlobalFilter(args?.where);
|
|
1546
|
+
const hasWhere = effWhere !== undefined;
|
|
1547
|
+
const whereObj = (effWhere ?? {});
|
|
1548
|
+
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
1549
|
+
const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1683
1550
|
const params = [];
|
|
1684
1551
|
const entry = this.acquireSql(ck, () => {
|
|
1685
1552
|
const freshParams = [];
|
|
1686
|
-
const clause =
|
|
1553
|
+
const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1687
1554
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1688
1555
|
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1689
1556
|
});
|
|
1690
|
-
if (
|
|
1557
|
+
if (hasWhere) {
|
|
1691
1558
|
this.collectWhereParams(whereObj, params);
|
|
1692
1559
|
}
|
|
1693
1560
|
return {
|
|
@@ -1717,9 +1584,13 @@ class QueryInterface {
|
|
|
1717
1584
|
}
|
|
1718
1585
|
}
|
|
1719
1586
|
}
|
|
1587
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1720
1588
|
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
1721
1589
|
const groupCols = groupColsRaw.map((c) => this.q(c));
|
|
1722
|
-
const
|
|
1590
|
+
const gbWhere = this.mergeGlobalFilter(args.where);
|
|
1591
|
+
const { sql: whereSql, params } = gbWhere
|
|
1592
|
+
? this.buildWhere(gbWhere)
|
|
1593
|
+
: { sql: '', params: [] };
|
|
1723
1594
|
// Build SELECT expressions: group-by columns + aggregate functions
|
|
1724
1595
|
const selectExprs = [...groupCols];
|
|
1725
1596
|
// _count
|
|
@@ -1962,7 +1833,11 @@ class QueryInterface {
|
|
|
1962
1833
|
});
|
|
1963
1834
|
}
|
|
1964
1835
|
buildAggregate(args) {
|
|
1965
|
-
|
|
1836
|
+
this.currentSkip = args.skipGlobalFilters;
|
|
1837
|
+
const aggWhere = this.mergeGlobalFilter(args.where);
|
|
1838
|
+
const { sql: whereSql, params } = aggWhere
|
|
1839
|
+
? this.buildWhere(aggWhere)
|
|
1840
|
+
: { sql: '', params: [] };
|
|
1966
1841
|
const meta = this.schema.tables[this.table];
|
|
1967
1842
|
if (meta) {
|
|
1968
1843
|
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
@@ -2140,6 +2015,36 @@ class QueryInterface {
|
|
|
2140
2015
|
}
|
|
2141
2016
|
return null;
|
|
2142
2017
|
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Reject any write against a view (H4). Views are introspected with
|
|
2020
|
+
* `isView: true` and are read-only in every engine; a write raises a
|
|
2021
|
+
* {@link ValidationError} (E003) rather than emitting SQL Postgres would
|
|
2022
|
+
* reject (or, worse, silently applying to an updatable view).
|
|
2023
|
+
*/
|
|
2024
|
+
assertWritable(operation) {
|
|
2025
|
+
if (this.tableMeta.isView) {
|
|
2026
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot ${operation} "${this.table}": it is a view (read-only). ` +
|
|
2027
|
+
'Views support reads (findMany/findFirst/…) but not writes.');
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
|
|
2032
|
+
* column (H3). Postgres computes these from other columns and errors if you
|
|
2033
|
+
* try to write them; we fail early with a clear {@link ValidationError} (E003)
|
|
2034
|
+
* instead of surfacing a cryptic driver error. Undefined values are ignored
|
|
2035
|
+
* (they're stripped from the statement anyway).
|
|
2036
|
+
*/
|
|
2037
|
+
assertNoGeneratedColumns(data, operation) {
|
|
2038
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2039
|
+
if (value === undefined)
|
|
2040
|
+
continue;
|
|
2041
|
+
const col = this.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === (0, schema_js_1.camelToSnake)(key));
|
|
2042
|
+
if (col?.isGeneratedStored) {
|
|
2043
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot ${operation} "${this.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
|
|
2044
|
+
'column whose value the database computes — remove it from your data.');
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2143
2048
|
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
2144
2049
|
toColumn(field) {
|
|
2145
2050
|
const mapped = this.tableMeta.columnMap[field];
|
|
@@ -2191,7 +2096,7 @@ class QueryInterface {
|
|
|
2191
2096
|
!Buffer.isBuffer(value)) {
|
|
2192
2097
|
const v = value;
|
|
2193
2098
|
const keys = Object.keys(v);
|
|
2194
|
-
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
2099
|
+
if (keys.length === 1 && filters_js_1.UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
2195
2100
|
const op = keys[0];
|
|
2196
2101
|
const opValue = v[op];
|
|
2197
2102
|
if (op === 'set') {
|
|
@@ -2308,15 +2213,15 @@ class QueryInterface {
|
|
|
2308
2213
|
continue;
|
|
2309
2214
|
}
|
|
2310
2215
|
// Operator objects
|
|
2311
|
-
if (isWhereOperator(value)) {
|
|
2312
|
-
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2216
|
+
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2217
|
+
parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
|
|
2313
2218
|
continue;
|
|
2314
2219
|
}
|
|
2315
2220
|
// Vector distance filter — metric (operator) and present comparators
|
|
2316
2221
|
// change the SQL shape, so both go in the fingerprint.
|
|
2317
|
-
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2222
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isVectorFilter)(value)) {
|
|
2318
2223
|
const dist = value.distance;
|
|
2319
|
-
const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
|
|
2224
|
+
const cmps = Object.keys(filters_js_1.VECTOR_DISTANCE_COMPARATORS)
|
|
2320
2225
|
.filter((c) => dist[c] !== undefined)
|
|
2321
2226
|
.sort()
|
|
2322
2227
|
.join('|');
|
|
@@ -2324,18 +2229,18 @@ class QueryInterface {
|
|
|
2324
2229
|
continue;
|
|
2325
2230
|
}
|
|
2326
2231
|
// JSON filter
|
|
2327
|
-
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2232
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2328
2233
|
const jKeys = Object.keys(value).sort();
|
|
2329
2234
|
parts.push(`${key}:json(${jKeys.join(',')})`);
|
|
2330
2235
|
continue;
|
|
2331
2236
|
}
|
|
2332
2237
|
// Array filter
|
|
2333
|
-
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
2238
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
2334
2239
|
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
2335
2240
|
continue;
|
|
2336
2241
|
}
|
|
2337
2242
|
// Text search filter
|
|
2338
|
-
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
2243
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isTextSearchFilter)(value)) {
|
|
2339
2244
|
const cfg = value.config ?? 'english';
|
|
2340
2245
|
parts.push(`${key}:fts(${cfg})`);
|
|
2341
2246
|
continue;
|
|
@@ -2344,7 +2249,7 @@ class QueryInterface {
|
|
|
2344
2249
|
// fingerprint distinct from real equality. The build path throws for
|
|
2345
2250
|
// these on non-JSON columns; sharing `key:eq` would let a cache entry
|
|
2346
2251
|
// warmed by genuine equality serve the bad filter silently.
|
|
2347
|
-
if (isUnmatchedPlainObject(value)) {
|
|
2252
|
+
if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
|
|
2348
2253
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
2349
2254
|
.sort()
|
|
2350
2255
|
.join(',')})`);
|
|
@@ -2409,10 +2314,10 @@ class QueryInterface {
|
|
|
2409
2314
|
if (value === null) {
|
|
2410
2315
|
parts.push(`${key}:null`);
|
|
2411
2316
|
}
|
|
2412
|
-
else if (isWhereOperator(value)) {
|
|
2413
|
-
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2317
|
+
else if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2318
|
+
parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
|
|
2414
2319
|
}
|
|
2415
|
-
else if (isUnmatchedPlainObject(value)) {
|
|
2320
|
+
else if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
|
|
2416
2321
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
2417
2322
|
.sort()
|
|
2418
2323
|
.join(',')})`);
|
|
@@ -2432,7 +2337,7 @@ class QueryInterface {
|
|
|
2432
2337
|
*/
|
|
2433
2338
|
collectWhereParams(where, params) {
|
|
2434
2339
|
// Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
|
|
2435
|
-
const keys = sortedKeys(where);
|
|
2340
|
+
const keys = (0, filters_js_1.sortedKeys)(where);
|
|
2436
2341
|
for (const key of keys) {
|
|
2437
2342
|
const value = where[key];
|
|
2438
2343
|
if (value === undefined)
|
|
@@ -2469,16 +2374,7 @@ class QueryInterface {
|
|
|
2469
2374
|
'none' in filterObj ||
|
|
2470
2375
|
'is' in filterObj ||
|
|
2471
2376
|
'isNot' in filterObj) {
|
|
2472
|
-
|
|
2473
|
-
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
2474
|
-
if (filterObj.none !== undefined && filterObj.none !== null)
|
|
2475
|
-
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
2476
|
-
if (filterObj.every !== undefined && filterObj.every !== null)
|
|
2477
|
-
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
2478
|
-
if (filterObj.is !== undefined && filterObj.is !== null)
|
|
2479
|
-
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
2480
|
-
if (filterObj.isNot !== undefined && filterObj.isNot !== null)
|
|
2481
|
-
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
2377
|
+
this.collectRelationFilterParams(relationDef, filterObj, params);
|
|
2482
2378
|
continue;
|
|
2483
2379
|
}
|
|
2484
2380
|
}
|
|
@@ -2487,7 +2383,7 @@ class QueryInterface {
|
|
|
2487
2383
|
continue;
|
|
2488
2384
|
const rawColumn = this.toColumn(key);
|
|
2489
2385
|
// Vector distance filter — mirrors buildVectorFilterClauses push order.
|
|
2490
|
-
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
2386
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isVectorFilter)(value)) {
|
|
2491
2387
|
// Validate the same way the build path does so the collect path never
|
|
2492
2388
|
// diverges (it would throw before any param was pushed).
|
|
2493
2389
|
this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
@@ -2495,7 +2391,7 @@ class QueryInterface {
|
|
|
2495
2391
|
continue;
|
|
2496
2392
|
}
|
|
2497
2393
|
// JSONB filter
|
|
2498
|
-
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2394
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2499
2395
|
const colType = this.getColumnPgType(rawColumn);
|
|
2500
2396
|
if (colType === 'json' || colType === 'jsonb') {
|
|
2501
2397
|
this.collectJsonFilterParams(value, params);
|
|
@@ -2503,7 +2399,7 @@ class QueryInterface {
|
|
|
2503
2399
|
}
|
|
2504
2400
|
}
|
|
2505
2401
|
// Array filter
|
|
2506
|
-
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
2402
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
2507
2403
|
const colType = this.getColumnPgType(rawColumn);
|
|
2508
2404
|
if (colType.startsWith('_')) {
|
|
2509
2405
|
this.collectArrayFilterParams(value, params);
|
|
@@ -2511,12 +2407,12 @@ class QueryInterface {
|
|
|
2511
2407
|
}
|
|
2512
2408
|
}
|
|
2513
2409
|
// Text search filter
|
|
2514
|
-
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
2410
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isTextSearchFilter)(value)) {
|
|
2515
2411
|
params.push(value.search);
|
|
2516
2412
|
continue;
|
|
2517
2413
|
}
|
|
2518
2414
|
// Operator objects
|
|
2519
|
-
if (isWhereOperator(value)) {
|
|
2415
|
+
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2520
2416
|
this.collectOperatorParams(rawColumn, value, params);
|
|
2521
2417
|
continue;
|
|
2522
2418
|
}
|
|
@@ -2526,13 +2422,51 @@ class QueryInterface {
|
|
|
2526
2422
|
params.push(value);
|
|
2527
2423
|
}
|
|
2528
2424
|
}
|
|
2529
|
-
/**
|
|
2425
|
+
/**
|
|
2426
|
+
* Param-collect mirror of {@link buildRelationFilter} for one relation-filter
|
|
2427
|
+
* object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
|
|
2428
|
+
* present branch and in the canonical order some→none→every→is→isNot, the
|
|
2429
|
+
* branch's sub-where params THEN the target table's global-filter params —
|
|
2430
|
+
* exactly the order buildRelationFilter emits. When no global filter applies
|
|
2431
|
+
* the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
|
|
2432
|
+
* Shared by every collect site that mirrors buildRelationFilter
|
|
2433
|
+
* (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
|
|
2434
|
+
*/
|
|
2435
|
+
collectRelationFilterParams(relDef, filterObj, params) {
|
|
2436
|
+
const target = relDef.to;
|
|
2437
|
+
if (filterObj.some !== undefined && filterObj.some !== null) {
|
|
2438
|
+
this.collectRelFilterParams(target, filterObj.some, params);
|
|
2439
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2440
|
+
}
|
|
2441
|
+
if (filterObj.none !== undefined && filterObj.none !== null) {
|
|
2442
|
+
this.collectRelFilterParams(target, filterObj.none, params);
|
|
2443
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2444
|
+
}
|
|
2445
|
+
if (filterObj.every !== undefined && filterObj.every !== null) {
|
|
2446
|
+
// gf is only emitted (build) when the `every` sub-where compiles to a
|
|
2447
|
+
// filter — otherwise `every` is trivially true and no subquery is built.
|
|
2448
|
+
if (this.buildSubWhereForRelation(target, filterObj.every, []) !== null) {
|
|
2449
|
+
this.collectRelFilterParams(target, filterObj.every, params);
|
|
2450
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
if (filterObj.is !== undefined) {
|
|
2454
|
+
if (filterObj.is !== null)
|
|
2455
|
+
this.collectRelFilterParams(target, filterObj.is, params);
|
|
2456
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2457
|
+
}
|
|
2458
|
+
if (filterObj.isNot !== undefined) {
|
|
2459
|
+
if (filterObj.isNot !== null)
|
|
2460
|
+
this.collectRelFilterParams(target, filterObj.isNot, params);
|
|
2461
|
+
this.collectTargetGlobalFilterExists(target, params);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2530
2464
|
collectRelFilterParams(targetTable, subWhere, params) {
|
|
2531
2465
|
const meta = this.schema.tables[targetTable];
|
|
2532
2466
|
if (!meta)
|
|
2533
2467
|
return;
|
|
2534
2468
|
// Sorted (canonical) order — MUST match fingerprintRelFilter and buildSubWhereForRelation.
|
|
2535
|
-
for (const field of sortedKeys(subWhere)) {
|
|
2469
|
+
for (const field of (0, filters_js_1.sortedKeys)(subWhere)) {
|
|
2536
2470
|
const value = subWhere[field];
|
|
2537
2471
|
if (value === undefined)
|
|
2538
2472
|
continue;
|
|
@@ -2554,22 +2488,14 @@ class QueryInterface {
|
|
|
2554
2488
|
if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
2555
2489
|
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
2556
2490
|
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
2557
|
-
//
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
if (norm.none != null)
|
|
2561
|
-
this.collectRelFilterParams(nestedRel.to, norm.none, params);
|
|
2562
|
-
if (norm.every != null)
|
|
2563
|
-
this.collectRelFilterParams(nestedRel.to, norm.every, params);
|
|
2564
|
-
if (norm.is != null)
|
|
2565
|
-
this.collectRelFilterParams(nestedRel.to, norm.is, params);
|
|
2566
|
-
if (norm.isNot != null)
|
|
2567
|
-
this.collectRelFilterParams(nestedRel.to, norm.isNot, params);
|
|
2491
|
+
// Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
|
|
2492
|
+
// params then target global-filter params).
|
|
2493
|
+
this.collectRelationFilterParams(nestedRel, norm, params);
|
|
2568
2494
|
continue;
|
|
2569
2495
|
}
|
|
2570
2496
|
}
|
|
2571
2497
|
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2572
|
-
if (isWhereOperator(value)) {
|
|
2498
|
+
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2573
2499
|
this.collectOperatorParams(col, value, params);
|
|
2574
2500
|
continue;
|
|
2575
2501
|
}
|
|
@@ -2580,7 +2506,7 @@ class QueryInterface {
|
|
|
2580
2506
|
/** Collect params from operator clauses. Mirrors buildOperatorClauses. */
|
|
2581
2507
|
collectOperatorParams(column, op, params) {
|
|
2582
2508
|
if (op.equals !== undefined && op.equals !== null) {
|
|
2583
|
-
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
2509
|
+
(0, filters_js_1.assertBindableEqualsOperand)(op.equals, `"${column}"`);
|
|
2584
2510
|
params.push(op.equals);
|
|
2585
2511
|
}
|
|
2586
2512
|
if (op.gt !== undefined)
|
|
@@ -2638,12 +2564,27 @@ class QueryInterface {
|
|
|
2638
2564
|
*/
|
|
2639
2565
|
collectOrderByParams(orderBy, params) {
|
|
2640
2566
|
for (const [key, dir] of Object.entries(orderBy)) {
|
|
2641
|
-
if (isVectorOrderBy(dir)) {
|
|
2567
|
+
if ((0, filters_js_1.isVectorOrderBy)(dir)) {
|
|
2642
2568
|
const rawColumn = this.toColumn(key);
|
|
2643
2569
|
// Re-run the same validation as buildOrderBy so the collect path can
|
|
2644
2570
|
// never push a param that the build path rejected (or vice versa).
|
|
2645
2571
|
this.vectorOperator(key, rawColumn, dir.distance.metric);
|
|
2646
2572
|
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2575
|
+
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2576
|
+
// subquery as `_count` — mirror its global-filter params. To-one relation
|
|
2577
|
+
// orderBy carries the target's global filter once per ordered column.
|
|
2578
|
+
if (this.isRelationOrderByValue(dir)) {
|
|
2579
|
+
const relDef = this.tableMeta.relations[key];
|
|
2580
|
+
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
2581
|
+
this.collectRelationCountParams(relDef, params);
|
|
2582
|
+
}
|
|
2583
|
+
else if (relDef) {
|
|
2584
|
+
for (const _col of Object.keys(dir)) {
|
|
2585
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2647
2588
|
}
|
|
2648
2589
|
}
|
|
2649
2590
|
}
|
|
@@ -2655,7 +2596,7 @@ class QueryInterface {
|
|
|
2655
2596
|
collectVectorFilterParams(field, rawColumn, filter, params) {
|
|
2656
2597
|
const dist = filter.distance;
|
|
2657
2598
|
this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
2658
|
-
for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
|
|
2599
|
+
for (const cmp of Object.keys(filters_js_1.VECTOR_DISTANCE_COMPARATORS)) {
|
|
2659
2600
|
const threshold = dist[cmp];
|
|
2660
2601
|
if (threshold !== undefined)
|
|
2661
2602
|
params.push(threshold);
|
|
@@ -2679,6 +2620,19 @@ class QueryInterface {
|
|
|
2679
2620
|
const spec = withClause[relName];
|
|
2680
2621
|
if (!spec)
|
|
2681
2622
|
continue;
|
|
2623
|
+
// Reserved `_count` key — fingerprint by the selected relation set so
|
|
2624
|
+
// `_count: true` and `_count: { posts: true }` never share a cache entry.
|
|
2625
|
+
if (relName === '_count') {
|
|
2626
|
+
const c = spec;
|
|
2627
|
+
parts.push(c === true
|
|
2628
|
+
? '_count(*)'
|
|
2629
|
+
: `_count(${Object.entries(c)
|
|
2630
|
+
.filter(([, v]) => v)
|
|
2631
|
+
.map(([k]) => k)
|
|
2632
|
+
.sort()
|
|
2633
|
+
.join(',')})`);
|
|
2634
|
+
continue;
|
|
2635
|
+
}
|
|
2682
2636
|
const relDef = meta.relations[relName];
|
|
2683
2637
|
if (!relDef) {
|
|
2684
2638
|
parts.push(`unknown:${relName}`);
|
|
@@ -2711,9 +2665,9 @@ class QueryInterface {
|
|
|
2711
2665
|
if (opts.where) {
|
|
2712
2666
|
subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
|
|
2713
2667
|
}
|
|
2714
|
-
// orderBy shape
|
|
2668
|
+
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
2715
2669
|
if (opts.orderBy) {
|
|
2716
|
-
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
|
|
2670
|
+
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
|
|
2717
2671
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2718
2672
|
}
|
|
2719
2673
|
// limit presence
|
|
@@ -2738,12 +2692,21 @@ class QueryInterface {
|
|
|
2738
2692
|
const meta = this.schema.tables[table ?? this.table];
|
|
2739
2693
|
if (!meta)
|
|
2740
2694
|
return;
|
|
2741
|
-
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
2695
|
+
for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
|
|
2742
2696
|
const relDef = meta.relations[relName];
|
|
2743
2697
|
if (!relDef)
|
|
2744
2698
|
continue;
|
|
2745
2699
|
this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
|
|
2746
2700
|
}
|
|
2701
|
+
// `_count` global-filter params — mirror buildSelectWithRelations, which
|
|
2702
|
+
// appends the count subqueries (and any target-filter params) AFTER every
|
|
2703
|
+
// relation subquery, in resolveCountRelations order.
|
|
2704
|
+
const countSpec = withClause._count;
|
|
2705
|
+
if (countSpec !== undefined) {
|
|
2706
|
+
for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
|
|
2707
|
+
this.collectRelationCountParams(rel, params);
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2747
2710
|
}
|
|
2748
2711
|
/**
|
|
2749
2712
|
* Collect params from a single relation subquery. Mirrors buildRelationSubquery.
|
|
@@ -2761,11 +2724,12 @@ class QueryInterface {
|
|
|
2761
2724
|
if (spec.where) {
|
|
2762
2725
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2763
2726
|
}
|
|
2727
|
+
this.collectTargetGlobalFilterAlias(targetTable, params);
|
|
2764
2728
|
if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
|
|
2765
2729
|
params.push(Number(spec.limit));
|
|
2766
2730
|
}
|
|
2767
2731
|
if (spec.with) {
|
|
2768
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2732
|
+
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
2769
2733
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2770
2734
|
if (!nestedRelDef)
|
|
2771
2735
|
continue;
|
|
@@ -2779,7 +2743,7 @@ class QueryInterface {
|
|
|
2779
2743
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2780
2744
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2781
2745
|
if (!willWrap && spec.with) {
|
|
2782
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2746
|
+
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
2783
2747
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2784
2748
|
if (!nestedRelDef)
|
|
2785
2749
|
continue;
|
|
@@ -2790,6 +2754,9 @@ class QueryInterface {
|
|
|
2790
2754
|
if (spec.where) {
|
|
2791
2755
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2792
2756
|
}
|
|
2757
|
+
// Global filter on the target — mirrors targetGlobalFilterAlias in
|
|
2758
|
+
// buildRelationSubquery (pushed after spec.where, before limit).
|
|
2759
|
+
this.collectTargetGlobalFilterAlias(targetTable, params);
|
|
2793
2760
|
// limit param — only hasMany parameterizes its limit (mirrors
|
|
2794
2761
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2795
2762
|
// pushing one here would orphan a param and desync the collect path.
|
|
@@ -2799,7 +2766,7 @@ class QueryInterface {
|
|
|
2799
2766
|
}
|
|
2800
2767
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
2801
2768
|
if (willWrap && spec.with) {
|
|
2802
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2769
|
+
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
2803
2770
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2804
2771
|
if (!nestedRelDef)
|
|
2805
2772
|
continue;
|
|
@@ -2821,7 +2788,7 @@ class QueryInterface {
|
|
|
2821
2788
|
!(v instanceof Date) &&
|
|
2822
2789
|
!(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
|
|
2823
2790
|
const keys = Object.keys(v);
|
|
2824
|
-
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
2791
|
+
if (keys.length === 1 && filters_js_1.UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
2825
2792
|
parts.push(`${k}:${keys[0]}`);
|
|
2826
2793
|
continue;
|
|
2827
2794
|
}
|
|
@@ -2843,7 +2810,7 @@ class QueryInterface {
|
|
|
2843
2810
|
!(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
|
|
2844
2811
|
const obj = v;
|
|
2845
2812
|
const keys = Object.keys(obj);
|
|
2846
|
-
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
2813
|
+
if (keys.length === 1 && filters_js_1.UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
2847
2814
|
params.push(obj[keys[0]]);
|
|
2848
2815
|
continue;
|
|
2849
2816
|
}
|
|
@@ -2859,14 +2826,148 @@ class QueryInterface {
|
|
|
2859
2826
|
return { sql: '', params: [] };
|
|
2860
2827
|
return { sql: ` WHERE ${clause}`, params };
|
|
2861
2828
|
}
|
|
2829
|
+
// -------------------------------------------------------------------------
|
|
2830
|
+
// Global filters (soft-delete / multi-tenancy — WS-G)
|
|
2831
|
+
//
|
|
2832
|
+
// A configured global filter for a table is AND-merged into the compiled WHERE
|
|
2833
|
+
// of every query on that table (via {@link mergeGlobalFilter}, so the merge is
|
|
2834
|
+
// captured in the where fingerprint/collect for free) and into every relation
|
|
2835
|
+
// subquery targeting it (rendered at build time against the subquery's alias/
|
|
2836
|
+
// table by the `*GlobalFilterAlias`/`*GlobalFilterExists` helpers, with the
|
|
2837
|
+
// shape folded into the SQL-cache key via {@link globalFilterCacheSegment}).
|
|
2838
|
+
// Function filters are evaluated per resolve — at query-build time — enabling
|
|
2839
|
+
// per-request tenancy via a closure. They must return a STABLE shape (same
|
|
2840
|
+
// keys/operators); only values may vary between calls.
|
|
2841
|
+
// -------------------------------------------------------------------------
|
|
2862
2842
|
/**
|
|
2863
|
-
*
|
|
2864
|
-
*
|
|
2865
|
-
*
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2843
|
+
* Resolve the configured global filter for `table`, evaluating a function
|
|
2844
|
+
* filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
|
|
2845
|
+
* no filter applies, the query opted out, or the filter is empty.
|
|
2846
|
+
*/
|
|
2847
|
+
resolveGlobalFilter(table, skip = this.currentSkip) {
|
|
2848
|
+
const filters = this.globalFilters;
|
|
2849
|
+
if (!filters)
|
|
2850
|
+
return null;
|
|
2851
|
+
if (skip === true)
|
|
2852
|
+
return null;
|
|
2853
|
+
if (Array.isArray(skip) && skip.includes(table))
|
|
2854
|
+
return null;
|
|
2855
|
+
const raw = filters[table];
|
|
2856
|
+
if (raw === undefined)
|
|
2857
|
+
return null;
|
|
2858
|
+
const resolved = typeof raw === 'function' ? raw() : raw;
|
|
2859
|
+
if (resolved === null || resolved === undefined)
|
|
2860
|
+
return null;
|
|
2861
|
+
const obj = resolved;
|
|
2862
|
+
// An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
|
|
2863
|
+
// nothing — treat it as absent so it never emits a dangling clause.
|
|
2864
|
+
if (Object.keys(obj).every((k) => obj[k] === undefined))
|
|
2865
|
+
return null;
|
|
2866
|
+
return obj;
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* AND-merge this table's resolved global filter into a user `where`. Either
|
|
2870
|
+
* side may be absent. When no filter applies the user where is returned by
|
|
2871
|
+
* reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
|
|
2869
2872
|
*/
|
|
2873
|
+
mergeGlobalFilter(userWhere) {
|
|
2874
|
+
const gf = this.resolveGlobalFilter(this.table);
|
|
2875
|
+
if (!gf)
|
|
2876
|
+
return userWhere;
|
|
2877
|
+
if (userWhere === undefined)
|
|
2878
|
+
return gf;
|
|
2879
|
+
return { AND: [userWhere, gf] };
|
|
2880
|
+
}
|
|
2881
|
+
/**
|
|
2882
|
+
* SQL clause for `targetTable`'s global filter rendered against `alias`
|
|
2883
|
+
* (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
|
|
2884
|
+
* `params`; returns `''` when no filter applies. Mirror:
|
|
2885
|
+
* {@link collectTargetGlobalFilterAlias}.
|
|
2886
|
+
*/
|
|
2887
|
+
targetGlobalFilterAlias(targetTable, alias, params) {
|
|
2888
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
2889
|
+
if (!gf)
|
|
2890
|
+
return '';
|
|
2891
|
+
const meta = this.schema.tables[targetTable];
|
|
2892
|
+
if (!meta)
|
|
2893
|
+
return '';
|
|
2894
|
+
return this.buildAliasWhere(targetTable, meta, alias, gf, params) ?? '';
|
|
2895
|
+
}
|
|
2896
|
+
/** Param-collect mirror of {@link targetGlobalFilterAlias}. */
|
|
2897
|
+
collectTargetGlobalFilterAlias(targetTable, params) {
|
|
2898
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
2899
|
+
if (!gf)
|
|
2900
|
+
return;
|
|
2901
|
+
const meta = this.schema.tables[targetTable];
|
|
2902
|
+
if (!meta)
|
|
2903
|
+
return;
|
|
2904
|
+
this.collectAliasWhereParams(targetTable, meta, gf, params);
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* SQL clause for `targetTable`'s global filter rendered against the bare
|
|
2908
|
+
* (unaliased) table name — the form used inside relation-filter `EXISTS`
|
|
2909
|
+
* subqueries. Pushes its params; `''` when none. Mirror:
|
|
2910
|
+
* {@link collectTargetGlobalFilterExists}.
|
|
2911
|
+
*/
|
|
2912
|
+
targetGlobalFilterExists(targetTable, params) {
|
|
2913
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
2914
|
+
if (!gf)
|
|
2915
|
+
return '';
|
|
2916
|
+
return this.buildSubWhereForRelation(targetTable, gf, params) ?? '';
|
|
2917
|
+
}
|
|
2918
|
+
/** Param-collect mirror of {@link targetGlobalFilterExists}. */
|
|
2919
|
+
collectTargetGlobalFilterExists(targetTable, params) {
|
|
2920
|
+
const gf = this.resolveGlobalFilter(targetTable);
|
|
2921
|
+
if (!gf)
|
|
2922
|
+
return;
|
|
2923
|
+
this.collectRelFilterParams(targetTable, gf, params);
|
|
2924
|
+
}
|
|
2925
|
+
/**
|
|
2926
|
+
* Value-invariant SQL-cache-key segment for the active global-filter
|
|
2927
|
+
* environment. Relation-subquery / relation-filter / `_count` / relation-
|
|
2928
|
+
* `orderBy` global filters are rendered at build time but their SHAPE is not
|
|
2929
|
+
* otherwise in the where/with fingerprint, so this segment guards the cache:
|
|
2930
|
+
* two different filter shapes never collide on one cached SQL text, while two
|
|
2931
|
+
* function-filter results of the SAME shape (differing only in values) share
|
|
2932
|
+
* the entry and bind their own params. Empty (`''`) when no filter applies, so
|
|
2933
|
+
* cache keys stay byte-identical when the feature is unused.
|
|
2934
|
+
*/
|
|
2935
|
+
globalFilterCacheSegment() {
|
|
2936
|
+
const filters = this.globalFilters;
|
|
2937
|
+
if (!filters)
|
|
2938
|
+
return '';
|
|
2939
|
+
const parts = [];
|
|
2940
|
+
for (const table of Object.keys(filters).sort()) {
|
|
2941
|
+
// Function filters for OTHER tables may be request-scoped closures that
|
|
2942
|
+
// throw outside their own context; a query on an unrelated table must not
|
|
2943
|
+
// break on them. A throwing filter can't have contributed SQL to this
|
|
2944
|
+
// query either (merging it would have thrown first), so a constant
|
|
2945
|
+
// marker keeps the key shape-distinct without evaluating it.
|
|
2946
|
+
let gf;
|
|
2947
|
+
try {
|
|
2948
|
+
gf = this.resolveGlobalFilter(table);
|
|
2949
|
+
}
|
|
2950
|
+
catch {
|
|
2951
|
+
parts.push(`${table}:!`);
|
|
2952
|
+
continue;
|
|
2953
|
+
}
|
|
2954
|
+
if (gf)
|
|
2955
|
+
parts.push(`${table}:${this.fingerprintWhere(gf)}`);
|
|
2956
|
+
}
|
|
2957
|
+
return parts.length ? `|gf=${parts.join(';')}` : '';
|
|
2958
|
+
}
|
|
2959
|
+
/**
|
|
2960
|
+
* True when the USER-supplied `where` compiles to no predicate (`{}`,
|
|
2961
|
+
* `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
|
|
2962
|
+
* signal the empty-`where` guard needs — the compiled emptiness, NOT the
|
|
2963
|
+
* fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
|
|
2964
|
+
* any configured global filter, so a global filter never lets an unguarded
|
|
2965
|
+
* mass mutation through.
|
|
2966
|
+
*/
|
|
2967
|
+
userPredicateIsEmpty(userWhere) {
|
|
2968
|
+
const throwaway = [];
|
|
2969
|
+
return this.buildWhereClause(userWhere, throwaway) === null;
|
|
2970
|
+
}
|
|
2870
2971
|
assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
|
|
2871
2972
|
if (whereSql.length > 0)
|
|
2872
2973
|
return;
|
|
@@ -2882,7 +2983,7 @@ class QueryInterface {
|
|
|
2882
2983
|
*/
|
|
2883
2984
|
buildWhereClause(where, params) {
|
|
2884
2985
|
// Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
|
|
2885
|
-
const keys = sortedKeys(where);
|
|
2986
|
+
const keys = (0, filters_js_1.sortedKeys)(where);
|
|
2886
2987
|
if (keys.length === 0)
|
|
2887
2988
|
return null;
|
|
2888
2989
|
const andClauses = [];
|
|
@@ -2950,13 +3051,13 @@ class QueryInterface {
|
|
|
2950
3051
|
continue;
|
|
2951
3052
|
}
|
|
2952
3053
|
// Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
|
|
2953
|
-
if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
|
|
3054
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isVectorFilter)(value)) {
|
|
2954
3055
|
const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
|
|
2955
3056
|
andClauses.push(...vecClauses);
|
|
2956
3057
|
continue;
|
|
2957
3058
|
}
|
|
2958
3059
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
2959
|
-
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3060
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2960
3061
|
const colType = this.getColumnPgType(rawColumn);
|
|
2961
3062
|
if (colType === 'json' || colType === 'jsonb') {
|
|
2962
3063
|
const jsonClauses = this.buildJsonFilterClauses(column, value, params);
|
|
@@ -2968,14 +3069,14 @@ class QueryInterface {
|
|
|
2968
3069
|
// equality (the previous behaviour) wasted hours of debugging time. Only
|
|
2969
3070
|
// throw when the operator is unambiguously JSON-specific — `contains` is
|
|
2970
3071
|
// shared with WhereOperator's LIKE so it must continue to fall through.
|
|
2971
|
-
const jsonKey = findJsonUniqueKey(value);
|
|
3072
|
+
const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
|
|
2972
3073
|
if (jsonKey) {
|
|
2973
3074
|
throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
|
|
2974
3075
|
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
2975
3076
|
}
|
|
2976
3077
|
}
|
|
2977
3078
|
// Handle Array filter operators (for array columns)
|
|
2978
|
-
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
3079
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
|
|
2979
3080
|
const colType = this.getColumnPgType(rawColumn);
|
|
2980
3081
|
if (colType.startsWith('_')) {
|
|
2981
3082
|
const arrayClauses = this.buildArrayFilterClauses(column, value, params, colType);
|
|
@@ -2985,20 +3086,20 @@ class QueryInterface {
|
|
|
2985
3086
|
// Strict validation: array operators (`has`, `hasEvery`, ...) on a
|
|
2986
3087
|
// non-array column always indicate a mistake. None of these keys
|
|
2987
3088
|
// overlap with other filter shapes so we can throw unconditionally.
|
|
2988
|
-
const arrayKey = findArrayUniqueKey(value);
|
|
3089
|
+
const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
|
|
2989
3090
|
if (arrayKey) {
|
|
2990
3091
|
throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
|
|
2991
3092
|
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
2992
3093
|
}
|
|
2993
3094
|
}
|
|
2994
3095
|
// Handle full-text search filter
|
|
2995
|
-
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
3096
|
+
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isTextSearchFilter)(value)) {
|
|
2996
3097
|
const tsClause = this.buildTextSearchClause(column, value, params);
|
|
2997
3098
|
andClauses.push(tsClause);
|
|
2998
3099
|
continue;
|
|
2999
3100
|
}
|
|
3000
3101
|
// Handle operator objects
|
|
3001
|
-
if (isWhereOperator(value)) {
|
|
3102
|
+
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3002
3103
|
const opClauses = this.buildOperatorClauses(column, value, params);
|
|
3003
3104
|
andClauses.push(...opClauses);
|
|
3004
3105
|
continue;
|
|
@@ -3038,55 +3139,69 @@ class QueryInterface {
|
|
|
3038
3139
|
// belongsTo: parent.fk = child.pk
|
|
3039
3140
|
correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
|
|
3040
3141
|
}
|
|
3041
|
-
//
|
|
3142
|
+
// The target table's global filter (soft-delete / tenancy) restricts the
|
|
3143
|
+
// DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
|
|
3144
|
+
// ignore filtered-out rows, and `every` quantifies over only the surviving
|
|
3145
|
+
// rows ("every NON-deleted related row matches P"). It is ANDed into the
|
|
3146
|
+
// correlation and its params pushed AFTER the per-branch filter — mirrored
|
|
3147
|
+
// exactly in collectWhereParams' relation-filter branch. `qt` is the bare
|
|
3148
|
+
// target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
|
|
3149
|
+
const gfAnd = () => {
|
|
3150
|
+
const gf = this.targetGlobalFilterExists(targetTable, params);
|
|
3151
|
+
return gf ? ` AND ${gf}` : '';
|
|
3152
|
+
};
|
|
3153
|
+
// "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
3042
3154
|
if (filterObj.some !== undefined) {
|
|
3043
3155
|
const subWhere = filterObj.some;
|
|
3044
3156
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3045
|
-
const
|
|
3046
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3157
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3158
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3047
3159
|
}
|
|
3048
|
-
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
|
|
3160
|
+
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
3049
3161
|
if (filterObj.none !== undefined) {
|
|
3050
3162
|
const subWhere = filterObj.none;
|
|
3051
3163
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3052
|
-
const
|
|
3053
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3164
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3165
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3054
3166
|
}
|
|
3055
|
-
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
|
|
3167
|
+
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
|
|
3056
3168
|
if (filterObj.every !== undefined) {
|
|
3057
3169
|
const subWhere = filterObj.every;
|
|
3058
3170
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3059
3171
|
if (filterClause) {
|
|
3060
|
-
|
|
3172
|
+
// gf params pushed AFTER filter params (collect mirrors this order), but
|
|
3173
|
+
// placed textually inside the domain so it restricts which rows count.
|
|
3174
|
+
const gf = gfAnd();
|
|
3175
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
|
|
3061
3176
|
}
|
|
3062
3177
|
else {
|
|
3063
|
-
// "every" with empty filter = true (all match trivially)
|
|
3178
|
+
// "every" with empty filter = true (all match trivially) — gf irrelevant.
|
|
3064
3179
|
}
|
|
3065
3180
|
}
|
|
3066
3181
|
// "is": EXISTS — for to-one relations (same SQL as "some").
|
|
3067
3182
|
// `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
|
|
3068
3183
|
if (filterObj.is !== undefined) {
|
|
3069
3184
|
if (filterObj.is === null) {
|
|
3070
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3185
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
|
|
3071
3186
|
}
|
|
3072
3187
|
else {
|
|
3073
3188
|
const subWhere = filterObj.is;
|
|
3074
3189
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3075
|
-
const
|
|
3076
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3190
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3191
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3077
3192
|
}
|
|
3078
3193
|
}
|
|
3079
3194
|
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
|
|
3080
3195
|
// `isNot: null` = "a related row exists" → EXISTS.
|
|
3081
3196
|
if (filterObj.isNot !== undefined) {
|
|
3082
3197
|
if (filterObj.isNot === null) {
|
|
3083
|
-
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3198
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
|
|
3084
3199
|
}
|
|
3085
3200
|
else {
|
|
3086
3201
|
const subWhere = filterObj.isNot;
|
|
3087
3202
|
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3088
|
-
const
|
|
3089
|
-
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${
|
|
3203
|
+
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
3204
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
3090
3205
|
}
|
|
3091
3206
|
}
|
|
3092
3207
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
@@ -3102,7 +3217,7 @@ class QueryInterface {
|
|
|
3102
3217
|
const qt = this.q(targetTable);
|
|
3103
3218
|
const conditions = [];
|
|
3104
3219
|
// Sorted (canonical) order — MUST match fingerprintRelFilter and collectRelFilterParams.
|
|
3105
|
-
for (const field of sortedKeys(subWhere)) {
|
|
3220
|
+
for (const field of (0, filters_js_1.sortedKeys)(subWhere)) {
|
|
3106
3221
|
const value = subWhere[field];
|
|
3107
3222
|
if (value === undefined)
|
|
3108
3223
|
continue;
|
|
@@ -3149,7 +3264,7 @@ class QueryInterface {
|
|
|
3149
3264
|
conditions.push(`${qCol} IS NULL`);
|
|
3150
3265
|
continue;
|
|
3151
3266
|
}
|
|
3152
|
-
if (isWhereOperator(value)) {
|
|
3267
|
+
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3153
3268
|
const opClauses = this.buildOperatorClauses(qCol, value, params);
|
|
3154
3269
|
conditions.push(...opClauses);
|
|
3155
3270
|
continue;
|
|
@@ -3177,7 +3292,7 @@ class QueryInterface {
|
|
|
3177
3292
|
* columns (object equality).
|
|
3178
3293
|
*/
|
|
3179
3294
|
assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
|
|
3180
|
-
if (!isUnmatchedPlainObject(value))
|
|
3295
|
+
if (!(0, filters_js_1.isUnmatchedPlainObject)(value))
|
|
3181
3296
|
return;
|
|
3182
3297
|
if (columnPgType === 'json' || columnPgType === 'jsonb')
|
|
3183
3298
|
return;
|
|
@@ -3202,7 +3317,7 @@ class QueryInterface {
|
|
|
3202
3317
|
buildAliasWhere(targetTable, targetMeta, alias, where, params) {
|
|
3203
3318
|
const clauses = [];
|
|
3204
3319
|
// Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
|
|
3205
|
-
for (const key of sortedKeys(where)) {
|
|
3320
|
+
for (const key of (0, filters_js_1.sortedKeys)(where)) {
|
|
3206
3321
|
const value = where[key];
|
|
3207
3322
|
if (value === undefined)
|
|
3208
3323
|
continue;
|
|
@@ -3245,7 +3360,7 @@ class QueryInterface {
|
|
|
3245
3360
|
clauses.push(`${qCol} IS NULL`);
|
|
3246
3361
|
continue;
|
|
3247
3362
|
}
|
|
3248
|
-
if (isWhereOperator(value)) {
|
|
3363
|
+
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3249
3364
|
clauses.push(...this.buildOperatorClauses(qCol, value, params));
|
|
3250
3365
|
continue;
|
|
3251
3366
|
}
|
|
@@ -3258,7 +3373,7 @@ class QueryInterface {
|
|
|
3258
3373
|
/** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
|
|
3259
3374
|
collectAliasWhereParams(targetTable, targetMeta, where, params) {
|
|
3260
3375
|
// Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
|
|
3261
|
-
for (const key of sortedKeys(where)) {
|
|
3376
|
+
for (const key of (0, filters_js_1.sortedKeys)(where)) {
|
|
3262
3377
|
const value = where[key];
|
|
3263
3378
|
if (value === undefined)
|
|
3264
3379
|
continue;
|
|
@@ -3281,22 +3396,14 @@ class QueryInterface {
|
|
|
3281
3396
|
if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
3282
3397
|
const norm = this.normalizeRelationFilter(aliasRel, value);
|
|
3283
3398
|
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3284
|
-
//
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
if (norm.none != null)
|
|
3288
|
-
this.collectRelFilterParams(aliasRel.to, norm.none, params);
|
|
3289
|
-
if (norm.every != null)
|
|
3290
|
-
this.collectRelFilterParams(aliasRel.to, norm.every, params);
|
|
3291
|
-
if (norm.is != null)
|
|
3292
|
-
this.collectRelFilterParams(aliasRel.to, norm.is, params);
|
|
3293
|
-
if (norm.isNot != null)
|
|
3294
|
-
this.collectRelFilterParams(aliasRel.to, norm.isNot, params);
|
|
3399
|
+
// Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
|
|
3400
|
+
// params then target global-filter params).
|
|
3401
|
+
this.collectRelationFilterParams(aliasRel, norm, params);
|
|
3295
3402
|
continue;
|
|
3296
3403
|
}
|
|
3297
3404
|
}
|
|
3298
3405
|
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
3299
|
-
if (isWhereOperator(value)) {
|
|
3406
|
+
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3300
3407
|
this.collectOperatorParams(col, value, params);
|
|
3301
3408
|
continue;
|
|
3302
3409
|
}
|
|
@@ -3350,11 +3457,11 @@ class QueryInterface {
|
|
|
3350
3457
|
continue;
|
|
3351
3458
|
}
|
|
3352
3459
|
}
|
|
3353
|
-
if (isWhereOperator(value)) {
|
|
3354
|
-
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
3460
|
+
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3461
|
+
parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
|
|
3355
3462
|
continue;
|
|
3356
3463
|
}
|
|
3357
|
-
if (isUnmatchedPlainObject(value)) {
|
|
3464
|
+
if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
|
|
3358
3465
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
3359
3466
|
.sort()
|
|
3360
3467
|
.join(',')})`);
|
|
@@ -3375,7 +3482,7 @@ class QueryInterface {
|
|
|
3375
3482
|
clauses.push(`${column} IS NULL`);
|
|
3376
3483
|
}
|
|
3377
3484
|
else {
|
|
3378
|
-
assertBindableEqualsOperand(op.equals, column);
|
|
3485
|
+
(0, filters_js_1.assertBindableEqualsOperand)(op.equals, column);
|
|
3379
3486
|
params.push(op.equals);
|
|
3380
3487
|
clauses.push(`${column} = ${this.p(params.length)}`);
|
|
3381
3488
|
}
|
|
@@ -3438,10 +3545,37 @@ class QueryInterface {
|
|
|
3438
3545
|
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
3439
3546
|
* ordering throws — KNN ordering is only supported at the top level.
|
|
3440
3547
|
*/
|
|
3548
|
+
/**
|
|
3549
|
+
* Value-shape fingerprint for a single orderBy entry, so two queries whose
|
|
3550
|
+
* ORDER BY differs only in nulls placement, vector metric, or relation-count
|
|
3551
|
+
* vs relation-column never collide on one cached SQL string. Captures the
|
|
3552
|
+
* SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
|
|
3553
|
+
*/
|
|
3554
|
+
orderByEntryFingerprint(d) {
|
|
3555
|
+
// Vector KNN ordering changes the emitted operator by metric and adds a
|
|
3556
|
+
// `::vector` param, so metric + direction must be part of the cache key.
|
|
3557
|
+
if ((0, filters_js_1.isVectorOrderBy)(d)) {
|
|
3558
|
+
return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
3559
|
+
}
|
|
3560
|
+
if ((0, filters_js_1.isOrderBySpec)(d))
|
|
3561
|
+
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3562
|
+
if (d && typeof d === 'object') {
|
|
3563
|
+
// Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
|
|
3564
|
+
return `rel(${Object.entries(d)
|
|
3565
|
+
.map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
|
|
3566
|
+
.sort()
|
|
3567
|
+
.join(',')})`;
|
|
3568
|
+
}
|
|
3569
|
+
return String(d);
|
|
3570
|
+
}
|
|
3441
3571
|
buildOrderBy(orderBy, params) {
|
|
3442
|
-
// Dev-only: validate that orderBy fields exist in the table schema
|
|
3572
|
+
// Dev-only: validate that orderBy fields exist in the table schema. Relation
|
|
3573
|
+
// orderBy keys (object values that are neither a vector nor an OrderBySpec)
|
|
3574
|
+
// are validated in the relation branch below, so skip them here.
|
|
3443
3575
|
if (process.env.NODE_ENV !== 'production') {
|
|
3444
|
-
for (const key of Object.
|
|
3576
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
3577
|
+
if (this.isRelationOrderByValue(value) && this.tableMeta.relations[key])
|
|
3578
|
+
continue;
|
|
3445
3579
|
const snakeKey = (0, schema_js_1.camelToSnake)(key);
|
|
3446
3580
|
if (!this.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in this.tableMeta.columnMap)) {
|
|
3447
3581
|
console.warn(`[turbine] Unknown orderBy field "${key}" for table "${this.tableMeta.name}". ` +
|
|
@@ -3450,28 +3584,217 @@ class QueryInterface {
|
|
|
3450
3584
|
}
|
|
3451
3585
|
}
|
|
3452
3586
|
const meta = this.schema.tables[this.table];
|
|
3587
|
+
let relOrdCounter = 0;
|
|
3453
3588
|
return Object.entries(orderBy)
|
|
3454
|
-
.map(([key,
|
|
3455
|
-
if (meta && !(key in meta.columnMap)) {
|
|
3456
|
-
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3457
|
-
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3458
|
-
}
|
|
3589
|
+
.map(([key, value]) => {
|
|
3459
3590
|
// Vector KNN ordering: { distance: { to, metric, direction? } }
|
|
3460
|
-
if (isVectorOrderBy(
|
|
3591
|
+
if ((0, filters_js_1.isVectorOrderBy)(value)) {
|
|
3592
|
+
if (meta && !(key in meta.columnMap)) {
|
|
3593
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3594
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3595
|
+
}
|
|
3461
3596
|
if (!params) {
|
|
3462
3597
|
throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
3463
3598
|
}
|
|
3464
3599
|
const rawColumn = this.toColumn(key);
|
|
3465
|
-
const operator = this.vectorOperator(key, rawColumn,
|
|
3466
|
-
const placeholder = this.pushVectorParam(key, rawColumn,
|
|
3467
|
-
const safeDir =
|
|
3600
|
+
const operator = this.vectorOperator(key, rawColumn, value.distance.metric);
|
|
3601
|
+
const placeholder = this.pushVectorParam(key, rawColumn, value.distance.to, params);
|
|
3602
|
+
const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3468
3603
|
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
3469
3604
|
}
|
|
3470
|
-
|
|
3471
|
-
|
|
3605
|
+
// Relation ordering: an object value that is not a vector or OrderBySpec,
|
|
3606
|
+
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
3607
|
+
// { name: 'asc' } }`).
|
|
3608
|
+
if (this.isRelationOrderByValue(value)) {
|
|
3609
|
+
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
|
|
3610
|
+
}
|
|
3611
|
+
// Scalar column ordering — a plain direction or an OrderBySpec (nulls).
|
|
3612
|
+
if (meta && !(key in meta.columnMap)) {
|
|
3613
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
|
|
3614
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3615
|
+
}
|
|
3616
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
|
|
3617
|
+
return `${this.toSqlColumn(key)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
3618
|
+
})
|
|
3619
|
+
.join(', ');
|
|
3620
|
+
}
|
|
3621
|
+
/**
|
|
3622
|
+
* True when an orderBy value is a relation-ordering object: a plain object
|
|
3623
|
+
* that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
|
|
3624
|
+
* in the orderBy clause is a relation name.
|
|
3625
|
+
*/
|
|
3626
|
+
isRelationOrderByValue(value) {
|
|
3627
|
+
return (typeof value === 'object' &&
|
|
3628
|
+
value !== null &&
|
|
3629
|
+
!Array.isArray(value) &&
|
|
3630
|
+
!(0, filters_js_1.isVectorOrderBy)(value) &&
|
|
3631
|
+
!(0, filters_js_1.isOrderBySpec)(value));
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
|
|
3635
|
+
* Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
|
|
3636
|
+
* other engine a caller asking for explicit nulls placement gets a clear
|
|
3637
|
+
* {@link UnsupportedFeatureError} (E017) instead of broken SQL.
|
|
3638
|
+
*/
|
|
3639
|
+
nullsSuffix(nulls) {
|
|
3640
|
+
if (!nulls)
|
|
3641
|
+
return '';
|
|
3642
|
+
if (this.dialect.name !== 'postgresql' && this.dialect.name !== 'sqlite') {
|
|
3643
|
+
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST/LAST ordering', this.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
|
|
3644
|
+
}
|
|
3645
|
+
return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
|
|
3646
|
+
}
|
|
3647
|
+
/**
|
|
3648
|
+
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
3649
|
+
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
3650
|
+
* to-one relation each entry names a target column and becomes a correlated
|
|
3651
|
+
* scalar subquery (supporting {@link OrderBySpec} nulls placement).
|
|
3652
|
+
*
|
|
3653
|
+
* Validation: relation must exist (E005); to-many only allows `_count`, and
|
|
3654
|
+
* to-one only allows real target columns (E003).
|
|
3655
|
+
*/
|
|
3656
|
+
buildRelationOrderBy(relName, value, alias, params) {
|
|
3657
|
+
const relDef = this.tableMeta.relations[relName];
|
|
3658
|
+
if (!relDef) {
|
|
3659
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
|
|
3660
|
+
`Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
|
|
3661
|
+
}
|
|
3662
|
+
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
3663
|
+
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
3664
|
+
const keys = Object.keys(value);
|
|
3665
|
+
if (keys.length !== 1 || keys[0] !== '_count') {
|
|
3666
|
+
throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
|
|
3667
|
+
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
3668
|
+
}
|
|
3669
|
+
const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
|
|
3670
|
+
return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
|
|
3671
|
+
}
|
|
3672
|
+
// To-one: each entry orders by a correlated scalar subquery on a target column.
|
|
3673
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
3674
|
+
if (!targetMeta)
|
|
3675
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
3676
|
+
const qTarget = this.q(relDef.to);
|
|
3677
|
+
const qParent = this.q(this.table);
|
|
3678
|
+
// belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
|
|
3679
|
+
const correlation = relDef.type === 'belongsTo'
|
|
3680
|
+
? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
3681
|
+
: this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3682
|
+
const entries = Object.entries(value);
|
|
3683
|
+
if (entries.length === 0) {
|
|
3684
|
+
throw new errors_js_1.ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
|
|
3685
|
+
}
|
|
3686
|
+
return entries
|
|
3687
|
+
.map(([col, dirValue]) => {
|
|
3688
|
+
const snakeCol = (0, schema_js_1.camelToSnake)(col);
|
|
3689
|
+
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
3690
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
3691
|
+
}
|
|
3692
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
3693
|
+
// Target's global filter applies here too — otherwise ordering keys off
|
|
3694
|
+
// a soft-deleted / other-tenant related row's value (matches the with
|
|
3695
|
+
// subquery semantics for belongsTo/hasOne).
|
|
3696
|
+
let where = correlation;
|
|
3697
|
+
if (params) {
|
|
3698
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
3699
|
+
if (gf)
|
|
3700
|
+
where += ` AND ${gf}`;
|
|
3701
|
+
}
|
|
3702
|
+
return `(SELECT ${alias}.${this.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${this.limitOneClause()}) ${dir}${this.nullsSuffix(nulls)}`;
|
|
3472
3703
|
})
|
|
3473
3704
|
.join(', ');
|
|
3474
3705
|
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
|
|
3708
|
+
* relation, correlated to `parentRef`. hasMany counts child rows via the FK;
|
|
3709
|
+
* manyToMany counts junction rows via the source key. Shared by the `_count`
|
|
3710
|
+
* `with` key and to-many relation orderBy.
|
|
3711
|
+
*
|
|
3712
|
+
* When `params` is supplied and the target has a global filter, it is
|
|
3713
|
+
* AND-merged so the count only sees surviving rows (a soft-deleted child is
|
|
3714
|
+
* not counted): hasMany filters the counted rows directly; manyToMany adds an
|
|
3715
|
+
* `EXISTS` on the target through the junction (the junction rows themselves
|
|
3716
|
+
* carry no filter). Params are mirrored by {@link collectRelationCountParams}.
|
|
3717
|
+
*/
|
|
3718
|
+
buildRelationCountExpr(relDef, parentRef, alias, params) {
|
|
3719
|
+
const qParent = this.q(parentRef);
|
|
3720
|
+
const count = this.castAgg('COUNT(*)', 'int');
|
|
3721
|
+
if (relDef.type === 'manyToMany') {
|
|
3722
|
+
if (!relDef.through) {
|
|
3723
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
|
|
3724
|
+
}
|
|
3725
|
+
const qJ = this.q(relDef.through.table);
|
|
3726
|
+
const jalias = `${alias}j`;
|
|
3727
|
+
const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
|
|
3728
|
+
const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
|
|
3729
|
+
let where = sourceKeys
|
|
3730
|
+
.map((jc, i) => `${jalias}.${this.q(jc)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
3731
|
+
.join(' AND ');
|
|
3732
|
+
if (params) {
|
|
3733
|
+
const targetExists = this.manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params);
|
|
3734
|
+
if (targetExists)
|
|
3735
|
+
where += ` AND ${targetExists}`;
|
|
3736
|
+
}
|
|
3737
|
+
return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
|
|
3738
|
+
}
|
|
3739
|
+
// hasMany: child FK correlates to the parent reference key.
|
|
3740
|
+
const qTarget = this.q(relDef.to);
|
|
3741
|
+
let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
3742
|
+
if (params) {
|
|
3743
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
3744
|
+
if (gf)
|
|
3745
|
+
where += ` AND ${gf}`;
|
|
3746
|
+
}
|
|
3747
|
+
return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
|
|
3748
|
+
}
|
|
3749
|
+
/**
|
|
3750
|
+
* `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
|
|
3751
|
+
* a manyToMany `_count` to targets that survive their global filter. `''` when
|
|
3752
|
+
* the target has no filter. Pushes gf params; mirror:
|
|
3753
|
+
* {@link collectManyToManyTargetGlobalFilter}.
|
|
3754
|
+
*/
|
|
3755
|
+
manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params) {
|
|
3756
|
+
const gf = this.resolveGlobalFilter(relDef.to);
|
|
3757
|
+
if (!gf || !relDef.through)
|
|
3758
|
+
return '';
|
|
3759
|
+
const tMeta = this.schema.tables[relDef.to];
|
|
3760
|
+
if (!tMeta || tMeta.primaryKey.length === 0)
|
|
3761
|
+
return '';
|
|
3762
|
+
const talias = `${alias}t`;
|
|
3763
|
+
const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
|
|
3764
|
+
const pk = tMeta.primaryKey;
|
|
3765
|
+
if (targetKeys.length !== pk.length)
|
|
3766
|
+
return '';
|
|
3767
|
+
const join = targetKeys.map((jc, i) => `${talias}.${this.q(pk[i])} = ${jalias}.${this.q(jc)}`).join(' AND ');
|
|
3768
|
+
const gfClause = this.buildAliasWhere(relDef.to, tMeta, talias, gf, params);
|
|
3769
|
+
const gfAnd = gfClause ? ` AND ${gfClause}` : '';
|
|
3770
|
+
return `EXISTS (SELECT 1 FROM ${this.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
|
|
3771
|
+
}
|
|
3772
|
+
/** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
|
|
3773
|
+
collectManyToManyTargetGlobalFilter(relDef, params) {
|
|
3774
|
+
const gf = this.resolveGlobalFilter(relDef.to);
|
|
3775
|
+
if (!gf || !relDef.through)
|
|
3776
|
+
return;
|
|
3777
|
+
const tMeta = this.schema.tables[relDef.to];
|
|
3778
|
+
if (!tMeta || tMeta.primaryKey.length === 0)
|
|
3779
|
+
return;
|
|
3780
|
+
const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
|
|
3781
|
+
if (targetKeys.length !== tMeta.primaryKey.length)
|
|
3782
|
+
return;
|
|
3783
|
+
this.collectAliasWhereParams(relDef.to, tMeta, gf, params);
|
|
3784
|
+
}
|
|
3785
|
+
/**
|
|
3786
|
+
* Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
|
|
3787
|
+
* params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
|
|
3788
|
+
* when a filter applies — no-op otherwise.
|
|
3789
|
+
*/
|
|
3790
|
+
collectRelationCountParams(relDef, params) {
|
|
3791
|
+
if (relDef.type === 'manyToMany') {
|
|
3792
|
+
this.collectManyToManyTargetGlobalFilter(relDef, params);
|
|
3793
|
+
}
|
|
3794
|
+
else {
|
|
3795
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3475
3798
|
// -------------------------------------------------------------------------
|
|
3476
3799
|
// pgvector helpers (similarity search)
|
|
3477
3800
|
// -------------------------------------------------------------------------
|
|
@@ -3490,10 +3813,10 @@ class QueryInterface {
|
|
|
3490
3813
|
throw new errors_js_1.ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
|
|
3491
3814
|
`(actual type: ${colType}); cannot apply a vector distance operation.`);
|
|
3492
3815
|
}
|
|
3493
|
-
const op = VECTOR_METRIC_OPERATORS[metric];
|
|
3816
|
+
const op = filters_js_1.VECTOR_METRIC_OPERATORS[metric];
|
|
3494
3817
|
if (!op) {
|
|
3495
3818
|
throw new errors_js_1.ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
|
|
3496
|
-
`Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
|
|
3819
|
+
`Valid metrics: ${Object.keys(filters_js_1.VECTOR_METRIC_OPERATORS).join(', ')}.`);
|
|
3497
3820
|
}
|
|
3498
3821
|
return op;
|
|
3499
3822
|
}
|
|
@@ -3600,6 +3923,19 @@ class QueryInterface {
|
|
|
3600
3923
|
const meta = this.schema.tables[table];
|
|
3601
3924
|
if (!meta)
|
|
3602
3925
|
return parsed;
|
|
3926
|
+
// Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
|
|
3927
|
+
// parseRow copies these unknown columns through under their raw key.
|
|
3928
|
+
let countObj;
|
|
3929
|
+
for (const key of Object.keys(parsed)) {
|
|
3930
|
+
if (key.startsWith('_count__')) {
|
|
3931
|
+
if (countObj === undefined)
|
|
3932
|
+
countObj = {};
|
|
3933
|
+
countObj[key.slice('_count__'.length)] = Number(parsed[key]);
|
|
3934
|
+
delete parsed[key];
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
if (countObj)
|
|
3938
|
+
parsed._count = countObj;
|
|
3603
3939
|
for (const [relName, relDef] of Object.entries(meta.relations)) {
|
|
3604
3940
|
const rawValue = row[relName];
|
|
3605
3941
|
if (rawValue === undefined)
|
|
@@ -3709,7 +4045,7 @@ class QueryInterface {
|
|
|
3709
4045
|
if (!meta)
|
|
3710
4046
|
return {};
|
|
3711
4047
|
const shapes = {};
|
|
3712
|
-
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
4048
|
+
for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
|
|
3713
4049
|
const relDef = meta.relations[relName];
|
|
3714
4050
|
if (!relDef)
|
|
3715
4051
|
continue; // buildSelectWithRelations already threw for this
|
|
@@ -3732,7 +4068,7 @@ class QueryInterface {
|
|
|
3732
4068
|
const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col));
|
|
3733
4069
|
const nested = {};
|
|
3734
4070
|
if (spec !== true && spec.with) {
|
|
3735
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
4071
|
+
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
3736
4072
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3737
4073
|
if (!nestedRelDef)
|
|
3738
4074
|
continue;
|
|
@@ -3865,7 +4201,10 @@ class QueryInterface {
|
|
|
3865
4201
|
const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
|
|
3866
4202
|
const relationSelects = [];
|
|
3867
4203
|
const aliasCounter = { n: 0 };
|
|
3868
|
-
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
4204
|
+
for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
|
|
4205
|
+
// `_count` is a reserved key handled after the relation subqueries.
|
|
4206
|
+
if (relName === '_count')
|
|
4207
|
+
continue;
|
|
3869
4208
|
const relDef = meta.relations[relName];
|
|
3870
4209
|
if (!relDef) {
|
|
3871
4210
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3875,6 +4214,18 @@ class QueryInterface {
|
|
|
3875
4214
|
const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
|
|
3876
4215
|
relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
|
|
3877
4216
|
}
|
|
4217
|
+
// Reserved `_count` key → one correlated COUNT(*) scalar subquery per
|
|
4218
|
+
// selected to-many relation, aliased `_count__<rel>`. Appended after the
|
|
4219
|
+
// relation subqueries; the only params they can push come from a global
|
|
4220
|
+
// filter on the counted target (mirrored at the tail of collectWithParams).
|
|
4221
|
+
// Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
|
|
4222
|
+
const countSpec = withClause._count;
|
|
4223
|
+
if (countSpec !== undefined) {
|
|
4224
|
+
for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
|
|
4225
|
+
const expr = this.buildRelationCountExpr(rel, table, `t${aliasCounter.n++}`, params);
|
|
4226
|
+
relationSelects.push(`${expr} AS ${this.q(`_count__${rel.name}`)}`);
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
3878
4229
|
return [baseCols, ...relationSelects].join(', ');
|
|
3879
4230
|
}
|
|
3880
4231
|
/**
|
|
@@ -4054,7 +4405,7 @@ class QueryInterface {
|
|
|
4054
4405
|
}
|
|
4055
4406
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
4056
4407
|
if (!willWrap && spec !== true && spec.with) {
|
|
4057
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
4408
|
+
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
4058
4409
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
4059
4410
|
if (!nestedRelDef) {
|
|
4060
4411
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -4075,13 +4426,13 @@ class QueryInterface {
|
|
|
4075
4426
|
let orderClause = '';
|
|
4076
4427
|
if (relOrderEntries.length > 0) {
|
|
4077
4428
|
const orders = relOrderEntries
|
|
4078
|
-
.map(([k,
|
|
4429
|
+
.map(([k, dirValue]) => {
|
|
4079
4430
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
4080
4431
|
if (!targetMeta.allColumns.includes(col)) {
|
|
4081
4432
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4082
4433
|
}
|
|
4083
|
-
const
|
|
4084
|
-
return `${alias}.${this.q(col)} ${
|
|
4434
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
4435
|
+
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4085
4436
|
})
|
|
4086
4437
|
.join(', ');
|
|
4087
4438
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -4107,6 +4458,12 @@ class QueryInterface {
|
|
|
4107
4458
|
if (extra)
|
|
4108
4459
|
whereClause += ` AND ${extra}`;
|
|
4109
4460
|
}
|
|
4461
|
+
// Global filter on the target table (soft-delete / tenancy) — AND-merged so
|
|
4462
|
+
// a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
|
|
4463
|
+
// mirrored by collectRelationSubqueryParams.
|
|
4464
|
+
const gfExtra = this.targetGlobalFilterAlias(targetTable, alias, params);
|
|
4465
|
+
if (gfExtra)
|
|
4466
|
+
whereClause += ` AND ${gfExtra}`;
|
|
4110
4467
|
// LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
4111
4468
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
4112
4469
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
@@ -4132,7 +4489,7 @@ class QueryInterface {
|
|
|
4132
4489
|
]);
|
|
4133
4490
|
// Build nested relation subqueries referencing innerAlias
|
|
4134
4491
|
if (spec !== true && spec.with) {
|
|
4135
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
4492
|
+
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
4136
4493
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
4137
4494
|
if (!nestedRelDef) {
|
|
4138
4495
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -4217,13 +4574,13 @@ class QueryInterface {
|
|
|
4217
4574
|
let orderClause = '';
|
|
4218
4575
|
if (relOrderEntries.length > 0) {
|
|
4219
4576
|
const orders = relOrderEntries
|
|
4220
|
-
.map(([k,
|
|
4577
|
+
.map(([k, dirValue]) => {
|
|
4221
4578
|
const col = (0, schema_js_1.camelToSnake)(k);
|
|
4222
4579
|
if (!targetMeta.allColumns.includes(col)) {
|
|
4223
4580
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4224
4581
|
}
|
|
4225
|
-
const
|
|
4226
|
-
return `${talias}.${this.q(col)} ${
|
|
4582
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
4583
|
+
return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4227
4584
|
})
|
|
4228
4585
|
.join(', ');
|
|
4229
4586
|
orderClause = ` ORDER BY ${orders}`;
|
|
@@ -4235,6 +4592,11 @@ class QueryInterface {
|
|
|
4235
4592
|
if (extra)
|
|
4236
4593
|
whereClause += ` AND ${extra}`;
|
|
4237
4594
|
}
|
|
4595
|
+
// Global filter on the target table (mirrors collectRelationSubqueryParams'
|
|
4596
|
+
// m2m branch: after spec.where, before limit).
|
|
4597
|
+
const gfExtra = this.targetGlobalFilterAlias(targetTable, talias, params);
|
|
4598
|
+
if (gfExtra)
|
|
4599
|
+
whereClause += ` AND ${gfExtra}`;
|
|
4238
4600
|
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
4239
4601
|
let limitClause = '';
|
|
4240
4602
|
if (spec !== true && spec.limit !== undefined) {
|
|
@@ -4253,7 +4615,7 @@ class QueryInterface {
|
|
|
4253
4615
|
]);
|
|
4254
4616
|
// Nested relations reference the inner alias.
|
|
4255
4617
|
if (spec !== true && spec.with) {
|
|
4256
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
4618
|
+
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
4257
4619
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
4258
4620
|
if (!nestedRelDef) {
|
|
4259
4621
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -4276,7 +4638,7 @@ class QueryInterface {
|
|
|
4276
4638
|
`${talias}.${this.q(col)}`,
|
|
4277
4639
|
]);
|
|
4278
4640
|
if (spec !== true && spec.with) {
|
|
4279
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
4641
|
+
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
4280
4642
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
4281
4643
|
if (!nestedRelDef) {
|
|
4282
4644
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -4402,7 +4764,7 @@ class QueryInterface {
|
|
|
4402
4764
|
const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
|
|
4403
4765
|
const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
|
|
4404
4766
|
const clauses = [];
|
|
4405
|
-
for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
|
|
4767
|
+
for (const [cmp, sqlOp] of Object.entries(filters_js_1.VECTOR_DISTANCE_COMPARATORS)) {
|
|
4406
4768
|
const threshold = dist[cmp];
|
|
4407
4769
|
if (threshold === undefined)
|
|
4408
4770
|
continue;
|
|
@@ -4424,7 +4786,7 @@ class QueryInterface {
|
|
|
4424
4786
|
*/
|
|
4425
4787
|
buildTextSearchClause(column, filter, params) {
|
|
4426
4788
|
const config = filter.config ?? 'english';
|
|
4427
|
-
if (!validateTextSearchConfig(config)) {
|
|
4789
|
+
if (!(0, filters_js_1.validateTextSearchConfig)(config)) {
|
|
4428
4790
|
throw new errors_js_1.ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
|
|
4429
4791
|
}
|
|
4430
4792
|
params.push(filter.search);
|