@simtlix/simfinity-core 3.15.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/package.json +1 -1
- package/src/auth/index.js +97 -36
- package/src/auth/operation-entries.js +111 -0
- package/src/const/QLOperator.js +2 -0
- package/src/execution-state.js +71 -0
- package/src/metadata.js +2 -2
- package/src/mutation-limits.js +37 -7
- package/src/operation-input.js +345 -0
- package/src/query-comparisons.js +54 -0
- package/src/query-frames.js +160 -26
- package/src/query-operators.js +98 -10
- package/src/query-plan.js +94 -29
- package/src/runtime.js +723 -77
- package/src/schema-registrations.js +6 -1
- package/src/transaction-references.js +259 -0
- package/src/transaction-schema.js +163 -0
- package/types/index.d.ts +177 -19
package/src/query-plan.js
CHANGED
|
@@ -4,7 +4,7 @@ import { paginationStages } from './query-limits.js';
|
|
|
4
4
|
import SimfinityError from './errors/simfinity.error.js';
|
|
5
5
|
import {
|
|
6
6
|
describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator, isQueryOperator,
|
|
7
|
-
textQueryOperator, validateTextFilterValue,
|
|
7
|
+
hasValuePath, takesListValue, textQueryOperator, validateComparisonOperand, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
|
|
8
8
|
} from './query-operators.js';
|
|
9
9
|
|
|
10
10
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
@@ -45,7 +45,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
45
45
|
const records = [];
|
|
46
46
|
const open = new Set();
|
|
47
47
|
const childFrame = (parent, path, quantifier, construct, anchor = []) => ({
|
|
48
|
-
base: [...parent.base, ...anchor, ...pathSegments(path)],
|
|
48
|
+
parent, base: [...parent.base, ...anchor, ...pathSegments(path)],
|
|
49
49
|
frames: [...parent.frames, { path, type: quantifier, construct }],
|
|
50
50
|
groups: new Map(),
|
|
51
51
|
});
|
|
@@ -96,7 +96,21 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
96
96
|
entries.push({ ...entry, segments, relative: segments.slice(frame.base.length), member: true });
|
|
97
97
|
}
|
|
98
98
|
};
|
|
99
|
-
const
|
|
99
|
+
const operandPath = (item, prefix = '') => hasValuePath(item)
|
|
100
|
+
? (prefix ? `${prefix}.${item.valuePath}` : item.valuePath) : undefined;
|
|
101
|
+
const addPredicatePaths = (path, item, frame, container, group = null, prefix = '') => {
|
|
102
|
+
const comparison = hasValuePath(item) || item?.__operand != null;
|
|
103
|
+
addPath(path, frame, container, group, item?.operator ?? 'EQ', comparison ? item : item ?? null,
|
|
104
|
+
comparison ? { role: 'left', comparison: true } : {});
|
|
105
|
+
if (!comparison) return;
|
|
106
|
+
let origin = frame;
|
|
107
|
+
if (item.__operand) {
|
|
108
|
+
for (let up = item.__operand.up; up > 0 && origin.parent; up--) origin = origin.parent;
|
|
109
|
+
}
|
|
110
|
+
const right = item.__operand ? item.__operand.path?.join('.') : operandPath(item, prefix);
|
|
111
|
+
addPath(right, origin, container, group, item.operator ?? 'EQ', item, { role: 'right', comparison: true });
|
|
112
|
+
};
|
|
113
|
+
const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier, prefix = '') => {
|
|
100
114
|
if (!isObject(item)) return;
|
|
101
115
|
const quantified = form === 'quantified';
|
|
102
116
|
const relative = pathSegments(relativePath);
|
|
@@ -112,9 +126,11 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
112
126
|
rawPath,
|
|
113
127
|
value: item.value,
|
|
114
128
|
hasValue: Object.hasOwn(item, 'value'),
|
|
129
|
+
...(hasValuePath(item) ? { hasValuePath: true, valuePath: operandPath(item, prefix) } : {}),
|
|
130
|
+
...(Object.hasOwn(item, '__operand') ? { operand: item.__operand } : {}),
|
|
115
131
|
ambiguous: form === 'condition' && typeof field === 'string' && field.includes('.') && !isPathless(rawPath),
|
|
116
132
|
depth,
|
|
117
|
-
construct: quantified || form === 'term' || form === 'condition' ? item : null,
|
|
133
|
+
construct: quantified || form === 'term' || form === 'condition' || hasValuePath(item) || item.__operand ? item : null,
|
|
118
134
|
emptiness: isEmptinessOperator(operator),
|
|
119
135
|
...(quantified ? { quantifier } : {}),
|
|
120
136
|
});
|
|
@@ -134,7 +150,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
134
150
|
const field = condition?.field;
|
|
135
151
|
const path = conditionPath(field, condition?.path);
|
|
136
152
|
if (paths) {
|
|
137
|
-
if (typeof field === 'string')
|
|
153
|
+
if (typeof field === 'string') addPredicatePaths(path, condition, inner, group, group);
|
|
138
154
|
} else addOperator(condition, 'condition', field, condition?.path, depth, inner, group, path);
|
|
139
155
|
}
|
|
140
156
|
continue;
|
|
@@ -149,7 +165,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
149
165
|
if (paths) {
|
|
150
166
|
// Invalid path-less terms still name the field, as before; validation rejects them
|
|
151
167
|
// before authorization marking. Their relative path names nothing in the child.
|
|
152
|
-
if (hasTermPath(term))
|
|
168
|
+
if (hasTermPath(term)) addPredicatePaths(term.path, term, frame, value);
|
|
153
169
|
else if (isPathless(term.path)) {
|
|
154
170
|
entries.push({ ...metadata([], frame, value), group: null, operator: term.operator ?? 'EQ', construct: term });
|
|
155
171
|
}
|
|
@@ -159,20 +175,21 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
159
175
|
}
|
|
160
176
|
if (!paths) {
|
|
161
177
|
const hasTerms = Array.isArray(value.terms);
|
|
162
|
-
if (!hasTerms || value.operator !== undefined) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
|
|
163
|
-
if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path));
|
|
178
|
+
if (!hasTerms || value.operator !== undefined || hasValuePath(value)) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
|
|
179
|
+
if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path), undefined, name);
|
|
164
180
|
continue;
|
|
165
181
|
}
|
|
166
182
|
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
167
183
|
if (!terms.some(hasTermPath)) {
|
|
168
184
|
const term = terms.find(pathless);
|
|
169
185
|
if (term) addPath(name, root, value, null, term.operator, term);
|
|
186
|
+
else if (hasValuePath(value)) addPredicatePaths(name, value, root, value);
|
|
170
187
|
else addPath(name, root, value, null, value.operator ?? 'EQ');
|
|
171
188
|
continue;
|
|
172
189
|
}
|
|
173
190
|
let field = false;
|
|
174
191
|
for (const term of terms) {
|
|
175
|
-
if (hasTermPath(term))
|
|
192
|
+
if (hasTermPath(term)) addPredicatePaths(`${name}.${term.path}`, term, root, value, null, name);
|
|
176
193
|
else if (!field && pathless(term)) {
|
|
177
194
|
addPath(name, root, value, null, term.operator, term);
|
|
178
195
|
field = true;
|
|
@@ -198,7 +215,9 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
198
215
|
* A quantified carrier names its path in the containing frame, with `quantifier` and its own
|
|
199
216
|
* construct; children enter its frame. The legacy `group` remains the conditions owner, otherwise
|
|
200
217
|
* null; operator and construct retain their old meanings except that quantifier filters now carry
|
|
201
|
-
* their own construct.
|
|
218
|
+
* their own construct. Comparisons emit left/right roles with comparison:true and one owning
|
|
219
|
+
* construct, including ordinary field filters; RHS paths preserve their lexical term/frame origin.
|
|
220
|
+
* An omitted filter operator is EQ; non-filter reads and quantified carriers
|
|
202
221
|
* have no operator. `expandMembers` appends stored emptiness member paths, preserving metadata.
|
|
203
222
|
*/
|
|
204
223
|
export const collectQueryPathEntries = (args, operation = 'find', options = {}) => (
|
|
@@ -209,7 +228,8 @@ export const collectQueryPathEntries = (args, operation = 'find', options = {})
|
|
|
209
228
|
* Internal: filter constructs only, with the same frame metadata as the path collector. Preserves
|
|
210
229
|
* form, field, rawPath, value, hasValue, ambiguous, deepest depth and emptiness. Quantified carriers
|
|
211
230
|
* have form `quantified`, operator null and their own construct; ordinary own filter operators keep
|
|
212
|
-
* construct null.
|
|
231
|
+
* construct null. Comparisons add hasValuePath:true and a frame-relative valuePath; their owning
|
|
232
|
+
* construct is always retained. Generated outer comparisons carry operand instead. Explicit groups add a depth level, implicit ones none. Invalid entries are left
|
|
213
233
|
* to the validator; unknown group keys and path on a plain group fail closed here.
|
|
214
234
|
*/
|
|
215
235
|
export const collectQueryOperators = (args, operation = 'find') => walkQueryEntries(args, operation);
|
|
@@ -256,10 +276,19 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
256
276
|
|
|
257
277
|
/**
|
|
258
278
|
* Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. Operators
|
|
259
|
-
* and values are validated here, text operators through validateTextFilterValue
|
|
279
|
+
* and values are validated here, text operators through validateTextFilterValue, CONTAINS_ALL
|
|
280
|
+
* through validateContainsAllValue and IIN through validateTextListFilterValue. With `operators`,
|
|
260
281
|
* the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
|
|
261
282
|
* without it every QLOperator value is planned.
|
|
262
283
|
*
|
|
284
|
+
* The list operators (#108 part 4) plan `{ kind: 'predicate', path, operator, value }` with the
|
|
285
|
+
* array as validated, neither cast nor deduplicated: CONTAINS_ALL's is a non-empty array of
|
|
286
|
+
* non-null scalars, on a scalar list or a scalar reached through embedded lists; IIN's is an array
|
|
287
|
+
* of strings, possibly empty, on a String field or list. CONTAINS_ALL's path and leaf rules are the
|
|
288
|
+
* shared validator's, which runs first: through a collection or a reference inside an embedded
|
|
289
|
+
* list it fails with INVALID_FILTER_PATH, on any other leaf with INVALID_FILTER_OPERATOR. IIN
|
|
290
|
+
* through a collection becomes a correlated ANY frame like IN, and CONTAINS_ALL is never cut.
|
|
291
|
+
*
|
|
263
292
|
* The emptiness operators plan `{ kind: 'predicate', path, operator, value: null }`, whose path may
|
|
264
293
|
* end at a scalar, a scalar list, an embedded list or an embedded object, after the checks of
|
|
265
294
|
* describeEmptiness in their order (on the entity's GraphQL type, or on the field descriptions when
|
|
@@ -281,9 +310,12 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
281
310
|
* explicit ANY/ALL/NONE and quantifier filters retain their quantifier, and scoped reference
|
|
282
311
|
* crossings inside a frame lift into `joins`. Aggregate collection joins remain legacy; later
|
|
283
312
|
* frames carry their legacy chain in `anchor`. Sorts through collections fail even in count mode.
|
|
313
|
+
* With `valuePath` (the same default as `quantifiers`), comparisons preserve their literal-free
|
|
314
|
+
* value:null placeholder and add operand:{up,path}. Same-frame scoped joins are the union of both
|
|
315
|
+
* paths on the predicate; generated outer operands own their joins in their ancestor frame.
|
|
284
316
|
*/
|
|
285
317
|
export const createQueryPlan = (models, entityName, source = {}, {
|
|
286
|
-
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null,
|
|
318
|
+
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null, valuePath = supported == null,
|
|
287
319
|
} = {}) => {
|
|
288
320
|
let input = source;
|
|
289
321
|
const allowed = supported == null ? null : new Set(supported);
|
|
@@ -295,19 +327,23 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
295
327
|
// The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
|
|
296
328
|
// arguments are planned on the target type, as trusted find arguments in their own frame, whatever
|
|
297
329
|
// the query's mode, with the operator names already read (`operators` may be a one-time iterable).
|
|
298
|
-
const liftScopes = (marker, targets, segments, operator) => {
|
|
330
|
+
const liftScopes = (marker, targets, segments, operator, anchors = []) => {
|
|
299
331
|
const label = `${operator} on ${segments.join('.')}`;
|
|
300
|
-
const paths = Object.keys(targets);
|
|
332
|
+
const paths = [...Object.keys(targets), ...anchors];
|
|
301
333
|
if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
|
|
302
334
|
const scopes = marker?.scopes;
|
|
303
335
|
if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled`);
|
|
304
336
|
const lifted = {};
|
|
305
337
|
for (const path of paths) {
|
|
306
338
|
if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
|
|
339
|
+
if (anchors.includes(path)) {
|
|
340
|
+
if (scopes[path] !== null) detached(`${label} requires a null aggregate anchor scope for ${path}`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
307
343
|
if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
|
|
308
344
|
lifted[path] = scopes[path] === null
|
|
309
345
|
? null
|
|
310
|
-
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers }).where;
|
|
346
|
+
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers, valuePath }).where;
|
|
311
347
|
}
|
|
312
348
|
return lifted;
|
|
313
349
|
};
|
|
@@ -329,7 +365,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
329
365
|
};
|
|
330
366
|
// `construct` is the term or group condition that holds the predicate, the only objects whose
|
|
331
367
|
// `__scope` is read; null for a field filter's own operator.
|
|
332
|
-
const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false) => {
|
|
368
|
+
const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false, ancestors = [], prefix = '') => {
|
|
333
369
|
const steps = resolveModelPath(models, frameEntity, path);
|
|
334
370
|
const op = operator ?? 'EQ';
|
|
335
371
|
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameEntity);
|
|
@@ -339,12 +375,38 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
339
375
|
}
|
|
340
376
|
if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
341
377
|
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
342
|
-
const
|
|
378
|
+
const comparison = hasValuePath(construct) || construct?.__operand !== undefined;
|
|
379
|
+
if (comparison) {
|
|
380
|
+
const internal = construct.__operand;
|
|
381
|
+
const rightPath = internal?.path ?? (prefix ? `${prefix}.${validateComparisonOperand(construct)}` : validateComparisonOperand(construct)).split('.');
|
|
382
|
+
const up = internal?.up ?? 0;
|
|
383
|
+
const rightEntity = up ? ancestors[ancestors.length - up] : frameEntity;
|
|
384
|
+
if (!rightEntity) fail('Invalid comparison operand frame', 'INVALID_FILTER_PATH');
|
|
385
|
+
resolveModelPath(models, rightEntity, rightPath);
|
|
386
|
+
const segments = Array.isArray(path) ? path : path.split('.');
|
|
387
|
+
const node = { kind: 'predicate', path: segments, operator: op, value: null, operand: { up, path: rightPath } };
|
|
388
|
+
const scopeTargets = (paths, entity) => Object.fromEntries([...new Set(paths.flatMap((item) => crossingKeys(item, { models, entity })))].map((key) => [key, resolveModelPath(models, entity, key).at(-1).target]));
|
|
389
|
+
// J collections retain their existing aggregate row. Validate their provenance, but only
|
|
390
|
+
// references become scoped joins; an empty collection scope must not lose the marker.
|
|
391
|
+
const anchors = (paths, entity) => [...new Set(paths.flatMap((parts) => resolveModelPath(models, entity, parts)
|
|
392
|
+
.flatMap((step, index) => step.kind === 'collection' ? [parts.slice(0, index + 1).join('.')] : [])))];
|
|
393
|
+
if (hasMarker(construct)) {
|
|
394
|
+
const paths = up ? [segments] : [segments, rightPath];
|
|
395
|
+
node.joins = liftScopes(construct.__scope, scopeTargets(paths, frameEntity), segments, op, anchors(paths, frameEntity));
|
|
396
|
+
}
|
|
397
|
+
if (internal && Object.hasOwn(internal, 'scopes')) {
|
|
398
|
+
node.operand.joins = liftScopes({ scopes: internal.scopes }, scopeTargets([rightPath], rightEntity), rightPath, op, anchors([rightPath], rightEntity));
|
|
399
|
+
}
|
|
400
|
+
return node;
|
|
401
|
+
}
|
|
402
|
+
const collection = takesListValue(op);
|
|
343
403
|
if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
|
|
344
404
|
if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
|
|
345
|
-
if (
|
|
405
|
+
if (collection && !Array.isArray(value)) fail(`${op} requires an array value`);
|
|
346
406
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
347
407
|
if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
|
|
408
|
+
if (op === 'CONTAINS_ALL') validateContainsAllValue(value);
|
|
409
|
+
if (op === 'IIN') validateTextListFilterValue(op, value, field.scalar, path);
|
|
348
410
|
const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
349
411
|
if (hasMarker(construct)) {
|
|
350
412
|
if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);
|
|
@@ -353,11 +415,13 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
353
415
|
}
|
|
354
416
|
return node;
|
|
355
417
|
};
|
|
418
|
+
const hasComparisonNode = (node) => Boolean(node && (node.operand || hasComparisonNode(node.where)
|
|
419
|
+
|| node.terms?.some(hasComparisonNode)));
|
|
356
420
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
357
421
|
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
|
|
358
422
|
for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
|
|
359
423
|
};
|
|
360
|
-
const group = (value, depth = -1, frameEntity = entityName, inFrame = false) => {
|
|
424
|
+
const group = (value, depth = -1, frameEntity = entityName, inFrame = false, ancestors = []) => {
|
|
361
425
|
validateGroup(value);
|
|
362
426
|
const level = depth + (value.__implicit === true ? 0 : 1);
|
|
363
427
|
if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
|
|
@@ -365,7 +429,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
365
429
|
for (const condition of value.conditions || []) {
|
|
366
430
|
if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
367
431
|
if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
368
|
-
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameEntity, inFrame));
|
|
432
|
+
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameEntity, inFrame, ancestors));
|
|
369
433
|
}
|
|
370
434
|
if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
|
|
371
435
|
fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
@@ -374,12 +438,12 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
374
438
|
const anchor = item.__implicit === true ? item.__anchor : undefined;
|
|
375
439
|
const path = item.path.split('.');
|
|
376
440
|
const target = resolveModelPath(models, frameEntity, [...(anchor ?? []), ...path]).at(-1).target;
|
|
377
|
-
const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true), scope: null };
|
|
441
|
+
const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true, [...ancestors, frameEntity]), scope: null };
|
|
378
442
|
if (anchor?.length) node.anchor = anchor;
|
|
379
443
|
if (hasMarker(item)) {
|
|
380
444
|
const fullPath = [...(anchor ?? []), ...path];
|
|
381
445
|
const key = fullPath.join('.');
|
|
382
|
-
const crossings = anchor?.length ? [] : crossingKeys(
|
|
446
|
+
const crossings = anchor?.length && !hasComparisonNode(node.where) ? [] : crossingKeys(fullPath, { models, entity: frameEntity });
|
|
383
447
|
const targets = Object.fromEntries(crossings.map((prefix) => [prefix, resolveModelPath(models, frameEntity, prefix).at(-1).target]));
|
|
384
448
|
targets[key] = target;
|
|
385
449
|
const scopes = liftScopes(item.__scope, targets, fullPath, quantifier);
|
|
@@ -388,17 +452,18 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
388
452
|
for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
|
|
389
453
|
if (!Object.hasOwn(item.__scope.scopes, prefix) || item.__scope.scopes[prefix] !== null) detached(`${quantifier} on ${key} requires a null aggregate anchor scope for ${prefix}`);
|
|
390
454
|
}
|
|
391
|
-
}
|
|
455
|
+
}
|
|
456
|
+
if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
|
|
392
457
|
}
|
|
393
458
|
terms.push(node);
|
|
394
459
|
}
|
|
395
|
-
for (const item of value.AND || []) terms.push(group(item, level, frameEntity, inFrame));
|
|
396
|
-
terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame))));
|
|
460
|
+
for (const item of value.AND || []) terms.push(group(item, level, frameEntity, inFrame, ancestors));
|
|
461
|
+
terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame, ancestors))));
|
|
397
462
|
return combine('and', terms);
|
|
398
463
|
};
|
|
399
464
|
// Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
|
|
400
465
|
validateGroup(input, ['AND', 'OR']);
|
|
401
|
-
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers };
|
|
466
|
+
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers, valuePath };
|
|
402
467
|
const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
|
|
403
468
|
context.J = J;
|
|
404
469
|
validateQuantifiedGroups(input, context);
|
|
@@ -417,7 +482,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
417
482
|
+ `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
|
|
418
483
|
}
|
|
419
484
|
if (object && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
420
|
-
if (!object) terms.push(predicate(name, value.operator, value.value));
|
|
485
|
+
if (!object) terms.push(predicate(name, value.operator, value.value, hasValuePath(value) || value.__operand ? value : null));
|
|
421
486
|
else for (const term of value.terms) {
|
|
422
487
|
if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
|
|
423
488
|
if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
|
|
@@ -425,7 +490,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
425
490
|
continue;
|
|
426
491
|
}
|
|
427
492
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
428
|
-
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term));
|
|
493
|
+
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term, entityName, false, [], name));
|
|
429
494
|
}
|
|
430
495
|
}
|
|
431
496
|
for (const item of input.AND || []) terms.push(group(item));
|