@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.
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
- textQueryOperator, validateTextFilterValue,
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);
@@ -256,10 +276,19 @@ export const resolveModelPath = (models, entityName, path) => {
256
276
 
257
277
  /**
258
278
  * Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. Operators
259
- * and values are validated here, text operators through validateTextFilterValue. With `operators`,
279
+ * and values are validated here, text operators through validateTextFilterValue, CONTAINS_ALL
280
+ * through validateContainsAllValue and IIN through validateTextListFilterValue. With `operators`,
260
281
  * the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
261
282
  * without it every QLOperator value is planned.
262
283
  *
284
+ * The list operators (#108 part 4) plan `{ kind: 'predicate', path, operator, value }` with the
285
+ * array as validated, neither cast nor deduplicated: CONTAINS_ALL's is a non-empty array of
286
+ * non-null scalars, on a scalar list or a scalar reached through embedded lists; IIN's is an array
287
+ * of strings, possibly empty, on a String field or list. CONTAINS_ALL's path and leaf rules are the
288
+ * shared validator's, which runs first: through a collection or a reference inside an embedded
289
+ * list it fails with INVALID_FILTER_PATH, on any other leaf with INVALID_FILTER_OPERATOR. IIN
290
+ * through a collection becomes a correlated ANY frame like IN, and CONTAINS_ALL is never cut.
291
+ *
263
292
  * The emptiness operators plan `{ kind: 'predicate', path, operator, value: null }`, whose path may
264
293
  * end at a scalar, a scalar list, an embedded list or an embedded object, after the checks of
265
294
  * describeEmptiness in their order (on the entity's GraphQL type, or on the field descriptions when
@@ -281,9 +310,12 @@ export const resolveModelPath = (models, entityName, path) => {
281
310
  * explicit ANY/ALL/NONE and quantifier filters retain their quantifier, and scoped reference
282
311
  * crossings inside a frame lift into `joins`. Aggregate collection joins remain legacy; later
283
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.
284
316
  */
285
317
  export const createQueryPlan = (models, entityName, source = {}, {
286
- mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null,
318
+ mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null, valuePath = supported == null,
287
319
  } = {}) => {
288
320
  let input = source;
289
321
  const allowed = supported == null ? null : new Set(supported);
@@ -295,19 +327,23 @@ export const createQueryPlan = (models, entityName, source = {}, {
295
327
  // The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
296
328
  // arguments are planned on the target type, as trusted find arguments in their own frame, whatever
297
329
  // the query's mode, with the operator names already read (`operators` may be a one-time iterable).
298
- const liftScopes = (marker, targets, segments, operator) => {
330
+ const liftScopes = (marker, targets, segments, operator, anchors = []) => {
299
331
  const label = `${operator} on ${segments.join('.')}`;
300
- const paths = Object.keys(targets);
332
+ const paths = [...Object.keys(targets), ...anchors];
301
333
  if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
302
334
  const scopes = marker?.scopes;
303
335
  if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled`);
304
336
  const lifted = {};
305
337
  for (const path of paths) {
306
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
+ }
307
343
  if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
308
344
  lifted[path] = scopes[path] === null
309
345
  ? null
310
- : 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;
311
347
  }
312
348
  return lifted;
313
349
  };
@@ -329,7 +365,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
329
365
  };
330
366
  // `construct` is the term or group condition that holds the predicate, the only objects whose
331
367
  // `__scope` is read; null for a field filter's own operator.
332
- 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 = '') => {
333
369
  const steps = resolveModelPath(models, frameEntity, path);
334
370
  const op = operator ?? 'EQ';
335
371
  if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameEntity);
@@ -339,12 +375,38 @@ export const createQueryPlan = (models, entityName, source = {}, {
339
375
  }
340
376
  if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
341
377
  if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
342
- const collection = ['IN', 'NIN', 'BTW'].includes(op);
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
+ }
402
+ const collection = takesListValue(op);
343
403
  if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
344
404
  if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
345
- if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
405
+ if (collection && !Array.isArray(value)) fail(`${op} requires an array value`);
346
406
  if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
347
407
  if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
408
+ if (op === 'CONTAINS_ALL') validateContainsAllValue(value);
409
+ if (op === 'IIN') validateTextListFilterValue(op, value, field.scalar, path);
348
410
  const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
349
411
  if (hasMarker(construct)) {
350
412
  if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);
@@ -353,11 +415,13 @@ export const createQueryPlan = (models, entityName, source = {}, {
353
415
  }
354
416
  return node;
355
417
  };
418
+ const hasComparisonNode = (node) => Boolean(node && (node.operand || hasComparisonNode(node.where)
419
+ || node.terms?.some(hasComparisonNode)));
356
420
  const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
357
421
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
358
422
  for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
359
423
  };
360
- const group = (value, depth = -1, frameEntity = entityName, inFrame = false) => {
424
+ const group = (value, depth = -1, frameEntity = entityName, inFrame = false, ancestors = []) => {
361
425
  validateGroup(value);
362
426
  const level = depth + (value.__implicit === true ? 0 : 1);
363
427
  if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
@@ -365,7 +429,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
365
429
  for (const condition of value.conditions || []) {
366
430
  if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
367
431
  if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
368
- 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));
369
433
  }
370
434
  if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
371
435
  fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
@@ -374,12 +438,12 @@ export const createQueryPlan = (models, entityName, source = {}, {
374
438
  const anchor = item.__implicit === true ? item.__anchor : undefined;
375
439
  const path = item.path.split('.');
376
440
  const target = resolveModelPath(models, frameEntity, [...(anchor ?? []), ...path]).at(-1).target;
377
- const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true), scope: null };
441
+ const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true, [...ancestors, frameEntity]), scope: null };
378
442
  if (anchor?.length) node.anchor = anchor;
379
443
  if (hasMarker(item)) {
380
444
  const fullPath = [...(anchor ?? []), ...path];
381
445
  const key = fullPath.join('.');
382
- const crossings = anchor?.length ? [] : crossingKeys(path, { models, entity: frameEntity });
446
+ const crossings = anchor?.length && !hasComparisonNode(node.where) ? [] : crossingKeys(fullPath, { models, entity: frameEntity });
383
447
  const targets = Object.fromEntries(crossings.map((prefix) => [prefix, resolveModelPath(models, frameEntity, prefix).at(-1).target]));
384
448
  targets[key] = target;
385
449
  const scopes = liftScopes(item.__scope, targets, fullPath, quantifier);
@@ -388,17 +452,18 @@ export const createQueryPlan = (models, entityName, source = {}, {
388
452
  for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
389
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}`);
390
454
  }
391
- } 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]]));
392
457
  }
393
458
  terms.push(node);
394
459
  }
395
- for (const item of value.AND || []) terms.push(group(item, level, frameEntity, inFrame));
396
- terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame))));
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))));
397
462
  return combine('and', terms);
398
463
  };
399
464
  // Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
400
465
  validateGroup(input, ['AND', 'OR']);
401
- 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 };
402
467
  const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
403
468
  context.J = J;
404
469
  validateQuantifiedGroups(input, context);
@@ -417,7 +482,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
417
482
  + `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
418
483
  }
419
484
  if (object && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
420
- 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));
421
486
  else for (const term of value.terms) {
422
487
  if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
423
488
  if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
@@ -425,7 +490,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
425
490
  continue;
426
491
  }
427
492
  if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
428
- 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));
429
494
  }
430
495
  }
431
496
  for (const item of input.AND || []) terms.push(group(item));