@simtlix/simfinity-core 3.12.0 → 3.14.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/src/query-plan.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { GROUP_KEYS, QUANTIFIED_LISTS } from './const/QLQuantifier.js';
2
+ import { aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, validateQuantifiedGroups } from './query-frames.js';
1
3
  import { paginationStages } from './query-limits.js';
2
4
  import SimfinityError from './errors/simfinity.error.js';
3
5
  import {
4
- QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator,
6
+ describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator, isQueryOperator,
5
7
  textQueryOperator, validateTextFilterValue,
6
8
  } from './query-operators.js';
7
9
 
@@ -11,7 +13,6 @@ const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityEr
11
13
  const detached = (message) => {
12
14
  throw new SimfinityError(`${message}; only the runtime writes and fills client scope markers (docs/guide/middleware.md)`, 'DETACHED_CLIENT_FILTER', 500);
13
15
  };
14
- const operators = new Set(QUERY_OPERATORS);
15
16
  const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
16
17
  // `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
17
18
  // that name is filtered like any other.
@@ -23,162 +24,196 @@ const nestedGroups = (group) => [...listOf(group.AND), ...listOf(group.OR)];
23
24
  const isPathless = (path) => path == null || path === '';
24
25
  const hasTermPath = (term) => typeof term?.path === 'string' && term.path !== '';
25
26
 
26
- /**
27
- * Internal: the model paths named by generated query arguments, as `{ segments, group, operator,
28
- * construct }` entries. Covers filter keys and relation terms, AND/OR conditions, list sort terms,
29
- * and aggregation groupId and fact paths; aggregate sort terms name result keys. Outside aggregate
30
- * mode a key named `aggregation` is a field filter like any other. `group` is the AND/OR group
31
- * object whose `conditions` hold the path, or null for the other forms. `operator` is the operator
32
- * that reads the path, an omitted one read as `EQ`, or null for sort, groupId and fact paths.
33
- * `construct` is the term or group condition object that names the path, or null for a field
34
- * filter's own operator and for sort, groupId and fact paths. Malformed entries are left to the
35
- * backends. An emptiness term without a path (omitted, null or "") names its field, also next to
36
- * sibling terms that have paths.
37
- *
38
- * With `expandMembers` and the queried `gqltype`, the path of each emptiness condition is followed by
39
- * the stored member paths that condition reads (emptinessMemberPaths), as entries with the same
40
- * group, operator and construct and `member: true`, so that the checks of client paths apply to them.
41
- * Only client arguments are expanded.
42
- */
43
- export const collectQueryPathEntries = (args, operation = 'find', { gqltype, expandMembers = false } = {}) => {
27
+ /** Internal: rejects group keys the query walker cannot inspect. Shape checks belong to the validator. */
28
+ export const validateQueryGroupKeys = (group, { quantified = false } = {}) => {
29
+ for (const key of Object.keys(group)) {
30
+ if (!GROUP_KEYS.has(key) || (key === 'path' && !quantified)) fail(`Unknown filter group key: ${key}`);
31
+ }
32
+ };
33
+
34
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
35
+ const pathSegments = (path) => (typeof path === 'string' && path ? path.split('.') : []);
36
+ const conditionPath = (field, path) => (isPathless(path) ? field : `${field}.${path}`);
37
+
38
+ // One graph walk for both collectors. Records are emitted after visiting so a group shared at
39
+ // several depths reports its deepest occurrence in that frame. Each quantified record owns a
40
+ // distinct child frame; the parent frame, construct and quantifier distinguish aliased positions.
41
+ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembers = false } = {}) => {
44
42
  const entries = [];
45
- const add = (path, group = null, operator = null, construct = null) => {
46
- if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group, operator, construct });
47
- };
48
- const addEmptiness = (path, group, operator, construct) => {
49
- add(path, group, operator, construct);
50
- if (!expandMembers || typeof path !== 'string' || !path) return;
51
- for (const segments of emptinessMemberPaths(gqltype, path.split('.'))) {
52
- entries.push({
53
- segments, group, operator, construct, member: true,
54
- });
55
- }
56
- };
57
43
  if (!args || typeof args !== 'object') return entries;
58
- const operatorOf = (item) => item?.operator ?? 'EQ';
59
- const visit = (group) => {
60
- if (!group || typeof group !== 'object') return;
61
- for (const condition of listOf(group.conditions)) {
62
- if (typeof condition?.field !== 'string') continue;
63
- const path = isPathless(condition.path) ? condition.field : `${condition.field}.${condition.path}`;
64
- if (isEmptinessOperator(condition.operator)) addEmptiness(path, group, condition.operator, condition);
65
- else add(path, group, operatorOf(condition), condition);
44
+ const root = { base: [], frames: [], groups: new Map() };
45
+ const records = [];
46
+ const open = new Set();
47
+ const childFrame = (parent, path, quantifier, construct, anchor = []) => ({
48
+ base: [...parent.base, ...anchor, ...pathSegments(path)],
49
+ frames: [...parent.frames, { path, type: quantifier, construct }],
50
+ groups: new Map(),
51
+ });
52
+ const visit = (group, frame, parentDepth, quantifier = null) => {
53
+ // Object cycles are cut even when a quantified edge would otherwise create another frame.
54
+ if (!isObject(group) || open.has(group)) return;
55
+ validateQueryGroupKeys(group, { quantified: quantifier !== null });
56
+ const depth = parentDepth + (group.__implicit === true ? 0 : 1);
57
+ let positions = frame.groups.get(group);
58
+ if (!positions) {
59
+ positions = new Map();
60
+ frame.groups.set(group, positions);
61
+ }
62
+ let record = positions.get(quantifier);
63
+ if (record && record.depth >= depth) return;
64
+ if (!record) {
65
+ const anchor = group.__implicit === true && Array.isArray(group.__anchor) ? group.__anchor : [];
66
+ record = {
67
+ group, frame, quantifier, depth, anchor,
68
+ inner: quantifier === null ? frame : childFrame(frame, group.path, quantifier, group, anchor),
69
+ };
70
+ positions.set(quantifier, record);
71
+ records.push(record);
72
+ } else record.depth = depth;
73
+ open.add(group);
74
+ for (const item of nestedGroups(group)) visit(item, record.inner, depth);
75
+ for (const type of QUANTIFIED_LISTS) {
76
+ for (const item of listOf(group[type])) visit(item, record.inner, depth, type);
66
77
  }
67
- for (const item of nestedGroups(group)) visit(item);
78
+ open.delete(group);
68
79
  };
69
80
  const controls = controlArgs(operation);
70
81
  for (const [name, value] of Object.entries(args)) {
71
- if (controls.has(name) || value == null) continue;
72
- const terms = listOf(value.terms);
73
- const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
74
- if (!terms.some(hasTermPath)) {
75
- const term = terms.find(pathless);
76
- if (term) addEmptiness(name, null, term.operator, term);
77
- else if (isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
78
- else add(name, null, operatorOf(value));
79
- continue;
80
- }
81
- let field = false;
82
- for (const term of terms) {
83
- if (hasTermPath(term)) {
84
- if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}`, null, term.operator, term);
85
- else add(`${name}.${term.path}`, null, operatorOf(term), term);
86
- } else if (!field && pathless(term)) {
87
- addEmptiness(name, null, term.operator, term);
88
- field = true;
89
- }
90
- }
91
- // An emptiness operator beside terms is rejected later; until then it reads the field too.
92
- if (!field && isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
82
+ if (!controls.has(name) && value != null) records.push({ name, value });
93
83
  }
94
- for (const item of nestedGroups(args)) visit(item);
95
- if (operation === 'aggregate') {
96
- add(args.aggregation?.groupId);
97
- for (const fact of listOf(args.aggregation?.facts)) add(fact?.path);
98
- } else for (const term of listOf(args.sort?.terms)) add(term?.field);
99
- return entries;
100
- };
84
+ for (const group of nestedGroups(args)) visit(group, root, -1);
101
85
 
102
- /**
103
- * Internal: every condition the arguments hold, as `{ operator, path, segments, form, field, rawPath,
104
- * value, hasValue, ambiguous, depth, construct, emptiness }` entries: field filters, relation terms
105
- * and AND/OR group conditions at any depth, with an omitted operator read as `EQ`.
106
- * - `form` is 'filter' (an argument's own operator, without a terms array), 'beside-terms' (an
107
- * operator written next to a terms array, which the compilers read only to reject an emptiness
108
- * operator), 'term' or 'condition'.
109
- * - `field` is the argument or condition field as written (a condition's may be dotted) and `rawPath`
110
- * the term or condition path as written. `path`, for messages, and `segments` join them; a path that
111
- * is omitted, null or "" names the field itself.
112
- * - `value` is the condition's value, and `hasValue` whether it has an own `value` key.
113
- * - `ambiguous` marks a condition with both a dotted field and a non-empty path.
114
- * - `depth` is 0 for argument filters and for the groups listed in args.AND and args.OR, and one more
115
- * for each nested group; a group reached at several depths reports the deepest.
116
- * - `construct` is the term or group condition object itself, or null for a field filter's own
117
- * operator ('filter' and 'beside-terms'). These objects are the only ones that carry a client scope
118
- * marker, `__scope` (#108 part 2b).
119
- * - `emptiness` tells whether the operator is IS_EMPTY or IS_NOT_EMPTY.
120
- * Sort, pagination and aggregation hold no operators. Malformed entries are skipped and left to the
121
- * backends; a group reached twice is read once.
122
- */
123
- export const collectQueryOperators = (args, operation = 'find') => {
124
- const entries = [];
125
- if (!args || typeof args !== 'object') return entries;
126
- const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
127
- const add = (item, form, field, rawPath, depth, ambiguous = false) => {
86
+ const metadata = (relative, frame, container, use = 'filter') => ({
87
+ segments: [...frame.base, ...relative], relative, frames: frame.frames, container, use,
88
+ });
89
+ const addPath = (path, frame, container, group = null, operator = null, construct = null, extra = {}) => {
90
+ if (typeof path !== 'string' || !path) return;
91
+ const relative = path.split('.');
92
+ const entry = { ...metadata(relative, frame, container), group, operator, construct, ...extra };
93
+ entries.push(entry);
94
+ if (!expandMembers || !isEmptinessOperator(operator)) return;
95
+ for (const segments of emptinessMemberPaths(gqltype, entry.segments)) {
96
+ entries.push({ ...entry, segments, relative: segments.slice(frame.base.length), member: true });
97
+ }
98
+ };
99
+ const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier) => {
128
100
  if (!isObject(item)) return;
129
- const path = isPathless(rawPath) ? field : `${field}.${rawPath}`;
130
- const operator = item.operator ?? 'EQ';
101
+ const quantified = form === 'quantified';
102
+ const relative = pathSegments(relativePath);
103
+ const meta = metadata(relative, frame, container);
104
+ const operator = quantified ? null : item.operator ?? 'EQ';
105
+ const path = typeof relativePath === 'string' ? meta.segments.join('.') : relativePath;
131
106
  entries.push({
107
+ ...meta,
132
108
  operator,
133
109
  path,
134
- segments: typeof path === 'string' && path ? path.split('.') : [],
135
110
  form,
136
111
  field,
137
112
  rawPath,
138
113
  value: item.value,
139
114
  hasValue: Object.hasOwn(item, 'value'),
140
- ambiguous,
115
+ ambiguous: form === 'condition' && typeof field === 'string' && field.includes('.') && !isPathless(rawPath),
141
116
  depth,
142
- construct: form === 'term' || form === 'condition' ? item : null,
117
+ construct: quantified || form === 'term' || form === 'condition' ? item : null,
143
118
  emptiness: isEmptinessOperator(operator),
119
+ ...(quantified ? { quantifier } : {}),
144
120
  });
145
121
  };
146
- // The deepest level of each group, as the backends' depth limit counts it; a group inside itself is
147
- // not entered again.
148
- const depths = new Map();
149
- const open = new Set();
150
- const measure = (group, depth) => {
151
- if (!isObject(group) || open.has(group) || depths.get(group) >= depth) return;
152
- depths.set(group, depth);
153
- open.add(group);
154
- for (const item of nestedGroups(group)) measure(item, depth + 1);
155
- open.delete(group);
122
+ const addQuantified = (construct, path, quantifier, frame, container, depth) => {
123
+ if (paths) addPath(path, frame, container, null, null, construct, { quantifier });
124
+ else addOperator(construct, 'quantified', path, undefined, depth, frame, container, path, quantifier);
156
125
  };
157
- const visited = new Set();
158
- const visit = (group) => {
159
- if (!isObject(group) || visited.has(group)) return;
160
- visited.add(group);
161
- for (const condition of listOf(group.conditions)) {
162
- const field = condition?.field;
163
- const rawPath = condition?.path;
164
- add(condition, 'condition', field, rawPath, depths.get(group), typeof field === 'string' && field.includes('.') && !isPathless(rawPath));
126
+ for (const record of records) {
127
+ if (Object.hasOwn(record, 'group')) {
128
+ const { group, frame, inner, quantifier, depth, anchor } = record;
129
+ if (quantifier !== null) {
130
+ const path = anchor.length && typeof group.path === 'string' ? [...anchor, group.path].join('.') : group.path;
131
+ addQuantified(group, path, quantifier, frame, group, depth);
132
+ }
133
+ for (const condition of listOf(group.conditions)) {
134
+ const field = condition?.field;
135
+ const path = conditionPath(field, condition?.path);
136
+ if (paths) {
137
+ if (typeof field === 'string') addPath(path, inner, group, group, condition.operator ?? 'EQ', condition);
138
+ } else addOperator(condition, 'condition', field, condition?.path, depth, inner, group, path);
139
+ }
140
+ continue;
165
141
  }
166
- for (const item of nestedGroups(group)) visit(item);
167
- };
168
- const controls = controlArgs(operation);
169
- for (const [name, value] of Object.entries(args)) {
170
- if (controls.has(name) || !isObject(value)) continue;
171
- // A relation filter's operators are in its terms; an operator beside them is read too, since a
172
- // backend that reads the field as a scalar would compile it.
173
- const terms = Array.isArray(value.terms);
174
- if (!terms || value.operator !== undefined) add(value, terms ? 'beside-terms' : 'filter', name, undefined, 0);
175
- if (terms) for (const term of value.terms) add(term, 'term', name, term?.path, 0);
142
+ const { name, value } = record;
143
+ const terms = listOf(value.terms);
144
+ if (value.quantifier != null) {
145
+ addQuantified(value, name, value.quantifier, root, value, 0);
146
+ const frame = childFrame(root, name, value.quantifier, value);
147
+ for (const term of terms) {
148
+ if (!isObject(term)) continue;
149
+ if (paths) {
150
+ // Invalid path-less terms still name the field, as before; validation rejects them
151
+ // before authorization marking. Their relative path names nothing in the child.
152
+ if (hasTermPath(term)) addPath(term.path, frame, value, null, term.operator ?? 'EQ', term);
153
+ else if (isPathless(term.path)) {
154
+ entries.push({ ...metadata([], frame, value), group: null, operator: term.operator ?? 'EQ', construct: term });
155
+ }
156
+ } else addOperator(term, 'term', name, term.path, 0, frame, value, isPathless(term.path) ? '' : term.path);
157
+ }
158
+ continue;
159
+ }
160
+ if (!paths) {
161
+ const hasTerms = Array.isArray(value.terms);
162
+ if (!hasTerms || value.operator !== undefined) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
163
+ if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path));
164
+ continue;
165
+ }
166
+ const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
167
+ if (!terms.some(hasTermPath)) {
168
+ const term = terms.find(pathless);
169
+ if (term) addPath(name, root, value, null, term.operator, term);
170
+ else addPath(name, root, value, null, value.operator ?? 'EQ');
171
+ continue;
172
+ }
173
+ let field = false;
174
+ for (const term of terms) {
175
+ if (hasTermPath(term)) addPath(`${name}.${term.path}`, root, value, null, term.operator ?? 'EQ', term);
176
+ else if (!field && pathless(term)) {
177
+ addPath(name, root, value, null, term.operator, term);
178
+ field = true;
179
+ }
180
+ }
181
+ // An emptiness operator beside terms is rejected later; until then it reads the field too.
182
+ if (!field && isEmptinessOperator(value.operator)) addPath(name, root, value, null, value.operator);
183
+ }
184
+ if (paths) {
185
+ const addUse = (path, use) => addPath(path, root, null, null, null, null, { use });
186
+ if (operation === 'aggregate') {
187
+ addUse(args.aggregation?.groupId, 'groupId');
188
+ for (const fact of listOf(args.aggregation?.facts)) addUse(fact?.path, 'fact');
189
+ } else for (const term of listOf(args.sort?.terms)) addUse(term?.field, 'sort');
176
190
  }
177
- for (const item of nestedGroups(args)) measure(item, 0);
178
- for (const item of nestedGroups(args)) visit(item);
179
191
  return entries;
180
192
  };
181
193
 
194
+ /**
195
+ * Internal: model paths named by filter keys, terms, logical/quantified groups, sort, groupId and
196
+ * facts. Each entry carries `use`, the correlation `container`, enclosing `frames` (relative path,
197
+ * quantifier `type`, marker `construct`), `relative` segments in its frame and absolute `segments`.
198
+ * A quantified carrier names its path in the containing frame, with `quantifier` and its own
199
+ * construct; children enter its frame. The legacy `group` remains the conditions owner, otherwise
200
+ * null; operator and construct retain their old meanings except that quantifier filters now carry
201
+ * their own construct. An omitted filter operator is EQ; non-filter reads and quantified carriers
202
+ * have no operator. `expandMembers` appends stored emptiness member paths, preserving metadata.
203
+ */
204
+ export const collectQueryPathEntries = (args, operation = 'find', options = {}) => (
205
+ walkQueryEntries(args, operation, { ...options, paths: true })
206
+ );
207
+
208
+ /**
209
+ * Internal: filter constructs only, with the same frame metadata as the path collector. Preserves
210
+ * form, field, rawPath, value, hasValue, ambiguous, deepest depth and emptiness. Quantified carriers
211
+ * have form `quantified`, operator null and their own construct; ordinary own filter operators keep
212
+ * construct null. Explicit groups add a depth level, implicit ones none. Invalid entries are left
213
+ * to the validator; unknown group keys and path on a plain group fail closed here.
214
+ */
215
+ export const collectQueryOperators = (args, operation = 'find') => walkQueryEntries(args, operation);
216
+
182
217
  /** Internal: the segment arrays of {@link collectQueryPathEntries}. */
183
218
  export const collectQueryPaths = (args, operation = 'find') => (
184
219
  collectQueryPathEntries(args, operation).map((entry) => entry.segments)
@@ -242,11 +277,15 @@ export const resolveModelPath = (models, entityName, path) => {
242
277
  * that path's target find scope arguments, planned on the target type in its own frame, or null when
243
278
  * the scope added nothing. A `__scope` without filled `scopes`, without one of those keys, or on a
244
279
  * construct that reads no reference or collection fails with DETACHED_CLIENT_FILTER (500) after the
245
- * checks above; a `__scope` anywhere else is never read.
280
+ * checks above. With `quantifiers`, collection scalar paths become correlated ANY frames,
281
+ * explicit ANY/ALL/NONE and quantifier filters retain their quantifier, and scoped reference
282
+ * crossings inside a frame lift into `joins`. Aggregate collection joins remain legacy; later
283
+ * frames carry their legacy chain in `anchor`. Sorts through collections fail even in count mode.
246
284
  */
247
- export const createQueryPlan = (models, entityName, input = {}, {
248
- mode = 'find', operators: supported, referenceEmptiness = supported == null,
285
+ export const createQueryPlan = (models, entityName, source = {}, {
286
+ mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null,
249
287
  } = {}) => {
288
+ let input = source;
250
289
  const allowed = supported == null ? null : new Set(supported);
251
290
  const references = referenceEmptiness === true;
252
291
  const combine = (kind, terms) => {
@@ -256,9 +295,8 @@ export const createQueryPlan = (models, entityName, input = {}, {
256
295
  // The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
257
296
  // arguments are planned on the target type, as trusted find arguments in their own frame, whatever
258
297
  // the query's mode, with the operator names already read (`operators` may be a one-time iterable).
259
- const liftScopes = (marker, shape, segments, operator) => {
298
+ const liftScopes = (marker, targets, segments, operator) => {
260
299
  const label = `${operator} on ${segments.join('.')}`;
261
- const targets = emptinessScopeTargets(shape, segments);
262
300
  const paths = Object.keys(targets);
263
301
  if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
264
302
  const scopes = marker?.scopes;
@@ -266,39 +304,40 @@ export const createQueryPlan = (models, entityName, input = {}, {
266
304
  const lifted = {};
267
305
  for (const path of paths) {
268
306
  if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
307
+ if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
269
308
  lifted[path] = scopes[path] === null
270
309
  ? null
271
- : createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references }).where;
310
+ : createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers }).where;
272
311
  }
273
312
  return lifted;
274
313
  };
275
314
  const hasMarker = (construct) => construct != null && typeof construct === 'object' && Object.hasOwn(construct, '__scope');
276
315
  // Rule 1 has resolved the path; rule 2 is the compiler's operators, rules 3 to 6 describeEmptiness's.
277
- const emptiness = (path, steps, operator, value, construct) => {
316
+ const emptiness = (path, steps, operator, value, construct, frameEntity) => {
278
317
  if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
279
318
  const segments = Array.isArray(path) ? path : path.split('.');
280
319
  const options = {
281
320
  operator, value, path: segments.join('.'), referenceEmptiness: references,
282
321
  };
283
- const gqltype = models.entities.find((item) => item.name === entityName)?.gqltype;
322
+ const gqltype = models.entities.find((item) => item.name === frameEntity)?.gqltype;
284
323
  const shape = gqltype ? describeEmptiness(gqltype, segments, options) : describeModelEmptiness(steps, options);
285
324
  const node = {
286
325
  kind: 'predicate', path: segments, operator, value: null,
287
326
  };
288
- if (hasMarker(construct)) node.scopes = liftScopes(construct.__scope, shape, segments, operator);
327
+ if (hasMarker(construct)) node.scopes = liftScopes(construct.__scope, emptinessScopeTargets(shape, segments), segments, operator);
289
328
  return node;
290
329
  };
291
330
  // `construct` is the term or group condition that holds the predicate, the only objects whose
292
331
  // `__scope` is read; null for a field filter's own operator.
293
- const predicate = (path, operator, value, construct = null) => {
294
- const steps = resolveModelPath(models, entityName, path);
332
+ const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false) => {
333
+ const steps = resolveModelPath(models, frameEntity, path);
295
334
  const op = operator ?? 'EQ';
296
- if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct);
335
+ if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameEntity);
297
336
  const field = steps.at(-1);
298
337
  if (field.kind !== 'scalar' && !field.inferred) {
299
338
  fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
300
339
  }
301
- if (!operators.has(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
340
+ if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
302
341
  if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
303
342
  const collection = ['IN', 'NIN', 'BTW'].includes(op);
304
343
  if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
@@ -306,28 +345,64 @@ export const createQueryPlan = (models, entityName, input = {}, {
306
345
  if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
307
346
  if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
308
347
  if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
309
- if (hasMarker(construct)) detached(`${op} on ${Array.isArray(path) ? path.join('.') : path} carries a client scope marker, but is not an emptiness condition`);
310
- return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
348
+ const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
349
+ if (hasMarker(construct)) {
350
+ if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);
351
+ const targets = Object.fromEntries(crossingKeys(node.path, { models, entity: frameEntity }).map((key) => [key, resolveModelPath(models, frameEntity, key).at(-1).target]));
352
+ node.joins = liftScopes(construct.__scope, targets, node.path, op);
353
+ }
354
+ return node;
311
355
  };
312
356
  const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
313
357
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
314
358
  for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
315
359
  };
316
- const group = (value, depth = 0) => {
360
+ const group = (value, depth = -1, frameEntity = entityName, inFrame = false) => {
317
361
  validateGroup(value);
318
- if (depth > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
362
+ const level = depth + (value.__implicit === true ? 0 : 1);
363
+ if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
319
364
  const terms = [];
320
365
  for (const condition of value.conditions || []) {
321
366
  if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
322
367
  if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
323
- terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition));
368
+ terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameEntity, inFrame));
324
369
  }
325
- for (const item of value.AND || []) terms.push(group(item, depth + 1));
326
- terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
370
+ if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
371
+ fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
372
+ }
373
+ for (const quantifier of QUANTIFIED_LISTS) for (const item of value[quantifier] || []) {
374
+ const anchor = item.__implicit === true ? item.__anchor : undefined;
375
+ const path = item.path.split('.');
376
+ const target = resolveModelPath(models, frameEntity, [...(anchor ?? []), ...path]).at(-1).target;
377
+ const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true), scope: null };
378
+ if (anchor?.length) node.anchor = anchor;
379
+ if (hasMarker(item)) {
380
+ const fullPath = [...(anchor ?? []), ...path];
381
+ const key = fullPath.join('.');
382
+ const crossings = anchor?.length ? [] : crossingKeys(path, { models, entity: frameEntity });
383
+ const targets = Object.fromEntries(crossings.map((prefix) => [prefix, resolveModelPath(models, frameEntity, prefix).at(-1).target]));
384
+ targets[key] = target;
385
+ const scopes = liftScopes(item.__scope, targets, fullPath, quantifier);
386
+ node.scope = scopes[key];
387
+ if (anchor?.length) {
388
+ for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
389
+ if (!Object.hasOwn(item.__scope.scopes, prefix) || item.__scope.scopes[prefix] !== null) detached(`${quantifier} on ${key} requires a null aggregate anchor scope for ${prefix}`);
390
+ }
391
+ } else if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
392
+ }
393
+ terms.push(node);
394
+ }
395
+ for (const item of value.AND || []) terms.push(group(item, level, frameEntity, inFrame));
396
+ terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame))));
327
397
  return combine('and', terms);
328
398
  };
329
- // Top-level arguments are field filters plus AND/OR; `conditions` only lists conditions inside groups.
399
+ // Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
330
400
  validateGroup(input, ['AND', 'OR']);
401
+ const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers };
402
+ const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
403
+ context.J = J;
404
+ validateQuantifiedGroups(input, context);
405
+ input = frameCollectionPaths(input, context);
331
406
  const controls = controlArgs(mode);
332
407
  const terms = [];
333
408
  for (const [name, value] of Object.entries(input)) {
@@ -371,16 +446,22 @@ export const createQueryPlan = (models, entityName, input = {}, {
371
446
  plan.aggregation = { groupId: aggregation.groupId.split('.'), facts: aggregation.facts.map((fact) => ({ ...fact, path: fact.path.split('.') })) };
372
447
  }
373
448
  if (input.sort && (!Array.isArray(input.sort.terms) || !input.sort.terms.length)) fail('Sort requires non-empty terms', 'INVALID_SORT');
374
- if (mode !== 'count') for (const sort of input.sort?.terms || []) {
449
+ for (const sort of input.sort?.terms || []) {
375
450
  if (!sort || (sort.order !== 'ASC' && sort.order !== 'DESC')) fail('Invalid sort order', 'INVALID_SORT');
376
451
  if (typeof sort.field !== 'string' || sort.field.split('.').some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) {
377
452
  fail('Invalid sort path', 'INVALID_FILTER_PATH');
378
453
  }
379
454
  if (mode !== 'aggregate') {
380
- const field = resolveModelPath(models, entityName, sort.field).at(-1);
455
+ const steps = resolveModelPath(models, entityName, sort.field);
456
+ let owner = entityName;
457
+ for (const step of steps) {
458
+ if (step.kind === 'collection') fail(`Sort path ${sort.field} crosses the collection ${owner}.${step.name}; a record has no single value there`, 'INVALID_FILTER_PATH');
459
+ if (step.target && !step.inferred) owner = step.target;
460
+ }
461
+ const field = steps.at(-1);
381
462
  if (field.kind !== 'scalar' && !field.inferred) fail('Sort path must end in a scalar field', 'INVALID_FILTER_PATH');
382
463
  }
383
- plan.sort.push({ path: sort.field.split('.'), order: sort.order });
464
+ if (mode !== 'count') plan.sort.push({ path: sort.field.split('.'), order: sort.order });
384
465
  }
385
466
  return plan;
386
467
  };