@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.
@@ -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, graphqlStep, isEmptinessOperator, modelStep,
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 childContext = (ctx, step) => (ctx.gqltype
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
- // One schema-path resolver for these operations, using the same step classifications as emptiness.
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
- 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
- return { steps, raw, end: current };
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 = resolve([...anchor, ...partsOf(frame.path)], current).end;
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
- let bucket = buckets.get(prefix);
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(prefix, bucket);
209
+ buckets.set(bucketKey, bucket);
210
+ bucketKinds.set(bucket, at.element && operator === 'NE' ? 'NONE' : 'ANY');
187
211
  }
188
- const keys = markerKeys(resolved, J);
189
- if (hasMarker(condition) && !hasMarker(bucket)) {
190
- bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, keys) };
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, resolve([...anchor, ...partsOf(group.path)], frame).end, new Set(), positionOf(position, group, type, group.path));
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 rewritten = rewriteGroup(bucket, resolve(path, frame).end, new Set(), positionOf(position, bucket, 'ANY', bucket.path));
208
- output.ANY = [...list(output.ANY), rewritten];
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 group = { conditions: [{ field: name, operator: value.operator ?? 'EQ', value: value.value }] };
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.ANY).length) {
235
- syntheticGroups.push({ __implicit: true, ANY: rewritten.ANY });
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) => ({ ...termCondition(term), field: pathless(term.path) ? name : `${name}.${term.path}` })) };
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
- if (rewritten.conditions.length === value.terms.length) continue;
245
- if (!rewritten.conditions.length) delete output[name];
246
- else output[name] = { ...value, terms: value.terms.filter((term, index) => rewritten.conditions.includes(group.conditions[index])) };
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' && (last.kind !== 'reference'
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 || last.kind !== 'collection') {
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 (isEmptinessOperator(entry.operator)) {
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(`join:${frame}:${steps.slice(0, index + 1).map(({ name }) => name).join('.')}`);
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(`join:${frame}:${path}`);
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(`join:${frame}:${path}`);
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] };
@@ -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 `quantifiers` are booleans, undefined or null (false). A name
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
  };