@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-frames.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
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,
|
|
11
|
-
validateTextListFilterValue,
|
|
9
|
+
validateTextListFilterValue, hasValuePath, validateComparisonOperand, validateComparisonTypes,
|
|
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,57 +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
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
step.scalar = effectiveScalarName(getNamedType(field.type));
|
|
60
|
-
raw.push(field);
|
|
61
|
-
} else {
|
|
62
|
-
const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
|
|
63
|
-
const field = fields?.find((item) => item.name === name && !item.private);
|
|
64
|
-
if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
65
|
-
step = { ...modelStep(field), scalar: field.scalar };
|
|
66
|
-
if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
|
|
67
|
-
raw.push(field);
|
|
68
|
-
}
|
|
69
|
-
onStep?.(step, current);
|
|
70
|
-
steps.push(step);
|
|
71
|
-
if (index < parts.length - 1 && !step.node) fail(`Field ${name} cannot be traversed`, 'INVALID_FILTER_PATH');
|
|
72
|
-
current = childContext(current, step);
|
|
32
|
+
const comparisonDescriptor = (resolved, context) => {
|
|
33
|
+
const last = resolved.steps.at(-1);
|
|
34
|
+
const previous = resolved.steps.at(-2);
|
|
35
|
+
return { ...last, list: last.kind === 'scalarList', identity: ['id', '_id'].includes(last.name)
|
|
36
|
+
&& (previous ? relation(previous) : !context.parent), version: last.version || ['Version', 'RecordVersion'].includes(last.scalar) };
|
|
37
|
+
};
|
|
38
|
+
const operandContext = (entry, ctx, up) => inFrames({ frames: entry.frames.slice(0, entry.frames.length - up) }, ctx);
|
|
39
|
+
const validateInternalOperand = (entry, current) => {
|
|
40
|
+
const operand = entry.operand;
|
|
41
|
+
if (operand === undefined) return;
|
|
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))
|
|
46
|
+
|| !Array.isArray(operand.path) || !operand.path.length
|
|
47
|
+
|| operand.path.some((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))
|
|
48
|
+
|| Object.keys(operand).some((key) => !['up', 'path', 'scopes', 'container'].includes(key)) || entry.hasValuePath || entry.value !== undefined) {
|
|
49
|
+
fail('Invalid compiler-generated comparison operand', 'INVALID_FILTER_PATH');
|
|
73
50
|
}
|
|
74
|
-
|
|
51
|
+
if (structural) resolveContainerContext(operand.container, current);
|
|
75
52
|
};
|
|
76
|
-
|
|
77
53
|
const inFrames = (entry, ctx) => {
|
|
78
54
|
let current = ctx;
|
|
79
55
|
for (const frame of entry.frames) {
|
|
80
56
|
const anchor = frame.construct.__implicit === true ? list(frame.construct.__anchor) : [];
|
|
81
|
-
current =
|
|
57
|
+
current = enterQueryFrame(current, [...anchor, ...partsOf(frame.path)]);
|
|
82
58
|
}
|
|
83
59
|
return current;
|
|
84
60
|
};
|
|
@@ -113,17 +89,24 @@ export const aggregateJoinPrefixes = (aggregation, ctx) => {
|
|
|
113
89
|
};
|
|
114
90
|
|
|
115
91
|
// The first non-J collection and its optional legacy-join anchor. J is supplied only at the root.
|
|
116
|
-
const cutAt = (resolved, J) => {
|
|
92
|
+
const cutAt = (resolved, J, elements = false) => {
|
|
117
93
|
let joined = false;
|
|
118
94
|
for (const [index, step] of resolved.steps.entries()) {
|
|
119
|
-
if (step.kind !== 'collection') continue;
|
|
95
|
+
if (step.kind !== 'collection' && !(elements && step.kind === 'embeddedList')) continue;
|
|
120
96
|
const prefix = resolved.steps.slice(0, index + 1).map(({ name }) => name);
|
|
121
|
-
if (J.has(prefix.join('.'))) { joined = true; continue; }
|
|
97
|
+
if (step.kind === 'collection' && J.has(prefix.join('.'))) { joined = true; continue; }
|
|
122
98
|
const anchor = joined ? prefix.slice(0, -1) : [];
|
|
123
|
-
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' };
|
|
124
100
|
}
|
|
125
101
|
return null;
|
|
126
102
|
};
|
|
103
|
+
const comparisonInGroup = (group, seen = new Set()) => {
|
|
104
|
+
if (seen.has(group)) return false;
|
|
105
|
+
seen.add(group);
|
|
106
|
+
return list(group.conditions).some((condition) => hasValuePath(condition) || condition.__operand !== undefined)
|
|
107
|
+
|| ['AND', 'OR', ...QUANTIFIED_LISTS].some((key) => list(group[key]).some((child) => comparisonInGroup(child, seen)));
|
|
108
|
+
};
|
|
109
|
+
const comparisonKeys = (resolved) => resolved.steps.flatMap((step, index, steps) => relation(step) ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []);
|
|
127
110
|
const markerKeys = (resolved, J = new Set()) => {
|
|
128
111
|
const cut = cutAt(resolved, J);
|
|
129
112
|
const anchored = resolved.steps.some((step, index, steps) => step.kind === 'collection'
|
|
@@ -136,14 +119,27 @@ const markerKeys = (resolved, J = new Set()) => {
|
|
|
136
119
|
const prefixScopes = (scopes, prefix, keys) => Object.fromEntries(Object.entries(scopes ?? {}).filter(([key]) => (
|
|
137
120
|
keys.includes(key) && (key === prefix || prefix.startsWith(`${key}.`))
|
|
138
121
|
)));
|
|
139
|
-
const cutCondition = (item, rest, prefix, keys) => {
|
|
122
|
+
const cutCondition = (item, rest, prefix, keys, rightKeys = []) => {
|
|
140
123
|
const { __scope, ...operands } = item;
|
|
141
124
|
delete operands.field;
|
|
142
125
|
delete operands.path;
|
|
143
126
|
const condition = { field: rest, ...operands };
|
|
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 };
|
|
130
|
+
else if (hasValuePath(item)) {
|
|
131
|
+
if (item.valuePath.startsWith(`${prefix}.`)) condition.valuePath = item.valuePath.slice(prefix.length + 1);
|
|
132
|
+
else {
|
|
133
|
+
delete condition.valuePath;
|
|
134
|
+
condition.__operand = { up: 1, path: item.valuePath.split('.') };
|
|
135
|
+
if (hasMarker(item) && rightKeys.length) {
|
|
136
|
+
condition.__operand.scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {}).filter(([key]) => rightKeys.includes(key)));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
144
140
|
if (hasMarker(item)) {
|
|
145
141
|
const scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {})
|
|
146
|
-
.filter(([key]) => keys.includes(key) && key.startsWith(`${prefix}.`)).map(([key, value]) => [key.slice(prefix.length + 1), value]));
|
|
142
|
+
.filter(([key]) => [...keys, ...rightKeys].includes(key) && key.startsWith(`${prefix}.`)).map(([key, value]) => [key.slice(prefix.length + 1), value]));
|
|
147
143
|
if (Object.keys(scopes).length) condition.__scope = { id: __scope.id, scopes };
|
|
148
144
|
}
|
|
149
145
|
return condition;
|
|
@@ -153,6 +149,31 @@ const termCondition = (term) => {
|
|
|
153
149
|
return { field: path, ...operands };
|
|
154
150
|
};
|
|
155
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
|
+
|
|
156
177
|
/** Internal: pure collection-frame rewrite. The same J must be threaded through repeated passes. */
|
|
157
178
|
export const frameCollectionPaths = (args, ctx) => {
|
|
158
179
|
if (ctx.quantifiers !== true) return args;
|
|
@@ -172,40 +193,47 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
172
193
|
positions.set(position, output);
|
|
173
194
|
active.set(input, output);
|
|
174
195
|
const buckets = new Map();
|
|
196
|
+
const bucketKinds = new Map();
|
|
175
197
|
const cut = (condition, path) => {
|
|
176
198
|
const resolved = resolve(path, frame);
|
|
177
199
|
const operator = condition.operator ?? 'EQ';
|
|
178
200
|
if (isEmptinessOperator(operator) || operator === 'CONTAINS_ALL') return null;
|
|
179
|
-
const at = cutAt(resolved, J);
|
|
201
|
+
const at = cutAt(resolved, J, ctx.elementFrames === true && (hasValuePath(condition) || condition.__operand !== undefined));
|
|
180
202
|
if (!at) return null;
|
|
181
203
|
const prefix = at.prefix.join('.');
|
|
182
|
-
|
|
204
|
+
const bucketKey = at.element ? Symbol(prefix) : prefix;
|
|
205
|
+
let bucket = buckets.get(bucketKey);
|
|
183
206
|
if (!bucket) {
|
|
184
207
|
bucket = { path: at.path, conditions: [], __implicit: true };
|
|
185
208
|
if (at.anchor.length) bucket.__anchor = at.anchor;
|
|
186
|
-
buckets.set(
|
|
209
|
+
buckets.set(bucketKey, bucket);
|
|
210
|
+
bucketKinds.set(bucket, at.element && operator === 'NE' ? 'NONE' : 'ANY');
|
|
187
211
|
}
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
212
|
+
const rightResolved = hasValuePath(condition) ? resolve(condition.valuePath, frame) : null;
|
|
213
|
+
const rightKeys = rightResolved ? comparisonKeys(rightResolved) : [];
|
|
214
|
+
const keys = hasValuePath(condition) || condition.__operand ? comparisonKeys(resolved) : markerKeys(resolved, J);
|
|
215
|
+
if (hasMarker(condition) && !hasMarker(bucket)
|
|
216
|
+
&& [...keys, ...rightKeys].some((key) => key === prefix || prefix.startsWith(`${key}.`))) {
|
|
217
|
+
bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, [...keys, ...rightKeys]) };
|
|
191
218
|
}
|
|
192
|
-
bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys));
|
|
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));
|
|
193
220
|
return bucket;
|
|
194
221
|
};
|
|
195
222
|
if (Array.isArray(input.conditions)) {
|
|
196
|
-
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}`));
|
|
197
224
|
}
|
|
198
225
|
for (const key of ['AND', 'OR']) if (Array.isArray(input[key])) output[key] = input[key].map((group) => rewriteGroup(group, frame, J, position));
|
|
199
226
|
for (const type of QUANTIFIED_LISTS) if (Array.isArray(input[type])) {
|
|
200
227
|
output[type] = input[type].map((group) => {
|
|
201
228
|
const anchor = group.__implicit === true ? list(group.__anchor) : [];
|
|
202
|
-
return rewriteGroup(group,
|
|
229
|
+
return rewriteGroup(group, enterQueryFrame(frame, [...anchor, ...partsOf(group.path)]), new Set(), positionOf(position, group, type, group.path));
|
|
203
230
|
});
|
|
204
231
|
}
|
|
205
232
|
for (const bucket of buckets.values()) {
|
|
206
233
|
const path = [...list(bucket.__anchor), ...partsOf(bucket.path)];
|
|
207
|
-
const
|
|
208
|
-
|
|
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];
|
|
209
237
|
}
|
|
210
238
|
active.delete(input);
|
|
211
239
|
return output;
|
|
@@ -229,21 +257,32 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
229
257
|
if (['scalar', 'scalarList'].includes(leaf.kind)) {
|
|
230
258
|
// Native planner inputs may name a scalar through collections. Each entry is its own
|
|
231
259
|
// correlation group; ignored terms and filter metadata never become predicates or markers.
|
|
232
|
-
const
|
|
260
|
+
const carrier = hasValuePath(value) ? { ...value } : { operator: value.operator ?? 'EQ', value: value.value };
|
|
261
|
+
const group = { conditions: [{ field: name, ...carrier }] };
|
|
233
262
|
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
234
|
-
if (list(rewritten
|
|
235
|
-
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]])) });
|
|
236
265
|
delete output[name];
|
|
237
266
|
}
|
|
238
267
|
continue;
|
|
239
268
|
}
|
|
240
269
|
if (!Array.isArray(value.terms) || valueLeaf(leaf, ctx)) continue;
|
|
241
|
-
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
|
+
}) };
|
|
242
276
|
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
277
|
+
if (list(rewritten.NONE).length) syntheticGroups.push({ __implicit: true, NONE: rewritten.NONE });
|
|
243
278
|
implicitGroups.push(...list(rewritten.ANY));
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 };
|
|
247
286
|
}
|
|
248
287
|
if (implicitGroups.length) syntheticGroups.push({ __implicit: true, ANY: implicitGroups });
|
|
249
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()));
|
|
@@ -373,7 +412,8 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
373
412
|
if (ctx.gqltype && leaf.kind === 'abstract') abstractFields.add(name);
|
|
374
413
|
if (value.quantifier == null && valueLeaf(leaf, ctx) && Array.isArray(value.terms) && value.operator === undefined) {
|
|
375
414
|
scalarCarriers.push({ form: 'filter', field: name, path: name, relative: partsOf(name), segments: partsOf(name), frames: [],
|
|
376
|
-
operator: 'EQ', value: value.value, construct: null
|
|
415
|
+
operator: 'EQ', value: value.value, construct: hasValuePath(value) ? value : null,
|
|
416
|
+
...(hasValuePath(value) ? { hasValuePath: true, valuePath: value.valuePath } : {}) });
|
|
377
417
|
}
|
|
378
418
|
if (!valueLeaf(leaf, ctx) && value.quantifier == null) {
|
|
379
419
|
if (isEmptinessOperator(value.operator)) fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term `
|
|
@@ -396,7 +436,12 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
396
436
|
entries.push(...abstractCarriers);
|
|
397
437
|
for (const entry of entries) {
|
|
398
438
|
if (ignoredValueTerm(entry, ctx)) continue;
|
|
439
|
+
if (entry.hasValuePath) validateComparisonOperand(entry.construct);
|
|
440
|
+
const comparison = entry.hasValuePath || entry.operand !== undefined;
|
|
399
441
|
const current = inFrames(entry, ctx);
|
|
442
|
+
validateInternalOperand(entry, current);
|
|
443
|
+
let rightResolved;
|
|
444
|
+
let rightCurrent;
|
|
400
445
|
const implicit = entry.form === 'quantified' && entry.construct.__implicit === true;
|
|
401
446
|
if (entry.form === 'condition' && typeof entry.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
402
447
|
if (entry.ambiguous) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
@@ -425,7 +470,10 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
425
470
|
}
|
|
426
471
|
// A hand-built implicit group, whatever its operators (#108 part 4): after any anchor, its path ends at a collection,
|
|
427
472
|
// whatever it crosses first, or at a reference without crossing a list, the explicit groups' rule for references.
|
|
428
|
-
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'
|
|
429
477
|
|| steps.slice(anchor.length, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind)))) {
|
|
430
478
|
fail('An implicit quantified path must end at a collection, or at a reference without crossing a list', 'INVALID_FILTER_PATH');
|
|
431
479
|
}
|
|
@@ -439,16 +487,51 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
439
487
|
if (ctx.operation !== 'aggregate' || entry.frames.length
|
|
440
488
|
|| !crossed.some((step) => step.kind === 'collection')
|
|
441
489
|
|| crossed.some((step, index) => step.kind === 'collection' && !prefixes.has(prefix(index)))
|
|
442
|
-
|| 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)))) {
|
|
443
492
|
fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
|
|
444
493
|
}
|
|
445
494
|
}
|
|
446
495
|
if (!implicit && !QUANTIFIERS.has(entry.quantifier)) fail('Unknown quantifier', 'INVALID_FILTER_VALUE');
|
|
447
496
|
if (!implicit) gates.push(() => {
|
|
448
|
-
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');
|
|
449
498
|
});
|
|
450
499
|
} else {
|
|
451
|
-
if (
|
|
500
|
+
if (comparison) {
|
|
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;
|
|
506
|
+
if (!['scalar', 'scalarList'].includes(last.kind) || !['scalar', 'scalarList'].includes(rightResolved.steps.at(-1).kind)) {
|
|
507
|
+
fail('Field comparison operands must end in scalar fields', 'INVALID_FILTER_PATH');
|
|
508
|
+
}
|
|
509
|
+
if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
|
|
510
|
+
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
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) {
|
|
513
|
+
const chain = entry.frames.slice(entry.frames.length - entry.operand.up)
|
|
514
|
+
.flatMap((frame) => [...list(frame.construct.__anchor), ...partsOf(frame.path)]);
|
|
515
|
+
const bound = resolve([...chain, ...path], rightCurrent).steps;
|
|
516
|
+
for (const [index, step] of bound.entries()) if (step.kind === 'collection') {
|
|
517
|
+
const key = bound.slice(0, index + 1).map(({ name }) => name).join('.');
|
|
518
|
+
if (ctx.J?.has(key) && entry.operand.up === entry.frames.length) collections.add(key);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (rightResolved.steps.some((step, index, right) => (step.kind === 'collection' || (ctx.elementFrames === true && step.kind === 'embeddedList'))
|
|
522
|
+
&& !collections.has(right.slice(0, index + 1).map(({ name }) => name).join('.')))) {
|
|
523
|
+
fail('The right operand introduces an unbound collection; use an explicit group', 'INVALID_FILTER_PATH');
|
|
524
|
+
}
|
|
525
|
+
validateComparisonTypes(comparisonDescriptor(resolved, current), comparisonDescriptor(rightResolved, rightCurrent), entry.operator, path.join('.'), rightPath.join('.'));
|
|
526
|
+
gates.push(() => {
|
|
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');
|
|
528
|
+
if (ctx.valuePath !== true || ctx.referenceEmptiness !== true) fail('Field comparisons require valuePath and referenceEmptiness support from this query compiler', 'UNSUPPORTED_QUERY');
|
|
529
|
+
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
530
|
+
if (ctx.quantifiers !== true && steps.some((step, index) => step.kind === 'collection' && !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.')))) {
|
|
531
|
+
fail('Collection field comparisons require quantifiers', 'UNSUPPORTED_QUERY');
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
} else if (isEmptinessOperator(entry.operator)) {
|
|
452
535
|
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
453
536
|
if (ctx.quantifiers === true && steps.slice(0, -1).some((step) => step.kind === 'collection')) fail('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
|
|
454
537
|
shape = describe(resolved, path, current, entry.operator, entry.value);
|
|
@@ -464,15 +547,23 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
464
547
|
validateValue(entry, resolved, current);
|
|
465
548
|
}
|
|
466
549
|
}
|
|
550
|
+
if (entry.operand && ctx.scopeMarkers !== 'pending' && (entry.operand.scopes !== undefined
|
|
551
|
+
|| hasMarker(entry.construct))) markers.push(() => {
|
|
552
|
+
// An outer binding can still read a collection row owned by aggregate J.
|
|
553
|
+
const keys = comparisonKeys(rightResolved);
|
|
554
|
+
const scopes = entry.operand.scopes;
|
|
555
|
+
if (keys.length && (!plain(scopes) || keys.some((key) => !Object.hasOwn(scopes, key) || scopes[key] === undefined))) detached('Outer operand scope marker is not filled for every crossing');
|
|
556
|
+
if (!keys.length && scopes !== undefined) detached('Outer operand scope marker reads no reference or collection');
|
|
557
|
+
});
|
|
467
558
|
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
468
|
-
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'))
|
|
469
560
|
&& !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.'))).length : 0;
|
|
470
561
|
gates.push(() => {
|
|
471
562
|
if (entry.frames.length + extra > getQueryMaxFrameNesting()) fail('Frame nesting too deep (maxFrameNesting)', 'FILTER_DEPTH_EXCEEDED');
|
|
472
563
|
});
|
|
473
564
|
if (hasMarker(entry.construct) && ctx.scopeMarkers !== 'pending') markers.push(() => {
|
|
474
565
|
const suffix = '; only the runtime writes and fills client scope markers (docs/guide/middleware.md)';
|
|
475
|
-
if (!shape && entry.form !== 'quantified' && !entry.frames.length
|
|
566
|
+
if (!comparison && !shape && entry.form !== 'quantified' && !entry.frames.length
|
|
476
567
|
&& (ctx.quantifiers !== true || !cutAt(resolved, ctx.J ?? new Set()))) {
|
|
477
568
|
// Mongo rejects these at its native predicate checkpoint, after parsing/casting once.
|
|
478
569
|
// Every other caller remains fail-closed here, after the common 400 gates above.
|
|
@@ -484,6 +575,9 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
484
575
|
const paths = emptinessScopePaths(shape, path);
|
|
485
576
|
keys = [...paths.joins, ...paths.frames];
|
|
486
577
|
} else keys = markerKeys(resolved, !entry.frames.length && (implicit || entry.form !== 'quantified') ? ctx.J ?? new Set() : new Set());
|
|
578
|
+
if (comparison) keys = [...new Set([...comparisonKeys(resolved), ...(!entry.operand ? comparisonKeys(rightResolved).map((key) => `${parentScopePrefix(entry.valuePath)}${key}`) : [])])];
|
|
579
|
+
const comparisonAnchor = implicit && anchor.length && comparisonInGroup(entry.construct);
|
|
580
|
+
if (comparisonAnchor) keys = comparisonKeys(resolved);
|
|
487
581
|
const scopes = entry.construct.__scope?.scopes;
|
|
488
582
|
if (shape) {
|
|
489
583
|
// Preserve 2b's direct-compiler diagnostics when the shared validator now runs first.
|
|
@@ -496,7 +590,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
496
590
|
for (const [index, step] of steps.slice(0, anchor.length).entries()) {
|
|
497
591
|
const key = steps.slice(0, index + 1).map(({ name }) => name).join('.');
|
|
498
592
|
if ((step.kind === 'collection' && scopes[key] !== null)
|
|
499
|
-
|| (step.kind === 'reference' && Object.hasOwn(scopes, key))) detached('Aggregate anchors require null J scopes and legacy reference joins');
|
|
593
|
+
|| (step.kind === 'reference' && !comparisonAnchor && Object.hasOwn(scopes, key))) detached('Aggregate anchors require null J scopes and legacy reference joins');
|
|
500
594
|
}
|
|
501
595
|
}
|
|
502
596
|
});
|
|
@@ -517,6 +611,7 @@ export const frameUnits = (args, ctx) => {
|
|
|
517
611
|
return identities.get(construct);
|
|
518
612
|
};
|
|
519
613
|
const frameKey = (entry) => entry.frames.reduce((key, frame) => `${key}/${id(frame.construct)}:${frame.type}:${frame.path}`, 'root');
|
|
614
|
+
const joinUnit = (frame, path, scoped) => `join:${frame}:${path}${scoped ? ':scoped' : ''}`;
|
|
520
615
|
for (const entry of collectQueryOperators(rewritten, ctx.operation)) {
|
|
521
616
|
if (ignoredValueTerm(entry, ctx)) continue;
|
|
522
617
|
const current = inFrames(entry, ctx);
|
|
@@ -527,18 +622,26 @@ export const frameUnits = (args, ctx) => {
|
|
|
527
622
|
const path = entry.relative;
|
|
528
623
|
const { steps } = resolve(path, current);
|
|
529
624
|
for (const [index, step] of steps.entries()) {
|
|
530
|
-
if (index >= anchor.length && index < steps.length - 1 && step.kind === 'reference') {
|
|
531
|
-
units.add(
|
|
625
|
+
if ((index >= anchor.length || comparisonInGroup(entry.construct)) && index < steps.length - 1 && step.kind === 'reference') {
|
|
626
|
+
units.add(joinUnit(frame, steps.slice(0, index + 1).map(({ name }) => name).join('.'), hasMarker(entry.construct)));
|
|
532
627
|
}
|
|
533
628
|
}
|
|
534
629
|
} else if (isEmptinessOperator(entry.operator)) {
|
|
535
630
|
const resolved = resolve(entry.relative, current);
|
|
536
631
|
const shape = describe(resolved, entry.relative, current, entry.operator, entry.value);
|
|
537
632
|
const paths = emptinessScopePaths(shape, entry.relative);
|
|
538
|
-
for (const path of paths.joins) units.add(
|
|
633
|
+
for (const path of paths.joins) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
539
634
|
for (const path of paths.frames) units.add(`emptiness-frame:${frame}:${path}`);
|
|
540
|
-
} else if (entry.frames.length) {
|
|
541
|
-
for (const path of crossingKeys(entry.relative, current)) units.add(
|
|
635
|
+
} else if (entry.frames.length || entry.hasValuePath || entry.operand) {
|
|
636
|
+
for (const path of crossingKeys(entry.relative, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
637
|
+
if (entry.hasValuePath) for (const path of crossingKeys(entry.valuePath, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
638
|
+
if (entry.operand) {
|
|
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')));
|
|
644
|
+
}
|
|
542
645
|
}
|
|
543
646
|
}
|
|
544
647
|
return { count: units.size, units: [...units] };
|
package/src/query-operators.js
CHANGED
|
@@ -14,6 +14,8 @@ export {
|
|
|
14
14
|
export {
|
|
15
15
|
ANCHOR_KEY, GROUP_KEYS, IMPLICIT_KEY, aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, frameUnits, validateQuantifiedGroups,
|
|
16
16
|
} from './query-frames.js';
|
|
17
|
+
export { hasValuePath, validateComparisonOperand, validateComparisonTypes } from './query-comparisons.js';
|
|
18
|
+
export { enterQueryFrame, resolveContainerContext, resolveQueryContext } from './query-context.js';
|
|
17
19
|
export { effectiveScalarName } from './metadata.js';
|
|
18
20
|
|
|
19
21
|
/** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
|
|
@@ -153,23 +155,23 @@ const invalidFeatures = (message) => {
|
|
|
153
155
|
};
|
|
154
156
|
|
|
155
157
|
/**
|
|
156
|
-
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers }`:
|
|
158
|
+
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers, valuePath, elementFrames }`:
|
|
157
159
|
* the operators it compiles and whether it compiles reference emptiness and collection frames.
|
|
158
160
|
* Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
|
|
159
161
|
* and neither feature. A declaration is an object whose `operators`
|
|
160
162
|
* is an array of operator names, whose names this version does not know are ignored, and whose
|
|
161
|
-
* optional `referenceEmptiness` and `
|
|
163
|
+
* optional `referenceEmptiness`, `quantifiers`, `valuePath` and `elementFrames` are booleans, undefined or null (false). A name
|
|
162
164
|
* counts once this version knows it: a declaration that listed `CONTAINS_ALL` or `IIN` before
|
|
163
165
|
* #108 part 4 added them receives them on upgrade.
|
|
164
|
-
* Quantifiers require reference emptiness for scoped joins. Other members are left for later
|
|
165
|
-
* features. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
166
|
+
* Quantifiers and valuePath require reference emptiness for scoped joins. Other members are left for later
|
|
167
|
+
* features. Element frames require all three other flags. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
166
168
|
*/
|
|
167
169
|
export const resolveQueryFeatures = (queryFeatures) => {
|
|
168
|
-
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false };
|
|
170
|
+
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false, valuePath: false, elementFrames: false };
|
|
169
171
|
if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
|
|
170
172
|
invalidFeatures('must be an object with an operators array');
|
|
171
173
|
}
|
|
172
|
-
const { operators, referenceEmptiness, quantifiers } = queryFeatures;
|
|
174
|
+
const { operators, referenceEmptiness, quantifiers, valuePath, elementFrames } = queryFeatures;
|
|
173
175
|
if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
|
|
174
176
|
invalidFeatures('operators must be an array of operator names');
|
|
175
177
|
}
|
|
@@ -182,9 +184,17 @@ export const resolveQueryFeatures = (queryFeatures) => {
|
|
|
182
184
|
if (quantifiers === true && referenceEmptiness !== true) {
|
|
183
185
|
invalidFeatures('quantifiers require referenceEmptiness: true');
|
|
184
186
|
}
|
|
187
|
+
if (valuePath != null && typeof valuePath !== 'boolean') invalidFeatures('valuePath must be a boolean');
|
|
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
|
+
}
|
|
185
193
|
return {
|
|
186
194
|
operators: new Set(operators.filter(isQueryOperator)),
|
|
187
195
|
referenceEmptiness: referenceEmptiness === true,
|
|
188
196
|
quantifiers: quantifiers === true,
|
|
197
|
+
valuePath: valuePath === true,
|
|
198
|
+
elementFrames: elementFrames === true,
|
|
189
199
|
};
|
|
190
200
|
};
|