@simtlix/simfinity-core 3.15.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.
@@ -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';
@@ -6,7 +6,10 @@ import { isVirtualField } from './relation-storage.js';
6
6
  import {
7
7
  describeEmptiness, describeModelEmptiness, emptinessScopePaths, graphqlStep, isEmptinessOperator, modelStep,
8
8
  } from './query-emptiness.js';
9
- import { isQueryOperator, textQueryOperator, validateTextFilterValue } from './query-operators.js';
9
+ import {
10
+ assertContainsAllPath, isQueryOperator, takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue,
11
+ validateTextListFilterValue, hasValuePath, validateComparisonOperand, validateComparisonTypes,
12
+ } from './query-operators.js';
10
13
  import { collectQueryOperators, validateQueryGroupKeys } from './query-plan.js';
11
14
  import { getQueryMaxFrameNesting } from './query-limits.js';
12
15
 
@@ -53,13 +56,17 @@ const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) =>
53
56
  fail(`Query path ${label} uses the virtual field ${current.gqltype.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
54
57
  }
55
58
  step = graphqlStep(name, field);
56
- 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';
57
64
  raw.push(field);
58
65
  } else {
59
66
  const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
60
67
  const field = fields?.find((item) => item.name === name && !item.private);
61
68
  if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
62
- step = { ...modelStep(field), scalar: field.scalar };
69
+ step = { ...field, ...modelStep(field) };
63
70
  if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
64
71
  raw.push(field);
65
72
  }
@@ -71,6 +78,24 @@ const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) =>
71
78
  return { steps, raw, end: current };
72
79
  };
73
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
+ };
74
99
  const inFrames = (entry, ctx) => {
75
100
  let current = ctx;
76
101
  for (const frame of entry.frames) {
@@ -121,6 +146,13 @@ const cutAt = (resolved, J) => {
121
146
  }
122
147
  return null;
123
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('.')] : []);
124
156
  const markerKeys = (resolved, J = new Set()) => {
125
157
  const cut = cutAt(resolved, J);
126
158
  const anchored = resolved.steps.some((step, index, steps) => step.kind === 'collection'
@@ -133,14 +165,25 @@ const markerKeys = (resolved, J = new Set()) => {
133
165
  const prefixScopes = (scopes, prefix, keys) => Object.fromEntries(Object.entries(scopes ?? {}).filter(([key]) => (
134
166
  keys.includes(key) && (key === prefix || prefix.startsWith(`${key}.`))
135
167
  )));
136
- const cutCondition = (item, rest, prefix, keys) => {
168
+ const cutCondition = (item, rest, prefix, keys, rightKeys = []) => {
137
169
  const { __scope, ...operands } = item;
138
170
  delete operands.field;
139
171
  delete operands.path;
140
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
+ }
141
184
  if (hasMarker(item)) {
142
185
  const scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {})
143
- .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]));
144
187
  if (Object.keys(scopes).length) condition.__scope = { id: __scope.id, scopes };
145
188
  }
146
189
  return condition;
@@ -182,11 +225,13 @@ export const frameCollectionPaths = (args, ctx) => {
182
225
  if (at.anchor.length) bucket.__anchor = at.anchor;
183
226
  buckets.set(prefix, bucket);
184
227
  }
185
- 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);
186
231
  if (hasMarker(condition) && !hasMarker(bucket)) {
187
- bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, keys) };
232
+ bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, [...keys, ...rightKeys]) };
188
233
  }
189
- 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));
190
235
  return bucket;
191
236
  };
192
237
  if (Array.isArray(input.conditions)) {
@@ -226,7 +271,8 @@ export const frameCollectionPaths = (args, ctx) => {
226
271
  if (['scalar', 'scalarList'].includes(leaf.kind)) {
227
272
  // Native planner inputs may name a scalar through collections. Each entry is its own
228
273
  // correlation group; ignored terms and filter metadata never become predicates or markers.
229
- 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 }] };
230
276
  const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
231
277
  if (list(rewritten.ANY).length) {
232
278
  syntheticGroups.push({ __implicit: true, ANY: rewritten.ANY });
@@ -235,7 +281,7 @@ export const frameCollectionPaths = (args, ctx) => {
235
281
  continue;
236
282
  }
237
283
  if (!Array.isArray(value.terms) || valueLeaf(leaf, ctx)) continue;
238
- 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}` } : {}) })) };
239
285
  const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
240
286
  implicitGroups.push(...list(rewritten.ANY));
241
287
  if (rewritten.conditions.length === value.terms.length) continue;
@@ -281,18 +327,25 @@ const validateShapes = (args) => {
281
327
  };
282
328
  const validateValue = (entry, resolved, ctx) => {
283
329
  const { operator, value } = entry;
330
+ const scalar = resolved.steps.at(-1).scalar;
284
331
  // GraphQL compilers historically report the text operator's own type/value diagnostic.
285
332
  // Model planning retains its generic scalar-shape checks before text validation.
286
333
  if (ctx.gqltype && textQueryOperator(operator)) {
287
- validateTextFilterValue(operator, value, resolved.steps.at(-1).scalar, entry.path);
334
+ validateTextFilterValue(operator, value, scalar, entry.path);
288
335
  return;
289
336
  }
290
- const array = ['IN', 'NIN', 'BTW'].includes(operator);
337
+ if (ctx.gqltype && operator === 'IIN') {
338
+ validateTextListFilterValue(operator, value, scalar, entry.path);
339
+ return;
340
+ }
341
+ const array = takesListValue(operator);
291
342
  if (value === undefined || (!array && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(operator))) fail('Expected a scalar filter value');
292
343
  if (array && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
293
344
  if (array && !Array.isArray(value)) fail(`${operator} requires an array value`);
294
345
  if (operator === 'BTW' && value.length !== 2) fail('BTW requires two values');
295
- if (textQueryOperator(operator)) validateTextFilterValue(operator, value, resolved.steps.at(-1).scalar, entry.path);
346
+ if (operator === 'CONTAINS_ALL') validateContainsAllValue(value);
347
+ if (textQueryOperator(operator)) validateTextFilterValue(operator, value, scalar, entry.path);
348
+ if (operator === 'IIN') validateTextListFilterValue(operator, value, scalar, entry.path);
296
349
  };
297
350
  /** Internal: check sort shape and path syntax without resolving or authorizing model fields. */
298
351
  export const assertSortSyntax = (sort) => {
@@ -363,7 +416,8 @@ export const validateQuantifiedGroups = (args, ctx) => {
363
416
  if (ctx.gqltype && leaf.kind === 'abstract') abstractFields.add(name);
364
417
  if (value.quantifier == null && valueLeaf(leaf, ctx) && Array.isArray(value.terms) && value.operator === undefined) {
365
418
  scalarCarriers.push({ form: 'filter', field: name, path: name, relative: partsOf(name), segments: partsOf(name), frames: [],
366
- 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 } : {}) });
367
421
  }
368
422
  if (!valueLeaf(leaf, ctx) && value.quantifier == null) {
369
423
  if (isEmptinessOperator(value.operator)) fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term `
@@ -386,7 +440,12 @@ export const validateQuantifiedGroups = (args, ctx) => {
386
440
  entries.push(...abstractCarriers);
387
441
  for (const entry of entries) {
388
442
  if (ignoredValueTerm(entry, ctx)) continue;
443
+ if (entry.hasValuePath) validateComparisonOperand(entry.construct);
444
+ validateInternalOperand(entry);
445
+ const comparison = entry.hasValuePath || entry.operand !== undefined;
389
446
  const current = inFrames(entry, ctx);
447
+ let rightResolved;
448
+ let rightCurrent;
390
449
  const implicit = entry.form === 'quantified' && entry.construct.__implicit === true;
391
450
  if (entry.form === 'condition' && typeof entry.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
392
451
  if (entry.ambiguous) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
@@ -407,12 +466,70 @@ export const validateQuantifiedGroups = (args, ctx) => {
407
466
  if (entry.form === 'quantified') {
408
467
  if (!implicit && (steps.slice(0, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind))
409
468
  || !['reference', 'collection', 'embeddedList'].includes(last.kind))) fail('A quantified path must end at a relation without crossing a list', 'INVALID_FILTER_PATH');
469
+ // A hand-built anchor (#108 part 4) is first a non-empty array of single path segments, before the path rule,
470
+ // which counts the anchor's steps by its elements; list() reads any other present value as [].
471
+ const anchored = implicit && entry.construct.__anchor != null;
472
+ if (anchored && (!anchor.length || anchor.some((name) => typeof name !== 'string' || name.includes('.')))) {
473
+ fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
474
+ }
475
+ // A hand-built implicit group, whatever its operators (#108 part 4): after any anchor, its path ends at a collection,
476
+ // whatever it crosses first, or at a reference without crossing a list, the explicit groups' rule for references.
477
+ if (implicit && last.kind !== 'collection' && (last.kind !== 'reference'
478
+ || steps.slice(anchor.length, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind)))) {
479
+ fail('An implicit quantified path must end at a collection, or at a reference without crossing a list', 'INVALID_FILTER_PATH');
480
+ }
481
+ // Then the shape the rewrite writes (cutAt): in an aggregate, on a group at the root, an anchor that crosses at
482
+ // least one collection, every one a J prefix, with any embedded lists, embedded objects and references between,
483
+ // and after it one step, a collection.
484
+ if (anchored) {
485
+ const prefixes = ctx.J ?? new Set();
486
+ const crossed = steps.slice(0, anchor.length);
487
+ const prefix = (index) => crossed.slice(0, index + 1).map(({ name }) => name).join('.');
488
+ if (ctx.operation !== 'aggregate' || entry.frames.length
489
+ || !crossed.some((step) => step.kind === 'collection')
490
+ || crossed.some((step, index) => step.kind === 'collection' && !prefixes.has(prefix(index)))
491
+ || partsOf(entry.construct.path).length !== 1 || last.kind !== 'collection') {
492
+ fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
493
+ }
494
+ }
410
495
  if (!implicit && !QUANTIFIERS.has(entry.quantifier)) fail('Unknown quantifier', 'INVALID_FILTER_VALUE');
411
496
  if (!implicit) gates.push(() => {
412
497
  if (ctx.quantifiers !== true || last.kind === 'embeddedList') fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
413
498
  });
414
499
  } else {
415
- 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)) {
416
533
  if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
417
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');
418
535
  shape = describe(resolved, path, current, entry.operator, entry.value);
@@ -420,15 +537,22 @@ export const validateQuantifiedGroups = (args, ctx) => {
420
537
  if (!valueLeaf(last, current)) fail(ordinaryCondition && steps.length === 1
421
538
  ? `Filter on object field "${last.name}" requires a path` : 'Filter on object field requires a scalar path',
422
539
  steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
423
- // CONTAINS_ALL has no semantics in this release; its excluded shorthand form has
424
- // the permanent path error specified for it, while every other use stays unknown.
425
- if (entry.operator === 'CONTAINS_ALL' && steps.slice(0, -1).some((step) => step.kind === 'collection')) fail('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
426
540
  if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
427
541
  if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
542
+ // CONTAINS_ALL's permanent path rules, then its leaf rule (#108 section 3.3), on the frame-relative path.
543
+ if (entry.operator === 'CONTAINS_ALL') assertContainsAllPath(steps);
428
544
  if (current.gqltype && last.kind === 'abstract' && entry.value === undefined) fail(`Expected a scalar value for ${entry.path}`);
429
545
  validateValue(entry, resolved, current);
430
546
  }
431
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
+ });
432
556
  const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
433
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'
434
558
  && !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.'))).length : 0;
@@ -437,7 +561,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
437
561
  });
438
562
  if (hasMarker(entry.construct) && ctx.scopeMarkers !== 'pending') markers.push(() => {
439
563
  const suffix = '; only the runtime writes and fills client scope markers (docs/guide/middleware.md)';
440
- if (!shape && entry.form !== 'quantified' && !entry.frames.length
564
+ if (!comparison && !shape && entry.form !== 'quantified' && !entry.frames.length
441
565
  && (ctx.quantifiers !== true || !cutAt(resolved, ctx.J ?? new Set()))) {
442
566
  // Mongo rejects these at its native predicate checkpoint, after parsing/casting once.
443
567
  // Every other caller remains fail-closed here, after the common 400 gates above.
@@ -449,6 +573,9 @@ export const validateQuantifiedGroups = (args, ctx) => {
449
573
  const paths = emptinessScopePaths(shape, path);
450
574
  keys = [...paths.joins, ...paths.frames];
451
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);
452
579
  const scopes = entry.construct.__scope?.scopes;
453
580
  if (shape) {
454
581
  // Preserve 2b's direct-compiler diagnostics when the shared validator now runs first.
@@ -461,7 +588,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
461
588
  for (const [index, step] of steps.slice(0, anchor.length).entries()) {
462
589
  const key = steps.slice(0, index + 1).map(({ name }) => name).join('.');
463
590
  if ((step.kind === 'collection' && scopes[key] !== null)
464
- || (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');
465
592
  }
466
593
  }
467
594
  });
@@ -482,6 +609,7 @@ export const frameUnits = (args, ctx) => {
482
609
  return identities.get(construct);
483
610
  };
484
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' : ''}`;
485
613
  for (const entry of collectQueryOperators(rewritten, ctx.operation)) {
486
614
  if (ignoredValueTerm(entry, ctx)) continue;
487
615
  const current = inFrames(entry, ctx);
@@ -492,18 +620,24 @@ export const frameUnits = (args, ctx) => {
492
620
  const path = entry.relative;
493
621
  const { steps } = resolve(path, current);
494
622
  for (const [index, step] of steps.entries()) {
495
- if (index >= anchor.length && index < steps.length - 1 && step.kind === 'reference') {
496
- 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)));
497
625
  }
498
626
  }
499
627
  } else if (isEmptinessOperator(entry.operator)) {
500
628
  const resolved = resolve(entry.relative, current);
501
629
  const shape = describe(resolved, entry.relative, current, entry.operator, entry.value);
502
630
  const paths = emptinessScopePaths(shape, entry.relative);
503
- 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)));
504
632
  for (const path of paths.frames) units.add(`emptiness-frame:${frame}:${path}`);
505
- } else if (entry.frames.length) {
506
- 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
+ }
507
641
  }
508
642
  }
509
643
  return { count: units.size, units: [...units] };
@@ -1,10 +1,11 @@
1
1
  import QLOperator from './const/QLOperator.js';
2
2
  import SimfinityError from './errors/simfinity.error.js';
3
3
 
4
- // Internal: the filter operators and the value rules every backend applies to them. They are shared
5
- // by the core runtime, the query plan and the MongoDB compiler through the
6
- // `./internal/query-operators` subpath, which is not public API. The emptiness operators live in
7
- // query-emptiness.js and are re-exported here, so the subpath stays the only one.
4
+ // Internal: the filter operators and the value rules every backend applies to them, and the path
5
+ // and leaf rules of the list operator CONTAINS_ALL (#108, part 4). They are shared by the core
6
+ // runtime, the query plan and the backend compilers through the `./internal/query-operators`
7
+ // subpath, which is not public API. The emptiness operators live in query-emptiness.js and are
8
+ // re-exported here, so the subpath stays the only one.
8
9
 
9
10
  export {
10
11
  EMPTINESS_QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopePaths,
@@ -13,6 +14,7 @@ export {
13
14
  export {
14
15
  ANCHOR_KEY, GROUP_KEYS, IMPLICIT_KEY, aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, frameUnits, validateQuantifiedGroups,
15
16
  } from './query-frames.js';
17
+ export { hasValuePath, validateComparisonOperand, validateComparisonTypes } from './query-comparisons.js';
16
18
  export { effectiveScalarName } from './metadata.js';
17
19
 
18
20
  /** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
@@ -28,7 +30,9 @@ export const LEGACY_QUERY_OPERATORS = Object.freeze(['EQ', 'NE', 'LT', 'LTE', 'G
28
30
  * The literal text operators (#108, part 1) and what each one matches: `contains`, `prefix`,
29
31
  * `suffix` or `exact` (the whole value), respecting case or ignoring it (`ignoreCase`). Values are
30
32
  * literal: `%`, `_`, `.`, `*`, `\`, `^`, `$` and `(` match themselves. `LIKE`, the case-sensitive
31
- * substring of 3.7.0, keeps its own validation and compilation and is not listed here.
33
+ * substring of 3.7.0, keeps its own validation and compilation and is not listed here. Neither is
34
+ * `IIN` (#108, part 4), `IN` ignoring case: it takes a list of such values, which
35
+ * {@link validateTextListFilterValue} checks.
32
36
  */
33
37
  export const TEXT_QUERY_OPERATORS = Object.freeze({
34
38
  ILIKE: Object.freeze({ match: 'contains', ignoreCase: true }),
@@ -66,26 +70,107 @@ export const validateTextFilterValue = (operator, value, scalar, path) => {
66
70
  return value;
67
71
  };
68
72
 
73
+ /** The operators whose value is an array: IN, NIN and BTW (3.7.0), CONTAINS_ALL and IIN (#108 part 4). */
74
+ export const LIST_VALUE_OPERATORS = Object.freeze(['IN', 'NIN', 'BTW', 'CONTAINS_ALL', 'IIN']);
75
+
76
+ /** Whether `operator` takes an array value. */
77
+ export const takesListValue = (operator) => LIST_VALUE_OPERATORS.includes(operator);
78
+
79
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
80
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
81
+
82
+ /**
83
+ * Validates the value of CONTAINS_ALL (#108 section 3.3) before any backend compiles it: a non-empty array of
84
+ * non-null scalars, none of them an array or a plain object. Anything else fails with INVALID_FILTER_VALUE (400).
85
+ * Returns the value unchanged: the compilers cast each element as they cast IN elements, and keep duplicates.
86
+ */
87
+ export const validateContainsAllValue = (value) => {
88
+ if (value === undefined || value === null) invalidValue('Expected a scalar filter value');
89
+ // Array.from reads a hole of a sparse array as undefined; `some` would skip it.
90
+ if (Array.isArray(value) && Array.from(value).some((item) => item == null || Array.isArray(item) || isPlainObject(item))) {
91
+ invalidValue('Filter lists require non-null scalar elements');
92
+ }
93
+ if (!Array.isArray(value)) invalidValue('CONTAINS_ALL requires an array value');
94
+ if (!value.length) invalidValue('CONTAINS_ALL requires a non-empty list');
95
+ return value;
96
+ };
97
+
98
+ /**
99
+ * Validates the value of IIN, IN ignoring case, before any backend compiles it: the field must be a `String` field
100
+ * or a `String` list by its effective scalar, as for IEQ, and the value an array of strings without U+0000; the
101
+ * empty array is valid and matches nothing. Anything else fails with INVALID_FILTER_VALUE (400). Returns the value
102
+ * unchanged: it is neither folded nor deduplicated.
103
+ */
104
+ export const validateTextListFilterValue = (operator, value, scalar, path) => {
105
+ const where = path == null ? '' : ` on ${Array.isArray(path) ? path.join('.') : path}`;
106
+ if (scalar !== 'String') invalidValue(`${operator} requires a String field${where}`);
107
+ if (!Array.isArray(value)) invalidValue(`${operator} requires an array value${where}`);
108
+ // Array.from reads a hole of a sparse array as undefined; `some` would skip it.
109
+ const items = Array.from(value);
110
+ if (items.some((item) => typeof item !== 'string')) invalidValue(`${operator} requires non-null string elements${where}`);
111
+ if (items.some((item) => item.includes('\u0000'))) invalidValue(`${operator} values cannot contain U+0000${where}`);
112
+ return value;
113
+ };
114
+
115
+ const failPath = (message, code) => { throw new SimfinityError(message, code, 400); };
116
+ // The validator's steps (graphqlStep, modelStep) and resolveModelPath's field descriptions, read alike.
117
+ const stepKind = (step) => {
118
+ if (step.kind === 'embedded') return step.list ? 'embeddedList' : 'embedded';
119
+ if (step.kind === 'scalar') return step.list ? 'scalarList' : 'scalar';
120
+ if (step.kind === 'reference' && step.inferred) return 'scalar';
121
+ return step.kind;
122
+ };
123
+
124
+ /**
125
+ * The permanent path and leaf rules of CONTAINS_ALL (#108 section 3.3), on the steps of a path relative to its
126
+ * frame: the path ends at a value, as for every operator (MISSING_FILTER_PATH for one segment, INVALID_FILTER_PATH
127
+ * otherwise; the validator has applied this rule already, and PostgreSQL's hand-built plans get core's codes from
128
+ * it), no collection is crossed (INVALID_FILTER_PATH, which the shorthand's exclusion states), no reference follows
129
+ * an embedded list (INVALID_FILTER_PATH: that join yields one referenced record per row), and the leaf is a list: a
130
+ * scalar list, or a scalar reached through an embedded list (INVALID_FILTER_OPERATOR otherwise).
131
+ */
132
+ export const assertContainsAllPath = (steps) => {
133
+ const kinds = steps.map(stepKind);
134
+ const names = steps.map(({ name }) => name);
135
+ // Core's object-end rule first (query-frames.js:420-422), so that every caller gets core's codes in core's order.
136
+ if (['embedded', 'embeddedList', 'reference', 'collection'].includes(kinds.at(-1))) {
137
+ failPath('Filter on object field requires a scalar path', steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
138
+ }
139
+ const crossed = kinds.slice(0, -1);
140
+ if (crossed.includes('collection')) failPath('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
141
+ const list = crossed.indexOf('embeddedList');
142
+ const reference = list === -1 ? -1 : crossed.findIndex((kind, index) => index > list && kind === 'reference');
143
+ if (reference !== -1) {
144
+ failPath(`CONTAINS_ALL cannot cross the reference ${names.slice(0, reference + 1).join('.')} inside an embedded list, which joins one referenced record per row; combine EQ conditions on the path instead`, 'INVALID_FILTER_PATH');
145
+ }
146
+ const leaf = kinds.at(-1);
147
+ if (leaf !== 'scalarList' && !(leaf === 'scalar' && list !== -1)) {
148
+ failPath(`CONTAINS_ALL requires a list field, or a field reached through an embedded list; ${names.join('.')} is not one`, 'INVALID_FILTER_OPERATOR');
149
+ }
150
+ };
151
+
69
152
  const invalidFeatures = (message) => {
70
153
  throw new SimfinityError(`DatabaseAdapter queryFeatures ${message}`, 'INVALID_QUERY_FEATURES', 500);
71
154
  };
72
155
 
73
156
  /**
74
- * Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers }`:
157
+ * Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers, valuePath }`:
75
158
  * the operators it compiles and whether it compiles reference emptiness and collection frames.
76
159
  * Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
77
160
  * and neither feature. A declaration is an object whose `operators`
78
161
  * is an array of operator names, whose names this version does not know are ignored, and whose
79
- * optional `referenceEmptiness` and `quantifiers` are booleans, undefined or null (false).
80
- * Quantifiers require reference emptiness for scoped joins. Other members are left for later
162
+ * optional `referenceEmptiness`, `quantifiers` and `valuePath` are booleans, undefined or null (false). A name
163
+ * counts once this version knows it: a declaration that listed `CONTAINS_ALL` or `IIN` before
164
+ * #108 part 4 added them receives them on upgrade.
165
+ * Quantifiers and valuePath require reference emptiness for scoped joins. Other members are left for later
81
166
  * features. Anything else throws `INVALID_QUERY_FEATURES` (500).
82
167
  */
83
168
  export const resolveQueryFeatures = (queryFeatures) => {
84
- 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 };
85
170
  if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
86
171
  invalidFeatures('must be an object with an operators array');
87
172
  }
88
- const { operators, referenceEmptiness, quantifiers } = queryFeatures;
173
+ const { operators, referenceEmptiness, quantifiers, valuePath } = queryFeatures;
89
174
  if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
90
175
  invalidFeatures('operators must be an array of operator names');
91
176
  }
@@ -98,9 +183,12 @@ export const resolveQueryFeatures = (queryFeatures) => {
98
183
  if (quantifiers === true && referenceEmptiness !== true) {
99
184
  invalidFeatures('quantifiers require referenceEmptiness: true');
100
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');
101
188
  return {
102
189
  operators: new Set(operators.filter(isQueryOperator)),
103
190
  referenceEmptiness: referenceEmptiness === true,
104
191
  quantifiers: quantifiers === true,
192
+ valuePath: valuePath === true,
105
193
  };
106
194
  };