@simtlix/simfinity-core 3.16.0 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query-plan.js CHANGED
@@ -2,9 +2,10 @@ 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
- takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
8
+ hasValuePath, takesListValue, textQueryOperator, validateComparisonOperand, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
8
9
  } from './query-operators.js';
9
10
 
10
11
  const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
@@ -45,7 +46,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
45
46
  const records = [];
46
47
  const open = new Set();
47
48
  const childFrame = (parent, path, quantifier, construct, anchor = []) => ({
48
- base: [...parent.base, ...anchor, ...pathSegments(path)],
49
+ parent, base: [...parent.base, ...anchor, ...pathSegments(path)],
49
50
  frames: [...parent.frames, { path, type: quantifier, construct }],
50
51
  groups: new Map(),
51
52
  });
@@ -96,7 +97,26 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
96
97
  entries.push({ ...entry, segments, relative: segments.slice(frame.base.length), member: true });
97
98
  }
98
99
  };
99
- const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier) => {
100
+ const operandPath = (item, prefix = '') => hasValuePath(item)
101
+ ? (prefix && !(typeof item.valuePath === 'string' && item.valuePath.startsWith('$parent.')) ? `${prefix}.${item.valuePath}` : item.valuePath) : undefined;
102
+ const addPredicatePaths = (path, item, frame, container, group = null, prefix = '') => {
103
+ const comparison = hasValuePath(item) || item?.__operand != null;
104
+ addPath(path, frame, container, group, item?.operator ?? 'EQ', comparison ? item : item ?? null,
105
+ comparison ? { role: 'left', comparison: true } : {});
106
+ if (!comparison) return;
107
+ let origin = frame;
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('.');
114
+ }
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('.');
117
+ addPath(right, origin, container, group, item.operator ?? 'EQ', item, { role: 'right', comparison: true });
118
+ };
119
+ const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier, prefix = '') => {
100
120
  if (!isObject(item)) return;
101
121
  const quantified = form === 'quantified';
102
122
  const relative = pathSegments(relativePath);
@@ -112,9 +132,11 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
112
132
  rawPath,
113
133
  value: item.value,
114
134
  hasValue: Object.hasOwn(item, 'value'),
135
+ ...(hasValuePath(item) ? { hasValuePath: true, valuePath: operandPath(item, prefix), ...(prefix && typeof item.valuePath === 'string' && item.valuePath.startsWith('$parent.') ? { valuePathPrefix: prefix } : {}) } : {}),
136
+ ...(Object.hasOwn(item, '__operand') ? { operand: item.__operand } : {}),
115
137
  ambiguous: form === 'condition' && typeof field === 'string' && field.includes('.') && !isPathless(rawPath),
116
138
  depth,
117
- construct: quantified || form === 'term' || form === 'condition' ? item : null,
139
+ construct: quantified || form === 'term' || form === 'condition' || hasValuePath(item) || item.__operand ? item : null,
118
140
  emptiness: isEmptinessOperator(operator),
119
141
  ...(quantified ? { quantifier } : {}),
120
142
  });
@@ -134,7 +156,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
134
156
  const field = condition?.field;
135
157
  const path = conditionPath(field, condition?.path);
136
158
  if (paths) {
137
- if (typeof field === 'string') addPath(path, inner, group, group, condition.operator ?? 'EQ', condition);
159
+ if (typeof field === 'string') addPredicatePaths(path, condition, inner, group, group);
138
160
  } else addOperator(condition, 'condition', field, condition?.path, depth, inner, group, path);
139
161
  }
140
162
  continue;
@@ -149,7 +171,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
149
171
  if (paths) {
150
172
  // Invalid path-less terms still name the field, as before; validation rejects them
151
173
  // 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);
174
+ if (hasTermPath(term)) addPredicatePaths(term.path, term, frame, value);
153
175
  else if (isPathless(term.path)) {
154
176
  entries.push({ ...metadata([], frame, value), group: null, operator: term.operator ?? 'EQ', construct: term });
155
177
  }
@@ -159,20 +181,21 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
159
181
  }
160
182
  if (!paths) {
161
183
  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));
184
+ if (!hasTerms || value.operator !== undefined || hasValuePath(value)) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
185
+ if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path), undefined, name);
164
186
  continue;
165
187
  }
166
188
  const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
167
189
  if (!terms.some(hasTermPath)) {
168
190
  const term = terms.find(pathless);
169
191
  if (term) addPath(name, root, value, null, term.operator, term);
192
+ else if (hasValuePath(value)) addPredicatePaths(name, value, root, value);
170
193
  else addPath(name, root, value, null, value.operator ?? 'EQ');
171
194
  continue;
172
195
  }
173
196
  let field = false;
174
197
  for (const term of terms) {
175
- if (hasTermPath(term)) addPath(`${name}.${term.path}`, root, value, null, term.operator ?? 'EQ', term);
198
+ if (hasTermPath(term)) addPredicatePaths(`${name}.${term.path}`, term, root, value, null, name);
176
199
  else if (!field && pathless(term)) {
177
200
  addPath(name, root, value, null, term.operator, term);
178
201
  field = true;
@@ -198,7 +221,9 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
198
221
  * A quantified carrier names its path in the containing frame, with `quantifier` and its own
199
222
  * construct; children enter its frame. The legacy `group` remains the conditions owner, otherwise
200
223
  * 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
224
+ * their own construct. Comparisons emit left/right roles with comparison:true and one owning
225
+ * construct, including ordinary field filters; RHS paths preserve their lexical term/frame origin.
226
+ * An omitted filter operator is EQ; non-filter reads and quantified carriers
202
227
  * have no operator. `expandMembers` appends stored emptiness member paths, preserving metadata.
203
228
  */
204
229
  export const collectQueryPathEntries = (args, operation = 'find', options = {}) => (
@@ -209,7 +234,8 @@ export const collectQueryPathEntries = (args, operation = 'find', options = {})
209
234
  * Internal: filter constructs only, with the same frame metadata as the path collector. Preserves
210
235
  * form, field, rawPath, value, hasValue, ambiguous, deepest depth and emptiness. Quantified carriers
211
236
  * 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
237
+ * construct null. Comparisons add hasValuePath:true and a frame-relative valuePath; their owning
238
+ * construct is always retained. Generated outer comparisons carry operand instead. Explicit groups add a depth level, implicit ones none. Invalid entries are left
213
239
  * to the validator; unknown group keys and path on a plain group fail closed here.
214
240
  */
215
241
  export const collectQueryOperators = (args, operation = 'find') => walkQueryEntries(args, operation);
@@ -290,11 +316,16 @@ export const resolveModelPath = (models, entityName, path) => {
290
316
  * explicit ANY/ALL/NONE and quantifier filters retain their quantifier, and scoped reference
291
317
  * crossings inside a frame lift into `joins`. Aggregate collection joins remain legacy; later
292
318
  * frames carry their legacy chain in `anchor`. Sorts through collections fail even in count mode.
319
+ * With `valuePath` (the same default as `quantifiers`), comparisons preserve their literal-free
320
+ * value:null placeholder and add operand:{up,path}. Same-frame scoped joins are the union of both
321
+ * paths on the predicate; generated outer operands own their joins in their ancestor frame.
293
322
  */
294
323
  export const createQueryPlan = (models, entityName, source = {}, {
295
- mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null,
324
+ mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null, valuePath = supported == null, elementFrames = supported == null,
296
325
  } = {}) => {
297
326
  let input = source;
327
+ const rootContext = { models, entity: entityName };
328
+ const modelSteps = (context, path) => resolveQueryContext(path, context).raw;
298
329
  const allowed = supported == null ? null : new Set(supported);
299
330
  const references = referenceEmptiness === true;
300
331
  const combine = (kind, terms) => {
@@ -304,31 +335,35 @@ export const createQueryPlan = (models, entityName, source = {}, {
304
335
  // The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
305
336
  // arguments are planned on the target type, as trusted find arguments in their own frame, whatever
306
337
  // the query's mode, with the operator names already read (`operators` may be a one-time iterable).
307
- const liftScopes = (marker, targets, segments, operator) => {
338
+ const liftScopes = (marker, targets, segments, operator, anchors = []) => {
308
339
  const label = `${operator} on ${segments.join('.')}`;
309
- const paths = Object.keys(targets);
340
+ const paths = [...Object.keys(targets), ...anchors];
310
341
  if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
311
342
  const scopes = marker?.scopes;
312
343
  if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled`);
313
344
  const lifted = {};
314
345
  for (const path of paths) {
315
346
  if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
347
+ if (anchors.includes(path)) {
348
+ if (scopes[path] !== null) detached(`${label} requires a null aggregate anchor scope for ${path}`);
349
+ continue;
350
+ }
316
351
  if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
317
352
  lifted[path] = scopes[path] === null
318
353
  ? null
319
- : createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers }).where;
354
+ : createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers, valuePath, elementFrames }).where;
320
355
  }
321
356
  return lifted;
322
357
  };
323
358
  const hasMarker = (construct) => construct != null && typeof construct === 'object' && Object.hasOwn(construct, '__scope');
324
359
  // Rule 1 has resolved the path; rule 2 is the compiler's operators, rules 3 to 6 describeEmptiness's.
325
- const emptiness = (path, steps, operator, value, construct, frameEntity) => {
360
+ const emptiness = (path, steps, operator, value, construct, frameContext) => {
326
361
  if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
327
362
  const segments = Array.isArray(path) ? path : path.split('.');
328
363
  const options = {
329
364
  operator, value, path: segments.join('.'), referenceEmptiness: references,
330
365
  };
331
- 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;
332
367
  const shape = gqltype ? describeEmptiness(gqltype, segments, options) : describeModelEmptiness(steps, options);
333
368
  const node = {
334
369
  kind: 'predicate', path: segments, operator, value: null,
@@ -338,16 +373,44 @@ export const createQueryPlan = (models, entityName, source = {}, {
338
373
  };
339
374
  // `construct` is the term or group condition that holds the predicate, the only objects whose
340
375
  // `__scope` is read; null for a field filter's own operator.
341
- const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false) => {
342
- 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);
343
378
  const op = operator ?? 'EQ';
344
- if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameEntity);
379
+ if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameContext);
345
380
  const field = steps.at(-1);
346
381
  if (field.kind !== 'scalar' && !field.inferred) {
347
382
  fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
348
383
  }
349
384
  if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
350
385
  if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
386
+ const comparison = hasValuePath(construct) || construct?.__operand !== undefined;
387
+ if (comparison) {
388
+ const internal = construct.__operand;
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('.')]);
394
+ const up = internal?.up ?? 0;
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);
398
+ const segments = Array.isArray(path) ? path : path.split('.');
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]));
401
+ // J collections retain their existing aggregate row. Validate their provenance, but only
402
+ // references become scoped joins; an empty collection scope must not lose the marker.
403
+ const anchors = (paths, entity) => [...new Set(paths.flatMap((parts) => modelSteps(entity, parts)
404
+ .flatMap((step, index) => step.kind === 'collection' ? [parts.slice(0, index + 1).join('.')] : [])))];
405
+ if (hasMarker(construct)) {
406
+ const paths = up || container ? [segments] : [segments, rightPath];
407
+ node.joins = liftScopes(construct.__scope, scopeTargets(paths, frameContext), segments, op, anchors(paths, frameContext));
408
+ }
409
+ if (internal && Object.hasOwn(internal, 'scopes')) {
410
+ node.operand.joins = liftScopes({ scopes: internal.scopes }, scopeTargets([rightPath], rightContext), rightPath, op, anchors([rightPath], rightContext));
411
+ }
412
+ return node;
413
+ }
351
414
  const collection = takesListValue(op);
352
415
  if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
353
416
  if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
@@ -359,16 +422,18 @@ export const createQueryPlan = (models, entityName, source = {}, {
359
422
  const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
360
423
  if (hasMarker(construct)) {
361
424
  if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);
362
- 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]));
363
426
  node.joins = liftScopes(construct.__scope, targets, node.path, op);
364
427
  }
365
428
  return node;
366
429
  };
430
+ const hasComparisonNode = (node) => Boolean(node && (node.operand || hasComparisonNode(node.where)
431
+ || node.terms?.some(hasComparisonNode)));
367
432
  const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
368
433
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
369
434
  for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
370
435
  };
371
- const group = (value, depth = -1, frameEntity = entityName, inFrame = false) => {
436
+ const group = (value, depth = -1, frameContext = rootContext, inFrame = false, ancestors = []) => {
372
437
  validateGroup(value);
373
438
  const level = depth + (value.__implicit === true ? 0 : 1);
374
439
  if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
@@ -376,7 +441,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
376
441
  for (const condition of value.conditions || []) {
377
442
  if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
378
443
  if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
379
- terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameEntity, inFrame));
444
+ terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameContext, inFrame, ancestors));
380
445
  }
381
446
  if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
382
447
  fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
@@ -384,32 +449,36 @@ export const createQueryPlan = (models, entityName, source = {}, {
384
449
  for (const quantifier of QUANTIFIED_LISTS) for (const item of value[quantifier] || []) {
385
450
  const anchor = item.__implicit === true ? item.__anchor : undefined;
386
451
  const path = item.path.split('.');
387
- const target = resolveModelPath(models, frameEntity, [...(anchor ?? []), ...path]).at(-1).target;
388
- const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true), 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 };
389
456
  if (anchor?.length) node.anchor = anchor;
390
457
  if (hasMarker(item)) {
391
458
  const fullPath = [...(anchor ?? []), ...path];
392
459
  const key = fullPath.join('.');
393
- const crossings = anchor?.length ? [] : crossingKeys(path, { models, entity: frameEntity });
394
- const targets = Object.fromEntries(crossings.map((prefix) => [prefix, resolveModelPath(models, frameEntity, prefix).at(-1).target]));
395
- targets[key] = target;
396
- const scopes = liftScopes(item.__scope, targets, fullPath, quantifier);
397
- 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;
398
466
  if (anchor?.length) {
399
467
  for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
400
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}`);
401
469
  }
402
- } else if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
470
+ }
471
+ if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
403
472
  }
404
473
  terms.push(node);
405
474
  }
406
- for (const item of value.AND || []) terms.push(group(item, level, frameEntity, inFrame));
407
- terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame))));
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))));
408
477
  return combine('and', terms);
409
478
  };
410
479
  // Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
411
480
  validateGroup(input, ['AND', 'OR']);
412
- const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers };
481
+ const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers, valuePath, elementFrames };
413
482
  const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
414
483
  context.J = J;
415
484
  validateQuantifiedGroups(input, context);
@@ -428,7 +497,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
428
497
  + `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
429
498
  }
430
499
  if (object && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
431
- if (!object) terms.push(predicate(name, value.operator, value.value));
500
+ if (!object) terms.push(predicate(name, value.operator, value.value, hasValuePath(value) || value.__operand ? value : null));
432
501
  else for (const term of value.terms) {
433
502
  if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
434
503
  if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
@@ -436,7 +505,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
436
505
  continue;
437
506
  }
438
507
  if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
439
- terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term));
508
+ terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term, rootContext, false, [], name));
440
509
  }
441
510
  }
442
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';
@@ -26,7 +27,7 @@ import QLSort from './const/QLSort.js';
26
27
  import {
27
28
  collectQueryOperators, collectQueryPathEntries, walkQueryPath,
28
29
  } from './query-plan.js';
29
- import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
30
+ import { QUERY_OPERATORS, hasValuePath, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
30
31
  import {
31
32
  assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
32
33
  } from './query-emptiness.js';
@@ -210,6 +211,7 @@ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
210
211
  field: `${prefix}.${condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field}`,
211
212
  operator: condition?.operator,
212
213
  value: condition?.value,
214
+ ...(hasValuePath(condition) ? { valuePath: `${prefix}.${condition.valuePath}` } : {}),
213
215
  }));
214
216
  }
215
217
  if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
@@ -220,7 +222,7 @@ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
220
222
  return prefixed;
221
223
  };
222
224
 
223
- const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
225
+ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup, gqltype) => {
224
226
  const conditions = [];
225
227
  const groups = [];
226
228
  for (const [name, value] of Object.entries(scopeArgs)) {
@@ -243,9 +245,15 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
243
245
  // An emptiness term without a path (omitted, null or "") tests the field itself; other
244
246
  // operators keep the path as written.
245
247
  const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
246
- conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
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}`;
250
+ conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value,
251
+ ...(hasValuePath(term) ? { valuePath: `${prefix}.${rightPath}` } : {}),
252
+ });
247
253
  }
248
- } else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
254
+ } else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value,
255
+ ...(hasValuePath(value) ? { valuePath: `${prefix}.${value.valuePath}` } : {}),
256
+ });
249
257
  }
250
258
  return conditions.length ? [{ conditions }, ...groups] : groups;
251
259
  };
@@ -599,7 +607,7 @@ export const createRuntime = (adapter) => {
599
607
  // IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
600
608
  // conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
601
609
  const {
602
- operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
610
+ operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers, valuePath: supportsValuePath, elementFrames: supportsElementFrames,
603
611
  } = resolveQueryFeatures(adapter.queryFeatures);
604
612
  const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
605
613
  // The write features the adapter implements, read once: with optimisticConcurrency, schemas may
@@ -735,6 +743,7 @@ const QLFilter = new GraphQLInputObjectType({
735
743
  fields: () => ({
736
744
  operator: { type: QLOperator },
737
745
  value: { type: QLValue },
746
+ valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
738
747
  }),
739
748
  });
740
749
 
@@ -743,6 +752,7 @@ const QLTypeFilter = new GraphQLInputObjectType({
743
752
  fields: () => ({
744
753
  operator: { type: QLOperator },
745
754
  value: { type: QLValue },
755
+ valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
746
756
  path: { type: GraphQLString },
747
757
  }),
748
758
  });
@@ -768,6 +778,7 @@ const QLFilterCondition = new GraphQLInputObjectType({
768
778
  field: { type: new GraphQLNonNull(GraphQLString) },
769
779
  operator: { type: QLOperator },
770
780
  value: { type: QLValue },
781
+ valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
771
782
  path: { type: GraphQLString },
772
783
  }),
773
784
  });
@@ -2291,13 +2302,13 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
2291
2302
  const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
2292
2303
  const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
2293
2304
  for (const {
2294
- segments, group, member, operator, use, frames, quantifier,
2305
+ segments, group, member, operator, use, frames, quantifier, comparison,
2295
2306
  } of entries) {
2296
2307
  const filter = use === 'filter';
2297
2308
  const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
2298
2309
  const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
2299
2310
  const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
2300
- const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
2311
+ const joins = !member && !comparison && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
2301
2312
  && !(filter && (frames.length || quantifier || (cut && !anchored)));
2302
2313
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
2303
2314
  const path = segments.slice(0, index + 1).join('.');
@@ -2341,7 +2352,7 @@ let virtualFieldsDeclared = false;
2341
2352
  // (500), the code of model declarations that cannot be honored at request time.
2342
2353
  const assertStoredQueryPaths = (gqltype, args, operation) => {
2343
2354
  if (!virtualFieldsDeclared) return;
2344
- for (const { segments } of collectQueryPathEntries(args, operation)) {
2355
+ for (const { segments } of collectQueryPathEntries(args, operation, { gqltype })) {
2345
2356
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
2346
2357
  if (!isVirtualField(field)) return;
2347
2358
  throw new SimfinityError(`Query path ${segments.slice(0, index + 1).join('.')}, added by middleware or a `
@@ -2400,7 +2411,7 @@ const assertEmptinessForm = (gqltype, {
2400
2411
  // carriers, which do not become predicates but must not use known undeclared capabilities.
2401
2412
  const queryFrameContext = (gqltype, args, operation) => ({
2402
2413
  gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
2403
- operators: supportedOperators,
2414
+ operators: supportedOperators, valuePath: supportsValuePath, elementFrames: supportsElementFrames,
2404
2415
  J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
2405
2416
  scopeMarkers: 'pending',
2406
2417
  });
@@ -2484,7 +2495,7 @@ const assertJoinedScopePaths = (rootType, groups, prefix, target) => {
2484
2495
  embeddedPrefix ||= !!(field.extensions?.relation?.embedded && getListShape(field.type));
2485
2496
  });
2486
2497
  const depth = prefix.split('.').length;
2487
- for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups })) {
2498
+ for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups }, 'find', { gqltype: rootType })) {
2488
2499
  let inList = false;
2489
2500
  walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field, index }) => {
2490
2501
  const relation = field.extensions?.relation;
@@ -2541,7 +2552,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
2541
2552
  const kept = scopeFilterArgs(scopeArgs);
2542
2553
  assertStoredQueryPaths(target.gqltype, kept, 'find');
2543
2554
  assertQueryConditions(target.gqltype, kept, 'find');
2544
- const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup);
2555
+ const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup, target.gqltype);
2545
2556
  if (!groups.length) continue;
2546
2557
  if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
2547
2558
  assertJoinedScopePaths(params.type.gqltype, groups, prefix, target);
@@ -2614,8 +2625,9 @@ const markClientConstructs = (checked, { aggregation, issued = new Map() } = {})
2614
2625
  };
2615
2626
  for (const { entry, shape } of conditions) {
2616
2627
  const { construct } = entry;
2617
- if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
2618
- const targets = pathRelations(gqltype, entry.segments).map(({ typeName }) => typeName);
2628
+ if (!construct || !(shape || entry.hasValuePath || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
2629
+ const comparisonPath = entry.hasValuePath ? resolveComparisonContext(entry.valuePath, queryEntryContext(entry, { gqltype }), entry.valuePathPrefix).resolved.end.absolute : [];
2630
+ const targets = [...pathRelations(gqltype, entry.segments), ...pathRelations(gqltype, comparisonPath)].map(({ typeName }) => typeName);
2619
2631
  if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
2620
2632
  if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
2621
2633
  const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
@@ -2658,7 +2670,8 @@ const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
2658
2670
  throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
2659
2671
  }
2660
2672
  if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
2661
- for (const relation of pathRelations(gqltype, entry.segments)) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
2673
+ const operandPaths = [entry.segments, ...(entry.hasValuePath ? [resolveComparisonContext(entry.valuePath, queryEntryContext(entry, { gqltype }), entry.valuePathPrefix).resolved.end.absolute] : [])];
2674
+ for (const relation of operandPaths.flatMap((parts) => pathRelations(gqltype, parts))) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
2662
2675
  throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
2663
2676
  }
2664
2677
  }
@@ -2735,7 +2748,15 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2735
2748
  const vectors = [];
2736
2749
  for (const { entry, shape, type } of positions) {
2737
2750
  const relations = pathRelations(type, entry.relative);
2738
- if (!shape && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
2751
+ if (entry.hasValuePath) {
2752
+ const existing = new Set(relations.map(({ path }) => 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)));
2758
+ }
2759
+ if (!shape && !entry.hasValuePath && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
2739
2760
  const relation = relationOnPath(gqltype, entry.segments);
2740
2761
  if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
2741
2762
  + `${entry.path}, which reads through ${relation} without its scope`);
@@ -2744,7 +2765,7 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2744
2765
  const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
2745
2766
  const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
2746
2767
  const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
2747
- .filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
2768
+ .filter((relation) => entry.hasValuePath || !anchored || relation.collection || (cut && relation.index >= cut.index))
2748
2769
  .map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
2749
2770
  for (const [key, target] of Object.entries(keys)) {
2750
2771
  if (Object.hasOwn(targets, key) && targets[key] !== target) {
@@ -2753,7 +2774,8 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2753
2774
  targets[key] = target;
2754
2775
  }
2755
2776
  if (!Object.keys(keys).length) unread.push(entry.path);
2756
- 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)) {
2757
2779
  vectors.push({ ...relation, absolute: [...entry.segments.slice(0, -entry.relative.length), ...relation.path.split('.')].join('.') });
2758
2780
  }
2759
2781
  }
package/types/index.d.ts CHANGED
@@ -77,6 +77,8 @@ export interface QLFilterCondition {
77
77
  field: string;
78
78
  operator?: QueryOperator | null;
79
79
  value?: any;
80
+ /** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
81
+ valuePath?: string | null;
80
82
  path?: string | null;
81
83
  }
82
84
 
@@ -101,6 +103,8 @@ export interface QLTypeFilterExpression {
101
103
  terms?: Array<{
102
104
  operator?: QueryOperator | null;
103
105
  value?: any;
106
+ /** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
107
+ valuePath?: string | null;
104
108
  path?: string | null;
105
109
  } | null> | null;
106
110
  }
@@ -115,6 +119,8 @@ export interface FilterCondition {
115
119
  field: string;
116
120
  operator?: QueryOperator | (string & {});
117
121
  value?: any;
122
+ /** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
123
+ valuePath?: string | null;
118
124
  path?: string;
119
125
  }
120
126
 
@@ -156,6 +162,10 @@ export interface QueryFeatures {
156
162
  * legacy joins. All adapters reject list sorts through collections, including count mode.
157
163
  */
158
164
  quantifiers?: boolean | null | undefined;
165
+ /** Field operands, including scoped references. Requires referenceEmptiness: true. */
166
+ valuePath?: boolean | null | undefined;
167
+ /** Embedded element frames and structural parent operands. Requires all three preceding flags. */
168
+ elementFrames?: boolean | null | undefined;
159
169
  }
160
170
 
161
171
  export interface ModelRegistration {
@@ -176,6 +186,10 @@ export interface FieldDescription {
176
186
  scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum' | 'Version';
177
187
  values?: string[];
178
188
  enumValues?: { name: string; value: unknown }[];
189
+ /** Logical GraphQL enum identity, required for field comparisons. */
190
+ enumName?: string;
191
+ /** Effective temporal base before storage normalizes Date and Time to DateTime. */
192
+ temporalName?: 'DateTime' | 'Date' | 'Time';
179
193
  target?: string;
180
194
  connectionField?: string;
181
195
  fields?: FieldDescription[];
@@ -897,6 +911,17 @@ export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapt
897
911
  /** Scoped single-reference joins, keyed by a dotted prefix relative to the containing frame. */
898
912
  export type QueryJoins = Record<string, QueryPredicate | null>;
899
913
 
914
+ /** A field operand in the same frame (up: 0) or an enclosing shorthand frame. */
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
+ };
924
+
900
925
  /**
901
926
  * `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. For
902
927
  * `CONTAINS_ALL` the value is a non-empty array of non-null scalars, validated and not cast; the
@@ -918,10 +943,11 @@ export type QueryJoins = Record<string, QueryPredicate | null>;
918
943
  * A `quantified` node selects elements with `ANY`, `ALL` or `NONE`, applying `scope` before `where`;
919
944
  * both predicates use paths relative to the element, and null means no restriction. Empty elements
920
945
  * give ANY false, ALL/NONE true; scope is never negated, and frames never multiply roots. `anchor` is
921
- * an aggregate's legacy join chain; when present, `path` is relative to it and `joins` is absent.
946
+ * an aggregate's legacy join chain; when present, `path` is relative to it. Comparison-bearing
947
+ * anchored frames may carry full-prefix joins as visibility guards, preserving the existing J row.
922
948
  */
923
949
  export type QueryPredicate =
924
- | { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
950
+ | { kind: 'predicate'; path: string[]; operator: string; value: any; operand?: QueryOperand; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
925
951
  | { kind: 'and' | 'or'; terms: QueryPredicate[] }
926
952
  | {
927
953
  kind: 'quantified';
@@ -975,6 +1001,13 @@ export interface QueryPlan {
975
1001
  * `quantifiers`, a row filter on an aggregate's join prefix, a legacy join otherwise. `CONTAINS_ALL`
976
1002
  * is never cut into a frame.
977
1003
  *
1004
+ * `valuePath` selects a compatible stored scalar operand instead of a literal value. It defaults
1005
+ * like `quantifiers`; a comparison node has `value: null` and `operand: { up, path, joins? }`.
1006
+ * Same-frame operands use the union of scoped paths in `joins`; internal shorthand outer bindings
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.
1008
+ * Null equality is two-valued, and a left scalar list uses null-safe EQ/NE membership.
1009
+ * Embedded-list comparisons require elementFrames; right scalar lists and incompatible logical types fail explicitly.
1010
+ *
978
1011
  * `referenceEmptiness` defaults to true when `operators` is omitted and to false when it is given.
979
1012
  * When it is false, an emptiness condition that crosses or ends at a reference or a collection, or
980
1013
  * reads a reference or collection member, fails with `UNSUPPORTED_QUERY` where the order above has
@@ -995,7 +1028,7 @@ export interface QueryPlan {
995
1028
  * checked before the path rule, the rest after it.
996
1029
  * A marker not filled for every path fails with `DETACHED_CLIENT_FILTER` (500).
997
1030
  */
998
- export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: 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;
999
1032
  export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
1000
1033
 
1001
1034
  /**