@simtlix/simfinity-core 3.17.0 → 3.19.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 +2 -0
- package/package.json +1 -1
- package/src/query-comparisons.js +2 -2
- package/src/query-context.js +117 -0
- package/src/query-frames.js +101 -97
- package/src/query-operators.js +11 -5
- package/src/query-plan.js +51 -36
- package/src/runtime.js +19 -11
- package/types/index.d.ts +14 -4
package/README.md
CHANGED
|
@@ -86,3 +86,5 @@ call fails without deleting installed metadata. Migrate clients selecting
|
|
|
86
86
|
for the format, protected HTTP access and composition limits.
|
|
87
87
|
|
|
88
88
|
`queryFeatures.valuePath: true` opts an adapter into stored-field comparisons and requires `referenceEmptiness: true`; only true enables it. `valuePath` is nullable on all three generated filter inputs and is mutually exclusive with a supplied `value` (including null). Core validates both operand paths, their logical types, visibility and collection correlation, and preserves outer origins internally. `createQueryPlan` adds `operand?: QueryOperand` with `{ up, path, joins? }` and a null value placeholder; same-frame scope unions live in predicate `joins`, ancestor scopes in operand `joins`. Literal nodes stay unchanged. `describeModels` retains additive `enumName` and effective `temporalName` metadata. See [the comparison rules and limits](https://simtlix.github.io/simfinity.js/guide/queries.html#compare-stored-fields) and [compiler contract](https://simtlix.github.io/simfinity.js/guide/sql-plugins.html#query-features).
|
|
89
|
+
|
|
90
|
+
Version 3.18.0 adds `queryFeatures.elementFrames: true` for embedded object `ANY`/`ALL`/`NONE` and structural `$parent` operands. It requires `quantifiers`, `referenceEmptiness` and `valuePath`, all true. Missing/null lists and null items contribute no elements; `ALL` and `NONE` are true without elements. Parent traversal includes single embedded containers and stops at entity boundaries. Filters preserve selected arrays and outer row cardinality. See [the syntax and examples](https://simtlix.github.io/simfinity.js/guide/queries.html#embedded-object-lists-and-parent); versions through 3.17.0 do not support this extension.
|
package/package.json
CHANGED
package/src/query-comparisons.js
CHANGED
|
@@ -14,8 +14,8 @@ export const validateComparisonOperand = (carrier) => {
|
|
|
14
14
|
if (!hasValuePath(carrier)) return null;
|
|
15
15
|
if (carrier.value !== undefined) fail('A filter cannot supply both value and valuePath');
|
|
16
16
|
if (typeof carrier.valuePath !== 'string') fail('valuePath requires a string field path');
|
|
17
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(carrier.valuePath)) {
|
|
18
|
-
fail('valuePath requires a
|
|
17
|
+
if (!/^(?:\$parent\.)*[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(carrier.valuePath)) {
|
|
18
|
+
fail('valuePath requires a field path optionally preceded by embedded $parent prefixes', 'INVALID_FILTER_PATH');
|
|
19
19
|
}
|
|
20
20
|
return carrier.valuePath;
|
|
21
21
|
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { getNamedType, isEnumType, isObjectType } from 'graphql';
|
|
2
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
3
|
+
import { effectiveScalarName } from './metadata.js';
|
|
4
|
+
import { isVirtualField } from './relation-storage.js';
|
|
5
|
+
import { graphqlStep, modelStep } from './query-emptiness.js';
|
|
6
|
+
|
|
7
|
+
const fail = (message, code = 'INVALID_FILTER_PATH') => { throw new SimfinityError(message, code, 400); };
|
|
8
|
+
const relation = (step) => ['reference', 'collection'].includes(step.kind);
|
|
9
|
+
const partsOf = (path) => (Array.isArray(path) ? path : typeof path === 'string' ? path.split('.') : []);
|
|
10
|
+
const bindingOf = (ctx) => ctx.binding ?? { depth: 0, path: [] };
|
|
11
|
+
|
|
12
|
+
const childContext = (ctx, step) => ({
|
|
13
|
+
...ctx,
|
|
14
|
+
...(ctx.gqltype ? { gqltype: step.node } : { entity: step.target ?? ctx.entity, fields: step.node }),
|
|
15
|
+
parent: ['embedded', 'embeddedList'].includes(step.kind) ? ctx : undefined,
|
|
16
|
+
absolute: [...(ctx.absolute ?? []), step.name],
|
|
17
|
+
binding: { depth: bindingOf(ctx).depth, path: [...bindingOf(ctx).path, step.name] },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// One schema-path resolver for these operations, using the same step classifications as emptiness.
|
|
21
|
+
// Model contexts retain embedded fields locally; relation steps switch to the target entity.
|
|
22
|
+
export const resolveQueryContext = (path, ctx, { onStep, virtualLabel, ordinaryCondition = false } = {}) => {
|
|
23
|
+
const parts = partsOf(path);
|
|
24
|
+
const invalid = parts.findIndex((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part));
|
|
25
|
+
if (!parts.length || invalid !== -1) {
|
|
26
|
+
if (ordinaryCondition && invalid > 0) fail(`Invalid filter path segment: "${parts[invalid]}"`, 'INVALID_FILTER_PATH');
|
|
27
|
+
fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
28
|
+
}
|
|
29
|
+
let current = ctx;
|
|
30
|
+
const steps = [];
|
|
31
|
+
const raw = [];
|
|
32
|
+
for (const [index, name] of parts.entries()) {
|
|
33
|
+
let step;
|
|
34
|
+
if (ctx.gqltype) {
|
|
35
|
+
const fields = isObjectType(current.gqltype) ? current.gqltype.getFields() : null;
|
|
36
|
+
const field = fields && Object.hasOwn(fields, name) ? fields[name] : null;
|
|
37
|
+
if (!field) fail(ordinaryCondition && index === 0 ? `Unknown filter field: ${name}`
|
|
38
|
+
: `Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
39
|
+
if (isVirtualField(field)) {
|
|
40
|
+
const label = virtualLabel?.(field) ?? parts.join('.');
|
|
41
|
+
fail(`Query path ${label} uses the virtual field ${current.gqltype.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
42
|
+
}
|
|
43
|
+
step = graphqlStep(name, field);
|
|
44
|
+
const type = getNamedType(field.type);
|
|
45
|
+
step.scalar = isEnumType(type) ? 'Enum' : effectiveScalarName(type);
|
|
46
|
+
step.enumName = isEnumType(type) ? type.name : undefined;
|
|
47
|
+
step.temporalName = ['Date', 'Time', 'DateTime'].includes(step.scalar) ? step.scalar : undefined;
|
|
48
|
+
step.version = step.scalar === 'RecordVersion' || step.scalar === 'Version';
|
|
49
|
+
raw.push(field);
|
|
50
|
+
} else {
|
|
51
|
+
const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
|
|
52
|
+
const field = fields?.find((item) => item.name === name && !item.private);
|
|
53
|
+
if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
54
|
+
step = { ...field, ...modelStep(field) };
|
|
55
|
+
if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
|
|
56
|
+
raw.push(field);
|
|
57
|
+
}
|
|
58
|
+
onStep?.(step, current);
|
|
59
|
+
steps.push(step);
|
|
60
|
+
if (index < parts.length - 1 && !step.node) fail(`Field ${name} cannot be traversed`, 'INVALID_FILTER_PATH');
|
|
61
|
+
current = childContext(current, step);
|
|
62
|
+
}
|
|
63
|
+
return { steps, raw, end: current };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Bind the endpoint while retaining its actual structural containers. */
|
|
67
|
+
export const enterQueryFrame = (context, path) => {
|
|
68
|
+
const { end } = resolveQueryContext(path, context);
|
|
69
|
+
return { ...end, frameParent: context, binding: { depth: bindingOf(context).depth + 1, path: [] } };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Resolve public comparison paths from their filter carrier, before normalization. */
|
|
73
|
+
export const resolveComparisonContext = (valuePath, context, prefix = '') => {
|
|
74
|
+
const parts = partsOf(valuePath);
|
|
75
|
+
if (parts[0] !== '$parent') {
|
|
76
|
+
return { resolved: resolveQueryContext([...(prefix ? partsOf(prefix) : []), ...parts], context), context };
|
|
77
|
+
}
|
|
78
|
+
let current = prefix ? resolveQueryContext(prefix, context).end : context;
|
|
79
|
+
while (parts[0] === '$parent') {
|
|
80
|
+
if (!current.parent) fail('$parent requires an embedded containing object');
|
|
81
|
+
current = current.parent;
|
|
82
|
+
parts.shift();
|
|
83
|
+
}
|
|
84
|
+
const resolved = resolveQueryContext(parts, current);
|
|
85
|
+
const binding = bindingOf(current);
|
|
86
|
+
return { resolved, context: current, path: parts,
|
|
87
|
+
container: { up: bindingOf(context).depth - binding.depth, path: binding.path } };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A structural operand may address only one of this context's bound containers. */
|
|
91
|
+
export const resolveContainerContext = (container, context) => {
|
|
92
|
+
if (!container || typeof container !== 'object' || Array.isArray(container)
|
|
93
|
+
|| Object.keys(container).some((key) => !['up', 'path'].includes(key))
|
|
94
|
+
|| !Number.isSafeInteger(container.up) || container.up < 0 || !Array.isArray(container.path)
|
|
95
|
+
|| container.path.some((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) {
|
|
96
|
+
fail('Invalid embedded comparison container');
|
|
97
|
+
}
|
|
98
|
+
for (let parent = context.parent; parent; parent = parent.parent) {
|
|
99
|
+
const binding = bindingOf(parent);
|
|
100
|
+
if (bindingOf(context).depth - binding.depth === container.up
|
|
101
|
+
&& binding.path.length === container.path.length && binding.path.every((part, index) => part === container.path[index])) return parent;
|
|
102
|
+
}
|
|
103
|
+
fail('Comparison container is not an embedded ancestor');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Recover a collector entry's bound context without inferring parents from dotted strings. */
|
|
107
|
+
export const queryEntryContext = (entry, context) => {
|
|
108
|
+
let current = context;
|
|
109
|
+
for (const frame of entry.frames) {
|
|
110
|
+
const anchor = frame.construct.__implicit === true && Array.isArray(frame.construct.__anchor) ? frame.construct.__anchor : [];
|
|
111
|
+
current = enterQueryFrame(current, [...anchor, ...partsOf(frame.path)]);
|
|
112
|
+
}
|
|
113
|
+
return current;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Distinct marker namespace for a public parent operand before its binding is normalized. */
|
|
117
|
+
export const parentScopePrefix = (valuePath) => typeof valuePath === 'string' ? valuePath.match(/^(?:\$parent\.)+/)?.[0] ?? '' : '';
|
package/src/query-frames.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { getNamedType,
|
|
1
|
+
import { getNamedType, isObjectType } from 'graphql';
|
|
2
2
|
import { GROUP_KEYS, QUANTIFIED_LISTS, QUANTIFIERS } from './const/QLQuantifier.js';
|
|
3
3
|
import SimfinityError from './errors/simfinity.error.js';
|
|
4
|
-
import { effectiveScalarName } from './metadata.js';
|
|
5
|
-
import { isVirtualField } from './relation-storage.js';
|
|
6
4
|
import {
|
|
7
|
-
describeEmptiness, describeModelEmptiness, emptinessScopePaths,
|
|
5
|
+
describeEmptiness, describeModelEmptiness, emptinessScopePaths, isEmptinessOperator,
|
|
8
6
|
} from './query-emptiness.js';
|
|
9
7
|
import {
|
|
10
8
|
assertContainsAllPath, isQueryOperator, takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue,
|
|
@@ -12,6 +10,7 @@ import {
|
|
|
12
10
|
} from './query-operators.js';
|
|
13
11
|
import { collectQueryOperators, validateQueryGroupKeys } from './query-plan.js';
|
|
14
12
|
import { getQueryMaxFrameNesting } from './query-limits.js';
|
|
13
|
+
import { enterQueryFrame, parentScopePrefix, resolveComparisonContext, resolveContainerContext, resolveQueryContext } from './query-context.js';
|
|
15
14
|
|
|
16
15
|
export { GROUP_KEYS };
|
|
17
16
|
export const IMPLICIT_KEY = '__implicit';
|
|
@@ -28,79 +27,34 @@ const controls = (operation) => new Set(['AND', 'OR', 'sort', 'pagination', ...(
|
|
|
28
27
|
const hasMarker = (value) => object(value) && Object.hasOwn(value, '__scope');
|
|
29
28
|
const nonemptyPath = (path) => typeof path === 'string' && path.length > 0;
|
|
30
29
|
const partsOf = (path) => (Array.isArray(path) ? path : typeof path === 'string' ? path.split('.') : []);
|
|
31
|
-
const
|
|
32
|
-
? { ...ctx, gqltype: step.node }
|
|
33
|
-
: { ...ctx, entity: step.target ?? ctx.entity, fields: step.node });
|
|
30
|
+
const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) => resolveQueryContext(path, ctx, { onStep, virtualLabel, ordinaryCondition });
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
// Model contexts retain embedded fields locally; relation steps switch to the target entity.
|
|
37
|
-
const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) => {
|
|
38
|
-
const parts = partsOf(path);
|
|
39
|
-
const invalid = parts.findIndex((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part));
|
|
40
|
-
if (!parts.length || invalid !== -1) {
|
|
41
|
-
if (ordinaryCondition && invalid > 0) fail(`Invalid filter path segment: "${parts[invalid]}"`, 'INVALID_FILTER_PATH');
|
|
42
|
-
fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
43
|
-
}
|
|
44
|
-
let current = ctx;
|
|
45
|
-
const steps = [];
|
|
46
|
-
const raw = [];
|
|
47
|
-
for (const [index, name] of parts.entries()) {
|
|
48
|
-
let step;
|
|
49
|
-
if (ctx.gqltype) {
|
|
50
|
-
const fields = isObjectType(current.gqltype) ? current.gqltype.getFields() : null;
|
|
51
|
-
const field = fields && Object.hasOwn(fields, name) ? fields[name] : null;
|
|
52
|
-
if (!field) fail(ordinaryCondition && index === 0 ? `Unknown filter field: ${name}`
|
|
53
|
-
: `Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
54
|
-
if (isVirtualField(field)) {
|
|
55
|
-
const label = virtualLabel?.(field) ?? parts.join('.');
|
|
56
|
-
fail(`Query path ${label} uses the virtual field ${current.gqltype.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
57
|
-
}
|
|
58
|
-
step = graphqlStep(name, field);
|
|
59
|
-
const type = getNamedType(field.type);
|
|
60
|
-
step.scalar = isEnumType(type) ? 'Enum' : effectiveScalarName(type);
|
|
61
|
-
step.enumName = isEnumType(type) ? type.name : undefined;
|
|
62
|
-
step.temporalName = ['Date', 'Time', 'DateTime'].includes(step.scalar) ? step.scalar : undefined;
|
|
63
|
-
step.version = step.scalar === 'RecordVersion' || step.scalar === 'Version';
|
|
64
|
-
raw.push(field);
|
|
65
|
-
} else {
|
|
66
|
-
const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
|
|
67
|
-
const field = fields?.find((item) => item.name === name && !item.private);
|
|
68
|
-
if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
69
|
-
step = { ...field, ...modelStep(field) };
|
|
70
|
-
if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
|
|
71
|
-
raw.push(field);
|
|
72
|
-
}
|
|
73
|
-
onStep?.(step, current);
|
|
74
|
-
steps.push(step);
|
|
75
|
-
if (index < parts.length - 1 && !step.node) fail(`Field ${name} cannot be traversed`, 'INVALID_FILTER_PATH');
|
|
76
|
-
current = childContext(current, step);
|
|
77
|
-
}
|
|
78
|
-
return { steps, raw, end: current };
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const comparisonDescriptor = (resolved) => {
|
|
32
|
+
const comparisonDescriptor = (resolved, context) => {
|
|
82
33
|
const last = resolved.steps.at(-1);
|
|
83
34
|
const previous = resolved.steps.at(-2);
|
|
84
35
|
return { ...last, list: last.kind === 'scalarList', identity: ['id', '_id'].includes(last.name)
|
|
85
|
-
&& (
|
|
36
|
+
&& (previous ? relation(previous) : !context.parent), version: last.version || ['Version', 'RecordVersion'].includes(last.scalar) };
|
|
86
37
|
};
|
|
87
38
|
const operandContext = (entry, ctx, up) => inFrames({ frames: entry.frames.slice(0, entry.frames.length - up) }, ctx);
|
|
88
|
-
const validateInternalOperand = (entry) => {
|
|
39
|
+
const validateInternalOperand = (entry, current) => {
|
|
89
40
|
const operand = entry.operand;
|
|
90
41
|
if (operand === undefined) return;
|
|
91
|
-
|
|
92
|
-
|
|
42
|
+
const structural = operand?.container !== undefined;
|
|
43
|
+
if (!plain(operand) || !Number.isSafeInteger(operand.up)
|
|
44
|
+
|| (structural ? operand.up !== 0 : operand.up <= 0 || operand.up > entry.frames.length
|
|
45
|
+
|| entry.frames.slice(-operand.up).some((frame) => frame.construct.__implicit !== true))
|
|
93
46
|
|| !Array.isArray(operand.path) || !operand.path.length
|
|
94
47
|
|| operand.path.some((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))
|
|
95
|
-
|| Object.keys(operand).some((key) => !['up', 'path', 'scopes'].includes(key)) || entry.hasValuePath || entry.value !== undefined) {
|
|
48
|
+
|| Object.keys(operand).some((key) => !['up', 'path', 'scopes', 'container'].includes(key)) || entry.hasValuePath || entry.value !== undefined) {
|
|
96
49
|
fail('Invalid compiler-generated comparison operand', 'INVALID_FILTER_PATH');
|
|
97
50
|
}
|
|
51
|
+
if (structural) resolveContainerContext(operand.container, current);
|
|
98
52
|
};
|
|
99
53
|
const inFrames = (entry, ctx) => {
|
|
100
54
|
let current = ctx;
|
|
101
55
|
for (const frame of entry.frames) {
|
|
102
56
|
const anchor = frame.construct.__implicit === true ? list(frame.construct.__anchor) : [];
|
|
103
|
-
current =
|
|
57
|
+
current = enterQueryFrame(current, [...anchor, ...partsOf(frame.path)]);
|
|
104
58
|
}
|
|
105
59
|
return current;
|
|
106
60
|
};
|
|
@@ -135,14 +89,14 @@ export const aggregateJoinPrefixes = (aggregation, ctx) => {
|
|
|
135
89
|
};
|
|
136
90
|
|
|
137
91
|
// The first non-J collection and its optional legacy-join anchor. J is supplied only at the root.
|
|
138
|
-
const cutAt = (resolved, J) => {
|
|
92
|
+
const cutAt = (resolved, J, elements = false) => {
|
|
139
93
|
let joined = false;
|
|
140
94
|
for (const [index, step] of resolved.steps.entries()) {
|
|
141
|
-
if (step.kind !== 'collection') continue;
|
|
95
|
+
if (step.kind !== 'collection' && !(elements && step.kind === 'embeddedList')) continue;
|
|
142
96
|
const prefix = resolved.steps.slice(0, index + 1).map(({ name }) => name);
|
|
143
|
-
if (J.has(prefix.join('.'))) { joined = true; continue; }
|
|
97
|
+
if (step.kind === 'collection' && J.has(prefix.join('.'))) { joined = true; continue; }
|
|
144
98
|
const anchor = joined ? prefix.slice(0, -1) : [];
|
|
145
|
-
return { index, prefix, anchor, path: prefix.slice(anchor.length).join('.') };
|
|
99
|
+
return { index, prefix, anchor, path: prefix.slice(anchor.length).join('.'), element: step.kind === 'embeddedList' };
|
|
146
100
|
}
|
|
147
101
|
return null;
|
|
148
102
|
};
|
|
@@ -170,7 +124,9 @@ const cutCondition = (item, rest, prefix, keys, rightKeys = []) => {
|
|
|
170
124
|
delete operands.field;
|
|
171
125
|
delete operands.path;
|
|
172
126
|
const condition = { field: rest, ...operands };
|
|
173
|
-
if (item.__operand) condition.__operand =
|
|
127
|
+
if (item.__operand) condition.__operand = item.__operand.container
|
|
128
|
+
? { ...item.__operand, container: { ...item.__operand.container, up: item.__operand.container.up + 1 } }
|
|
129
|
+
: { ...item.__operand, up: item.__operand.up + 1 };
|
|
174
130
|
else if (hasValuePath(item)) {
|
|
175
131
|
if (item.valuePath.startsWith(`${prefix}.`)) condition.valuePath = item.valuePath.slice(prefix.length + 1);
|
|
176
132
|
else {
|
|
@@ -193,6 +149,31 @@ const termCondition = (term) => {
|
|
|
193
149
|
return { field: path, ...operands };
|
|
194
150
|
};
|
|
195
151
|
|
|
152
|
+
// Public parent syntax is resolved once at its carrier. Rewrites must retain that binding.
|
|
153
|
+
const normalizeParentOperand = (item, frame, prefix = '') => {
|
|
154
|
+
if (!hasValuePath(item) || !item.valuePath.startsWith('$parent.')) return item;
|
|
155
|
+
const { container, path, resolved } = resolveComparisonContext(item.valuePath, frame, prefix);
|
|
156
|
+
const result = container.up === 0 ? { ...item, valuePath: [...container.path, ...path].join('.') }
|
|
157
|
+
: { ...item, __operand: { up: 0, path, container } };
|
|
158
|
+
if (container.up !== 0) delete result.valuePath;
|
|
159
|
+
if (!hasMarker(item)) return result;
|
|
160
|
+
const scopePrefix = parentScopePrefix(item.valuePath);
|
|
161
|
+
if (plain(item.__scope?.scopes)) {
|
|
162
|
+
const entries = Object.entries(item.__scope.scopes);
|
|
163
|
+
const parentScopes = Object.fromEntries(entries.filter(([key]) => key.startsWith(scopePrefix)).map(([key, value]) => [key.slice(scopePrefix.length), value]));
|
|
164
|
+
const localScopes = Object.fromEntries(entries.filter(([key]) => !key.startsWith(scopePrefix)));
|
|
165
|
+
if (container.up === 0) {
|
|
166
|
+
for (const [key, value] of Object.entries(parentScopes)) localScopes[[...container.path, key].join('.')] = value;
|
|
167
|
+
} else if (Object.keys(parentScopes).length) result.__operand.scopes = parentScopes;
|
|
168
|
+
if (Object.keys(localScopes).length) result.__scope = { ...item.__scope, scopes: localScopes };
|
|
169
|
+
else delete result.__scope;
|
|
170
|
+
} else if (container.up !== 0 && comparisonKeys(resolved).length) {
|
|
171
|
+
// Pending runtime provenance still counts this separately scoped outer read in the budget.
|
|
172
|
+
result.__operand.scopes = undefined;
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
};
|
|
176
|
+
|
|
196
177
|
/** Internal: pure collection-frame rewrite. The same J must be threaded through repeated passes. */
|
|
197
178
|
export const frameCollectionPaths = (args, ctx) => {
|
|
198
179
|
if (ctx.quantifiers !== true) return args;
|
|
@@ -212,42 +193,47 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
212
193
|
positions.set(position, output);
|
|
213
194
|
active.set(input, output);
|
|
214
195
|
const buckets = new Map();
|
|
196
|
+
const bucketKinds = new Map();
|
|
215
197
|
const cut = (condition, path) => {
|
|
216
198
|
const resolved = resolve(path, frame);
|
|
217
199
|
const operator = condition.operator ?? 'EQ';
|
|
218
200
|
if (isEmptinessOperator(operator) || operator === 'CONTAINS_ALL') return null;
|
|
219
|
-
const at = cutAt(resolved, J);
|
|
201
|
+
const at = cutAt(resolved, J, ctx.elementFrames === true && (hasValuePath(condition) || condition.__operand !== undefined));
|
|
220
202
|
if (!at) return null;
|
|
221
203
|
const prefix = at.prefix.join('.');
|
|
222
|
-
|
|
204
|
+
const bucketKey = at.element ? Symbol(prefix) : prefix;
|
|
205
|
+
let bucket = buckets.get(bucketKey);
|
|
223
206
|
if (!bucket) {
|
|
224
207
|
bucket = { path: at.path, conditions: [], __implicit: true };
|
|
225
208
|
if (at.anchor.length) bucket.__anchor = at.anchor;
|
|
226
|
-
buckets.set(
|
|
209
|
+
buckets.set(bucketKey, bucket);
|
|
210
|
+
bucketKinds.set(bucket, at.element && operator === 'NE' ? 'NONE' : 'ANY');
|
|
227
211
|
}
|
|
228
212
|
const rightResolved = hasValuePath(condition) ? resolve(condition.valuePath, frame) : null;
|
|
229
213
|
const rightKeys = rightResolved ? comparisonKeys(rightResolved) : [];
|
|
230
214
|
const keys = hasValuePath(condition) || condition.__operand ? comparisonKeys(resolved) : markerKeys(resolved, J);
|
|
231
|
-
if (hasMarker(condition) && !hasMarker(bucket)
|
|
215
|
+
if (hasMarker(condition) && !hasMarker(bucket)
|
|
216
|
+
&& [...keys, ...rightKeys].some((key) => key === prefix || prefix.startsWith(`${key}.`))) {
|
|
232
217
|
bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, [...keys, ...rightKeys]) };
|
|
233
218
|
}
|
|
234
|
-
bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys, rightKeys));
|
|
219
|
+
bucket.conditions.push(cutCondition(at.element && operator === 'NE' ? { ...condition, operator: 'EQ' } : condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys, rightKeys));
|
|
235
220
|
return bucket;
|
|
236
221
|
};
|
|
237
222
|
if (Array.isArray(input.conditions)) {
|
|
238
|
-
output.conditions = input.conditions.filter((item) => !cut(item, pathless(item.path) ? item.field : `${item.field}.${item.path}`));
|
|
223
|
+
output.conditions = input.conditions.map((item) => normalizeParentOperand(item, frame)).filter((item) => !cut(item, pathless(item.path) ? item.field : `${item.field}.${item.path}`));
|
|
239
224
|
}
|
|
240
225
|
for (const key of ['AND', 'OR']) if (Array.isArray(input[key])) output[key] = input[key].map((group) => rewriteGroup(group, frame, J, position));
|
|
241
226
|
for (const type of QUANTIFIED_LISTS) if (Array.isArray(input[type])) {
|
|
242
227
|
output[type] = input[type].map((group) => {
|
|
243
228
|
const anchor = group.__implicit === true ? list(group.__anchor) : [];
|
|
244
|
-
return rewriteGroup(group,
|
|
229
|
+
return rewriteGroup(group, enterQueryFrame(frame, [...anchor, ...partsOf(group.path)]), new Set(), positionOf(position, group, type, group.path));
|
|
245
230
|
});
|
|
246
231
|
}
|
|
247
232
|
for (const bucket of buckets.values()) {
|
|
248
233
|
const path = [...list(bucket.__anchor), ...partsOf(bucket.path)];
|
|
249
|
-
const
|
|
250
|
-
|
|
234
|
+
const type = bucketKinds.get(bucket);
|
|
235
|
+
const rewritten = rewriteGroup(bucket, enterQueryFrame(frame, path), new Set(), positionOf(position, bucket, type, bucket.path));
|
|
236
|
+
output[type] = [...list(output[type]), rewritten];
|
|
251
237
|
}
|
|
252
238
|
active.delete(input);
|
|
253
239
|
return output;
|
|
@@ -274,19 +260,29 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
274
260
|
const carrier = hasValuePath(value) ? { ...value } : { operator: value.operator ?? 'EQ', value: value.value };
|
|
275
261
|
const group = { conditions: [{ field: name, ...carrier }] };
|
|
276
262
|
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
277
|
-
if (list(rewritten
|
|
278
|
-
syntheticGroups.push({ __implicit: true,
|
|
263
|
+
if (QUANTIFIED_LISTS.some((key) => list(rewritten[key]).length)) {
|
|
264
|
+
syntheticGroups.push({ __implicit: true, ...Object.fromEntries(QUANTIFIED_LISTS.filter((key) => list(rewritten[key]).length).map((key) => [key, rewritten[key]])) });
|
|
279
265
|
delete output[name];
|
|
280
266
|
}
|
|
281
267
|
continue;
|
|
282
268
|
}
|
|
283
269
|
if (!Array.isArray(value.terms) || valueLeaf(leaf, ctx)) continue;
|
|
284
|
-
const group = { conditions: value.terms.map((term) =>
|
|
270
|
+
const group = { conditions: value.terms.map((term) => {
|
|
271
|
+
const parent = hasValuePath(term) && term.valuePath.startsWith('$parent.');
|
|
272
|
+
const carrier = parent ? normalizeParentOperand(term, ctx, name) : term;
|
|
273
|
+
return { ...termCondition(carrier), field: pathless(term.path) ? name : `${name}.${term.path}`,
|
|
274
|
+
...(hasValuePath(carrier) && !parent ? { valuePath: `${name}.${carrier.valuePath}` } : {}) };
|
|
275
|
+
}) };
|
|
285
276
|
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
277
|
+
if (list(rewritten.NONE).length) syntheticGroups.push({ __implicit: true, NONE: rewritten.NONE });
|
|
286
278
|
implicitGroups.push(...list(rewritten.ANY));
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
279
|
+
const parentIndices = new Set(value.terms.flatMap((term, index) => parentScopePrefix(term.valuePath) ? [index] : []));
|
|
280
|
+
const parentConditions = group.conditions.filter((condition, index) => parentIndices.has(index) && rewritten.conditions.includes(condition));
|
|
281
|
+
if (parentConditions.length) syntheticGroups.push({ __implicit: true, conditions: parentConditions });
|
|
282
|
+
if (rewritten.conditions.length === value.terms.length && !parentIndices.size) continue;
|
|
283
|
+
const remaining = value.terms.filter((term, index) => !parentIndices.has(index) && rewritten.conditions.includes(group.conditions[index]));
|
|
284
|
+
if (!remaining.length) delete output[name];
|
|
285
|
+
else output[name] = { ...value, terms: remaining };
|
|
290
286
|
}
|
|
291
287
|
if (implicitGroups.length) syntheticGroups.push({ __implicit: true, ANY: implicitGroups });
|
|
292
288
|
for (const key of ['AND', 'OR']) if (Array.isArray(args[key])) output[key] = args[key].map((group) => rewriteGroup(group, ctx, ctx.J ?? new Set()));
|
|
@@ -441,9 +437,9 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
441
437
|
for (const entry of entries) {
|
|
442
438
|
if (ignoredValueTerm(entry, ctx)) continue;
|
|
443
439
|
if (entry.hasValuePath) validateComparisonOperand(entry.construct);
|
|
444
|
-
validateInternalOperand(entry);
|
|
445
440
|
const comparison = entry.hasValuePath || entry.operand !== undefined;
|
|
446
441
|
const current = inFrames(entry, ctx);
|
|
442
|
+
validateInternalOperand(entry, current);
|
|
447
443
|
let rightResolved;
|
|
448
444
|
let rightCurrent;
|
|
449
445
|
const implicit = entry.form === 'quantified' && entry.construct.__implicit === true;
|
|
@@ -474,7 +470,10 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
474
470
|
}
|
|
475
471
|
// A hand-built implicit group, whatever its operators (#108 part 4): after any anchor, its path ends at a collection,
|
|
476
472
|
// whatever it crosses first, or at a reference without crossing a list, the explicit groups' rule for references.
|
|
477
|
-
if (implicit && last.kind !== 'collection'
|
|
473
|
+
if (implicit && last.kind !== 'collection'
|
|
474
|
+
&& !(ctx.elementFrames === true && last.kind === 'embeddedList' && comparisonInGroup(entry.construct)
|
|
475
|
+
&& !steps.slice(anchor.length, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind)))
|
|
476
|
+
&& (last.kind !== 'reference'
|
|
478
477
|
|| steps.slice(anchor.length, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind)))) {
|
|
479
478
|
fail('An implicit quantified path must end at a collection, or at a reference without crossing a list', 'INVALID_FILTER_PATH');
|
|
480
479
|
}
|
|
@@ -488,26 +487,29 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
488
487
|
if (ctx.operation !== 'aggregate' || entry.frames.length
|
|
489
488
|
|| !crossed.some((step) => step.kind === 'collection')
|
|
490
489
|
|| crossed.some((step, index) => step.kind === 'collection' && !prefixes.has(prefix(index)))
|
|
491
|
-
|| partsOf(entry.construct.path).length !== 1
|
|
490
|
+
|| partsOf(entry.construct.path).length !== 1
|
|
491
|
+
|| (last.kind !== 'collection' && !(ctx.elementFrames === true && last.kind === 'embeddedList' && comparisonInGroup(entry.construct)))) {
|
|
492
492
|
fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
495
|
if (!implicit && !QUANTIFIERS.has(entry.quantifier)) fail('Unknown quantifier', 'INVALID_FILTER_VALUE');
|
|
496
496
|
if (!implicit) gates.push(() => {
|
|
497
|
-
if (ctx.quantifiers !== true || last.kind === 'embeddedList') fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
497
|
+
if (ctx.quantifiers !== true || (last.kind === 'embeddedList' && ctx.elementFrames !== true)) fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
498
498
|
});
|
|
499
499
|
} else {
|
|
500
500
|
if (comparison) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
501
|
+
const publicOperand = entry.operand ? null : resolveComparisonContext(entry.valuePath, current, entry.valuePathPrefix);
|
|
502
|
+
rightCurrent = entry.operand?.container ? resolveContainerContext(entry.operand.container, current)
|
|
503
|
+
: entry.operand ? operandContext(entry, ctx, entry.operand.up) : publicOperand.context;
|
|
504
|
+
const rightPath = entry.operand?.path ?? publicOperand.path ?? partsOf(entry.valuePath);
|
|
505
|
+
rightResolved = entry.operand ? resolve(rightPath, rightCurrent) : publicOperand.resolved;
|
|
504
506
|
if (!['scalar', 'scalarList'].includes(last.kind) || !['scalar', 'scalarList'].includes(rightResolved.steps.at(-1).kind)) {
|
|
505
507
|
fail('Field comparison operands must end in scalar fields', 'INVALID_FILTER_PATH');
|
|
506
508
|
}
|
|
507
509
|
if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
|
|
508
510
|
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
509
|
-
const collections = new Set(steps.flatMap((step, index) => step.kind === 'collection' ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []));
|
|
510
|
-
if (entry.operand) {
|
|
511
|
+
const collections = new Set(steps.flatMap((step, index) => (step.kind === 'collection' || (ctx.elementFrames === true && step.kind === 'embeddedList')) ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []));
|
|
512
|
+
if (entry.operand && !entry.operand.container) {
|
|
511
513
|
const chain = entry.frames.slice(entry.frames.length - entry.operand.up)
|
|
512
514
|
.flatMap((frame) => [...list(frame.construct.__anchor), ...partsOf(frame.path)]);
|
|
513
515
|
const bound = resolve([...chain, ...path], rightCurrent).steps;
|
|
@@ -516,13 +518,13 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
516
518
|
if (ctx.J?.has(key) && entry.operand.up === entry.frames.length) collections.add(key);
|
|
517
519
|
}
|
|
518
520
|
}
|
|
519
|
-
if (rightResolved.steps.some((step, index, right) => step.kind === 'collection'
|
|
521
|
+
if (rightResolved.steps.some((step, index, right) => (step.kind === 'collection' || (ctx.elementFrames === true && step.kind === 'embeddedList'))
|
|
520
522
|
&& !collections.has(right.slice(0, index + 1).map(({ name }) => name).join('.')))) {
|
|
521
523
|
fail('The right operand introduces an unbound collection; use an explicit group', 'INVALID_FILTER_PATH');
|
|
522
524
|
}
|
|
523
|
-
validateComparisonTypes(comparisonDescriptor(resolved), comparisonDescriptor(rightResolved), entry.operator, path.join('.'), rightPath.join('.'));
|
|
525
|
+
validateComparisonTypes(comparisonDescriptor(resolved, current), comparisonDescriptor(rightResolved, rightCurrent), entry.operator, path.join('.'), rightPath.join('.'));
|
|
524
526
|
gates.push(() => {
|
|
525
|
-
if ([...steps, ...rightResolved.steps].some((step) => step.kind === 'embeddedList')) fail('Field comparisons through embedded lists require element frames', 'UNSUPPORTED_QUERY');
|
|
527
|
+
if (ctx.elementFrames !== true && ([...steps, ...rightResolved.steps].some((step) => step.kind === 'embeddedList') || publicOperand?.container || entry.operand?.container)) fail('Field comparisons through embedded lists require element frames', 'UNSUPPORTED_QUERY');
|
|
526
528
|
if (ctx.valuePath !== true || ctx.referenceEmptiness !== true) fail('Field comparisons require valuePath and referenceEmptiness support from this query compiler', 'UNSUPPORTED_QUERY');
|
|
527
529
|
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
528
530
|
if (ctx.quantifiers !== true && steps.some((step, index) => step.kind === 'collection' && !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.')))) {
|
|
@@ -554,7 +556,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
554
556
|
if (!keys.length && scopes !== undefined) detached('Outer operand scope marker reads no reference or collection');
|
|
555
557
|
});
|
|
556
558
|
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
557
|
-
const extra = entry.form === 'quantified' ? 1 : shape ? (emptinessScopePaths(shape, path).frames.length ? 1 : 0) : ctx.quantifiers === true ? steps.filter((step, index) => step.kind === 'collection'
|
|
559
|
+
const extra = entry.form === 'quantified' ? 1 : shape ? (emptinessScopePaths(shape, path).frames.length ? 1 : 0) : ctx.quantifiers === true ? steps.filter((step, index) => (step.kind === 'collection' || (ctx.elementFrames === true && comparison && step.kind === 'embeddedList'))
|
|
558
560
|
&& !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.'))).length : 0;
|
|
559
561
|
gates.push(() => {
|
|
560
562
|
if (entry.frames.length + extra > getQueryMaxFrameNesting()) fail('Frame nesting too deep (maxFrameNesting)', 'FILTER_DEPTH_EXCEEDED');
|
|
@@ -573,7 +575,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
573
575
|
const paths = emptinessScopePaths(shape, path);
|
|
574
576
|
keys = [...paths.joins, ...paths.frames];
|
|
575
577
|
} else keys = markerKeys(resolved, !entry.frames.length && (implicit || entry.form !== 'quantified') ? ctx.J ?? new Set() : new Set());
|
|
576
|
-
if (comparison) keys = [...new Set([...comparisonKeys(resolved), ...(!entry.operand ? comparisonKeys(rightResolved) : [])])];
|
|
578
|
+
if (comparison) keys = [...new Set([...comparisonKeys(resolved), ...(!entry.operand ? comparisonKeys(rightResolved).map((key) => `${parentScopePrefix(entry.valuePath)}${key}`) : [])])];
|
|
577
579
|
const comparisonAnchor = implicit && anchor.length && comparisonInGroup(entry.construct);
|
|
578
580
|
if (comparisonAnchor) keys = comparisonKeys(resolved);
|
|
579
581
|
const scopes = entry.construct.__scope?.scopes;
|
|
@@ -634,9 +636,11 @@ export const frameUnits = (args, ctx) => {
|
|
|
634
636
|
for (const path of crossingKeys(entry.relative, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
635
637
|
if (entry.hasValuePath) for (const path of crossingKeys(entry.valuePath, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
636
638
|
if (entry.operand) {
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
|
|
639
|
+
const container = entry.operand.container;
|
|
640
|
+
const up = container?.up ?? entry.operand.up;
|
|
641
|
+
const outer = operandContext(entry, ctx, up);
|
|
642
|
+
const owner = frameKey({ frames: entry.frames.slice(0, entry.frames.length - up) });
|
|
643
|
+
for (const path of crossingKeys([...(container?.path ?? []), ...entry.operand.path], outer)) units.add(joinUnit(owner, path, Object.hasOwn(entry.operand, 'scopes')));
|
|
640
644
|
}
|
|
641
645
|
}
|
|
642
646
|
}
|
package/src/query-operators.js
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
ANCHOR_KEY, GROUP_KEYS, IMPLICIT_KEY, aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, frameUnits, validateQuantifiedGroups,
|
|
16
16
|
} from './query-frames.js';
|
|
17
17
|
export { hasValuePath, validateComparisonOperand, validateComparisonTypes } from './query-comparisons.js';
|
|
18
|
+
export { enterQueryFrame, resolveContainerContext, resolveQueryContext } from './query-context.js';
|
|
18
19
|
export { effectiveScalarName } from './metadata.js';
|
|
19
20
|
|
|
20
21
|
/** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
|
|
@@ -154,23 +155,23 @@ const invalidFeatures = (message) => {
|
|
|
154
155
|
};
|
|
155
156
|
|
|
156
157
|
/**
|
|
157
|
-
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers, valuePath }`:
|
|
158
|
+
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers, valuePath, elementFrames }`:
|
|
158
159
|
* the operators it compiles and whether it compiles reference emptiness and collection frames.
|
|
159
160
|
* Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
|
|
160
161
|
* and neither feature. A declaration is an object whose `operators`
|
|
161
162
|
* is an array of operator names, whose names this version does not know are ignored, and whose
|
|
162
|
-
* optional `referenceEmptiness`, `quantifiers` and `
|
|
163
|
+
* optional `referenceEmptiness`, `quantifiers`, `valuePath` and `elementFrames` are booleans, undefined or null (false). A name
|
|
163
164
|
* counts once this version knows it: a declaration that listed `CONTAINS_ALL` or `IIN` before
|
|
164
165
|
* #108 part 4 added them receives them on upgrade.
|
|
165
166
|
* Quantifiers and valuePath require reference emptiness for scoped joins. Other members are left for later
|
|
166
|
-
* features. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
167
|
+
* features. Element frames require all three other flags. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
167
168
|
*/
|
|
168
169
|
export const resolveQueryFeatures = (queryFeatures) => {
|
|
169
|
-
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false, valuePath: false };
|
|
170
|
+
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false, valuePath: false, elementFrames: false };
|
|
170
171
|
if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
|
|
171
172
|
invalidFeatures('must be an object with an operators array');
|
|
172
173
|
}
|
|
173
|
-
const { operators, referenceEmptiness, quantifiers, valuePath } = queryFeatures;
|
|
174
|
+
const { operators, referenceEmptiness, quantifiers, valuePath, elementFrames } = queryFeatures;
|
|
174
175
|
if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
|
|
175
176
|
invalidFeatures('operators must be an array of operator names');
|
|
176
177
|
}
|
|
@@ -185,10 +186,15 @@ export const resolveQueryFeatures = (queryFeatures) => {
|
|
|
185
186
|
}
|
|
186
187
|
if (valuePath != null && typeof valuePath !== 'boolean') invalidFeatures('valuePath must be a boolean');
|
|
187
188
|
if (valuePath === true && referenceEmptiness !== true) invalidFeatures('valuePath requires referenceEmptiness: true');
|
|
189
|
+
if (elementFrames != null && typeof elementFrames !== 'boolean') invalidFeatures('elementFrames must be a boolean');
|
|
190
|
+
if (elementFrames === true && (referenceEmptiness !== true || quantifiers !== true || valuePath !== true)) {
|
|
191
|
+
invalidFeatures('elementFrames requires quantifiers, referenceEmptiness and valuePath: true');
|
|
192
|
+
}
|
|
188
193
|
return {
|
|
189
194
|
operators: new Set(operators.filter(isQueryOperator)),
|
|
190
195
|
referenceEmptiness: referenceEmptiness === true,
|
|
191
196
|
quantifiers: quantifiers === true,
|
|
192
197
|
valuePath: valuePath === true,
|
|
198
|
+
elementFrames: elementFrames === true,
|
|
193
199
|
};
|
|
194
200
|
};
|
package/src/query-plan.js
CHANGED
|
@@ -2,6 +2,7 @@ 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
8
|
hasValuePath, takesListValue, textQueryOperator, validateComparisonOperand, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
|
|
@@ -97,17 +98,22 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
97
98
|
}
|
|
98
99
|
};
|
|
99
100
|
const operandPath = (item, prefix = '') => hasValuePath(item)
|
|
100
|
-
? (prefix ? `${prefix}.${item.valuePath}` : item.valuePath) : undefined;
|
|
101
|
+
? (prefix && !(typeof item.valuePath === 'string' && item.valuePath.startsWith('$parent.')) ? `${prefix}.${item.valuePath}` : item.valuePath) : undefined;
|
|
101
102
|
const addPredicatePaths = (path, item, frame, container, group = null, prefix = '') => {
|
|
102
103
|
const comparison = hasValuePath(item) || item?.__operand != null;
|
|
103
104
|
addPath(path, frame, container, group, item?.operator ?? 'EQ', comparison ? item : item ?? null,
|
|
104
105
|
comparison ? { role: 'left', comparison: true } : {});
|
|
105
106
|
if (!comparison) return;
|
|
106
107
|
let origin = frame;
|
|
107
|
-
|
|
108
|
-
|
|
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('.');
|
|
109
114
|
}
|
|
110
|
-
|
|
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('.');
|
|
111
117
|
addPath(right, origin, container, group, item.operator ?? 'EQ', item, { role: 'right', comparison: true });
|
|
112
118
|
};
|
|
113
119
|
const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier, prefix = '') => {
|
|
@@ -126,7 +132,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
126
132
|
rawPath,
|
|
127
133
|
value: item.value,
|
|
128
134
|
hasValue: Object.hasOwn(item, 'value'),
|
|
129
|
-
...(hasValuePath(item) ? { hasValuePath: true, valuePath: operandPath(item, prefix) } : {}),
|
|
135
|
+
...(hasValuePath(item) ? { hasValuePath: true, valuePath: operandPath(item, prefix), ...(prefix && typeof item.valuePath === 'string' && item.valuePath.startsWith('$parent.') ? { valuePathPrefix: prefix } : {}) } : {}),
|
|
130
136
|
...(Object.hasOwn(item, '__operand') ? { operand: item.__operand } : {}),
|
|
131
137
|
ambiguous: form === 'condition' && typeof field === 'string' && field.includes('.') && !isPathless(rawPath),
|
|
132
138
|
depth,
|
|
@@ -315,9 +321,11 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
315
321
|
* paths on the predicate; generated outer operands own their joins in their ancestor frame.
|
|
316
322
|
*/
|
|
317
323
|
export const createQueryPlan = (models, entityName, source = {}, {
|
|
318
|
-
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null, valuePath = supported == null,
|
|
324
|
+
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null, valuePath = supported == null, elementFrames = supported == null,
|
|
319
325
|
} = {}) => {
|
|
320
326
|
let input = source;
|
|
327
|
+
const rootContext = { models, entity: entityName };
|
|
328
|
+
const modelSteps = (context, path) => resolveQueryContext(path, context).raw;
|
|
321
329
|
const allowed = supported == null ? null : new Set(supported);
|
|
322
330
|
const references = referenceEmptiness === true;
|
|
323
331
|
const combine = (kind, terms) => {
|
|
@@ -343,19 +351,19 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
343
351
|
if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
|
|
344
352
|
lifted[path] = scopes[path] === null
|
|
345
353
|
? null
|
|
346
|
-
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers, valuePath }).where;
|
|
354
|
+
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers, valuePath, elementFrames }).where;
|
|
347
355
|
}
|
|
348
356
|
return lifted;
|
|
349
357
|
};
|
|
350
358
|
const hasMarker = (construct) => construct != null && typeof construct === 'object' && Object.hasOwn(construct, '__scope');
|
|
351
359
|
// Rule 1 has resolved the path; rule 2 is the compiler's operators, rules 3 to 6 describeEmptiness's.
|
|
352
|
-
const emptiness = (path, steps, operator, value, construct,
|
|
360
|
+
const emptiness = (path, steps, operator, value, construct, frameContext) => {
|
|
353
361
|
if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
354
362
|
const segments = Array.isArray(path) ? path : path.split('.');
|
|
355
363
|
const options = {
|
|
356
364
|
operator, value, path: segments.join('.'), referenceEmptiness: references,
|
|
357
365
|
};
|
|
358
|
-
const gqltype = models.entities.find((item) => item.name ===
|
|
366
|
+
const gqltype = frameContext.fields ? null : models.entities.find((item) => item.name === frameContext.entity)?.gqltype;
|
|
359
367
|
const shape = gqltype ? describeEmptiness(gqltype, segments, options) : describeModelEmptiness(steps, options);
|
|
360
368
|
const node = {
|
|
361
369
|
kind: 'predicate', path: segments, operator, value: null,
|
|
@@ -365,10 +373,10 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
365
373
|
};
|
|
366
374
|
// `construct` is the term or group condition that holds the predicate, the only objects whose
|
|
367
375
|
// `__scope` is read; null for a field filter's own operator.
|
|
368
|
-
const predicate = (path, operator, value, construct = null,
|
|
369
|
-
const steps =
|
|
376
|
+
const predicate = (path, operator, value, construct = null, frameContext = rootContext, inFrame = false, ancestors = [], prefix = '') => {
|
|
377
|
+
const steps = modelSteps(frameContext, path);
|
|
370
378
|
const op = operator ?? 'EQ';
|
|
371
|
-
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct,
|
|
379
|
+
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameContext);
|
|
372
380
|
const field = steps.at(-1);
|
|
373
381
|
if (field.kind !== 'scalar' && !field.inferred) {
|
|
374
382
|
fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
@@ -378,24 +386,28 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
378
386
|
const comparison = hasValuePath(construct) || construct?.__operand !== undefined;
|
|
379
387
|
if (comparison) {
|
|
380
388
|
const internal = construct.__operand;
|
|
381
|
-
const
|
|
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('.')]);
|
|
382
394
|
const up = internal?.up ?? 0;
|
|
383
|
-
const
|
|
384
|
-
if (!
|
|
385
|
-
|
|
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);
|
|
386
398
|
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,
|
|
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]));
|
|
389
401
|
// J collections retain their existing aggregate row. Validate their provenance, but only
|
|
390
402
|
// references become scoped joins; an empty collection scope must not lose the marker.
|
|
391
|
-
const anchors = (paths, entity) => [...new Set(paths.flatMap((parts) =>
|
|
403
|
+
const anchors = (paths, entity) => [...new Set(paths.flatMap((parts) => modelSteps(entity, parts)
|
|
392
404
|
.flatMap((step, index) => step.kind === 'collection' ? [parts.slice(0, index + 1).join('.')] : [])))];
|
|
393
405
|
if (hasMarker(construct)) {
|
|
394
|
-
const paths = up ? [segments] : [segments, rightPath];
|
|
395
|
-
node.joins = liftScopes(construct.__scope, scopeTargets(paths,
|
|
406
|
+
const paths = up || container ? [segments] : [segments, rightPath];
|
|
407
|
+
node.joins = liftScopes(construct.__scope, scopeTargets(paths, frameContext), segments, op, anchors(paths, frameContext));
|
|
396
408
|
}
|
|
397
409
|
if (internal && Object.hasOwn(internal, 'scopes')) {
|
|
398
|
-
node.operand.joins = liftScopes({ scopes: internal.scopes }, scopeTargets([rightPath],
|
|
410
|
+
node.operand.joins = liftScopes({ scopes: internal.scopes }, scopeTargets([rightPath], rightContext), rightPath, op, anchors([rightPath], rightContext));
|
|
399
411
|
}
|
|
400
412
|
return node;
|
|
401
413
|
}
|
|
@@ -410,7 +422,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
410
422
|
const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
411
423
|
if (hasMarker(construct)) {
|
|
412
424
|
if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);
|
|
413
|
-
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]));
|
|
414
426
|
node.joins = liftScopes(construct.__scope, targets, node.path, op);
|
|
415
427
|
}
|
|
416
428
|
return node;
|
|
@@ -421,7 +433,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
421
433
|
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
|
|
422
434
|
for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
|
|
423
435
|
};
|
|
424
|
-
const group = (value, depth = -1,
|
|
436
|
+
const group = (value, depth = -1, frameContext = rootContext, inFrame = false, ancestors = []) => {
|
|
425
437
|
validateGroup(value);
|
|
426
438
|
const level = depth + (value.__implicit === true ? 0 : 1);
|
|
427
439
|
if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
|
|
@@ -429,7 +441,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
429
441
|
for (const condition of value.conditions || []) {
|
|
430
442
|
if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
431
443
|
if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
432
|
-
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));
|
|
433
445
|
}
|
|
434
446
|
if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
|
|
435
447
|
fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
@@ -437,17 +449,20 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
437
449
|
for (const quantifier of QUANTIFIED_LISTS) for (const item of value[quantifier] || []) {
|
|
438
450
|
const anchor = item.__implicit === true ? item.__anchor : undefined;
|
|
439
451
|
const path = item.path.split('.');
|
|
440
|
-
const
|
|
441
|
-
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 };
|
|
442
456
|
if (anchor?.length) node.anchor = anchor;
|
|
443
457
|
if (hasMarker(item)) {
|
|
444
458
|
const fullPath = [...(anchor ?? []), ...path];
|
|
445
459
|
const key = fullPath.join('.');
|
|
446
|
-
const crossings = anchor?.length && !hasComparisonNode(node.where) ? [] : crossingKeys(fullPath,
|
|
447
|
-
const targets = Object.fromEntries(crossings.map((prefix) => [prefix,
|
|
448
|
-
targets[key] = target;
|
|
449
|
-
const
|
|
450
|
-
|
|
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;
|
|
451
466
|
if (anchor?.length) {
|
|
452
467
|
for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
|
|
453
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}`);
|
|
@@ -457,13 +472,13 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
457
472
|
}
|
|
458
473
|
terms.push(node);
|
|
459
474
|
}
|
|
460
|
-
for (const item of value.AND || []) terms.push(group(item, level,
|
|
461
|
-
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))));
|
|
462
477
|
return combine('and', terms);
|
|
463
478
|
};
|
|
464
479
|
// Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
|
|
465
480
|
validateGroup(input, ['AND', 'OR']);
|
|
466
|
-
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers, valuePath };
|
|
481
|
+
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers, valuePath, elementFrames };
|
|
467
482
|
const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
|
|
468
483
|
context.J = J;
|
|
469
484
|
validateQuantifiedGroups(input, context);
|
|
@@ -490,7 +505,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
490
505
|
continue;
|
|
491
506
|
}
|
|
492
507
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
493
|
-
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));
|
|
494
509
|
}
|
|
495
510
|
}
|
|
496
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';
|
|
@@ -221,7 +222,7 @@ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
|
|
|
221
222
|
return prefixed;
|
|
222
223
|
};
|
|
223
224
|
|
|
224
|
-
const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
225
|
+
const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup, gqltype) => {
|
|
225
226
|
const conditions = [];
|
|
226
227
|
const groups = [];
|
|
227
228
|
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
@@ -244,8 +245,10 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
|
244
245
|
// An emptiness term without a path (omitted, null or "") tests the field itself; other
|
|
245
246
|
// operators keep the path as written.
|
|
246
247
|
const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
|
|
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}`;
|
|
247
250
|
conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value,
|
|
248
|
-
...(hasValuePath(term) ? { valuePath: `${prefix}.${
|
|
251
|
+
...(hasValuePath(term) ? { valuePath: `${prefix}.${rightPath}` } : {}),
|
|
249
252
|
});
|
|
250
253
|
}
|
|
251
254
|
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value,
|
|
@@ -604,7 +607,7 @@ export const createRuntime = (adapter) => {
|
|
|
604
607
|
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
605
608
|
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
606
609
|
const {
|
|
607
|
-
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers, valuePath: supportsValuePath,
|
|
610
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers, valuePath: supportsValuePath, elementFrames: supportsElementFrames,
|
|
608
611
|
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
609
612
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
610
613
|
// The write features the adapter implements, read once: with optimisticConcurrency, schemas may
|
|
@@ -2349,7 +2352,7 @@ let virtualFieldsDeclared = false;
|
|
|
2349
2352
|
// (500), the code of model declarations that cannot be honored at request time.
|
|
2350
2353
|
const assertStoredQueryPaths = (gqltype, args, operation) => {
|
|
2351
2354
|
if (!virtualFieldsDeclared) return;
|
|
2352
|
-
for (const { segments } of collectQueryPathEntries(args, operation)) {
|
|
2355
|
+
for (const { segments } of collectQueryPathEntries(args, operation, { gqltype })) {
|
|
2353
2356
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2354
2357
|
if (!isVirtualField(field)) return;
|
|
2355
2358
|
throw new SimfinityError(`Query path ${segments.slice(0, index + 1).join('.')}, added by middleware or a `
|
|
@@ -2408,7 +2411,7 @@ const assertEmptinessForm = (gqltype, {
|
|
|
2408
2411
|
// carriers, which do not become predicates but must not use known undeclared capabilities.
|
|
2409
2412
|
const queryFrameContext = (gqltype, args, operation) => ({
|
|
2410
2413
|
gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
|
|
2411
|
-
operators: supportedOperators, valuePath: supportsValuePath,
|
|
2414
|
+
operators: supportedOperators, valuePath: supportsValuePath, elementFrames: supportsElementFrames,
|
|
2412
2415
|
J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
|
|
2413
2416
|
scopeMarkers: 'pending',
|
|
2414
2417
|
});
|
|
@@ -2492,7 +2495,7 @@ const assertJoinedScopePaths = (rootType, groups, prefix, target) => {
|
|
|
2492
2495
|
embeddedPrefix ||= !!(field.extensions?.relation?.embedded && getListShape(field.type));
|
|
2493
2496
|
});
|
|
2494
2497
|
const depth = prefix.split('.').length;
|
|
2495
|
-
for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups })) {
|
|
2498
|
+
for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups }, 'find', { gqltype: rootType })) {
|
|
2496
2499
|
let inList = false;
|
|
2497
2500
|
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field, index }) => {
|
|
2498
2501
|
const relation = field.extensions?.relation;
|
|
@@ -2549,7 +2552,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
2549
2552
|
const kept = scopeFilterArgs(scopeArgs);
|
|
2550
2553
|
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2551
2554
|
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2552
|
-
const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup);
|
|
2555
|
+
const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup, target.gqltype);
|
|
2553
2556
|
if (!groups.length) continue;
|
|
2554
2557
|
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
2555
2558
|
assertJoinedScopePaths(params.type.gqltype, groups, prefix, target);
|
|
@@ -2623,7 +2626,7 @@ const markClientConstructs = (checked, { aggregation, issued = new Map() } = {})
|
|
|
2623
2626
|
for (const { entry, shape } of conditions) {
|
|
2624
2627
|
const { construct } = entry;
|
|
2625
2628
|
if (!construct || !(shape || entry.hasValuePath || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2626
|
-
const comparisonPath = entry.hasValuePath ?
|
|
2629
|
+
const comparisonPath = entry.hasValuePath ? resolveComparisonContext(entry.valuePath, queryEntryContext(entry, { gqltype }), entry.valuePathPrefix).resolved.end.absolute : [];
|
|
2627
2630
|
const targets = [...pathRelations(gqltype, entry.segments), ...pathRelations(gqltype, comparisonPath)].map(({ typeName }) => typeName);
|
|
2628
2631
|
if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
|
|
2629
2632
|
if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
|
|
@@ -2667,7 +2670,7 @@ const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
|
|
|
2667
2670
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
|
|
2668
2671
|
}
|
|
2669
2672
|
if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
|
|
2670
|
-
const operandPaths = [entry.segments, ...(entry.hasValuePath ? [
|
|
2673
|
+
const operandPaths = [entry.segments, ...(entry.hasValuePath ? [resolveComparisonContext(entry.valuePath, queryEntryContext(entry, { gqltype }), entry.valuePathPrefix).resolved.end.absolute] : [])];
|
|
2671
2674
|
for (const relation of operandPaths.flatMap((parts) => pathRelations(gqltype, parts))) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
|
|
2672
2675
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
|
|
2673
2676
|
}
|
|
@@ -2747,7 +2750,11 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2747
2750
|
const relations = pathRelations(type, entry.relative);
|
|
2748
2751
|
if (entry.hasValuePath) {
|
|
2749
2752
|
const existing = new Set(relations.map(({ path }) => path));
|
|
2750
|
-
|
|
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)));
|
|
2751
2758
|
}
|
|
2752
2759
|
if (!shape && !entry.hasValuePath && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
|
|
2753
2760
|
const relation = relationOnPath(gqltype, entry.segments);
|
|
@@ -2767,7 +2774,8 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2767
2774
|
targets[key] = target;
|
|
2768
2775
|
}
|
|
2769
2776
|
if (!Object.keys(keys).length) unread.push(entry.path);
|
|
2770
|
-
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)) {
|
|
2771
2779
|
vectors.push({ ...relation, absolute: [...entry.segments.slice(0, -entry.relative.length), ...relation.path.split('.')].join('.') });
|
|
2772
2780
|
}
|
|
2773
2781
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -164,6 +164,8 @@ export interface QueryFeatures {
|
|
|
164
164
|
quantifiers?: boolean | null | undefined;
|
|
165
165
|
/** Field operands, including scoped references. Requires referenceEmptiness: true. */
|
|
166
166
|
valuePath?: boolean | null | undefined;
|
|
167
|
+
/** Embedded element frames and structural parent operands. Requires all three preceding flags. */
|
|
168
|
+
elementFrames?: boolean | null | undefined;
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
export interface ModelRegistration {
|
|
@@ -910,7 +912,15 @@ export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapt
|
|
|
910
912
|
export type QueryJoins = Record<string, QueryPredicate | null>;
|
|
911
913
|
|
|
912
914
|
/** A field operand in the same frame (up: 0) or an enclosing shorthand frame. */
|
|
913
|
-
export type QueryOperand = {
|
|
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
|
+
};
|
|
914
924
|
|
|
915
925
|
/**
|
|
916
926
|
* `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. For
|
|
@@ -994,9 +1004,9 @@ export interface QueryPlan {
|
|
|
994
1004
|
* `valuePath` selects a compatible stored scalar operand instead of a literal value. It defaults
|
|
995
1005
|
* like `quantifiers`; a comparison node has `value: null` and `operand: { up, path, joins? }`.
|
|
996
1006
|
* Same-frame operands use the union of scoped paths in `joins`; internal shorthand outer bindings
|
|
997
|
-
* use positive `up` and their own operand joins.
|
|
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.
|
|
998
1008
|
* Null equality is two-valued, and a left scalar list uses null-safe EQ/NE membership.
|
|
999
|
-
* Embedded-list comparisons
|
|
1009
|
+
* Embedded-list comparisons require elementFrames; right scalar lists and incompatible logical types fail explicitly.
|
|
1000
1010
|
*
|
|
1001
1011
|
* `referenceEmptiness` defaults to true when `operators` is omitted and to false when it is given.
|
|
1002
1012
|
* When it is false, an emptiness condition that crosses or ends at a reference or a collection, or
|
|
@@ -1018,7 +1028,7 @@ export interface QueryPlan {
|
|
|
1018
1028
|
* checked before the path rule, the rest after it.
|
|
1019
1029
|
* A marker not filled for every path fails with `DETACHED_CLIENT_FILTER` (500).
|
|
1020
1030
|
*/
|
|
1021
|
-
export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: boolean; valuePath?: 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;
|
|
1022
1032
|
export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
|
|
1023
1033
|
|
|
1024
1034
|
/**
|