@simtlix/simfinity-core 3.16.0 → 3.17.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
@@ -84,3 +84,5 @@ pinned to the facade's exact version, including under pnpm or Yarn PnP. A late
84
84
  call fails without deleting installed metadata. Migrate clients selecting
85
85
  `__Field.extensions` before opting out. See the [descriptor reference](https://simtlix.github.io/simfinity.js/reference/client-descriptor.html)
86
86
  for the format, protected HTTP access and composition limits.
87
+
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.16.0",
3
+ "version": "3.17.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/metadata.js CHANGED
@@ -54,7 +54,7 @@ const scalarDescription = (type) => {
54
54
  if (storedValues.has(stored) && !Object.is(storedValues.get(stored), entry.value)) invalid(`Enum storage collision in ${type.name}: distinct internal values serialize to ${stored}`);
55
55
  storedValues.set(stored, entry.value);
56
56
  }
57
- return { scalar: 'Enum', values: [...storedValues.keys()], enumValues: type.getValues().map(({ name, value }) => ({ name, value })) };
57
+ return { scalar: 'Enum', enumName: type.name, values: [...storedValues.keys()], enumValues: type.getValues().map(({ name, value }) => ({ name, value })) };
58
58
  }
59
59
  // The walk stops on a type that still has a base only where the chain repeats.
60
60
  if (rootScalar(type).baseScalarType) invalid(`Cyclic base scalar ${type.name}`);
@@ -63,7 +63,7 @@ const scalarDescription = (type) => {
63
63
  if (!['String', 'ID', 'Int', 'Float', 'Boolean', 'DateTime'].includes(scalar)) {
64
64
  invalid(`Unsupported scalar ${type.name}: declare a supported baseScalarType`);
65
65
  }
66
- return { scalar };
66
+ return { scalar, ...(scalar === 'DateTime' ? { temporalName: name } : {}) };
67
67
  };
68
68
 
69
69
  // The version field of a protected entity, described the same way whether the type carries Simfinity's
@@ -0,0 +1,54 @@
1
+ import SimfinityError from './errors/simfinity.error.js';
2
+
3
+ const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
4
+ const equality = new Set(['EQ', 'NE']);
5
+ const ordered = new Set(['LT', 'LTE', 'GT', 'GTE']);
6
+ const text = new Set(['LIKE', 'STARTS_WITH', 'ENDS_WITH']);
7
+ const temporal = new Set(['DateTime', 'Date', 'Time']);
8
+
9
+ /** A nullable optional path selects a field operand only when non-null and non-undefined. */
10
+ export const hasValuePath = (carrier) => carrier?.valuePath !== undefined && carrier?.valuePath !== null;
11
+
12
+ /** Validate the public operand syntax before resolving either path. Literal carriers are unchanged. */
13
+ export const validateComparisonOperand = (carrier) => {
14
+ if (!hasValuePath(carrier)) return null;
15
+ if (carrier.value !== undefined) fail('A filter cannot supply both value and valuePath');
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');
19
+ }
20
+ return carrier.valuePath;
21
+ };
22
+
23
+ const typeOf = (field, path) => {
24
+ if (field.identity || field.scalar === 'ID') return 'ID';
25
+ if (field.scalar === 'Enum') {
26
+ if (!field.enumName) fail(`Field comparison on ${path} requires logical enumName metadata`, 'UNSUPPORTED_QUERY');
27
+ return `Enum:${field.enumName}`;
28
+ }
29
+ if (temporal.has(field.scalar)) {
30
+ if (!field.temporalName) fail(`Field comparison on ${path} requires effective temporalName metadata`, 'UNSUPPORTED_QUERY');
31
+ return field.temporalName;
32
+ }
33
+ return field.scalar;
34
+ };
35
+
36
+ /** Logical comparison rules shared by runtime/model validation and hand-built engine plans. */
37
+ export const validateComparisonTypes = (left, right, operator = 'EQ', leftPath = 'left', rightPath = 'right') => {
38
+ if (right.list) fail(`The right operand ${rightPath} must be a scalar, not a list`, 'INVALID_FILTER_PATH');
39
+ if (!equality.has(operator) && !ordered.has(operator) && !text.has(operator)) {
40
+ fail(`${operator} does not accept valuePath`, 'INVALID_FILTER_OPERATOR');
41
+ }
42
+ if (left.list && !equality.has(operator)) fail(`${operator} does not accept a list left operand`, 'INVALID_FILTER_OPERATOR');
43
+ const a = typeOf(left, leftPath);
44
+ const b = typeOf(right, rightPath);
45
+ const numeric = ['Int', 'Float'].includes(a) && ['Int', 'Float'].includes(b);
46
+ const supported = numeric || (a === b && (['String', 'Boolean', 'ID'].includes(a) || temporal.has(a) || a?.startsWith('Enum:')));
47
+ if (!supported || left.version || right.version) {
48
+ fail(`Incompatible field comparison: ${leftPath} (${a}) and ${rightPath} (${b})`);
49
+ }
50
+ if (text.has(operator) && a !== 'String') fail(`${operator} requires String operands`, 'INVALID_FILTER_OPERATOR');
51
+ if (ordered.has(operator) && !numeric && a !== 'String' && !temporal.has(a)) {
52
+ fail(`${operator} does not order ${a} operands`, 'INVALID_FILTER_OPERATOR');
53
+ }
54
+ };
@@ -1,4 +1,4 @@
1
- import { getNamedType, isObjectType } from 'graphql';
1
+ import { getNamedType, isEnumType, 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
4
  import { effectiveScalarName } from './metadata.js';
@@ -8,7 +8,7 @@ import {
8
8
  } from './query-emptiness.js';
9
9
  import {
10
10
  assertContainsAllPath, isQueryOperator, takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue,
11
- validateTextListFilterValue,
11
+ validateTextListFilterValue, hasValuePath, validateComparisonOperand, validateComparisonTypes,
12
12
  } from './query-operators.js';
13
13
  import { collectQueryOperators, validateQueryGroupKeys } from './query-plan.js';
14
14
  import { getQueryMaxFrameNesting } from './query-limits.js';
@@ -56,13 +56,17 @@ const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) =>
56
56
  fail(`Query path ${label} uses the virtual field ${current.gqltype.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
57
57
  }
58
58
  step = graphqlStep(name, field);
59
- step.scalar = effectiveScalarName(getNamedType(field.type));
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';
60
64
  raw.push(field);
61
65
  } else {
62
66
  const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
63
67
  const field = fields?.find((item) => item.name === name && !item.private);
64
68
  if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
65
- step = { ...modelStep(field), scalar: field.scalar };
69
+ step = { ...field, ...modelStep(field) };
66
70
  if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
67
71
  raw.push(field);
68
72
  }
@@ -74,6 +78,24 @@ const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) =>
74
78
  return { steps, raw, end: current };
75
79
  };
76
80
 
81
+ const comparisonDescriptor = (resolved) => {
82
+ const last = resolved.steps.at(-1);
83
+ const previous = resolved.steps.at(-2);
84
+ return { ...last, list: last.kind === 'scalarList', identity: ['id', '_id'].includes(last.name)
85
+ && (!previous || relation(previous)), version: last.version || ['Version', 'RecordVersion'].includes(last.scalar) };
86
+ };
87
+ const operandContext = (entry, ctx, up) => inFrames({ frames: entry.frames.slice(0, entry.frames.length - up) }, ctx);
88
+ const validateInternalOperand = (entry) => {
89
+ const operand = entry.operand;
90
+ 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)
93
+ || !Array.isArray(operand.path) || !operand.path.length
94
+ || 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) {
96
+ fail('Invalid compiler-generated comparison operand', 'INVALID_FILTER_PATH');
97
+ }
98
+ };
77
99
  const inFrames = (entry, ctx) => {
78
100
  let current = ctx;
79
101
  for (const frame of entry.frames) {
@@ -124,6 +146,13 @@ const cutAt = (resolved, J) => {
124
146
  }
125
147
  return null;
126
148
  };
149
+ const comparisonInGroup = (group, seen = new Set()) => {
150
+ if (seen.has(group)) return false;
151
+ seen.add(group);
152
+ return list(group.conditions).some((condition) => hasValuePath(condition) || condition.__operand !== undefined)
153
+ || ['AND', 'OR', ...QUANTIFIED_LISTS].some((key) => list(group[key]).some((child) => comparisonInGroup(child, seen)));
154
+ };
155
+ const comparisonKeys = (resolved) => resolved.steps.flatMap((step, index, steps) => relation(step) ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []);
127
156
  const markerKeys = (resolved, J = new Set()) => {
128
157
  const cut = cutAt(resolved, J);
129
158
  const anchored = resolved.steps.some((step, index, steps) => step.kind === 'collection'
@@ -136,14 +165,25 @@ const markerKeys = (resolved, J = new Set()) => {
136
165
  const prefixScopes = (scopes, prefix, keys) => Object.fromEntries(Object.entries(scopes ?? {}).filter(([key]) => (
137
166
  keys.includes(key) && (key === prefix || prefix.startsWith(`${key}.`))
138
167
  )));
139
- const cutCondition = (item, rest, prefix, keys) => {
168
+ const cutCondition = (item, rest, prefix, keys, rightKeys = []) => {
140
169
  const { __scope, ...operands } = item;
141
170
  delete operands.field;
142
171
  delete operands.path;
143
172
  const condition = { field: rest, ...operands };
173
+ if (item.__operand) condition.__operand = { ...item.__operand, up: item.__operand.up + 1 };
174
+ else if (hasValuePath(item)) {
175
+ if (item.valuePath.startsWith(`${prefix}.`)) condition.valuePath = item.valuePath.slice(prefix.length + 1);
176
+ else {
177
+ delete condition.valuePath;
178
+ condition.__operand = { up: 1, path: item.valuePath.split('.') };
179
+ if (hasMarker(item) && rightKeys.length) {
180
+ condition.__operand.scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {}).filter(([key]) => rightKeys.includes(key)));
181
+ }
182
+ }
183
+ }
144
184
  if (hasMarker(item)) {
145
185
  const scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {})
146
- .filter(([key]) => keys.includes(key) && key.startsWith(`${prefix}.`)).map(([key, value]) => [key.slice(prefix.length + 1), value]));
186
+ .filter(([key]) => [...keys, ...rightKeys].includes(key) && key.startsWith(`${prefix}.`)).map(([key, value]) => [key.slice(prefix.length + 1), value]));
147
187
  if (Object.keys(scopes).length) condition.__scope = { id: __scope.id, scopes };
148
188
  }
149
189
  return condition;
@@ -185,11 +225,13 @@ export const frameCollectionPaths = (args, ctx) => {
185
225
  if (at.anchor.length) bucket.__anchor = at.anchor;
186
226
  buckets.set(prefix, bucket);
187
227
  }
188
- const keys = markerKeys(resolved, J);
228
+ const rightResolved = hasValuePath(condition) ? resolve(condition.valuePath, frame) : null;
229
+ const rightKeys = rightResolved ? comparisonKeys(rightResolved) : [];
230
+ const keys = hasValuePath(condition) || condition.__operand ? comparisonKeys(resolved) : markerKeys(resolved, J);
189
231
  if (hasMarker(condition) && !hasMarker(bucket)) {
190
- bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, keys) };
232
+ bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, [...keys, ...rightKeys]) };
191
233
  }
192
- bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys));
234
+ bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys, rightKeys));
193
235
  return bucket;
194
236
  };
195
237
  if (Array.isArray(input.conditions)) {
@@ -229,7 +271,8 @@ export const frameCollectionPaths = (args, ctx) => {
229
271
  if (['scalar', 'scalarList'].includes(leaf.kind)) {
230
272
  // Native planner inputs may name a scalar through collections. Each entry is its own
231
273
  // correlation group; ignored terms and filter metadata never become predicates or markers.
232
- const group = { conditions: [{ field: name, operator: value.operator ?? 'EQ', value: value.value }] };
274
+ const carrier = hasValuePath(value) ? { ...value } : { operator: value.operator ?? 'EQ', value: value.value };
275
+ const group = { conditions: [{ field: name, ...carrier }] };
233
276
  const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
234
277
  if (list(rewritten.ANY).length) {
235
278
  syntheticGroups.push({ __implicit: true, ANY: rewritten.ANY });
@@ -238,7 +281,7 @@ export const frameCollectionPaths = (args, ctx) => {
238
281
  continue;
239
282
  }
240
283
  if (!Array.isArray(value.terms) || valueLeaf(leaf, ctx)) continue;
241
- const group = { conditions: value.terms.map((term) => ({ ...termCondition(term), field: pathless(term.path) ? name : `${name}.${term.path}` })) };
284
+ const group = { conditions: value.terms.map((term) => ({ ...termCondition(term), field: pathless(term.path) ? name : `${name}.${term.path}`, ...(hasValuePath(term) ? { valuePath: `${name}.${term.valuePath}` } : {}) })) };
242
285
  const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
243
286
  implicitGroups.push(...list(rewritten.ANY));
244
287
  if (rewritten.conditions.length === value.terms.length) continue;
@@ -373,7 +416,8 @@ export const validateQuantifiedGroups = (args, ctx) => {
373
416
  if (ctx.gqltype && leaf.kind === 'abstract') abstractFields.add(name);
374
417
  if (value.quantifier == null && valueLeaf(leaf, ctx) && Array.isArray(value.terms) && value.operator === undefined) {
375
418
  scalarCarriers.push({ form: 'filter', field: name, path: name, relative: partsOf(name), segments: partsOf(name), frames: [],
376
- operator: 'EQ', value: value.value, construct: null });
419
+ operator: 'EQ', value: value.value, construct: hasValuePath(value) ? value : null,
420
+ ...(hasValuePath(value) ? { hasValuePath: true, valuePath: value.valuePath } : {}) });
377
421
  }
378
422
  if (!valueLeaf(leaf, ctx) && value.quantifier == null) {
379
423
  if (isEmptinessOperator(value.operator)) fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term `
@@ -396,7 +440,12 @@ export const validateQuantifiedGroups = (args, ctx) => {
396
440
  entries.push(...abstractCarriers);
397
441
  for (const entry of entries) {
398
442
  if (ignoredValueTerm(entry, ctx)) continue;
443
+ if (entry.hasValuePath) validateComparisonOperand(entry.construct);
444
+ validateInternalOperand(entry);
445
+ const comparison = entry.hasValuePath || entry.operand !== undefined;
399
446
  const current = inFrames(entry, ctx);
447
+ let rightResolved;
448
+ let rightCurrent;
400
449
  const implicit = entry.form === 'quantified' && entry.construct.__implicit === true;
401
450
  if (entry.form === 'condition' && typeof entry.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
402
451
  if (entry.ambiguous) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
@@ -448,7 +497,39 @@ export const validateQuantifiedGroups = (args, ctx) => {
448
497
  if (ctx.quantifiers !== true || last.kind === 'embeddedList') fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
449
498
  });
450
499
  } else {
451
- if (isEmptinessOperator(entry.operator)) {
500
+ if (comparison) {
501
+ rightCurrent = entry.operand ? operandContext(entry, ctx, entry.operand.up) : current;
502
+ const rightPath = entry.operand?.path ?? partsOf(entry.valuePath);
503
+ rightResolved = resolve(rightPath, rightCurrent);
504
+ if (!['scalar', 'scalarList'].includes(last.kind) || !['scalar', 'scalarList'].includes(rightResolved.steps.at(-1).kind)) {
505
+ fail('Field comparison operands must end in scalar fields', 'INVALID_FILTER_PATH');
506
+ }
507
+ if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
508
+ 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 chain = entry.frames.slice(entry.frames.length - entry.operand.up)
512
+ .flatMap((frame) => [...list(frame.construct.__anchor), ...partsOf(frame.path)]);
513
+ const bound = resolve([...chain, ...path], rightCurrent).steps;
514
+ for (const [index, step] of bound.entries()) if (step.kind === 'collection') {
515
+ const key = bound.slice(0, index + 1).map(({ name }) => name).join('.');
516
+ if (ctx.J?.has(key) && entry.operand.up === entry.frames.length) collections.add(key);
517
+ }
518
+ }
519
+ if (rightResolved.steps.some((step, index, right) => step.kind === 'collection'
520
+ && !collections.has(right.slice(0, index + 1).map(({ name }) => name).join('.')))) {
521
+ fail('The right operand introduces an unbound collection; use an explicit group', 'INVALID_FILTER_PATH');
522
+ }
523
+ validateComparisonTypes(comparisonDescriptor(resolved), comparisonDescriptor(rightResolved), entry.operator, path.join('.'), rightPath.join('.'));
524
+ gates.push(() => {
525
+ if ([...steps, ...rightResolved.steps].some((step) => step.kind === 'embeddedList')) fail('Field comparisons through embedded lists require element frames', 'UNSUPPORTED_QUERY');
526
+ if (ctx.valuePath !== true || ctx.referenceEmptiness !== true) fail('Field comparisons require valuePath and referenceEmptiness support from this query compiler', 'UNSUPPORTED_QUERY');
527
+ const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
528
+ if (ctx.quantifiers !== true && steps.some((step, index) => step.kind === 'collection' && !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.')))) {
529
+ fail('Collection field comparisons require quantifiers', 'UNSUPPORTED_QUERY');
530
+ }
531
+ });
532
+ } else if (isEmptinessOperator(entry.operator)) {
452
533
  if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
453
534
  if (ctx.quantifiers === true && steps.slice(0, -1).some((step) => step.kind === 'collection')) fail('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
454
535
  shape = describe(resolved, path, current, entry.operator, entry.value);
@@ -464,6 +545,14 @@ export const validateQuantifiedGroups = (args, ctx) => {
464
545
  validateValue(entry, resolved, current);
465
546
  }
466
547
  }
548
+ if (entry.operand && ctx.scopeMarkers !== 'pending' && (entry.operand.scopes !== undefined
549
+ || hasMarker(entry.construct))) markers.push(() => {
550
+ // An outer binding can still read a collection row owned by aggregate J.
551
+ const keys = comparisonKeys(rightResolved);
552
+ const scopes = entry.operand.scopes;
553
+ if (keys.length && (!plain(scopes) || keys.some((key) => !Object.hasOwn(scopes, key) || scopes[key] === undefined))) detached('Outer operand scope marker is not filled for every crossing');
554
+ if (!keys.length && scopes !== undefined) detached('Outer operand scope marker reads no reference or collection');
555
+ });
467
556
  const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
468
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'
469
558
  && !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.'))).length : 0;
@@ -472,7 +561,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
472
561
  });
473
562
  if (hasMarker(entry.construct) && ctx.scopeMarkers !== 'pending') markers.push(() => {
474
563
  const suffix = '; only the runtime writes and fills client scope markers (docs/guide/middleware.md)';
475
- if (!shape && entry.form !== 'quantified' && !entry.frames.length
564
+ if (!comparison && !shape && entry.form !== 'quantified' && !entry.frames.length
476
565
  && (ctx.quantifiers !== true || !cutAt(resolved, ctx.J ?? new Set()))) {
477
566
  // Mongo rejects these at its native predicate checkpoint, after parsing/casting once.
478
567
  // Every other caller remains fail-closed here, after the common 400 gates above.
@@ -484,6 +573,9 @@ export const validateQuantifiedGroups = (args, ctx) => {
484
573
  const paths = emptinessScopePaths(shape, path);
485
574
  keys = [...paths.joins, ...paths.frames];
486
575
  } 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) : [])])];
577
+ const comparisonAnchor = implicit && anchor.length && comparisonInGroup(entry.construct);
578
+ if (comparisonAnchor) keys = comparisonKeys(resolved);
487
579
  const scopes = entry.construct.__scope?.scopes;
488
580
  if (shape) {
489
581
  // Preserve 2b's direct-compiler diagnostics when the shared validator now runs first.
@@ -496,7 +588,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
496
588
  for (const [index, step] of steps.slice(0, anchor.length).entries()) {
497
589
  const key = steps.slice(0, index + 1).map(({ name }) => name).join('.');
498
590
  if ((step.kind === 'collection' && scopes[key] !== null)
499
- || (step.kind === 'reference' && Object.hasOwn(scopes, key))) detached('Aggregate anchors require null J scopes and legacy reference joins');
591
+ || (step.kind === 'reference' && !comparisonAnchor && Object.hasOwn(scopes, key))) detached('Aggregate anchors require null J scopes and legacy reference joins');
500
592
  }
501
593
  }
502
594
  });
@@ -517,6 +609,7 @@ export const frameUnits = (args, ctx) => {
517
609
  return identities.get(construct);
518
610
  };
519
611
  const frameKey = (entry) => entry.frames.reduce((key, frame) => `${key}/${id(frame.construct)}:${frame.type}:${frame.path}`, 'root');
612
+ const joinUnit = (frame, path, scoped) => `join:${frame}:${path}${scoped ? ':scoped' : ''}`;
520
613
  for (const entry of collectQueryOperators(rewritten, ctx.operation)) {
521
614
  if (ignoredValueTerm(entry, ctx)) continue;
522
615
  const current = inFrames(entry, ctx);
@@ -527,18 +620,24 @@ export const frameUnits = (args, ctx) => {
527
620
  const path = entry.relative;
528
621
  const { steps } = resolve(path, current);
529
622
  for (const [index, step] of steps.entries()) {
530
- if (index >= anchor.length && index < steps.length - 1 && step.kind === 'reference') {
531
- units.add(`join:${frame}:${steps.slice(0, index + 1).map(({ name }) => name).join('.')}`);
623
+ if ((index >= anchor.length || comparisonInGroup(entry.construct)) && index < steps.length - 1 && step.kind === 'reference') {
624
+ units.add(joinUnit(frame, steps.slice(0, index + 1).map(({ name }) => name).join('.'), hasMarker(entry.construct)));
532
625
  }
533
626
  }
534
627
  } else if (isEmptinessOperator(entry.operator)) {
535
628
  const resolved = resolve(entry.relative, current);
536
629
  const shape = describe(resolved, entry.relative, current, entry.operator, entry.value);
537
630
  const paths = emptinessScopePaths(shape, entry.relative);
538
- for (const path of paths.joins) units.add(`join:${frame}:${path}`);
631
+ for (const path of paths.joins) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
539
632
  for (const path of paths.frames) units.add(`emptiness-frame:${frame}:${path}`);
540
- } else if (entry.frames.length) {
541
- for (const path of crossingKeys(entry.relative, current)) units.add(`join:${frame}:${path}`);
633
+ } else if (entry.frames.length || entry.hasValuePath || entry.operand) {
634
+ for (const path of crossingKeys(entry.relative, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
635
+ if (entry.hasValuePath) for (const path of crossingKeys(entry.valuePath, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
636
+ 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')));
640
+ }
542
641
  }
543
642
  }
544
643
  return { count: units.size, units: [...units] };
@@ -14,6 +14,7 @@ export {
14
14
  export {
15
15
  ANCHOR_KEY, GROUP_KEYS, IMPLICIT_KEY, aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, frameUnits, validateQuantifiedGroups,
16
16
  } from './query-frames.js';
17
+ export { hasValuePath, validateComparisonOperand, validateComparisonTypes } from './query-comparisons.js';
17
18
  export { effectiveScalarName } from './metadata.js';
18
19
 
19
20
  /** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
@@ -153,23 +154,23 @@ const invalidFeatures = (message) => {
153
154
  };
154
155
 
155
156
  /**
156
- * Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers }`:
157
+ * Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers, valuePath }`:
157
158
  * the operators it compiles and whether it compiles reference emptiness and collection frames.
158
159
  * Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
159
160
  * and neither feature. A declaration is an object whose `operators`
160
161
  * is an array of operator names, whose names this version does not know are ignored, and whose
161
- * optional `referenceEmptiness` and `quantifiers` are booleans, undefined or null (false). A name
162
+ * optional `referenceEmptiness`, `quantifiers` and `valuePath` are booleans, undefined or null (false). A name
162
163
  * counts once this version knows it: a declaration that listed `CONTAINS_ALL` or `IIN` before
163
164
  * #108 part 4 added them receives them on upgrade.
164
- * Quantifiers require reference emptiness for scoped joins. Other members are left for later
165
+ * Quantifiers and valuePath require reference emptiness for scoped joins. Other members are left for later
165
166
  * features. Anything else throws `INVALID_QUERY_FEATURES` (500).
166
167
  */
167
168
  export const resolveQueryFeatures = (queryFeatures) => {
168
- if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false };
169
+ if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false, valuePath: false };
169
170
  if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
170
171
  invalidFeatures('must be an object with an operators array');
171
172
  }
172
- const { operators, referenceEmptiness, quantifiers } = queryFeatures;
173
+ const { operators, referenceEmptiness, quantifiers, valuePath } = queryFeatures;
173
174
  if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
174
175
  invalidFeatures('operators must be an array of operator names');
175
176
  }
@@ -182,9 +183,12 @@ export const resolveQueryFeatures = (queryFeatures) => {
182
183
  if (quantifiers === true && referenceEmptiness !== true) {
183
184
  invalidFeatures('quantifiers require referenceEmptiness: true');
184
185
  }
186
+ if (valuePath != null && typeof valuePath !== 'boolean') invalidFeatures('valuePath must be a boolean');
187
+ if (valuePath === true && referenceEmptiness !== true) invalidFeatures('valuePath requires referenceEmptiness: true');
185
188
  return {
186
189
  operators: new Set(operators.filter(isQueryOperator)),
187
190
  referenceEmptiness: referenceEmptiness === true,
188
191
  quantifiers: quantifiers === true,
192
+ valuePath: valuePath === true,
189
193
  };
190
194
  };
package/src/query-plan.js CHANGED
@@ -4,7 +4,7 @@ import { paginationStages } from './query-limits.js';
4
4
  import SimfinityError from './errors/simfinity.error.js';
5
5
  import {
6
6
  describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator, isQueryOperator,
7
- takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
7
+ hasValuePath, takesListValue, textQueryOperator, validateComparisonOperand, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
8
8
  } from './query-operators.js';
9
9
 
10
10
  const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
@@ -45,7 +45,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
45
45
  const records = [];
46
46
  const open = new Set();
47
47
  const childFrame = (parent, path, quantifier, construct, anchor = []) => ({
48
- base: [...parent.base, ...anchor, ...pathSegments(path)],
48
+ parent, base: [...parent.base, ...anchor, ...pathSegments(path)],
49
49
  frames: [...parent.frames, { path, type: quantifier, construct }],
50
50
  groups: new Map(),
51
51
  });
@@ -96,7 +96,21 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
96
96
  entries.push({ ...entry, segments, relative: segments.slice(frame.base.length), member: true });
97
97
  }
98
98
  };
99
- const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier) => {
99
+ const operandPath = (item, prefix = '') => hasValuePath(item)
100
+ ? (prefix ? `${prefix}.${item.valuePath}` : item.valuePath) : undefined;
101
+ const addPredicatePaths = (path, item, frame, container, group = null, prefix = '') => {
102
+ const comparison = hasValuePath(item) || item?.__operand != null;
103
+ addPath(path, frame, container, group, item?.operator ?? 'EQ', comparison ? item : item ?? null,
104
+ comparison ? { role: 'left', comparison: true } : {});
105
+ if (!comparison) return;
106
+ let origin = frame;
107
+ if (item.__operand) {
108
+ for (let up = item.__operand.up; up > 0 && origin.parent; up--) origin = origin.parent;
109
+ }
110
+ const right = item.__operand ? item.__operand.path?.join('.') : operandPath(item, prefix);
111
+ addPath(right, origin, container, group, item.operator ?? 'EQ', item, { role: 'right', comparison: true });
112
+ };
113
+ const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier, prefix = '') => {
100
114
  if (!isObject(item)) return;
101
115
  const quantified = form === 'quantified';
102
116
  const relative = pathSegments(relativePath);
@@ -112,9 +126,11 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
112
126
  rawPath,
113
127
  value: item.value,
114
128
  hasValue: Object.hasOwn(item, 'value'),
129
+ ...(hasValuePath(item) ? { hasValuePath: true, valuePath: operandPath(item, prefix) } : {}),
130
+ ...(Object.hasOwn(item, '__operand') ? { operand: item.__operand } : {}),
115
131
  ambiguous: form === 'condition' && typeof field === 'string' && field.includes('.') && !isPathless(rawPath),
116
132
  depth,
117
- construct: quantified || form === 'term' || form === 'condition' ? item : null,
133
+ construct: quantified || form === 'term' || form === 'condition' || hasValuePath(item) || item.__operand ? item : null,
118
134
  emptiness: isEmptinessOperator(operator),
119
135
  ...(quantified ? { quantifier } : {}),
120
136
  });
@@ -134,7 +150,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
134
150
  const field = condition?.field;
135
151
  const path = conditionPath(field, condition?.path);
136
152
  if (paths) {
137
- if (typeof field === 'string') addPath(path, inner, group, group, condition.operator ?? 'EQ', condition);
153
+ if (typeof field === 'string') addPredicatePaths(path, condition, inner, group, group);
138
154
  } else addOperator(condition, 'condition', field, condition?.path, depth, inner, group, path);
139
155
  }
140
156
  continue;
@@ -149,7 +165,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
149
165
  if (paths) {
150
166
  // Invalid path-less terms still name the field, as before; validation rejects them
151
167
  // 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);
168
+ if (hasTermPath(term)) addPredicatePaths(term.path, term, frame, value);
153
169
  else if (isPathless(term.path)) {
154
170
  entries.push({ ...metadata([], frame, value), group: null, operator: term.operator ?? 'EQ', construct: term });
155
171
  }
@@ -159,20 +175,21 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
159
175
  }
160
176
  if (!paths) {
161
177
  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));
178
+ if (!hasTerms || value.operator !== undefined || hasValuePath(value)) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
179
+ if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path), undefined, name);
164
180
  continue;
165
181
  }
166
182
  const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
167
183
  if (!terms.some(hasTermPath)) {
168
184
  const term = terms.find(pathless);
169
185
  if (term) addPath(name, root, value, null, term.operator, term);
186
+ else if (hasValuePath(value)) addPredicatePaths(name, value, root, value);
170
187
  else addPath(name, root, value, null, value.operator ?? 'EQ');
171
188
  continue;
172
189
  }
173
190
  let field = false;
174
191
  for (const term of terms) {
175
- if (hasTermPath(term)) addPath(`${name}.${term.path}`, root, value, null, term.operator ?? 'EQ', term);
192
+ if (hasTermPath(term)) addPredicatePaths(`${name}.${term.path}`, term, root, value, null, name);
176
193
  else if (!field && pathless(term)) {
177
194
  addPath(name, root, value, null, term.operator, term);
178
195
  field = true;
@@ -198,7 +215,9 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
198
215
  * A quantified carrier names its path in the containing frame, with `quantifier` and its own
199
216
  * construct; children enter its frame. The legacy `group` remains the conditions owner, otherwise
200
217
  * 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
218
+ * their own construct. Comparisons emit left/right roles with comparison:true and one owning
219
+ * construct, including ordinary field filters; RHS paths preserve their lexical term/frame origin.
220
+ * An omitted filter operator is EQ; non-filter reads and quantified carriers
202
221
  * have no operator. `expandMembers` appends stored emptiness member paths, preserving metadata.
203
222
  */
204
223
  export const collectQueryPathEntries = (args, operation = 'find', options = {}) => (
@@ -209,7 +228,8 @@ export const collectQueryPathEntries = (args, operation = 'find', options = {})
209
228
  * Internal: filter constructs only, with the same frame metadata as the path collector. Preserves
210
229
  * form, field, rawPath, value, hasValue, ambiguous, deepest depth and emptiness. Quantified carriers
211
230
  * 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
231
+ * construct null. Comparisons add hasValuePath:true and a frame-relative valuePath; their owning
232
+ * construct is always retained. Generated outer comparisons carry operand instead. Explicit groups add a depth level, implicit ones none. Invalid entries are left
213
233
  * to the validator; unknown group keys and path on a plain group fail closed here.
214
234
  */
215
235
  export const collectQueryOperators = (args, operation = 'find') => walkQueryEntries(args, operation);
@@ -290,9 +310,12 @@ export const resolveModelPath = (models, entityName, path) => {
290
310
  * explicit ANY/ALL/NONE and quantifier filters retain their quantifier, and scoped reference
291
311
  * crossings inside a frame lift into `joins`. Aggregate collection joins remain legacy; later
292
312
  * frames carry their legacy chain in `anchor`. Sorts through collections fail even in count mode.
313
+ * With `valuePath` (the same default as `quantifiers`), comparisons preserve their literal-free
314
+ * value:null placeholder and add operand:{up,path}. Same-frame scoped joins are the union of both
315
+ * paths on the predicate; generated outer operands own their joins in their ancestor frame.
293
316
  */
294
317
  export const createQueryPlan = (models, entityName, source = {}, {
295
- mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null,
318
+ mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null, valuePath = supported == null,
296
319
  } = {}) => {
297
320
  let input = source;
298
321
  const allowed = supported == null ? null : new Set(supported);
@@ -304,19 +327,23 @@ export const createQueryPlan = (models, entityName, source = {}, {
304
327
  // The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
305
328
  // arguments are planned on the target type, as trusted find arguments in their own frame, whatever
306
329
  // the query's mode, with the operator names already read (`operators` may be a one-time iterable).
307
- const liftScopes = (marker, targets, segments, operator) => {
330
+ const liftScopes = (marker, targets, segments, operator, anchors = []) => {
308
331
  const label = `${operator} on ${segments.join('.')}`;
309
- const paths = Object.keys(targets);
332
+ const paths = [...Object.keys(targets), ...anchors];
310
333
  if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
311
334
  const scopes = marker?.scopes;
312
335
  if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled`);
313
336
  const lifted = {};
314
337
  for (const path of paths) {
315
338
  if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
339
+ if (anchors.includes(path)) {
340
+ if (scopes[path] !== null) detached(`${label} requires a null aggregate anchor scope for ${path}`);
341
+ continue;
342
+ }
316
343
  if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
317
344
  lifted[path] = scopes[path] === null
318
345
  ? null
319
- : createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers }).where;
346
+ : createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers, valuePath }).where;
320
347
  }
321
348
  return lifted;
322
349
  };
@@ -338,7 +365,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
338
365
  };
339
366
  // `construct` is the term or group condition that holds the predicate, the only objects whose
340
367
  // `__scope` is read; null for a field filter's own operator.
341
- const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false) => {
368
+ const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false, ancestors = [], prefix = '') => {
342
369
  const steps = resolveModelPath(models, frameEntity, path);
343
370
  const op = operator ?? 'EQ';
344
371
  if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameEntity);
@@ -348,6 +375,30 @@ export const createQueryPlan = (models, entityName, source = {}, {
348
375
  }
349
376
  if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
350
377
  if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
378
+ const comparison = hasValuePath(construct) || construct?.__operand !== undefined;
379
+ if (comparison) {
380
+ const internal = construct.__operand;
381
+ const rightPath = internal?.path ?? (prefix ? `${prefix}.${validateComparisonOperand(construct)}` : validateComparisonOperand(construct)).split('.');
382
+ 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);
386
+ 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]));
389
+ // J collections retain their existing aggregate row. Validate their provenance, but only
390
+ // 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)
392
+ .flatMap((step, index) => step.kind === 'collection' ? [parts.slice(0, index + 1).join('.')] : [])))];
393
+ if (hasMarker(construct)) {
394
+ const paths = up ? [segments] : [segments, rightPath];
395
+ node.joins = liftScopes(construct.__scope, scopeTargets(paths, frameEntity), segments, op, anchors(paths, frameEntity));
396
+ }
397
+ if (internal && Object.hasOwn(internal, 'scopes')) {
398
+ node.operand.joins = liftScopes({ scopes: internal.scopes }, scopeTargets([rightPath], rightEntity), rightPath, op, anchors([rightPath], rightEntity));
399
+ }
400
+ return node;
401
+ }
351
402
  const collection = takesListValue(op);
352
403
  if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
353
404
  if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
@@ -364,11 +415,13 @@ export const createQueryPlan = (models, entityName, source = {}, {
364
415
  }
365
416
  return node;
366
417
  };
418
+ const hasComparisonNode = (node) => Boolean(node && (node.operand || hasComparisonNode(node.where)
419
+ || node.terms?.some(hasComparisonNode)));
367
420
  const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
368
421
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
369
422
  for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
370
423
  };
371
- const group = (value, depth = -1, frameEntity = entityName, inFrame = false) => {
424
+ const group = (value, depth = -1, frameEntity = entityName, inFrame = false, ancestors = []) => {
372
425
  validateGroup(value);
373
426
  const level = depth + (value.__implicit === true ? 0 : 1);
374
427
  if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
@@ -376,7 +429,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
376
429
  for (const condition of value.conditions || []) {
377
430
  if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
378
431
  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));
432
+ terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameEntity, inFrame, ancestors));
380
433
  }
381
434
  if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
382
435
  fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
@@ -385,12 +438,12 @@ export const createQueryPlan = (models, entityName, source = {}, {
385
438
  const anchor = item.__implicit === true ? item.__anchor : undefined;
386
439
  const path = item.path.split('.');
387
440
  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 };
441
+ const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true, [...ancestors, frameEntity]), scope: null };
389
442
  if (anchor?.length) node.anchor = anchor;
390
443
  if (hasMarker(item)) {
391
444
  const fullPath = [...(anchor ?? []), ...path];
392
445
  const key = fullPath.join('.');
393
- const crossings = anchor?.length ? [] : crossingKeys(path, { models, entity: frameEntity });
446
+ const crossings = anchor?.length && !hasComparisonNode(node.where) ? [] : crossingKeys(fullPath, { models, entity: frameEntity });
394
447
  const targets = Object.fromEntries(crossings.map((prefix) => [prefix, resolveModelPath(models, frameEntity, prefix).at(-1).target]));
395
448
  targets[key] = target;
396
449
  const scopes = liftScopes(item.__scope, targets, fullPath, quantifier);
@@ -399,17 +452,18 @@ export const createQueryPlan = (models, entityName, source = {}, {
399
452
  for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
400
453
  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
454
  }
402
- } else if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
455
+ }
456
+ if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
403
457
  }
404
458
  terms.push(node);
405
459
  }
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))));
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))));
408
462
  return combine('and', terms);
409
463
  };
410
464
  // Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
411
465
  validateGroup(input, ['AND', 'OR']);
412
- const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers };
466
+ const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers, valuePath };
413
467
  const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
414
468
  context.J = J;
415
469
  validateQuantifiedGroups(input, context);
@@ -428,7 +482,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
428
482
  + `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
429
483
  }
430
484
  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));
485
+ if (!object) terms.push(predicate(name, value.operator, value.value, hasValuePath(value) || value.__operand ? value : null));
432
486
  else for (const term of value.terms) {
433
487
  if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
434
488
  if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
@@ -436,7 +490,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
436
490
  continue;
437
491
  }
438
492
  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));
493
+ terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term, entityName, false, [], name));
440
494
  }
441
495
  }
442
496
  for (const item of input.AND || []) terms.push(group(item));
package/src/runtime.js CHANGED
@@ -26,7 +26,7 @@ import QLSort from './const/QLSort.js';
26
26
  import {
27
27
  collectQueryOperators, collectQueryPathEntries, walkQueryPath,
28
28
  } from './query-plan.js';
29
- import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
29
+ import { QUERY_OPERATORS, hasValuePath, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
30
30
  import {
31
31
  assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
32
32
  } from './query-emptiness.js';
@@ -210,6 +210,7 @@ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
210
210
  field: `${prefix}.${condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field}`,
211
211
  operator: condition?.operator,
212
212
  value: condition?.value,
213
+ ...(hasValuePath(condition) ? { valuePath: `${prefix}.${condition.valuePath}` } : {}),
213
214
  }));
214
215
  }
215
216
  if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
@@ -243,9 +244,13 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
243
244
  // An emptiness term without a path (omitted, null or "") tests the field itself; other
244
245
  // operators keep the path as written.
245
246
  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 });
247
+ conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value,
248
+ ...(hasValuePath(term) ? { valuePath: `${prefix}.${name}.${term.valuePath}` } : {}),
249
+ });
247
250
  }
248
- } else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
251
+ } else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value,
252
+ ...(hasValuePath(value) ? { valuePath: `${prefix}.${value.valuePath}` } : {}),
253
+ });
249
254
  }
250
255
  return conditions.length ? [{ conditions }, ...groups] : groups;
251
256
  };
@@ -599,7 +604,7 @@ export const createRuntime = (adapter) => {
599
604
  // IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
600
605
  // conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
601
606
  const {
602
- operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
607
+ operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers, valuePath: supportsValuePath,
603
608
  } = resolveQueryFeatures(adapter.queryFeatures);
604
609
  const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
605
610
  // The write features the adapter implements, read once: with optimisticConcurrency, schemas may
@@ -735,6 +740,7 @@ const QLFilter = new GraphQLInputObjectType({
735
740
  fields: () => ({
736
741
  operator: { type: QLOperator },
737
742
  value: { type: QLValue },
743
+ valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
738
744
  }),
739
745
  });
740
746
 
@@ -743,6 +749,7 @@ const QLTypeFilter = new GraphQLInputObjectType({
743
749
  fields: () => ({
744
750
  operator: { type: QLOperator },
745
751
  value: { type: QLValue },
752
+ valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
746
753
  path: { type: GraphQLString },
747
754
  }),
748
755
  });
@@ -768,6 +775,7 @@ const QLFilterCondition = new GraphQLInputObjectType({
768
775
  field: { type: new GraphQLNonNull(GraphQLString) },
769
776
  operator: { type: QLOperator },
770
777
  value: { type: QLValue },
778
+ valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
771
779
  path: { type: GraphQLString },
772
780
  }),
773
781
  });
@@ -2291,13 +2299,13 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
2291
2299
  const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
2292
2300
  const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
2293
2301
  for (const {
2294
- segments, group, member, operator, use, frames, quantifier,
2302
+ segments, group, member, operator, use, frames, quantifier, comparison,
2295
2303
  } of entries) {
2296
2304
  const filter = use === 'filter';
2297
2305
  const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
2298
2306
  const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
2299
2307
  const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
2300
- const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
2308
+ const joins = !member && !comparison && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
2301
2309
  && !(filter && (frames.length || quantifier || (cut && !anchored)));
2302
2310
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
2303
2311
  const path = segments.slice(0, index + 1).join('.');
@@ -2400,7 +2408,7 @@ const assertEmptinessForm = (gqltype, {
2400
2408
  // carriers, which do not become predicates but must not use known undeclared capabilities.
2401
2409
  const queryFrameContext = (gqltype, args, operation) => ({
2402
2410
  gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
2403
- operators: supportedOperators,
2411
+ operators: supportedOperators, valuePath: supportsValuePath,
2404
2412
  J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
2405
2413
  scopeMarkers: 'pending',
2406
2414
  });
@@ -2614,8 +2622,9 @@ const markClientConstructs = (checked, { aggregation, issued = new Map() } = {})
2614
2622
  };
2615
2623
  for (const { entry, shape } of conditions) {
2616
2624
  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);
2625
+ 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('.')] : [];
2627
+ const targets = [...pathRelations(gqltype, entry.segments), ...pathRelations(gqltype, comparisonPath)].map(({ typeName }) => typeName);
2619
2628
  if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
2620
2629
  if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
2621
2630
  const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
@@ -2658,7 +2667,8 @@ const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
2658
2667
  throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
2659
2668
  }
2660
2669
  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)) {
2670
+ const operandPaths = [entry.segments, ...(entry.hasValuePath ? [[...entry.segments.slice(0, -entry.relative.length), ...entry.valuePath.split('.')]] : [])];
2671
+ for (const relation of operandPaths.flatMap((parts) => pathRelations(gqltype, parts))) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
2662
2672
  throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
2663
2673
  }
2664
2674
  }
@@ -2735,7 +2745,11 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2735
2745
  const vectors = [];
2736
2746
  for (const { entry, shape, type } of positions) {
2737
2747
  const relations = pathRelations(type, entry.relative);
2738
- if (!shape && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
2748
+ if (entry.hasValuePath) {
2749
+ const existing = new Set(relations.map(({ path }) => path));
2750
+ relations.push(...pathRelations(type, entry.valuePath.split('.')).filter(({ path }) => !existing.has(path)));
2751
+ }
2752
+ if (!shape && !entry.hasValuePath && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
2739
2753
  const relation = relationOnPath(gqltype, entry.segments);
2740
2754
  if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
2741
2755
  + `${entry.path}, which reads through ${relation} without its scope`);
@@ -2744,7 +2758,7 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2744
2758
  const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
2745
2759
  const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
2746
2760
  const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
2747
- .filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
2761
+ .filter((relation) => entry.hasValuePath || !anchored || relation.collection || (cut && relation.index >= cut.index))
2748
2762
  .map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
2749
2763
  for (const [key, target] of Object.entries(keys)) {
2750
2764
  if (Object.hasOwn(targets, key) && targets[key] !== target) {
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,8 @@ 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;
159
167
  }
160
168
 
161
169
  export interface ModelRegistration {
@@ -176,6 +184,10 @@ export interface FieldDescription {
176
184
  scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum' | 'Version';
177
185
  values?: string[];
178
186
  enumValues?: { name: string; value: unknown }[];
187
+ /** Logical GraphQL enum identity, required for field comparisons. */
188
+ enumName?: string;
189
+ /** Effective temporal base before storage normalizes Date and Time to DateTime. */
190
+ temporalName?: 'DateTime' | 'Date' | 'Time';
179
191
  target?: string;
180
192
  connectionField?: string;
181
193
  fields?: FieldDescription[];
@@ -897,6 +909,9 @@ export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapt
897
909
  /** Scoped single-reference joins, keyed by a dotted prefix relative to the containing frame. */
898
910
  export type QueryJoins = Record<string, QueryPredicate | null>;
899
911
 
912
+ /** A field operand in the same frame (up: 0) or an enclosing shorthand frame. */
913
+ export type QueryOperand = { up: number; path: string[]; joins?: QueryJoins };
914
+
900
915
  /**
901
916
  * `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. For
902
917
  * `CONTAINS_ALL` the value is a non-empty array of non-null scalars, validated and not cast; the
@@ -918,10 +933,11 @@ export type QueryJoins = Record<string, QueryPredicate | null>;
918
933
  * A `quantified` node selects elements with `ANY`, `ALL` or `NONE`, applying `scope` before `where`;
919
934
  * both predicates use paths relative to the element, and null means no restriction. Empty elements
920
935
  * 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.
936
+ * an aggregate's legacy join chain; when present, `path` is relative to it. Comparison-bearing
937
+ * anchored frames may carry full-prefix joins as visibility guards, preserving the existing J row.
922
938
  */
923
939
  export type QueryPredicate =
924
- | { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
940
+ | { kind: 'predicate'; path: string[]; operator: string; value: any; operand?: QueryOperand; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
925
941
  | { kind: 'and' | 'or'; terms: QueryPredicate[] }
926
942
  | {
927
943
  kind: 'quantified';
@@ -975,6 +991,13 @@ export interface QueryPlan {
975
991
  * `quantifiers`, a row filter on an aggregate's join prefix, a legacy join otherwise. `CONTAINS_ALL`
976
992
  * is never cut into a frame.
977
993
  *
994
+ * `valuePath` selects a compatible stored scalar operand instead of a literal value. It defaults
995
+ * like `quantifiers`; a comparison node has `value: null` and `operand: { up, path, joins? }`.
996
+ * 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.
998
+ * 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.
1000
+ *
978
1001
  * `referenceEmptiness` defaults to true when `operators` is omitted and to false when it is given.
979
1002
  * When it is false, an emptiness condition that crosses or ends at a reference or a collection, or
980
1003
  * reads a reference or collection member, fails with `UNSUPPORTED_QUERY` where the order above has
@@ -995,7 +1018,7 @@ export interface QueryPlan {
995
1018
  * checked before the path rule, the rest after it.
996
1019
  * A marker not filled for every path fails with `DETACHED_CLIENT_FILTER` (500).
997
1020
  */
998
- export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: boolean }): QueryPlan;
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;
999
1022
  export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
1000
1023
 
1001
1024
  /**