@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.17.0",
3
+ "version": "3.19.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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 nonempty dotted field path; outer paths require an explicit group', 'INVALID_FILTER_PATH');
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] ?? '' : '';
@@ -1,10 +1,8 @@
1
- import { getNamedType, isEnumType, isObjectType } from 'graphql';
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,
@@ -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 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
- 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
- && (!previous || relation(previous)), version: last.version || ['Version', 'RecordVersion'].includes(last.scalar) };
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
- if (!plain(operand) || !Number.isSafeInteger(operand.up) || operand.up <= 0 || operand.up > entry.frames.length
92
- || entry.frames.slice(-operand.up).some((frame) => frame.construct.__implicit !== true)
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 = resolve([...anchor, ...partsOf(frame.path)], current).end;
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 = { ...item.__operand, up: item.__operand.up + 1 };
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
- let bucket = buckets.get(prefix);
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(prefix, bucket);
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, 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));
245
230
  });
246
231
  }
247
232
  for (const bucket of buckets.values()) {
248
233
  const path = [...list(bucket.__anchor), ...partsOf(bucket.path)];
249
- const rewritten = rewriteGroup(bucket, resolve(path, frame).end, new Set(), positionOf(position, bucket, 'ANY', bucket.path));
250
- 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];
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.ANY).length) {
278
- 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]])) });
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) => ({ ...termCondition(term), field: pathless(term.path) ? name : `${name}.${term.path}`, ...(hasValuePath(term) ? { valuePath: `${name}.${term.valuePath}` } : {}) })) };
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
- if (rewritten.conditions.length === value.terms.length) continue;
288
- if (!rewritten.conditions.length) delete output[name];
289
- 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 };
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' && (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'
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 || last.kind !== 'collection') {
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
- rightCurrent = entry.operand ? operandContext(entry, ctx, entry.operand.up) : current;
502
- const rightPath = entry.operand?.path ?? partsOf(entry.valuePath);
503
- rightResolved = resolve(rightPath, rightCurrent);
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 outer = operandContext(entry, ctx, entry.operand.up);
638
- const owner = frameKey({ frames: entry.frames.slice(0, entry.frames.length - entry.operand.up) });
639
- for (const path of crossingKeys(entry.operand.path, outer)) units.add(joinUnit(owner, path, Object.hasOwn(entry.operand, 'scopes')));
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
  }
@@ -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 `valuePath` are booleans, undefined or null (false). A name
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
- if (item.__operand) {
108
- for (let up = item.__operand.up; up > 0 && origin.parent; up--) origin = origin.parent;
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
- const right = item.__operand ? item.__operand.path?.join('.') : operandPath(item, prefix);
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, frameEntity) => {
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 === frameEntity)?.gqltype;
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, frameEntity = entityName, inFrame = false, ancestors = [], prefix = '') => {
369
- const steps = resolveModelPath(models, frameEntity, path);
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, frameEntity);
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 rightPath = internal?.path ?? (prefix ? `${prefix}.${validateComparisonOperand(construct)}` : validateComparisonOperand(construct)).split('.');
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 rightEntity = up ? ancestors[ancestors.length - up] : frameEntity;
384
- if (!rightEntity) fail('Invalid comparison operand frame', 'INVALID_FILTER_PATH');
385
- resolveModelPath(models, rightEntity, rightPath);
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, { models, entity })))].map((key) => [key, resolveModelPath(models, entity, key).at(-1).target]));
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) => resolveModelPath(models, entity, 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, frameEntity), segments, op, anchors(paths, frameEntity));
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], rightEntity), rightPath, op, anchors([rightPath], rightEntity));
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, { models, entity: frameEntity }).map((key) => [key, resolveModelPath(models, frameEntity, key).at(-1).target]));
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, frameEntity = entityName, inFrame = false, ancestors = []) => {
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, frameEntity, inFrame, ancestors));
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 target = resolveModelPath(models, frameEntity, [...(anchor ?? []), ...path]).at(-1).target;
441
- const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true, [...ancestors, frameEntity]), scope: null };
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, { models, entity: frameEntity });
447
- const targets = Object.fromEntries(crossings.map((prefix) => [prefix, resolveModelPath(models, frameEntity, prefix).at(-1).target]));
448
- targets[key] = target;
449
- const scopes = liftScopes(item.__scope, targets, fullPath, quantifier);
450
- node.scope = scopes[key];
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, frameEntity, inFrame, ancestors));
461
- terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame, ancestors))));
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, entityName, false, [], name));
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}.${name}.${term.valuePath}` } : {}),
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 ? [...entry.segments.slice(0, -entry.relative.length), ...entry.valuePath.split('.')] : [];
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 ? [[...entry.segments.slice(0, -entry.relative.length), ...entry.valuePath.split('.')]] : [])];
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
- relations.push(...pathRelations(type, entry.valuePath.split('.')).filter(({ path }) => !existing.has(path)));
2753
+ const right = resolveComparisonContext(entry.valuePath, queryEntryContext(entry, { gqltype }), entry.valuePathPrefix);
2754
+ const prefix = parentScopePrefix(entry.valuePath);
2755
+ const rightRelations = pathRelations(right.context.gqltype, right.path ?? entry.valuePath.split('.'))
2756
+ .map((relation) => ({ ...relation, path: `${prefix}${relation.path}` }));
2757
+ relations.push(...rightRelations.filter(({ path }) => !existing.has(path)));
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 = { up: number; path: string[]; joins?: QueryJoins };
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. Public inputs do not accept outer-frame syntax.
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, right scalar lists and incompatible logical types fail explicitly.
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
  /**