@simtlix/simfinity-js 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -510,6 +510,8 @@ query {
510
510
  }
511
511
  ```
512
512
 
513
+ In Example 9, `episodes` is in J because `totalRevenue` crosses it. Its join preserves one row per episode, including one null row when none exists, so `seriesCount` counts those rows rather than distinct series. A collection used only by a filter becomes an `ANY` frame and does not multiply facts; beyond a joined prefix it tests the joined alias through an anchor. Explicit `ANY`/`ALL`/`NONE` and relation-filter `quantifier` always stay frames. See [aggregate collection semantics](../../docs/guide/queries.md#aggregate-records).
514
+
513
515
  ## Field Path Resolution
514
516
 
515
517
  The `groupId` and `path` parameters support:
@@ -528,7 +530,7 @@ The `groupId` and `path` parameters support:
528
530
  The aggregation queries are translated to MongoDB aggregation pipelines:
529
531
 
530
532
  1. **$lookup**: Used for non-embedded related entities
531
- 2. **$unwind**: Used to flatten joined collections
533
+ 2. **$unwind**: Expands collection prefixes used by `groupId` or facts (J), preserving their joined-row cardinality; filter-only collection frames use correlated `$lookup` existence probes without unwinding root rows
532
534
  3. **$match**: Applied for filtering (before grouping)
533
535
  4. **$group**: Groups by the specified field with aggregation operations
534
536
  5. **$project**: Formats the final output with groupId and facts fields
package/README.md CHANGED
@@ -42,6 +42,10 @@ Text filters compile to escaped regular expressions, so a value never becomes re
42
42
 
43
43
  The adapter also declares `queryFeatures.referenceEmptiness`, so these operators cross single references and end at references and collections, such as `director IS_EMPTY`, `director.name IS_EMPTY` or `seasons IS_EMPTY`, and an embedded object with a reference or collection member follows the member rule. A crossing joins the target with `$lookup` and `$unwind`, and the target's `id` right after it is its record identity, which is empty exactly when the reference is. A reference or collection is a frame: one `$lookup` that keeps at most one record id, so it never repeats a document, matched with `"alias.0": { $exists: false }` and its `$nor`; a collection frame reads the child's connection field and also tests its local key for null, because `$lookup` matches a missing local key with every child whose connection field is null. The joins and frames run before the `$match`, so they also filter counts, aggregates and batched collection reads, and they probe the `_id` index or the index on the connection field, which generated models create and a supplied model needs to declare. A client's condition reads each target through its `find` scope, inside the `$lookup` pipeline, so a record the scope hides reads as missing, also on the way to a later hop. Conditions that middleware and scopes add, and `buildQuery()` called directly, read stored data, so a dangling reference, to a record that no longer exists, is empty. A related type without a model fails with `INVALID_FILTER_PATH` (400), as for the other operators, and supplied models on another database or connection and discriminator targets are not supported, as for every relation path. A collection inside an embedded type, which only a supplied model can declare, is read through the embedded object's `_id`, as relation paths read it. The [query scope guide](https://simtlix.github.io/simfinity.js/guide/query-scope.html) describes client and trusted conditions, and the [middleware guide](https://simtlix.github.io/simfinity.js/guide/middleware.html) how to edit client filters without detaching them.
44
44
 
45
+ The adapter declares `queryFeatures.quantifiers: true`. Ordinary filter paths through collections compile as correlated `ANY` frames: `reviews.rating GTE 4` keeps each parent once, so pagination counts roots and filters no longer multiply aggregate rows. Conditions in the same group that share a collection prefix must match the same child; separate groups have independent frames. Explicit `ANY`, `ALL` and `NONE` groups and `quantifier` field filters accept paths relative to the target element, including nested frames and references. `ALL` holds when every visible element matches and is true for an empty set; `ALL` without conditions is always true, including inside `OR`. Client target `find` scopes run inside each frame before its conditions, and `ALL` negates only the conditions; trusted conditions from middleware, scopes and direct `buildQuery()` calls read stored data. A reference inside a client frame reads its target through the scoped join in that sub-pipeline.
46
+
47
+ A quantified frame uses one `$lookup` with the child scope, inner joins and the condition, followed by `$limit: 1` and `$project: { _id: 1 }`, tested by the presence or absence of `"__sf_qN.0"`. Collection frames correlated through a reference or embedded `_id` start with a non-null check on the child's connection field; legacy aggregate collection joins whose local key is not the root `_id` (a collection reached through a reference or an embedded object) use the same guard, avoiding orphan matches through missing parents. Root-anchored aggregate collection joins keep their form. Reference frames need no guard. `groupId` and fact paths preserve their collection joins and row cardinality, while other aggregate filters use frames anchored on the relevant joined row. Sort paths through a collection reject with `INVALID_FILTER_PATH` (400), also for counted pages; sorts through references remain available. Existing own-field and reference predicates, embedded-list predicates and 2b emptiness forms retain their pipelines. Generated and batched collection reads use the same frame pipelines unchanged inside `$unionWith`. These concise lookups require MongoDB 5.0 or later; see the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) for correlation, scopes, depth and compatibility rules.
48
+
45
49
  The facade registers generated models on your application's default Mongoose instance and does not change global Mongoose options such as `strictQuery`. Supplied models may use another connection, such as one from `mongoose.createConnection()`: generated mutations and `saveObject()` use the model's connection, and, in the default `referentialIntegrity: 'off'` mode, custom mutations use the default connection unless it is unused, never opened and without compiled models, also on its `useDb()` descendants, and the registered models share one MongoDB client; with transactional reference integrity they use the first protected model's connection. Named views need no session: they run the generated list path. Custom queries registered with `registerCustomQuery()` receive no session; a callback that needs a transaction opens it with the `transaction()` of the Mongoose connection whose models it reads. `createMongoAdapter().castId` accepts only an ObjectId or its 24-character hexadecimal string and throws `NOT_VALID_ID` (400) otherwise; create new identifiers with `new mongoose.Types.ObjectId()`. Owned transactions retry transient failures after a short random delay, and a write conflict that outlasts the retries fails with `TRANSACTION_RETRY_EXCEEDED` (409).
46
50
 
47
51
  Mongoose 8.24.2 or later within version 8 is required to exclude the update-casting prototype-pollution vulnerability. Existing applications should update Mongoose and their dependency lockfile, then run `npm audit`; upgrading Simfinity does not refresh every transitive dependency already locked by the application.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "MongoDB/Mongoose adapter and compatibility facade for Simfinity GraphQL APIs",
5
5
  "main": "src/index.js",
6
6
  "types": "./types/index.d.ts",
@@ -29,8 +29,8 @@
29
29
  "owner": "simtlix"
30
30
  },
31
31
  "dependencies": {
32
- "@simtlix/simfinity-core": "3.13.0",
33
- "@simtlix/simfinity-mcp": "3.13.0"
32
+ "@simtlix/simfinity-core": "3.14.0",
33
+ "@simtlix/simfinity-mcp": "3.14.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -16,7 +16,7 @@ const withSession = (query, session) => (session ? query.session(session) : quer
16
16
  const WRITE_FEATURES = Object.freeze({ optimisticConcurrency: true });
17
17
  // Every filter operator the query compiler supports, and emptiness through references and collections (#108 part
18
18
  // 2b); the runtime rejects any other operator.
19
- const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS, referenceEmptiness: true });
19
+ const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS, referenceEmptiness: true, quantifiers: true });
20
20
 
21
21
  const assertVersionModel = ({ gqltype, model }) => {
22
22
  if (!model || !isProtectedType(gqltype)) return;
@@ -10,7 +10,8 @@ import mongoose from 'mongoose';
10
10
 
11
11
  import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
12
12
  import {
13
- TEXT_QUERY_OPERATORS, describeEmptiness, effectiveScalarName, emptinessScopePaths, isEmptinessOperator,
13
+ TEXT_QUERY_OPERATORS, aggregateJoinPrefixes, crossingKeys, describeEmptiness, effectiveScalarName, emptinessScopePaths,
14
+ frameCollectionPaths, isEmptinessOperator, validateQuantifiedGroups,
14
15
  textQueryOperator, validateTextFilterValue,
15
16
  } from '@simtlix/simfinity-core/internal/query-operators';
16
17
  import {
@@ -43,6 +44,8 @@ export const MONGO_QUERY_OPERATORS = Object.freeze([
43
44
  export const createMongoQueries = ({ getModel, getRegistrations }) => {
44
45
  // One alias per relation path for each query build, keyed by the build's `included` accumulator.
45
46
  const lookupAliases = new WeakMap();
47
+ // Only actual reference joins reached through embedded lists multiply a body's candidates.
48
+ const multiplyingBuilds = new WeakSet();
46
49
 
47
50
  const allocateLookupAlias = (aggregationsIncluded, relationPath) => {
48
51
  let aliases = lookupAliases.get(aggregationsIncluded);
@@ -62,7 +65,7 @@ const relationBuild = (aggregationsIncluded) => {
62
65
  let build = relationBuilds.get(aggregationsIncluded);
63
66
  if (!build) {
64
67
  build = {
65
- aliases: new Map(), counts: { join: 0, frame: 0 }, scopes: new WeakMap(), scopeCount: 0,
68
+ aliases: new Map(), counts: { join: 0, frame: 0, scope: 0, serial: 0 }, scopes: new WeakMap(), serials: new WeakMap(),
66
69
  };
67
70
  relationBuilds.set(aggregationsIncluded, build);
68
71
  }
@@ -92,6 +95,7 @@ const buildRelationLookup = ({
92
95
  {
93
96
  $lookup: {
94
97
  from: collectionName, foreignField, localField, as: alias,
98
+ ...(foreignField !== '_id' && localField !== '_id' ? { pipeline: [{ $match: { [foreignField]: { $ne: null } } }] } : {}),
95
99
  },
96
100
  },
97
101
  { $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
@@ -195,13 +199,14 @@ const identifierLeaf = (model, schemaPath, gqltype) => {
195
199
  };
196
200
 
197
201
  // Resolve GraphQL paths and storage paths together; supplied models own ID casting.
198
- const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
202
+ const resolveQueryPath = (gqltype, path, aggregationsIncluded, origin) => {
199
203
  assertValidFilterPath(path);
200
204
  const parts = path.split('.');
201
205
  let currentType = gqltype;
202
- let currentModel = getModel(gqltype);
203
- let mongoPath = '';
204
- let schemaPath = '';
206
+ let currentModel = origin?.model ?? getModel(gqltype);
207
+ let mongoPath = origin?.parent.key ?? '';
208
+ let schemaPath = origin?.parent.schemaPath ?? '';
209
+ let vector = false;
205
210
  const aggregateClauses = {};
206
211
 
207
212
  for (const [index, part] of parts.entries()) {
@@ -218,6 +223,7 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
218
223
  if (fieldType instanceof GraphQLObjectType) {
219
224
  if (index === parts.length - 1) throw filterError(`Query path must end in a scalar field: ${path}`, 'INVALID_FILTER_PATH');
220
225
  if (!relation || relation.embedded) {
226
+ vector ||= !!getListShape(field.type);
221
227
  mongoPath = mongoPath ? `${mongoPath}.${part}` : part;
222
228
  schemaPath = schemaPath ? `${schemaPath}.${part}` : part;
223
229
  } else {
@@ -229,6 +235,8 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
229
235
  ? normalizeConnectionField(fieldType, relation.connectionField || part).storageFieldName
230
236
  : getFieldStorageName(part, field);
231
237
  const localLeaf = isList ? '_id' : connField;
238
+ if (vector) multiplyingBuilds.add(aggregationsIncluded);
239
+ vector = false;
232
240
  const alias = allocateLookupAlias(aggregationsIncluded, parts.slice(0, index + 1).join('.'));
233
241
  aggregateClauses[alias] = buildRelationLookup({
234
242
  collectionName: relatedModel.collection.collectionName,
@@ -394,7 +402,7 @@ const compileTargetScope = async (build, scope, targetType) => {
394
402
  const stages = [];
395
403
  const match = await collectFiltersAndLookups(scope, targetType, stages, {}, 'find');
396
404
  if (match) stages.push(match);
397
- const result = stages.length ? { token: `s${build.scopeCount++}`, stages } : null;
405
+ const result = stages.length ? { token: `s${build.counts.scope++}`, stages } : null;
398
406
  if (!build.scopes.has(scope)) build.scopes.set(scope, new Map());
399
407
  build.scopes.get(scope).set(targetType, result);
400
408
  return result;
@@ -491,6 +499,179 @@ const buildEmptinessClause = async (gqltype, path, operator, value, construct, l
491
499
  return form(ctx, shape, parent, type, segments.at(-1));
492
500
  };
493
501
 
502
+ // A build keeps one rewrite context; aggregate J paths must survive every logical-group pass.
503
+ const rewriteContext = (gqltype, operation = 'find', aggregation) => {
504
+ const ctx = { gqltype, operation, quantifiers: true, referenceEmptiness: true, operators: new Set(MONGO_QUERY_OPERATORS), base: '', inFrame: false };
505
+ // Ordinary root markers reject at the existing post-value guards, preserving native order.
506
+ ctx.deferOrdinaryRootMarkers = true;
507
+ ctx.J = operation === 'aggregate' ? aggregateJoinPrefixes(aggregation, ctx) : new Set();
508
+ return ctx;
509
+ };
510
+
511
+ // Cross references in the containing pipeline. Collection steps occur here only for an aggregate
512
+ // anchor: all ordinary collection crossings have already become frames in the shared rewrite.
513
+ const crossFramePath = async (gqltype, segments, scopes, build, lookups, included) => {
514
+ let type = gqltype;
515
+ let parent = { key: '', schemaPath: '', path: '' };
516
+ let model = getModel(type);
517
+ const chain = [];
518
+ let scoped = false;
519
+ let vector = false;
520
+ for (const name of segments) {
521
+ const field = type.getFields()[name];
522
+ const targetType = getNamedType(field.type);
523
+ const path = joinPath(parent.path, name);
524
+ const relation = field.extensions?.relation;
525
+ if (!relation || relation.embedded) {
526
+ vector ||= !!getListShape(field.type);
527
+ parent = { key: joinPath(parent.key, name), schemaPath: joinPath(parent.schemaPath, name), path };
528
+ } else {
529
+ const target = getModel(targetType);
530
+ if (!target) throw filterError(`Related model is not available for ${path}`, 'INVALID_FILTER_PATH');
531
+ const collection = !!getListShape(field.type);
532
+ const foreignField = collection ? normalizeConnectionField(targetType, relation.connectionField || name).storageFieldName : '_id';
533
+ const localField = joinPath(parent.key, collection ? '_id' : getFieldStorageName(name, field));
534
+ const scope = scopes && Object.hasOwn(scopes, path) ? await compileTargetScope(build, scopes[path], targetType) : null;
535
+ if (vector && scope) throw new SimfinityError('A reference inside an embedded list cannot apply a target scope', 'FORBIDDEN_FILTER_PATH', 403);
536
+ chain.push(scope ? scope.token : '-');
537
+ scoped ||= !!scope;
538
+ let alias;
539
+ if (!scoped) {
540
+ alias = allocateLookupAlias(included, path);
541
+ lookups[alias] = buildRelationLookup({ collectionName: target.collection.collectionName, localField, foreignField, alias });
542
+ } else {
543
+ alias = allocateRelationAlias(build, 'join', `${path} ${chain.join(',')}`);
544
+ // A vector with a null target scope keeps every target. The chain alias isolates it
545
+ // from a trusted join while its local key preserves the earlier restricted reference.
546
+ lookups[alias] = vector ? buildRelationLookup({ collectionName: target.collection.collectionName, localField, foreignField, alias }) : { stages: [
547
+ { $lookup: { from: target.collection.collectionName, localField, foreignField, pipeline: [...(scope?.stages ?? []), { $limit: 1 }], as: alias } },
548
+ { $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
549
+ ] };
550
+ }
551
+ if (vector) multiplyingBuilds.add(included);
552
+ vector = false;
553
+ parent = { key: alias, schemaPath: '', path };
554
+ model = target;
555
+ }
556
+ type = targetType;
557
+ }
558
+ return { type, parent, model, chain };
559
+ };
560
+
561
+ // A non-emptiness marker is legal only inside a frame, on a condition crossing references. Its
562
+ // keys are relative to the element, just like 2b emptiness keys in the same sub-build.
563
+ const resolveConditionPath = async (gqltype, path, construct, included, ctx) => {
564
+ if (!hasScopeMarker(construct)) return resolveQueryPath(gqltype, path, included);
565
+ // buildQueryTerms rejects the marker immediately after buildMatchesClause; group conditions
566
+ // also retain their raw-condition guard. No accepted path parses or casts a value twice.
567
+ if (!ctx?.inFrame && ctx?.deferOrdinaryRootMarkers === true) return resolveQueryPath(gqltype, path, included);
568
+ if (!ctx?.inFrame) throw detachedFilter(`${construct.operator ?? 'EQ'} on ${joinPath(ctx?.base, path)} carries a client scope marker, but is not an emptiness condition`);
569
+ const paths = crossingKeys(path.split('.'), { gqltype });
570
+ const scopes = clientScopes(construct, `${construct.operator ?? 'EQ'} on ${joinPath(ctx.base, path)}`, paths);
571
+ const aggregateClauses = {};
572
+ const crossed = await crossFramePath(gqltype, path.split('.').slice(0, -1), scopes, relationBuild(included), aggregateClauses, included);
573
+ const resolved = resolveQueryPath(crossed.type, path.split('.').at(-1), included, crossed);
574
+ return { ...resolved, aggregateClauses: { ...aggregateClauses, ...resolved.aggregateClauses } };
575
+ };
576
+
577
+ const compileFrame = async (ctx, quantifier, group) => {
578
+ const build = relationBuild(ctx.included);
579
+ const anchor = group.__implicit === true ? group.__anchor ?? [] : [];
580
+ const segments = [...anchor, ...group.path.split('.')];
581
+ const paths = [];
582
+ let type = ctx.gqltype;
583
+ let vector = false;
584
+ let multiplyingPrefix = false;
585
+ for (const [index, name] of segments.entries()) {
586
+ const field = type.getFields()[name];
587
+ if (field.extensions?.relation && !field.extensions.relation.embedded) {
588
+ if (index < segments.length - 1 && vector) multiplyingPrefix = true;
589
+ vector = false;
590
+ if (!getModel(getNamedType(field.type))) throw filterError(`Related model is not available for ${joinPath(ctx.base, segments.join('.'))}`, 'INVALID_FILTER_PATH');
591
+ if (!anchor.length || index >= anchor.length || getListShape(field.type)) paths.push(segments.slice(0, index + 1).join('.'));
592
+ } else {
593
+ vector ||= !!getListShape(field.type);
594
+ }
595
+ type = getNamedType(field.type);
596
+ }
597
+ const scopes = clientScopes(group, `${quantifier} on ${joinPath(ctx.base, segments.join('.'))}`, paths);
598
+ if (anchor.length && scopes) {
599
+ for (const path of paths.filter((key) => anchor.join('.').startsWith(key))) {
600
+ if (scopes[path] !== null) throw detachedFilter('Aggregate anchors require null J scopes and legacy reference joins');
601
+ }
602
+ }
603
+ const emptyAll = quantifier === 'ALL' && !['conditions', 'ANY', 'ALL', 'NONE', 'AND', 'OR'].some((key) => group[key]?.length);
604
+ // A vector prefix belongs to this predicate, not to the containing result set. A
605
+ // self-correlated witness keeps its joins local without discarding any target. Aggregate
606
+ // anchors retain their intentional expansion. NONE tests the positive ANY witness; ALL
607
+ // looks for a prefix whose complete ALL test fails, including vacuous empty prefixes.
608
+ if (multiplyingPrefix && !anchor.length && !ctx.prefixIsolated && !emptyAll) {
609
+ const model = getModel(ctx.gqltype);
610
+ if (!model) throw filterError(`Related model is not available for ${ctx.gqltype.name}`, 'INVALID_FILTER_PATH');
611
+ if (!build.serials.has(group)) build.serials.set(group, build.counts.serial++);
612
+ const alias = allocateRelationAlias(build, 'frame', `prefix:${build.serials.get(group)}`);
613
+ const stages = [];
614
+ const included = {};
615
+ relationBuilds.set(included, { ...relationBuild(included), counts: build.counts, scopes: build.scopes });
616
+ const positive = await compileFrame({ ...ctx, stages, included, prefixIsolated: true }, quantifier === 'NONE' ? 'ANY' : quantifier, group);
617
+ if (quantifier === 'ALL' && !Object.keys(positive).length) return {};
618
+ ctx.stages.push({ $lookup: {
619
+ from: model.collection.collectionName, localField: '_id', foreignField: '_id',
620
+ pipeline: [...stages, { $match: quantifier === 'ALL' ? { $nor: [positive] } : positive }, { $limit: 1 }, { $project: { _id: 1 } }],
621
+ as: alias,
622
+ } });
623
+ ctx.included[alias] = true;
624
+ return { [`${alias}.0`]: { $exists: quantifier === 'ANY' } };
625
+ }
626
+ const lookups = {};
627
+ const crossed = await crossFramePath(ctx.gqltype, segments.slice(0, -1), anchor.length ? null : scopes, build, lookups, ctx.included);
628
+ const name = segments.at(-1);
629
+ const field = crossed.type.getFields()[name];
630
+ const targetType = getNamedType(field.type);
631
+ const path = segments.join('.');
632
+ const scope = scopes ? await compileTargetScope(build, scopes[path], targetType) : null;
633
+ if (emptyAll) return {};
634
+ // Allocate before compiling the body: descendants follow their parent's serial even though
635
+ // their lookups live inside its pipeline. No quantified frame shares a 2b emptiness frame.
636
+ if (!build.serials.has(group)) build.serials.set(group, build.counts.serial++);
637
+ const alias = allocateRelationAlias(build, 'frame', `group:${build.serials.get(group)} ${path} ${[...crossed.chain, scope ? scope.token : '-'].join(',')}`);
638
+ const stages = [];
639
+ const included = {};
640
+ relationBuilds.set(included, { ...relationBuild(included), counts: build.counts, scopes: build.scopes });
641
+ const childCtx = { ...rewriteContext(targetType), base: joinPath(ctx.base, path), inFrame: true };
642
+ const body = await buildFilterGroupMatch(group, targetType, stages, included, 0, childCtx);
643
+ if (quantifier === 'ALL' && body === null) return {};
644
+ // ALL negates one truth value for the actual child. With vector joins, negating
645
+ // individual expanded rows would incorrectly turn a bad sibling target into a failing
646
+ // child. Keep the complete positive body together, with its scope admission outside.
647
+ let bodyStages = stages;
648
+ let bodyMatch = quantifier === 'ALL' ? { $nor: [body] } : body;
649
+ if (quantifier === 'ALL' && multiplyingBuilds.has(included)) {
650
+ const witness = allocateRelationAlias(build, 'frame', `body:${build.serials.get(group)}`);
651
+ bodyStages = [{ $lookup: {
652
+ from: getModel(targetType).collection.collectionName, localField: '_id', foreignField: '_id',
653
+ pipeline: [...stages, { $match: body }, { $limit: 1 }, { $project: { _id: 1 } }],
654
+ as: witness,
655
+ } }];
656
+ bodyMatch = { [`${witness}.0`]: { $exists: false } };
657
+ }
658
+ const collection = !!getListShape(field.type);
659
+ const localField = joinPath(crossed.parent.key, collection ? '_id' : getFieldStorageName(name, field));
660
+ const foreignField = collection ? normalizeConnectionField(targetType, field.extensions.relation.connectionField || name).storageFieldName : '_id';
661
+ lookups[alias] = { stages: [{ $lookup: {
662
+ from: getModel(targetType).collection.collectionName, localField, foreignField,
663
+ pipeline: [
664
+ ...(collection && localField !== '_id' ? [{ $match: { [foreignField]: { $ne: null } } }] : []),
665
+ ...(scope?.stages ?? []), ...bodyStages,
666
+ ...(body === null ? [] : [{ $match: bodyMatch }]),
667
+ { $limit: 1 }, { $project: { _id: 1 } },
668
+ ],
669
+ as: alias,
670
+ } }] };
671
+ appendLookups(ctx.stages, ctx.included, lookups);
672
+ return { [`${alias}.0`]: { $exists: quantifier === 'ANY' } };
673
+ };
674
+
494
675
  const castFilterValue = (value, resolved) => {
495
676
  if (value === null) return null;
496
677
  if (value === undefined || Array.isArray(value)
@@ -556,7 +737,7 @@ const buildMatchesClause = (resolved, operator, value, path) => {
556
737
  return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced, path) };
557
738
  };
558
739
 
559
- const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded) => {
740
+ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded, ctx) => {
560
741
  const aggregateClauses = {};
561
742
  const matchesClauses = [];
562
743
  if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
@@ -606,10 +787,10 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
606
787
  }
607
788
  if (isObject) assertValidFilterPath(term.path);
608
789
  const path = isObject ? `${fieldName}.${term.path}` : fieldName;
609
- const resolved = resolveQueryPath(gqltype, path, aggregationsIncluded);
790
+ const resolved = await resolveConditionPath(gqltype, path, isObject ? term : null, aggregationsIncluded, ctx);
610
791
  Object.assign(aggregateClauses, resolved.aggregateClauses);
611
792
  matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value, path));
612
- if (isObject && hasScopeMarker(term)) {
793
+ if (isObject && hasScopeMarker(term) && !ctx?.inFrame) {
613
794
  throw detachedFilter(`${term.operator ?? 'EQ'} on ${path} carries a client scope marker, but is not an emptiness condition`);
614
795
  }
615
796
  }
@@ -625,7 +806,15 @@ const validateLogicalLists = (group, keys = ['AND', 'OR', 'conditions']) => {
625
806
  }
626
807
  };
627
808
 
628
- const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, aggregationsIncluded, depth = 0) => {
809
+ const buildFilterGroupMatch = async (rawFilterGroup, gqltype, aggregateClauses, aggregationsIncluded, depth = 0, rewriteCtx) => {
810
+ let filterGroup = rawFilterGroup;
811
+ let ctx = rewriteCtx;
812
+ if (!ctx) {
813
+ ctx = rewriteContext(gqltype);
814
+ const input = { AND: [filterGroup] };
815
+ validateQuantifiedGroups(input, ctx);
816
+ filterGroup = frameCollectionPaths(input, ctx).AND[0];
817
+ }
629
818
  if (depth > MAX_FILTER_GROUP_DEPTH) {
630
819
  throw new SimfinityError('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED', 400);
631
820
  }
@@ -661,15 +850,15 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
661
850
  throw new SimfinityError(`Filter on object field "${condition.field}" requires a path`, 'MISSING_FILTER_PATH', 400);
662
851
  }
663
852
  filterInput = {
664
- terms: [{ path: condition.path, operator: condition.operator, value: condition.value }],
853
+ terms: [{ ...rawCondition, field: undefined, path: condition.path, operator: condition.operator, value: condition.value }],
665
854
  };
666
855
  } else {
667
856
  if (condition.path != null && condition.path !== '') throw filterError(`Cannot traverse scalar field ${condition.field}`, 'INVALID_FILTER_PATH');
668
857
  filterInput = { operator: condition.operator, value: condition.value };
669
858
  }
670
859
 
671
- const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded);
672
- if (hasScopeMarker(rawCondition)) {
860
+ const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded, ctx);
861
+ if (hasScopeMarker(rawCondition) && !ctx.inFrame) {
673
862
  throw detachedFilter(`${condition.operator ?? 'EQ'} on ${path} carries a client scope marker, but is not an emptiness condition`);
674
863
  }
675
864
  appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
@@ -681,9 +870,13 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
681
870
  }
682
871
  }
683
872
 
873
+ for (const quantifier of ['ANY', 'ALL', 'NONE']) {
874
+ for (const group of filterGroup[quantifier] ?? []) parts.push(await compileFrame({ ...ctx, stages: aggregateClauses, included: aggregationsIncluded }, quantifier, group));
875
+ }
876
+
684
877
  if (filterGroup.AND?.length > 0) {
685
878
  for (const subGroup of filterGroup.AND) {
686
- const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
879
+ const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + (subGroup.__implicit === true ? 0 : 1), ctx);
687
880
  if (subMatch) parts.push(subMatch);
688
881
  }
689
882
  }
@@ -691,7 +884,7 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
691
884
  if (filterGroup.OR?.length > 0) {
692
885
  const orParts = [];
693
886
  for (const subGroup of filterGroup.OR) {
694
- const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
887
+ const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + (subGroup.__implicit === true ? 0 : 1), ctx);
695
888
  if (subMatch) orParts.push(subMatch);
696
889
  }
697
890
  if (orParts.length === 1) {
@@ -710,7 +903,10 @@ const FIND_CONTROL_KEYS = new Set(['pagination', 'sort', 'AND', 'OR']);
710
903
  // `aggregation` is the expression argument only on aggregate queries; elsewhere it names a field.
711
904
  const AGGREGATE_CONTROL_KEYS = new Set([...FIND_CONTROL_KEYS, 'aggregation']);
712
905
 
713
- const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggregationsIncluded, operation = 'find') => {
906
+ const collectFiltersAndLookups = async (rawInput, gqltype, aggregateClauses, aggregationsIncluded, operation = 'find', aggregation) => {
907
+ const ctx = rewriteContext(gqltype, operation, aggregation);
908
+ validateQuantifiedGroups(rawInput, ctx);
909
+ const input = frameCollectionPaths(rawInput, ctx);
714
910
  // Top-level arguments are field filters plus AND/OR groups; `conditions` is a key only inside groups.
715
911
  validateLogicalLists(input, ['AND', 'OR']);
716
912
  const controlKeys = operation === 'aggregate' ? AGGREGATE_CONTROL_KEYS : FIND_CONTROL_KEYS;
@@ -722,7 +918,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
722
918
  for (const [key, filterField] of Object.entries(input)) {
723
919
  if (controlKeys.has(key)) continue;
724
920
  const qlField = fields[key];
725
- const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded);
921
+ const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded, ctx);
726
922
  appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
727
923
  for (const matchClause of Object.values(result.matchesClauses)) {
728
924
  for (const [matchKey, match] of Object.entries(matchClause)) {
@@ -742,7 +938,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
742
938
 
743
939
  if (input.AND?.length > 0) {
744
940
  for (const group of input.AND) {
745
- const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
941
+ const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded, 0, ctx);
746
942
  if (groupMatch) topLevelAndParts.push(groupMatch);
747
943
  }
748
944
  }
@@ -750,7 +946,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
750
946
  if (input.OR?.length > 0) {
751
947
  const orParts = [];
752
948
  for (const group of input.OR) {
753
- const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
949
+ const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded, 0, ctx);
754
950
  if (groupMatch) orParts.push(groupMatch);
755
951
  }
756
952
  if (orParts.length === 1) {
@@ -777,6 +973,15 @@ const validateSortTerms = (sort) => {
777
973
  const buildSortClause = (sortTerms, gqltype, aggregateClauses, aggregationsIncluded) => {
778
974
  const sortExpressions = {};
779
975
  for (const sort of sortTerms) {
976
+ let type = gqltype;
977
+ for (const name of sort.field.split('.')) {
978
+ const field = type.getFields?.()[name];
979
+ if (!field) break;
980
+ if (field.extensions?.relation && !field.extensions.relation.embedded && getListShape(field.type)) {
981
+ throw filterError(`Sort path ${sort.field} crosses the collection ${type.name}.${name}; a record has no single value there`, 'INVALID_FILTER_PATH');
982
+ }
983
+ type = getNamedType(field.type);
984
+ }
780
985
  const resolved = resolveQueryPath(gqltype, sort.field, aggregationsIncluded);
781
986
  appendLookups(aggregateClauses, aggregationsIncluded, resolved.aggregateClauses);
782
987
  sortExpressions[resolved.mongoPath] = sort.order === 'ASC' ? 1 : -1;
@@ -817,7 +1022,7 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
817
1022
  const aggregationsIncluded = {};
818
1023
  const paging = paginationStages(input.pagination, false);
819
1024
 
820
- const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'aggregate');
1025
+ const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'aggregate', aggregationExpression);
821
1026
  if (matchStage) aggregateClauses.push(matchStage);
822
1027
 
823
1028
  const { groupId, facts } = aggregationExpression ?? {};
package/types/index.d.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  DatabaseAdapter,
18
18
  MutationLimitsOptions,
19
19
  QueryFeatures,
20
+ QueryLimitsOptions,
20
21
  RuntimeRegistration,
21
22
  SimfinityError,
22
23
  ViewOptions,
@@ -50,6 +51,12 @@ export type {
50
51
  MutationLimitsOptions,
51
52
  PermissionSchema,
52
53
  PolicyExpression,
54
+ QLFilterCondition,
55
+ QLFilterGroup,
56
+ QLQuantifiedGroup,
57
+ QLTypeFilterExpression,
58
+ QueryLimitsOptions,
59
+ QueryQuantifier,
53
60
  ScopeFunction,
54
61
  ScopeOperation,
55
62
  ScopeParams,
@@ -103,7 +110,7 @@ export interface MongoAdapter extends DatabaseAdapter {
103
110
  /** Frozen declaration of atomic expectedVersion writes; wrappers must forward every argument. */
104
111
  readonly writeFeatures: Readonly<{ optimisticConcurrency: true }>;
105
112
  /**
106
- * Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true`. The literal text
113
+ * Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true` and `quantifiers: true`. The literal text
107
114
  * operators compile to escaped regular expressions: `ILIKE` unanchored, `STARTS_WITH` and
108
115
  * `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z`, and `IEQ` with both, with
109
116
  * the `i` option for the case-insensitive ones. `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query
@@ -284,21 +291,10 @@ export function use(
284
291
  /** Globally prevent Mongoose collection creation for generated models. */
285
292
  export function preventCreatingCollection(prevent: boolean): void;
286
293
 
287
- /** Process-wide limits for generated list and paginated aggregate queries. */
288
- export interface QueryLimitsOptions {
289
- /** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
290
- maxPageSize?: number;
291
- /**
292
- * Positive safe integer; defaults to 10. The most distinct scoped joins and frames that the
293
- * client's reference and collection emptiness may need in one list, aggregate or collection-field
294
- * call; more fail with `FILTER_DEPTH_EXCEEDED` (400).
295
- */
296
- maxReferenceFrames?: number;
297
- }
298
-
299
294
  /**
300
- * Configure at startup. Invalid configuration throws INVALID_QUERY_LIMITS (400) and keeps both
301
- * limits; an omitted option takes its default again.
295
+ * Process-wide limits for generated list and paginated aggregate queries. Configure at startup.
296
+ * Invalid configuration throws INVALID_QUERY_LIMITS (400) and keeps all limits; an omitted option
297
+ * takes its default again.
302
298
  */
303
299
  export function configureQueryLimits(options?: QueryLimitsOptions): void;
304
300
 
@@ -352,10 +348,13 @@ export function getType(
352
348
  * Query building (src/index.js)
353
349
  * ========================================================================== */
354
350
 
355
- /** A QLFilterGroup input value (recursive AND/OR groups plus flat conditions). */
351
+ /** A QLFilterGroup input value: conditions, logical groups and element-relative quantified groups. */
356
352
  export interface FilterGroupInput {
357
353
  AND?: FilterGroupInput[];
358
354
  OR?: FilterGroupInput[];
355
+ ANY?: QuantifiedGroupInput[];
356
+ ALL?: QuantifiedGroupInput[];
357
+ NONE?: QuantifiedGroupInput[];
359
358
  conditions?: Array<{
360
359
  field: string;
361
360
  operator?: string;
@@ -367,6 +366,11 @@ export interface FilterGroupInput {
367
366
  [key: string]: any;
368
367
  }
369
368
 
369
+ /** A reference or collection frame; its body uses paths relative to the target element. */
370
+ export interface QuantifiedGroupInput extends FilterGroupInput {
371
+ path: string;
372
+ }
373
+
370
374
  /**
371
375
  * Translate list-query arguments (filters, AND/OR, sort, pagination) into a MongoDB aggregation
372
376
  * pipeline. `input.aggregation` filters a field named `aggregation`; the aggregate stage is never
@@ -403,16 +407,25 @@ export interface FilterGroupInput {
403
407
  * that a scope hides reads as missing. A crossing or frame after a scoped crossing stays in that chain,
404
408
  * also when its own scope is null. The aliases are unset at the end of find pipelines. A target type
405
409
  * without a model fails with `INVALID_FILTER_PATH` (400), and a marker that is not filled for every
406
- * path the construct reads, or that sits on a construct that reads none or on another operator, with
410
+ * path the construct reads, or that sits on a construct that reads no relation or on an ordinary
411
+ * condition outside a quantified frame, with
407
412
  * `DETACHED_CLIENT_FILTER` (500), after the 400 checks. Supplied models on another database or
408
413
  * connection and discriminator targets are not supported here, as for the other relation paths.
409
414
  *
415
+ * Ordinary paths through collections compile as correlated `ANY` frames. Conditions in one group
416
+ * sharing a collection must hold on the same child, and each parent appears once. Explicit `ANY`,
417
+ * `ALL` and `NONE` groups and `quantifier` field filters accept element-relative conditions; `ALL`
418
+ * alone is true, including an OR branch. Target find scopes precede frame conditions; trusted
419
+ * constructs read stored data. References inside a client frame use its element-relative scope
420
+ * keys. Off-root collection correlation keys get an inner non-null connection guard. Aggregate
421
+ * groupId/fact collection prefixes keep legacy joins, and other filters can anchor frames on them.
422
+ * Collection sorts reject with `INVALID_FILTER_PATH`, including count mode.
423
+ *
410
424
  * The checks, and their codes, are those of `createQueryPlan` in the same order: `MISSING_FILTER_PATH`
411
425
  * for an emptiness operator on an object filter without terms or beside terms, `INVALID_FILTER_PATH`
412
426
  * for a value inside an embedded list or a collection and for an interface or union field or member,
413
427
  * `INVALID_FILTER_OPERATOR` for an embedded type that reaches itself through single embedded members,
414
- * and `INVALID_FILTER_VALUE` for a value that is not omitted or null. Other operators keep their
415
- * pipelines.
428
+ * and `INVALID_FILTER_VALUE` for a value that is not omitted or null.
416
429
  */
417
430
  export function buildQuery(
418
431
  input: Record<string, any>,