@simtlix/simfinity-core 3.16.0 → 3.18.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 +4 -0
- package/package.json +1 -1
- package/src/metadata.js +2 -2
- package/src/query-comparisons.js +54 -0
- package/src/query-context.js +117 -0
- package/src/query-frames.js +188 -85
- package/src/query-operators.js +16 -6
- package/src/query-plan.js +105 -36
- package/src/runtime.js +39 -17
- package/types/index.d.ts +36 -3
package/src/query-plan.js
CHANGED
|
@@ -2,9 +2,10 @@ import { GROUP_KEYS, QUANTIFIED_LISTS } from './const/QLQuantifier.js';
|
|
|
2
2
|
import { aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, validateQuantifiedGroups } from './query-frames.js';
|
|
3
3
|
import { paginationStages } from './query-limits.js';
|
|
4
4
|
import SimfinityError from './errors/simfinity.error.js';
|
|
5
|
+
import { enterQueryFrame, queryEntryContext, resolveComparisonContext, resolveContainerContext, resolveQueryContext } from './query-context.js';
|
|
5
6
|
import {
|
|
6
7
|
describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator, isQueryOperator,
|
|
7
|
-
takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
|
|
8
|
+
hasValuePath, takesListValue, textQueryOperator, validateComparisonOperand, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
|
|
8
9
|
} from './query-operators.js';
|
|
9
10
|
|
|
10
11
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
@@ -45,7 +46,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
45
46
|
const records = [];
|
|
46
47
|
const open = new Set();
|
|
47
48
|
const childFrame = (parent, path, quantifier, construct, anchor = []) => ({
|
|
48
|
-
base: [...parent.base, ...anchor, ...pathSegments(path)],
|
|
49
|
+
parent, base: [...parent.base, ...anchor, ...pathSegments(path)],
|
|
49
50
|
frames: [...parent.frames, { path, type: quantifier, construct }],
|
|
50
51
|
groups: new Map(),
|
|
51
52
|
});
|
|
@@ -96,7 +97,26 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
96
97
|
entries.push({ ...entry, segments, relative: segments.slice(frame.base.length), member: true });
|
|
97
98
|
}
|
|
98
99
|
};
|
|
99
|
-
const
|
|
100
|
+
const operandPath = (item, prefix = '') => hasValuePath(item)
|
|
101
|
+
? (prefix && !(typeof item.valuePath === 'string' && item.valuePath.startsWith('$parent.')) ? `${prefix}.${item.valuePath}` : item.valuePath) : undefined;
|
|
102
|
+
const addPredicatePaths = (path, item, frame, container, group = null, prefix = '') => {
|
|
103
|
+
const comparison = hasValuePath(item) || item?.__operand != null;
|
|
104
|
+
addPath(path, frame, container, group, item?.operator ?? 'EQ', comparison ? item : item ?? null,
|
|
105
|
+
comparison ? { role: 'left', comparison: true } : {});
|
|
106
|
+
if (!comparison) return;
|
|
107
|
+
let origin = frame;
|
|
108
|
+
let right = item.__operand ? item.__operand.path?.join('.') : operandPath(item, prefix);
|
|
109
|
+
let binding = item.__operand?.container;
|
|
110
|
+
if (!item.__operand && gqltype && typeof item.valuePath === 'string' && item.valuePath.startsWith('$parent.')) {
|
|
111
|
+
const resolved = resolveComparisonContext(item.valuePath, queryEntryContext(frame, { gqltype }), prefix);
|
|
112
|
+
binding = resolved.container;
|
|
113
|
+
right = resolved.path.join('.');
|
|
114
|
+
}
|
|
115
|
+
for (let up = binding?.up ?? item.__operand?.up ?? 0; up > 0 && origin.parent; up--) origin = origin.parent;
|
|
116
|
+
if (binding?.path.length) right = [...binding.path, right].join('.');
|
|
117
|
+
addPath(right, origin, container, group, item.operator ?? 'EQ', item, { role: 'right', comparison: true });
|
|
118
|
+
};
|
|
119
|
+
const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier, prefix = '') => {
|
|
100
120
|
if (!isObject(item)) return;
|
|
101
121
|
const quantified = form === 'quantified';
|
|
102
122
|
const relative = pathSegments(relativePath);
|
|
@@ -112,9 +132,11 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
112
132
|
rawPath,
|
|
113
133
|
value: item.value,
|
|
114
134
|
hasValue: Object.hasOwn(item, 'value'),
|
|
135
|
+
...(hasValuePath(item) ? { hasValuePath: true, valuePath: operandPath(item, prefix), ...(prefix && typeof item.valuePath === 'string' && item.valuePath.startsWith('$parent.') ? { valuePathPrefix: prefix } : {}) } : {}),
|
|
136
|
+
...(Object.hasOwn(item, '__operand') ? { operand: item.__operand } : {}),
|
|
115
137
|
ambiguous: form === 'condition' && typeof field === 'string' && field.includes('.') && !isPathless(rawPath),
|
|
116
138
|
depth,
|
|
117
|
-
construct: quantified || form === 'term' || form === 'condition' ? item : null,
|
|
139
|
+
construct: quantified || form === 'term' || form === 'condition' || hasValuePath(item) || item.__operand ? item : null,
|
|
118
140
|
emptiness: isEmptinessOperator(operator),
|
|
119
141
|
...(quantified ? { quantifier } : {}),
|
|
120
142
|
});
|
|
@@ -134,7 +156,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
134
156
|
const field = condition?.field;
|
|
135
157
|
const path = conditionPath(field, condition?.path);
|
|
136
158
|
if (paths) {
|
|
137
|
-
if (typeof field === 'string')
|
|
159
|
+
if (typeof field === 'string') addPredicatePaths(path, condition, inner, group, group);
|
|
138
160
|
} else addOperator(condition, 'condition', field, condition?.path, depth, inner, group, path);
|
|
139
161
|
}
|
|
140
162
|
continue;
|
|
@@ -149,7 +171,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
149
171
|
if (paths) {
|
|
150
172
|
// Invalid path-less terms still name the field, as before; validation rejects them
|
|
151
173
|
// before authorization marking. Their relative path names nothing in the child.
|
|
152
|
-
if (hasTermPath(term))
|
|
174
|
+
if (hasTermPath(term)) addPredicatePaths(term.path, term, frame, value);
|
|
153
175
|
else if (isPathless(term.path)) {
|
|
154
176
|
entries.push({ ...metadata([], frame, value), group: null, operator: term.operator ?? 'EQ', construct: term });
|
|
155
177
|
}
|
|
@@ -159,20 +181,21 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
159
181
|
}
|
|
160
182
|
if (!paths) {
|
|
161
183
|
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));
|
|
184
|
+
if (!hasTerms || value.operator !== undefined || hasValuePath(value)) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
|
|
185
|
+
if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path), undefined, name);
|
|
164
186
|
continue;
|
|
165
187
|
}
|
|
166
188
|
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
167
189
|
if (!terms.some(hasTermPath)) {
|
|
168
190
|
const term = terms.find(pathless);
|
|
169
191
|
if (term) addPath(name, root, value, null, term.operator, term);
|
|
192
|
+
else if (hasValuePath(value)) addPredicatePaths(name, value, root, value);
|
|
170
193
|
else addPath(name, root, value, null, value.operator ?? 'EQ');
|
|
171
194
|
continue;
|
|
172
195
|
}
|
|
173
196
|
let field = false;
|
|
174
197
|
for (const term of terms) {
|
|
175
|
-
if (hasTermPath(term))
|
|
198
|
+
if (hasTermPath(term)) addPredicatePaths(`${name}.${term.path}`, term, root, value, null, name);
|
|
176
199
|
else if (!field && pathless(term)) {
|
|
177
200
|
addPath(name, root, value, null, term.operator, term);
|
|
178
201
|
field = true;
|
|
@@ -198,7 +221,9 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
198
221
|
* A quantified carrier names its path in the containing frame, with `quantifier` and its own
|
|
199
222
|
* construct; children enter its frame. The legacy `group` remains the conditions owner, otherwise
|
|
200
223
|
* null; operator and construct retain their old meanings except that quantifier filters now carry
|
|
201
|
-
* their own construct.
|
|
224
|
+
* their own construct. Comparisons emit left/right roles with comparison:true and one owning
|
|
225
|
+
* construct, including ordinary field filters; RHS paths preserve their lexical term/frame origin.
|
|
226
|
+
* An omitted filter operator is EQ; non-filter reads and quantified carriers
|
|
202
227
|
* have no operator. `expandMembers` appends stored emptiness member paths, preserving metadata.
|
|
203
228
|
*/
|
|
204
229
|
export const collectQueryPathEntries = (args, operation = 'find', options = {}) => (
|
|
@@ -209,7 +234,8 @@ export const collectQueryPathEntries = (args, operation = 'find', options = {})
|
|
|
209
234
|
* Internal: filter constructs only, with the same frame metadata as the path collector. Preserves
|
|
210
235
|
* form, field, rawPath, value, hasValue, ambiguous, deepest depth and emptiness. Quantified carriers
|
|
211
236
|
* have form `quantified`, operator null and their own construct; ordinary own filter operators keep
|
|
212
|
-
* construct null.
|
|
237
|
+
* construct null. Comparisons add hasValuePath:true and a frame-relative valuePath; their owning
|
|
238
|
+
* construct is always retained. Generated outer comparisons carry operand instead. Explicit groups add a depth level, implicit ones none. Invalid entries are left
|
|
213
239
|
* to the validator; unknown group keys and path on a plain group fail closed here.
|
|
214
240
|
*/
|
|
215
241
|
export const collectQueryOperators = (args, operation = 'find') => walkQueryEntries(args, operation);
|
|
@@ -290,11 +316,16 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
290
316
|
* explicit ANY/ALL/NONE and quantifier filters retain their quantifier, and scoped reference
|
|
291
317
|
* crossings inside a frame lift into `joins`. Aggregate collection joins remain legacy; later
|
|
292
318
|
* frames carry their legacy chain in `anchor`. Sorts through collections fail even in count mode.
|
|
319
|
+
* With `valuePath` (the same default as `quantifiers`), comparisons preserve their literal-free
|
|
320
|
+
* value:null placeholder and add operand:{up,path}. Same-frame scoped joins are the union of both
|
|
321
|
+
* paths on the predicate; generated outer operands own their joins in their ancestor frame.
|
|
293
322
|
*/
|
|
294
323
|
export const createQueryPlan = (models, entityName, source = {}, {
|
|
295
|
-
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null,
|
|
324
|
+
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null, valuePath = supported == null, elementFrames = supported == null,
|
|
296
325
|
} = {}) => {
|
|
297
326
|
let input = source;
|
|
327
|
+
const rootContext = { models, entity: entityName };
|
|
328
|
+
const modelSteps = (context, path) => resolveQueryContext(path, context).raw;
|
|
298
329
|
const allowed = supported == null ? null : new Set(supported);
|
|
299
330
|
const references = referenceEmptiness === true;
|
|
300
331
|
const combine = (kind, terms) => {
|
|
@@ -304,31 +335,35 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
304
335
|
// The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
|
|
305
336
|
// arguments are planned on the target type, as trusted find arguments in their own frame, whatever
|
|
306
337
|
// the query's mode, with the operator names already read (`operators` may be a one-time iterable).
|
|
307
|
-
const liftScopes = (marker, targets, segments, operator) => {
|
|
338
|
+
const liftScopes = (marker, targets, segments, operator, anchors = []) => {
|
|
308
339
|
const label = `${operator} on ${segments.join('.')}`;
|
|
309
|
-
const paths = Object.keys(targets);
|
|
340
|
+
const paths = [...Object.keys(targets), ...anchors];
|
|
310
341
|
if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
|
|
311
342
|
const scopes = marker?.scopes;
|
|
312
343
|
if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled`);
|
|
313
344
|
const lifted = {};
|
|
314
345
|
for (const path of paths) {
|
|
315
346
|
if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
|
|
347
|
+
if (anchors.includes(path)) {
|
|
348
|
+
if (scopes[path] !== null) detached(`${label} requires a null aggregate anchor scope for ${path}`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
316
351
|
if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
|
|
317
352
|
lifted[path] = scopes[path] === null
|
|
318
353
|
? null
|
|
319
|
-
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers }).where;
|
|
354
|
+
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers, valuePath, elementFrames }).where;
|
|
320
355
|
}
|
|
321
356
|
return lifted;
|
|
322
357
|
};
|
|
323
358
|
const hasMarker = (construct) => construct != null && typeof construct === 'object' && Object.hasOwn(construct, '__scope');
|
|
324
359
|
// Rule 1 has resolved the path; rule 2 is the compiler's operators, rules 3 to 6 describeEmptiness's.
|
|
325
|
-
const emptiness = (path, steps, operator, value, construct,
|
|
360
|
+
const emptiness = (path, steps, operator, value, construct, frameContext) => {
|
|
326
361
|
if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
327
362
|
const segments = Array.isArray(path) ? path : path.split('.');
|
|
328
363
|
const options = {
|
|
329
364
|
operator, value, path: segments.join('.'), referenceEmptiness: references,
|
|
330
365
|
};
|
|
331
|
-
const gqltype = models.entities.find((item) => item.name ===
|
|
366
|
+
const gqltype = frameContext.fields ? null : models.entities.find((item) => item.name === frameContext.entity)?.gqltype;
|
|
332
367
|
const shape = gqltype ? describeEmptiness(gqltype, segments, options) : describeModelEmptiness(steps, options);
|
|
333
368
|
const node = {
|
|
334
369
|
kind: 'predicate', path: segments, operator, value: null,
|
|
@@ -338,16 +373,44 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
338
373
|
};
|
|
339
374
|
// `construct` is the term or group condition that holds the predicate, the only objects whose
|
|
340
375
|
// `__scope` is read; null for a field filter's own operator.
|
|
341
|
-
const predicate = (path, operator, value, construct = null,
|
|
342
|
-
const steps =
|
|
376
|
+
const predicate = (path, operator, value, construct = null, frameContext = rootContext, inFrame = false, ancestors = [], prefix = '') => {
|
|
377
|
+
const steps = modelSteps(frameContext, path);
|
|
343
378
|
const op = operator ?? 'EQ';
|
|
344
|
-
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct,
|
|
379
|
+
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameContext);
|
|
345
380
|
const field = steps.at(-1);
|
|
346
381
|
if (field.kind !== 'scalar' && !field.inferred) {
|
|
347
382
|
fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
348
383
|
}
|
|
349
384
|
if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
350
385
|
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
386
|
+
const comparison = hasValuePath(construct) || construct?.__operand !== undefined;
|
|
387
|
+
if (comparison) {
|
|
388
|
+
const internal = construct.__operand;
|
|
389
|
+
const publicOperand = internal ? null : resolveComparisonContext(validateComparisonOperand(construct), frameContext, prefix);
|
|
390
|
+
const boundContainer = internal?.container ?? publicOperand?.container;
|
|
391
|
+
const container = boundContainer?.up === 0 && !internal ? undefined : boundContainer;
|
|
392
|
+
const rightPath = internal?.path ?? (boundContainer ? (container ? publicOperand.path : [...boundContainer.path, ...publicOperand.path])
|
|
393
|
+
: [...(prefix ? prefix.split('.') : []), ...construct.valuePath.split('.')]);
|
|
394
|
+
const up = internal?.up ?? 0;
|
|
395
|
+
const rightContext = container ? (publicOperand?.context ?? resolveContainerContext(container, frameContext)) : up ? ancestors[ancestors.length - up] : frameContext;
|
|
396
|
+
if (!rightContext) fail('Invalid comparison operand frame', 'INVALID_FILTER_PATH');
|
|
397
|
+
modelSteps(rightContext, rightPath);
|
|
398
|
+
const segments = Array.isArray(path) ? path : path.split('.');
|
|
399
|
+
const node = { kind: 'predicate', path: segments, operator: op, value: null, operand: { up, path: rightPath, ...(container ? { container } : {}) } };
|
|
400
|
+
const scopeTargets = (paths, entity) => Object.fromEntries([...new Set(paths.flatMap((item) => crossingKeys(item, entity)))].map((key) => [key, modelSteps(entity, key).at(-1).target]));
|
|
401
|
+
// J collections retain their existing aggregate row. Validate their provenance, but only
|
|
402
|
+
// references become scoped joins; an empty collection scope must not lose the marker.
|
|
403
|
+
const anchors = (paths, entity) => [...new Set(paths.flatMap((parts) => modelSteps(entity, parts)
|
|
404
|
+
.flatMap((step, index) => step.kind === 'collection' ? [parts.slice(0, index + 1).join('.')] : [])))];
|
|
405
|
+
if (hasMarker(construct)) {
|
|
406
|
+
const paths = up || container ? [segments] : [segments, rightPath];
|
|
407
|
+
node.joins = liftScopes(construct.__scope, scopeTargets(paths, frameContext), segments, op, anchors(paths, frameContext));
|
|
408
|
+
}
|
|
409
|
+
if (internal && Object.hasOwn(internal, 'scopes')) {
|
|
410
|
+
node.operand.joins = liftScopes({ scopes: internal.scopes }, scopeTargets([rightPath], rightContext), rightPath, op, anchors([rightPath], rightContext));
|
|
411
|
+
}
|
|
412
|
+
return node;
|
|
413
|
+
}
|
|
351
414
|
const collection = takesListValue(op);
|
|
352
415
|
if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
|
|
353
416
|
if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
|
|
@@ -359,16 +422,18 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
359
422
|
const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
360
423
|
if (hasMarker(construct)) {
|
|
361
424
|
if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);
|
|
362
|
-
const targets = Object.fromEntries(crossingKeys(node.path,
|
|
425
|
+
const targets = Object.fromEntries(crossingKeys(node.path, frameContext).map((key) => [key, modelSteps(frameContext, key).at(-1).target]));
|
|
363
426
|
node.joins = liftScopes(construct.__scope, targets, node.path, op);
|
|
364
427
|
}
|
|
365
428
|
return node;
|
|
366
429
|
};
|
|
430
|
+
const hasComparisonNode = (node) => Boolean(node && (node.operand || hasComparisonNode(node.where)
|
|
431
|
+
|| node.terms?.some(hasComparisonNode)));
|
|
367
432
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
368
433
|
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
|
|
369
434
|
for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
|
|
370
435
|
};
|
|
371
|
-
const group = (value, depth = -1,
|
|
436
|
+
const group = (value, depth = -1, frameContext = rootContext, inFrame = false, ancestors = []) => {
|
|
372
437
|
validateGroup(value);
|
|
373
438
|
const level = depth + (value.__implicit === true ? 0 : 1);
|
|
374
439
|
if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
|
|
@@ -376,7 +441,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
376
441
|
for (const condition of value.conditions || []) {
|
|
377
442
|
if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
378
443
|
if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
379
|
-
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition,
|
|
444
|
+
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameContext, inFrame, ancestors));
|
|
380
445
|
}
|
|
381
446
|
if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
|
|
382
447
|
fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
@@ -384,32 +449,36 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
384
449
|
for (const quantifier of QUANTIFIED_LISTS) for (const item of value[quantifier] || []) {
|
|
385
450
|
const anchor = item.__implicit === true ? item.__anchor : undefined;
|
|
386
451
|
const path = item.path.split('.');
|
|
387
|
-
const
|
|
388
|
-
const
|
|
452
|
+
const fullContextPath = [...(anchor ?? []), ...path];
|
|
453
|
+
const targetContext = enterQueryFrame(frameContext, fullContextPath);
|
|
454
|
+
const target = modelSteps(frameContext, fullContextPath).at(-1).target;
|
|
455
|
+
const node = { kind: 'quantified', quantifier, path, where: group(item, level, targetContext, true, [...ancestors, frameContext]), scope: null };
|
|
389
456
|
if (anchor?.length) node.anchor = anchor;
|
|
390
457
|
if (hasMarker(item)) {
|
|
391
458
|
const fullPath = [...(anchor ?? []), ...path];
|
|
392
459
|
const key = fullPath.join('.');
|
|
393
|
-
const crossings = anchor?.length ? [] : crossingKeys(
|
|
394
|
-
const targets = Object.fromEntries(crossings.map((prefix) => [prefix,
|
|
395
|
-
targets[key] = target;
|
|
396
|
-
const
|
|
397
|
-
|
|
460
|
+
const crossings = anchor?.length && !hasComparisonNode(node.where) ? [] : crossingKeys(fullPath, frameContext);
|
|
461
|
+
const targets = Object.fromEntries(crossings.map((prefix) => [prefix, modelSteps(frameContext, prefix).at(-1).target]));
|
|
462
|
+
if (target) targets[key] = target;
|
|
463
|
+
const anchors = anchor?.length ? modelSteps(frameContext, anchor).flatMap((field, index) => field.kind === 'collection' ? [anchor.slice(0, index + 1).join('.')] : []) : [];
|
|
464
|
+
const scopes = liftScopes(item.__scope, targets, fullPath, quantifier, anchors);
|
|
465
|
+
node.scope = target ? scopes[key] : null;
|
|
398
466
|
if (anchor?.length) {
|
|
399
467
|
for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
|
|
400
468
|
if (!Object.hasOwn(item.__scope.scopes, prefix) || item.__scope.scopes[prefix] !== null) detached(`${quantifier} on ${key} requires a null aggregate anchor scope for ${prefix}`);
|
|
401
469
|
}
|
|
402
|
-
}
|
|
470
|
+
}
|
|
471
|
+
if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
|
|
403
472
|
}
|
|
404
473
|
terms.push(node);
|
|
405
474
|
}
|
|
406
|
-
for (const item of value.AND || []) terms.push(group(item, level,
|
|
407
|
-
terms.push(combine('or', (value.OR || []).map((item) => group(item, level,
|
|
475
|
+
for (const item of value.AND || []) terms.push(group(item, level, frameContext, inFrame, ancestors));
|
|
476
|
+
terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameContext, inFrame, ancestors))));
|
|
408
477
|
return combine('and', terms);
|
|
409
478
|
};
|
|
410
479
|
// Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
|
|
411
480
|
validateGroup(input, ['AND', 'OR']);
|
|
412
|
-
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers };
|
|
481
|
+
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers, valuePath, elementFrames };
|
|
413
482
|
const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
|
|
414
483
|
context.J = J;
|
|
415
484
|
validateQuantifiedGroups(input, context);
|
|
@@ -428,7 +497,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
428
497
|
+ `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
|
|
429
498
|
}
|
|
430
499
|
if (object && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
431
|
-
if (!object) terms.push(predicate(name, value.operator, value.value));
|
|
500
|
+
if (!object) terms.push(predicate(name, value.operator, value.value, hasValuePath(value) || value.__operand ? value : null));
|
|
432
501
|
else for (const term of value.terms) {
|
|
433
502
|
if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
|
|
434
503
|
if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
|
|
@@ -436,7 +505,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
436
505
|
continue;
|
|
437
506
|
}
|
|
438
507
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
439
|
-
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term));
|
|
508
|
+
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term, rootContext, false, [], name));
|
|
440
509
|
}
|
|
441
510
|
}
|
|
442
511
|
for (const item of input.AND || []) terms.push(group(item));
|
package/src/runtime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parentScopePrefix, queryEntryContext, resolveComparisonContext } from './query-context.js';
|
|
1
2
|
import {
|
|
2
3
|
configureQueryLimits, getQueryMaxPageSize, getQueryMaxReferenceFrames, paginationStages,
|
|
3
4
|
} from './query-limits.js';
|
|
@@ -26,7 +27,7 @@ import QLSort from './const/QLSort.js';
|
|
|
26
27
|
import {
|
|
27
28
|
collectQueryOperators, collectQueryPathEntries, walkQueryPath,
|
|
28
29
|
} from './query-plan.js';
|
|
29
|
-
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
30
|
+
import { QUERY_OPERATORS, hasValuePath, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
30
31
|
import {
|
|
31
32
|
assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
|
|
32
33
|
} from './query-emptiness.js';
|
|
@@ -210,6 +211,7 @@ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
|
|
|
210
211
|
field: `${prefix}.${condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field}`,
|
|
211
212
|
operator: condition?.operator,
|
|
212
213
|
value: condition?.value,
|
|
214
|
+
...(hasValuePath(condition) ? { valuePath: `${prefix}.${condition.valuePath}` } : {}),
|
|
213
215
|
}));
|
|
214
216
|
}
|
|
215
217
|
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
@@ -220,7 +222,7 @@ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
|
|
|
220
222
|
return prefixed;
|
|
221
223
|
};
|
|
222
224
|
|
|
223
|
-
const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
225
|
+
const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup, gqltype) => {
|
|
224
226
|
const conditions = [];
|
|
225
227
|
const groups = [];
|
|
226
228
|
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
@@ -243,9 +245,15 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
|
243
245
|
// An emptiness term without a path (omitted, null or "") tests the field itself; other
|
|
244
246
|
// operators keep the path as written.
|
|
245
247
|
const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
|
|
246
|
-
|
|
248
|
+
const parent = parentScopePrefix(term?.valuePath) ? resolveComparisonContext(term.valuePath, { gqltype }, name) : null;
|
|
249
|
+
const rightPath = parent ? [...parent.container.path, ...parent.path].join('.') : `${name}.${term?.valuePath}`;
|
|
250
|
+
conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value,
|
|
251
|
+
...(hasValuePath(term) ? { valuePath: `${prefix}.${rightPath}` } : {}),
|
|
252
|
+
});
|
|
247
253
|
}
|
|
248
|
-
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value
|
|
254
|
+
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value,
|
|
255
|
+
...(hasValuePath(value) ? { valuePath: `${prefix}.${value.valuePath}` } : {}),
|
|
256
|
+
});
|
|
249
257
|
}
|
|
250
258
|
return conditions.length ? [{ conditions }, ...groups] : groups;
|
|
251
259
|
};
|
|
@@ -599,7 +607,7 @@ export const createRuntime = (adapter) => {
|
|
|
599
607
|
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
600
608
|
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
601
609
|
const {
|
|
602
|
-
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
|
|
610
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers, valuePath: supportsValuePath, elementFrames: supportsElementFrames,
|
|
603
611
|
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
604
612
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
605
613
|
// The write features the adapter implements, read once: with optimisticConcurrency, schemas may
|
|
@@ -735,6 +743,7 @@ const QLFilter = new GraphQLInputObjectType({
|
|
|
735
743
|
fields: () => ({
|
|
736
744
|
operator: { type: QLOperator },
|
|
737
745
|
value: { type: QLValue },
|
|
746
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
738
747
|
}),
|
|
739
748
|
});
|
|
740
749
|
|
|
@@ -743,6 +752,7 @@ const QLTypeFilter = new GraphQLInputObjectType({
|
|
|
743
752
|
fields: () => ({
|
|
744
753
|
operator: { type: QLOperator },
|
|
745
754
|
value: { type: QLValue },
|
|
755
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
746
756
|
path: { type: GraphQLString },
|
|
747
757
|
}),
|
|
748
758
|
});
|
|
@@ -768,6 +778,7 @@ const QLFilterCondition = new GraphQLInputObjectType({
|
|
|
768
778
|
field: { type: new GraphQLNonNull(GraphQLString) },
|
|
769
779
|
operator: { type: QLOperator },
|
|
770
780
|
value: { type: QLValue },
|
|
781
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
771
782
|
path: { type: GraphQLString },
|
|
772
783
|
}),
|
|
773
784
|
});
|
|
@@ -2291,13 +2302,13 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
|
|
|
2291
2302
|
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2292
2303
|
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
2293
2304
|
for (const {
|
|
2294
|
-
segments, group, member, operator, use, frames, quantifier,
|
|
2305
|
+
segments, group, member, operator, use, frames, quantifier, comparison,
|
|
2295
2306
|
} of entries) {
|
|
2296
2307
|
const filter = use === 'filter';
|
|
2297
2308
|
const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
|
|
2298
2309
|
const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
|
|
2299
2310
|
const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
|
|
2300
|
-
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2311
|
+
const joins = !member && !comparison && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2301
2312
|
&& !(filter && (frames.length || quantifier || (cut && !anchored)));
|
|
2302
2313
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2303
2314
|
const path = segments.slice(0, index + 1).join('.');
|
|
@@ -2341,7 +2352,7 @@ let virtualFieldsDeclared = false;
|
|
|
2341
2352
|
// (500), the code of model declarations that cannot be honored at request time.
|
|
2342
2353
|
const assertStoredQueryPaths = (gqltype, args, operation) => {
|
|
2343
2354
|
if (!virtualFieldsDeclared) return;
|
|
2344
|
-
for (const { segments } of collectQueryPathEntries(args, operation)) {
|
|
2355
|
+
for (const { segments } of collectQueryPathEntries(args, operation, { gqltype })) {
|
|
2345
2356
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2346
2357
|
if (!isVirtualField(field)) return;
|
|
2347
2358
|
throw new SimfinityError(`Query path ${segments.slice(0, index + 1).join('.')}, added by middleware or a `
|
|
@@ -2400,7 +2411,7 @@ const assertEmptinessForm = (gqltype, {
|
|
|
2400
2411
|
// carriers, which do not become predicates but must not use known undeclared capabilities.
|
|
2401
2412
|
const queryFrameContext = (gqltype, args, operation) => ({
|
|
2402
2413
|
gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
|
|
2403
|
-
operators: supportedOperators,
|
|
2414
|
+
operators: supportedOperators, valuePath: supportsValuePath, elementFrames: supportsElementFrames,
|
|
2404
2415
|
J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
|
|
2405
2416
|
scopeMarkers: 'pending',
|
|
2406
2417
|
});
|
|
@@ -2484,7 +2495,7 @@ const assertJoinedScopePaths = (rootType, groups, prefix, target) => {
|
|
|
2484
2495
|
embeddedPrefix ||= !!(field.extensions?.relation?.embedded && getListShape(field.type));
|
|
2485
2496
|
});
|
|
2486
2497
|
const depth = prefix.split('.').length;
|
|
2487
|
-
for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups })) {
|
|
2498
|
+
for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups }, 'find', { gqltype: rootType })) {
|
|
2488
2499
|
let inList = false;
|
|
2489
2500
|
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field, index }) => {
|
|
2490
2501
|
const relation = field.extensions?.relation;
|
|
@@ -2541,7 +2552,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
2541
2552
|
const kept = scopeFilterArgs(scopeArgs);
|
|
2542
2553
|
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2543
2554
|
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2544
|
-
const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup);
|
|
2555
|
+
const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup, target.gqltype);
|
|
2545
2556
|
if (!groups.length) continue;
|
|
2546
2557
|
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
2547
2558
|
assertJoinedScopePaths(params.type.gqltype, groups, prefix, target);
|
|
@@ -2614,8 +2625,9 @@ const markClientConstructs = (checked, { aggregation, issued = new Map() } = {})
|
|
|
2614
2625
|
};
|
|
2615
2626
|
for (const { entry, shape } of conditions) {
|
|
2616
2627
|
const { construct } = entry;
|
|
2617
|
-
if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2618
|
-
const
|
|
2628
|
+
if (!construct || !(shape || entry.hasValuePath || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2629
|
+
const comparisonPath = entry.hasValuePath ? resolveComparisonContext(entry.valuePath, queryEntryContext(entry, { gqltype }), entry.valuePathPrefix).resolved.end.absolute : [];
|
|
2630
|
+
const targets = [...pathRelations(gqltype, entry.segments), ...pathRelations(gqltype, comparisonPath)].map(({ typeName }) => typeName);
|
|
2619
2631
|
if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
|
|
2620
2632
|
if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
|
|
2621
2633
|
const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
|
|
@@ -2658,7 +2670,8 @@ const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
|
|
|
2658
2670
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
|
|
2659
2671
|
}
|
|
2660
2672
|
if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
|
|
2661
|
-
|
|
2673
|
+
const operandPaths = [entry.segments, ...(entry.hasValuePath ? [resolveComparisonContext(entry.valuePath, queryEntryContext(entry, { gqltype }), entry.valuePathPrefix).resolved.end.absolute] : [])];
|
|
2674
|
+
for (const relation of operandPaths.flatMap((parts) => pathRelations(gqltype, parts))) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
|
|
2662
2675
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
|
|
2663
2676
|
}
|
|
2664
2677
|
}
|
|
@@ -2735,7 +2748,15 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2735
2748
|
const vectors = [];
|
|
2736
2749
|
for (const { entry, shape, type } of positions) {
|
|
2737
2750
|
const relations = pathRelations(type, entry.relative);
|
|
2738
|
-
if (
|
|
2751
|
+
if (entry.hasValuePath) {
|
|
2752
|
+
const existing = new Set(relations.map(({ path }) => path));
|
|
2753
|
+
const right = resolveComparisonContext(entry.valuePath, queryEntryContext(entry, { gqltype }), entry.valuePathPrefix);
|
|
2754
|
+
const prefix = parentScopePrefix(entry.valuePath);
|
|
2755
|
+
const rightRelations = pathRelations(right.context.gqltype, right.path ?? entry.valuePath.split('.'))
|
|
2756
|
+
.map((relation) => ({ ...relation, path: `${prefix}${relation.path}` }));
|
|
2757
|
+
relations.push(...rightRelations.filter(({ path }) => !existing.has(path)));
|
|
2758
|
+
}
|
|
2759
|
+
if (!shape && !entry.hasValuePath && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
|
|
2739
2760
|
const relation = relationOnPath(gqltype, entry.segments);
|
|
2740
2761
|
if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2741
2762
|
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
@@ -2744,7 +2765,7 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2744
2765
|
const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
|
|
2745
2766
|
const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
|
|
2746
2767
|
const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
|
|
2747
|
-
.filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2768
|
+
.filter((relation) => entry.hasValuePath || !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2748
2769
|
.map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
|
|
2749
2770
|
for (const [key, target] of Object.entries(keys)) {
|
|
2750
2771
|
if (Object.hasOwn(targets, key) && targets[key] !== target) {
|
|
@@ -2753,7 +2774,8 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2753
2774
|
targets[key] = target;
|
|
2754
2775
|
}
|
|
2755
2776
|
if (!Object.keys(keys).length) unread.push(entry.path);
|
|
2756
|
-
for (const relation of relations) if (!relation.collection && relation.insideList && Object.hasOwn(keys, relation.path)
|
|
2777
|
+
for (const relation of relations) if (!relation.collection && relation.insideList && Object.hasOwn(keys, relation.path)
|
|
2778
|
+
&& !(supportsElementFrames && entry.hasValuePath)) {
|
|
2757
2779
|
vectors.push({ ...relation, absolute: [...entry.segments.slice(0, -entry.relative.length), ...relation.path.split('.')].join('.') });
|
|
2758
2780
|
}
|
|
2759
2781
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -77,6 +77,8 @@ export interface QLFilterCondition {
|
|
|
77
77
|
field: string;
|
|
78
78
|
operator?: QueryOperator | null;
|
|
79
79
|
value?: any;
|
|
80
|
+
/** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
|
|
81
|
+
valuePath?: string | null;
|
|
80
82
|
path?: string | null;
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -101,6 +103,8 @@ export interface QLTypeFilterExpression {
|
|
|
101
103
|
terms?: Array<{
|
|
102
104
|
operator?: QueryOperator | null;
|
|
103
105
|
value?: any;
|
|
106
|
+
/** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
|
|
107
|
+
valuePath?: string | null;
|
|
104
108
|
path?: string | null;
|
|
105
109
|
} | null> | null;
|
|
106
110
|
}
|
|
@@ -115,6 +119,8 @@ export interface FilterCondition {
|
|
|
115
119
|
field: string;
|
|
116
120
|
operator?: QueryOperator | (string & {});
|
|
117
121
|
value?: any;
|
|
122
|
+
/** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
|
|
123
|
+
valuePath?: string | null;
|
|
118
124
|
path?: string;
|
|
119
125
|
}
|
|
120
126
|
|
|
@@ -156,6 +162,10 @@ export interface QueryFeatures {
|
|
|
156
162
|
* legacy joins. All adapters reject list sorts through collections, including count mode.
|
|
157
163
|
*/
|
|
158
164
|
quantifiers?: boolean | null | undefined;
|
|
165
|
+
/** Field operands, including scoped references. Requires referenceEmptiness: true. */
|
|
166
|
+
valuePath?: boolean | null | undefined;
|
|
167
|
+
/** Embedded element frames and structural parent operands. Requires all three preceding flags. */
|
|
168
|
+
elementFrames?: boolean | null | undefined;
|
|
159
169
|
}
|
|
160
170
|
|
|
161
171
|
export interface ModelRegistration {
|
|
@@ -176,6 +186,10 @@ export interface FieldDescription {
|
|
|
176
186
|
scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum' | 'Version';
|
|
177
187
|
values?: string[];
|
|
178
188
|
enumValues?: { name: string; value: unknown }[];
|
|
189
|
+
/** Logical GraphQL enum identity, required for field comparisons. */
|
|
190
|
+
enumName?: string;
|
|
191
|
+
/** Effective temporal base before storage normalizes Date and Time to DateTime. */
|
|
192
|
+
temporalName?: 'DateTime' | 'Date' | 'Time';
|
|
179
193
|
target?: string;
|
|
180
194
|
connectionField?: string;
|
|
181
195
|
fields?: FieldDescription[];
|
|
@@ -897,6 +911,17 @@ export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapt
|
|
|
897
911
|
/** Scoped single-reference joins, keyed by a dotted prefix relative to the containing frame. */
|
|
898
912
|
export type QueryJoins = Record<string, QueryPredicate | null>;
|
|
899
913
|
|
|
914
|
+
/** A field operand in the same frame (up: 0) or an enclosing shorthand frame. */
|
|
915
|
+
export type QueryOperand = {
|
|
916
|
+
up: number;
|
|
917
|
+
path: string[];
|
|
918
|
+
joins?: QueryJoins;
|
|
919
|
+
/** Structural embedded container: bound frame distance and a single-object path within it.
|
|
920
|
+
* When present, the outer `up` is zero and `path` is relative to this container.
|
|
921
|
+
* This must identify an actual embedded ancestor, never an arbitrary outer entity. */
|
|
922
|
+
container?: { up: number; path: string[] };
|
|
923
|
+
};
|
|
924
|
+
|
|
900
925
|
/**
|
|
901
926
|
* `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. For
|
|
902
927
|
* `CONTAINS_ALL` the value is a non-empty array of non-null scalars, validated and not cast; the
|
|
@@ -918,10 +943,11 @@ export type QueryJoins = Record<string, QueryPredicate | null>;
|
|
|
918
943
|
* A `quantified` node selects elements with `ANY`, `ALL` or `NONE`, applying `scope` before `where`;
|
|
919
944
|
* both predicates use paths relative to the element, and null means no restriction. Empty elements
|
|
920
945
|
* give ANY false, ALL/NONE true; scope is never negated, and frames never multiply roots. `anchor` is
|
|
921
|
-
* an aggregate's legacy join chain; when present, `path` is relative to it
|
|
946
|
+
* an aggregate's legacy join chain; when present, `path` is relative to it. Comparison-bearing
|
|
947
|
+
* anchored frames may carry full-prefix joins as visibility guards, preserving the existing J row.
|
|
922
948
|
*/
|
|
923
949
|
export type QueryPredicate =
|
|
924
|
-
| { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
|
|
950
|
+
| { kind: 'predicate'; path: string[]; operator: string; value: any; operand?: QueryOperand; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
|
|
925
951
|
| { kind: 'and' | 'or'; terms: QueryPredicate[] }
|
|
926
952
|
| {
|
|
927
953
|
kind: 'quantified';
|
|
@@ -975,6 +1001,13 @@ export interface QueryPlan {
|
|
|
975
1001
|
* `quantifiers`, a row filter on an aggregate's join prefix, a legacy join otherwise. `CONTAINS_ALL`
|
|
976
1002
|
* is never cut into a frame.
|
|
977
1003
|
*
|
|
1004
|
+
* `valuePath` selects a compatible stored scalar operand instead of a literal value. It defaults
|
|
1005
|
+
* like `quantifiers`; a comparison node has `value: null` and `operand: { up, path, joins? }`.
|
|
1006
|
+
* Same-frame operands use the union of scoped paths in `joins`; internal shorthand outer bindings
|
|
1007
|
+
* use positive `up` and their own operand joins. Structural $parent operands add container: { up, path } with outer up zero; existing up semantics stay unchanged.
|
|
1008
|
+
* Null equality is two-valued, and a left scalar list uses null-safe EQ/NE membership.
|
|
1009
|
+
* Embedded-list comparisons require elementFrames; right scalar lists and incompatible logical types fail explicitly.
|
|
1010
|
+
*
|
|
978
1011
|
* `referenceEmptiness` defaults to true when `operators` is omitted and to false when it is given.
|
|
979
1012
|
* When it is false, an emptiness condition that crosses or ends at a reference or a collection, or
|
|
980
1013
|
* reads a reference or collection member, fails with `UNSUPPORTED_QUERY` where the order above has
|
|
@@ -995,7 +1028,7 @@ export interface QueryPlan {
|
|
|
995
1028
|
* checked before the path rule, the rest after it.
|
|
996
1029
|
* A marker not filled for every path fails with `DETACHED_CLIENT_FILTER` (500).
|
|
997
1030
|
*/
|
|
998
|
-
export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: boolean }): QueryPlan;
|
|
1031
|
+
export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: boolean; valuePath?: boolean; elementFrames?: boolean }): QueryPlan;
|
|
999
1032
|
export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
|
|
1000
1033
|
|
|
1001
1034
|
/**
|