@simtlix/simfinity-js 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,9 @@ Virtual fields (`extensions: { virtual: true }`, with the field's own `resolve`)
28
28
 
29
29
  Text filters compile to escaped regular expressions, so a value never becomes regex syntax: `LIKE` and `ILIKE` match anywhere, `STARTS_WITH` and `ISTARTS_WITH` are anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z` (so a trailing newline is part of the value), and `IEQ` with both. The `I` operators add the `i` option, which applies the Unicode simple case folding of the server's PCRE2 build (`É`/`é`, `ẞ`/`ß`, `σ`/`ς`, `µ`/`μ`, `ſ`/`s`), never a length-changing or Turkic folding (`ß`/`ss`, `İ`/`i`), and does not fold characters newer than that build, such as U+A7DC; the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) lists where PostgreSQL differs. Only `STARTS_WITH` reads a prefix range of an index on the field, a String list or an embedded path; the other text operators test every key of such an index, or every document without one, and `$regex` ignores collations, so a case-insensitive collation index does not serve them. A value whose escaped pattern is longer than MongoDB's 32,764-byte pattern limit fails with `INVALID_FILTER_VALUE` (400) before the query runs, also for `LIKE`. `createMongoAdapter()` declares every operator it compiles in `queryFeatures`; a copy of the adapter without it supports only the operators of 3.7.0.
30
30
 
31
- `IS_EMPTY` and `IS_NOT_EMPTY` take no value and compile to plain query operators inside the same `$match`, so they also filter counts, aggregates and batched collection reads (`$unionWith`): `$in: [null, ""]` and `$nin` on `String` fields, which an index on the field serves; `null` and `$ne: null` on other scalars, enums and the record identity `_id`; on an application scalar without a supported base, such as a `JSON` scalar, which MongoDB stores as written, `null` without arrays, `{ $and: [{ p: null }, { p: { $not: { $type: "array" } } }] }`, and its `$nor`, so that an array, also `[null]`, is a value, as reads return it; `"p.0": { $exists: false }` and `$exists: true` on scalar lists and embedded lists, which never look inside the items; and, on an embedded object, an `$or` of `null` and the `IS_EMPTY` form of every stored member, with its `$nor` for `IS_NOT_EMPTY`, so an object whose stored members are all empty, such as `{ w: null, label: "" }`, is empty. A term or group condition without a `path` tests the object or list field itself. Embedded `id` members are read where other filters read them, so an object of a supplied subdocument schema with an automatic `_id` is never empty, unlike on PostgreSQL. A path that continues inside an embedded list or a collection, such as `lines.qty` or `seasons.year`, fails with the permanent `INVALID_FILTER_PATH` (400), since emptiness of a value inside a list is not defined. In this release, any other path that crosses or ends at a reference, a path that ends at a collection, such as `seasons`, and an embedded object with a reference or collection member fail with `UNSUPPORTED_QUERY` (400); the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html#empty-values) has the rules and the other codes.
31
+ `IS_EMPTY` and `IS_NOT_EMPTY` take no value and compile to plain query operators inside the same `$match`, so they also filter counts, aggregates and batched collection reads (`$unionWith`): `$in: [null, ""]` and `$nin` on `String` fields, which an index on the field serves; `null` and `$ne: null` on other scalars, enums and the record identity `_id`; on an application scalar without a supported base, such as a `JSON` scalar, which MongoDB stores as written, `null` without arrays, `{ $and: [{ p: null }, { p: { $not: { $type: "array" } } }] }`, and its `$nor`, so that an array, also `[null]`, is a value, as reads return it; `"p.0": { $exists: false }` and `$exists: true` on scalar lists and embedded lists, which never look inside the items; and, on an embedded object, an `$or` of `null` and the `IS_EMPTY` form of every stored member, with its `$nor` for `IS_NOT_EMPTY`, so an object whose stored members are all empty, such as `{ w: null, label: "" }`, is empty. A term or group condition without a `path` tests the object or list field itself. Embedded `id` members are read where other filters read them, so an object of a supplied subdocument schema with an automatic `_id` is never empty, unlike on PostgreSQL. A path that continues inside an embedded list or a collection, such as `lines.qty` or `seasons.year`, fails with the permanent `INVALID_FILTER_PATH` (400), since emptiness of a value inside a list is not defined; the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html#empty-values) has the rules and the other codes.
32
+
33
+ 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.
32
34
 
33
35
  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. 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).
34
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "3.10.0",
3
+ "version": "3.11.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.10.0",
33
- "@simtlix/simfinity-mcp": "3.10.0"
32
+ "@simtlix/simfinity-core": "3.11.0",
33
+ "@simtlix/simfinity-mcp": "3.11.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -12,8 +12,9 @@ import {
12
12
 
13
13
  const withSession = (query, session) => (session ? query.session(session) : query);
14
14
 
15
- // Every filter operator the query compiler supports; the runtime rejects any other one.
16
- const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS });
15
+ // Every filter operator the query compiler supports, and emptiness through references and collections (#108 part
16
+ // 2b); the runtime rejects any other operator.
17
+ const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS, referenceEmptiness: true });
17
18
 
18
19
  // A write that fails only on malformed ObjectId values reports NOT_VALID_ID, not a driver error.
19
20
  const mapIdCastErrors = async (write) => {
@@ -10,8 +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, isEmptinessOperator, textQueryOperator,
14
- validateTextFilterValue,
13
+ TEXT_QUERY_OPERATORS, describeEmptiness, effectiveScalarName, emptinessScopePaths, isEmptinessOperator,
14
+ textQueryOperator, validateTextFilterValue,
15
15
  } from '@simtlix/simfinity-core/internal/query-operators';
16
16
  import {
17
17
  getFieldStorageName, getListShape, hasStoredIdentity, isVirtualField, normalizeConnectionField,
@@ -26,6 +26,9 @@ const isGraphQLisoDate = (typeName) => (
26
26
 
27
27
  // GraphQL reserves `__` names, so these aliases never replace a stored field.
28
28
  const LOOKUP_ALIAS_PREFIX = '__sf_l';
29
+ // The scoped joins and frames of reference and collection emptiness (#108 part 2b). Each kind has its own counter, so
30
+ // the lookup aliases keep their numbers.
31
+ const RELATION_ALIAS_PREFIXES = { join: '__sf_j', frame: '__sf_q' };
29
32
 
30
33
  /**
31
34
  * The filter operators this compiler supports, which the adapter declares as its queryFeatures. They
@@ -51,10 +54,32 @@ const allocateLookupAlias = (aggregationsIncluded, relationPath) => {
51
54
  return aliases.get(relationPath);
52
55
  };
53
56
 
57
+ // The scoped joins and frames of one build, keyed by the build's `included` accumulator like the lookup aliases:
58
+ // their aliases by kind and key, a counter per kind, and each target scope compiled once.
59
+ const relationBuilds = new WeakMap();
60
+
61
+ const relationBuild = (aggregationsIncluded) => {
62
+ let build = relationBuilds.get(aggregationsIncluded);
63
+ if (!build) {
64
+ build = {
65
+ aliases: new Map(), counts: { join: 0, frame: 0 }, scopes: new WeakMap(), scopeCount: 0,
66
+ };
67
+ relationBuilds.set(aggregationsIncluded, build);
68
+ }
69
+ return build;
70
+ };
71
+
72
+ const allocateRelationAlias = (build, kind, key) => {
73
+ const entry = `${kind} ${key}`;
74
+ if (!build.aliases.has(entry)) build.aliases.set(entry, `${RELATION_ALIAS_PREFIXES[kind]}${build.counts[kind]++}`);
75
+ return build.aliases.get(entry);
76
+ };
77
+
78
+ // Lookup entries are `{ stages }` by alias: a join's `$lookup` and `$unwind`, or a frame's single `$lookup`.
54
79
  const appendLookups = (aggregateClauses, aggregationsIncluded, lookups) => {
55
- for (const [alias, { lookup, unwind }] of Object.entries(lookups)) {
80
+ for (const [alias, { stages }] of Object.entries(lookups)) {
56
81
  if (!Object.hasOwn(aggregationsIncluded, alias)) {
57
- aggregateClauses.push(lookup, unwind);
82
+ aggregateClauses.push(...stages);
58
83
  aggregationsIncluded[alias] = true;
59
84
  }
60
85
  }
@@ -63,12 +88,14 @@ const appendLookups = (aggregateClauses, aggregationsIncluded, lookups) => {
63
88
  const buildRelationLookup = ({
64
89
  collectionName, localField, foreignField, alias,
65
90
  }) => ({
66
- lookup: {
67
- $lookup: {
68
- from: collectionName, foreignField, localField, as: alias,
91
+ stages: [
92
+ {
93
+ $lookup: {
94
+ from: collectionName, foreignField, localField, as: alias,
95
+ },
69
96
  },
70
- },
71
- unwind: { $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
97
+ { $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
98
+ ],
72
99
  });
73
100
 
74
101
  const escapeRegex = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -230,72 +257,238 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
230
257
  throw filterError(`Invalid query path: ${path}`, 'INVALID_FILTER_PATH');
231
258
  };
232
259
 
233
- // The emptiness operators IS_EMPTY and IS_NOT_EMPTY (#108, part 2a). Core's describeEmptiness applies
234
- // their checks in the order every caller uses and describes what "empty" means for the path. It
235
- // rejects references, collections, interface and union fields and anything inside a list, so the
236
- // path and every member it reads are stored in the queried document, through single embedded objects.
237
- // The forms are plain query operators that need no lookup, valid wherever the $match runs.
260
+ // The emptiness operators IS_EMPTY and IS_NOT_EMPTY (#108, parts 2a and 2b). Core's describeEmptiness applies their
261
+ // checks in the order every caller uses and describes what "empty" means for the path. It rejects interface and
262
+ // union fields and anything inside a list. On the queried document's own fields, through single embedded objects,
263
+ // the forms are plain query operators that need no lookup, valid wherever the $match runs. A path may also cross
264
+ // single references and end at a reference or a collection, and an embedded object may have reference and
265
+ // collection members (part 2b). A crossing reads the target through a join, and a reference or collection end or
266
+ // member through a frame: one `$lookup` that keeps at most one record id, so it never repeats a document, and that
267
+ // is empty when no record exists. A construct the client sent carries the client scope marker that the runtime
268
+ // fills, `__scope: { id, scopes }`, on its term or group condition; `scopes` holds each target's find scope
269
+ // arguments by path, or null when the scope added nothing. Its crossings are then scoped joins and its frames scoped
270
+ // frames, so a record the scope hides reads as missing. A construct without a marker, from middleware, a scope or a
271
+ // direct buildQuery() call, reads stored data, and its leaves share the plain lookup aliases with the other
272
+ // operators. The joins and frames sit with the other lookups before the $match, so they also filter counts,
273
+ // aggregates and batched collection reads.
238
274
  const isPathless = (path) => path == null || path === '';
275
+ const joinPath = (base, name) => (base ? `${base}.${name}` : name);
239
276
 
240
- // The stored key of the member `name` of `type`, an object stored at `schemaPath` ('' at the root):
241
- // a non-object `id` member follows identifierLeaf, as resolveQueryPath does; others keep their name.
242
- const emptinessKey = (model, type, schemaPath, name) => {
277
+ const hasScopeMarker = (construct) => construct != null && typeof construct === 'object' && Object.hasOwn(construct, '__scope');
278
+ // Only the runtime writes and fills client scope markers, after middleware, so a marker that cannot be honored here
279
+ // is an internal error, never a construct compiled with stored semantics.
280
+ const detachedFilter = (message) => new SimfinityError(
281
+ `${message}; only the runtime writes and fills client scope markers (docs/guide/middleware.md)`, 'DETACHED_CLIENT_FILTER', 500,
282
+ );
283
+
284
+ // Where a value is: the Mongo key that reads it, its storage path relative to the documents of `model` (which
285
+ // identifierLeaf and the identity rule read), and its dotted GraphQL path from the queried record, which names
286
+ // its scope. `{ key: '', schemaPath: '', path: '' }` is the queried document; a joined document has its alias as
287
+ // key, an empty storage path and its crossing as path.
288
+ const memberPlace = (model, parent, type, name) => {
243
289
  const field = type.getFields()[name];
290
+ // A non-object `id` member follows identifierLeaf, as resolveQueryPath does; others keep their name.
244
291
  const leaf = name === 'id' && !(getNamedType(field.type) instanceof GraphQLObjectType)
245
- ? identifierLeaf(model, schemaPath, type)
292
+ ? identifierLeaf(model, parent.schemaPath, type)
246
293
  : name;
247
- return schemaPath ? `${schemaPath}.${leaf}` : leaf;
294
+ return { key: joinPath(parent.key, leaf), schemaPath: joinPath(parent.schemaPath, leaf), path: joinPath(parent.path, name) };
248
295
  };
249
296
 
250
297
  // MongoDB stores the built-in scalars, dates and enums as single values, but keeps an application scalar
251
298
  // without a supported base as written, arrays included, and `{ p: null }` also matches an array that
252
299
  // holds a null. The null rule of such a scalar leaves arrays out, so `[null]` is a value, as GraphQL
253
- // reads it. The record identity `_id` is never an array, so it keeps the plain form.
300
+ // reads it. The record identity `_id`, of the queried or a joined document, is never an array, so it keeps the
301
+ // plain form.
254
302
  const SINGLE_VALUE_SCALARS = new Set(['String', 'ID', 'Int', 'Float', 'Boolean', 'DateTime', 'Date', 'Time']);
255
- const holdsArrays = (key, type) => (
256
- key !== '_id' && isScalarType(type) && !SINGLE_VALUE_SCALARS.has(effectiveScalarName(type))
303
+ const holdsArrays = (place, type) => (
304
+ place.schemaPath !== '_id' && isScalarType(type) && !SINGLE_VALUE_SCALARS.has(effectiveScalarName(type))
257
305
  );
258
- const nullForm = (key, type) => (holdsArrays(key, type)
259
- ? { $and: [{ [key]: null }, { [key]: { $not: { $type: 'array' } } }] }
260
- : { [key]: null });
261
-
262
- // The IS_EMPTY form of `shape` for the value stored at `key`; `type` is the named type of its field.
263
- // An object is missing, null, or has every stored member empty. Without stored members it is always
264
- // empty: MongoDB rejects an empty $and, so the empty query, which matches every document, stands in.
265
- const emptyForm = (model, shape, key, type) => {
266
- if (shape.rule === 'string') return { [key]: { $in: [null, ''] } };
267
- if (shape.rule === 'null') return nullForm(key, type);
268
- if (shape.rule === 'list') return { [`${key}.0`]: { $exists: false } };
269
- const fields = type.getFields();
270
- const members = shape.members.map((member) => emptyForm(
271
- model, member.shape, emptinessKey(model, type, key, member.name), getNamedType(fields[member.name].type),
272
- ));
273
- return { $or: [{ [key]: null }, members.length ? { $and: members } : {}] };
306
+ const nullForm = (place, type) => (holdsArrays(place, type)
307
+ ? { $and: [{ [place.key]: null }, { [place.key]: { $not: { $type: 'array' } } }] }
308
+ : { [place.key]: null });
309
+
310
+ // A frame: NONE over the reference or collection `name` of the object at `parent`, whose type is `type`. Its
311
+ // `$lookup` keeps at most one id of a target record that the reference points to (`foreignField: '_id'`) or of a
312
+ // child whose connection field, `shape.connectionField`, holds the object's `_id`, among those the target scope
313
+ // admits. A frame is keyed by its path and by the scopes of every crossing before it and its own, so a frame after
314
+ // a scoped crossing never shares a trusted one, even when its own scope is null. A collection frame also tests its
315
+ // local key for null: `$lookup` matches a missing local key with every child whose connection field is null.
316
+ const frameForm = (ctx, shape, parent, type, name) => {
317
+ const field = type.getFields()[name];
318
+ const path = joinPath(parent.path, name);
319
+ const scope = ctx.scopes.get(path) ?? null;
320
+ const alias = allocateRelationAlias(ctx.build, 'frame', `${path} ${[...ctx.chain, scope ? scope.token : '-'].join(',')}`);
321
+ const collection = shape.rule === 'collection';
322
+ const localField = joinPath(parent.key, collection ? '_id' : getFieldStorageName(name, field));
323
+ ctx.lookups[alias] = {
324
+ stages: [{
325
+ $lookup: {
326
+ from: getModel(getNamedType(field.type)).collection.collectionName,
327
+ localField,
328
+ foreignField: collection ? shape.connectionField : '_id',
329
+ pipeline: [...(scope?.stages ?? []), { $limit: 1 }, { $project: { _id: 1 } }],
330
+ as: alias,
331
+ },
332
+ }],
333
+ };
334
+ const none = { [`${alias}.0`]: { $exists: false } };
335
+ return collection ? { $or: [{ [localField]: null }, none] } : none;
336
+ };
337
+
338
+ // The IS_EMPTY form of `shape` for the member `name` of the object at `parent`, whose type is `type`. An object is
339
+ // missing, null, or has every stored member empty. Without stored members it is always empty: MongoDB rejects an
340
+ // empty $and, so the empty query, which matches every document, stands in.
341
+ const emptyForm = (ctx, shape, parent, type, name) => {
342
+ if (shape.rule === 'reference' || shape.rule === 'collection') return frameForm(ctx, shape, parent, type, name);
343
+ const place = memberPlace(ctx.model, parent, type, name);
344
+ const valueType = getNamedType(type.getFields()[name].type);
345
+ if (shape.rule === 'string') return { [place.key]: { $in: [null, ''] } };
346
+ if (shape.rule === 'null') return nullForm(place, valueType);
347
+ if (shape.rule === 'list') return { [`${place.key}.0`]: { $exists: false } };
348
+ const members = shape.members.map((member) => emptyForm(ctx, member.shape, place, valueType, member.name));
349
+ return { $or: [{ [place.key]: null }, members.length ? { $and: members } : {}] };
350
+ };
351
+
352
+ // IS_NOT_EMPTY is the exact complement: the positive form of a leaf, which an index can serve, or the $nor of the
353
+ // IS_EMPTY form of an object, a reference, a collection or a scalar that may hold arrays.
354
+ const notEmptyForm = (ctx, shape, parent, type, name) => {
355
+ if (shape.rule === 'reference' || shape.rule === 'collection' || shape.rule === 'object') {
356
+ return { $nor: [emptyForm(ctx, shape, parent, type, name)] };
357
+ }
358
+ const place = memberPlace(ctx.model, parent, type, name);
359
+ const valueType = getNamedType(type.getFields()[name].type);
360
+ if (shape.rule === 'string') return { [place.key]: { $nin: [null, ''] } };
361
+ if (shape.rule === 'null') return holdsArrays(place, valueType) ? { $nor: [nullForm(place, valueType)] } : { [place.key]: { $ne: null } };
362
+ return { [`${place.key}.0`]: { $exists: true } };
363
+ };
364
+
365
+ // The GraphQL target type of each reference and collection path an emptiness construct reads (its crossings, its end
366
+ // and its members), by dotted path from the queried type.
367
+ const relationTargets = (gqltype, segments, shape) => {
368
+ const targets = new Map();
369
+ let type = gqltype;
370
+ for (const [index, name] of segments.entries()) {
371
+ type = getNamedType(type.getFields()[name].type);
372
+ targets.set(segments.slice(0, index + 1).join('.'), type);
373
+ }
374
+ const visit = (node, objectType, path) => {
375
+ if (node.rule !== 'object') return;
376
+ for (const member of node.members) {
377
+ const memberType = getNamedType(objectType.getFields()[member.name].type);
378
+ targets.set(`${path}.${member.name}`, memberType);
379
+ visit(member.shape, memberType, `${path}.${member.name}`);
380
+ }
381
+ };
382
+ visit(shape, type, segments.join('.'));
383
+ return targets;
384
+ };
385
+
386
+ // A target's find scope from a filled client scope marker, compiled once per build and target type with this
387
+ // compiler, in a fresh alias space: its own lookups live on the joined documents, and ID casting and state names are
388
+ // the target's. Scope arguments are trusted. Gives `{ token, stages }`, or null for a null scope or one that adds no
389
+ // stage, which reads like no scope.
390
+ const compileTargetScope = async (build, scope, targetType) => {
391
+ if (scope === null) return null;
392
+ const compiled = build.scopes.get(scope);
393
+ if (compiled?.has(targetType)) return compiled.get(targetType);
394
+ const stages = [];
395
+ const match = await collectFiltersAndLookups(scope, targetType, stages, {}, 'find');
396
+ if (match) stages.push(match);
397
+ const result = stages.length ? { token: `s${build.scopeCount++}`, stages } : null;
398
+ if (!build.scopes.has(scope)) build.scopes.set(scope, new Map());
399
+ build.scopes.get(scope).set(targetType, result);
400
+ return result;
274
401
  };
275
402
 
276
- // IS_NOT_EMPTY is the exact complement: the positive form of a leaf, which an index can serve, or
277
- // the $nor of the IS_EMPTY form of an object or of a scalar that may hold arrays.
278
- const notEmptyForm = (model, shape, key, type) => {
279
- if (shape.rule === 'string') return { [key]: { $nin: [null, ''] } };
280
- if (shape.rule === 'null') return holdsArrays(key, type) ? { $nor: [nullForm(key, type)] } : { [key]: { $ne: null } };
281
- if (shape.rule === 'list') return { [`${key}.0`]: { $exists: true } };
282
- return { $nor: [emptyForm(model, shape, key, type)] };
403
+ // The scopes of a client construct's filled marker, or null for a construct without one. Fails closed: a marker
404
+ // on a construct that reads no reference or collection, or whose `scopes` lack a path the construct reads, is an
405
+ // internal error.
406
+ const clientScopes = (construct, label, paths) => {
407
+ if (!hasScopeMarker(construct)) return null;
408
+ if (!paths.length) throw detachedFilter(`${label} carries a client scope marker, but reads no reference or collection`);
409
+ const scopes = construct.__scope?.scopes;
410
+ if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) {
411
+ throw detachedFilter(`${label} carries a client scope marker whose scopes are not filled`);
412
+ }
413
+ for (const path of paths) {
414
+ if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) {
415
+ throw detachedFilter(`${label} carries a client scope marker without the scope of ${path}`);
416
+ }
417
+ }
418
+ return scopes;
283
419
  };
284
420
 
285
- // The match clause of one emptiness condition on `path`, the field itself or a dotted path from it.
286
- const buildEmptinessClause = (gqltype, path, operator, value) => {
421
+ // The match clause of one emptiness condition on `path`, the field itself or a dotted path from it. `construct` is
422
+ // the term or group condition that holds it, the only objects whose client scope marker is read, or null. Its joins
423
+ // and frames are added to `lookups` as entries by alias, which the caller appends before the $match. A crossing, end
424
+ // or member whose target has no model fails with INVALID_FILTER_PATH before any entry is added.
425
+ const buildEmptinessClause = async (gqltype, path, operator, value, construct, lookups, aggregationsIncluded) => {
287
426
  const segments = path.split('.');
288
427
  const shape = describeEmptiness(gqltype, segments, { operator, value, path });
289
- const model = getModel(gqltype);
428
+ const { joins, frames } = emptinessScopePaths(shape, segments);
429
+ const paths = [...joins, ...frames];
430
+ const targets = relationTargets(gqltype, segments, shape);
431
+ if (paths.some((relationPath) => !getModel(targets.get(relationPath)))) {
432
+ throw filterError(`Related model is not available for ${path}`, 'INVALID_FILTER_PATH');
433
+ }
434
+ const scopes = clientScopes(construct, `${operator} on ${path}`, paths);
435
+ const build = relationBuild(aggregationsIncluded);
436
+ const compiled = new Map();
437
+ if (scopes) {
438
+ for (const relationPath of paths) compiled.set(relationPath, await compileTargetScope(build, scopes[relationPath], targets.get(relationPath)));
439
+ }
440
+ const ctx = {
441
+ build, lookups, scopes: compiled, chain: [], model: getModel(gqltype),
442
+ };
443
+ // Crossings: a plain lookup, shared with the other operators, while every crossing so far is trusted or has a null
444
+ // scope; from the first scoped crossing on, a scoped join keyed by the scopes of every crossing up to it, also when
445
+ // its own scope is null, correlated through the joins before it.
446
+ const crossings = new Set(joins);
447
+ let parent = { key: '', schemaPath: '', path: '' };
290
448
  let type = gqltype;
291
- let schemaPath = '';
449
+ let scoped = false;
292
450
  for (const name of segments.slice(0, -1)) {
293
- schemaPath = schemaPath ? `${schemaPath}.${name}` : name;
294
- type = getNamedType(type.getFields()[name].type);
451
+ const field = type.getFields()[name];
452
+ const fieldType = getNamedType(field.type);
453
+ const crossing = joinPath(parent.path, name);
454
+ if (!crossings.has(crossing)) {
455
+ parent = { key: joinPath(parent.key, name), schemaPath: joinPath(parent.schemaPath, name), path: crossing };
456
+ } else {
457
+ const scope = compiled.get(crossing) ?? null;
458
+ ctx.chain.push(scope ? scope.token : '-');
459
+ scoped ||= !!scope;
460
+ const target = getModel(fieldType);
461
+ const localField = joinPath(parent.key, getFieldStorageName(name, field));
462
+ let alias;
463
+ if (!scoped) {
464
+ alias = allocateLookupAlias(aggregationsIncluded, crossing);
465
+ lookups[alias] = buildRelationLookup({
466
+ collectionName: target.collection.collectionName, localField, foreignField: '_id', alias,
467
+ });
468
+ } else {
469
+ alias = allocateRelationAlias(build, 'join', `${crossing} ${ctx.chain.join(',')}`);
470
+ lookups[alias] = {
471
+ stages: [
472
+ {
473
+ $lookup: {
474
+ from: target.collection.collectionName,
475
+ localField,
476
+ foreignField: '_id',
477
+ pipeline: [...(scope?.stages ?? []), { $limit: 1 }],
478
+ as: alias,
479
+ },
480
+ },
481
+ { $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
482
+ ],
483
+ };
484
+ }
485
+ parent = { key: alias, schemaPath: '', path: crossing };
486
+ ctx.model = target;
487
+ }
488
+ type = fieldType;
295
489
  }
296
- const name = segments.at(-1);
297
490
  const form = operator === 'IS_EMPTY' ? emptyForm : notEmptyForm;
298
- return form(model, shape, emptinessKey(model, type, schemaPath, name), getNamedType(type.getFields()[name].type));
491
+ return form(ctx, shape, parent, type, segments.at(-1));
299
492
  };
300
493
 
301
494
  const castFilterValue = (value, resolved) => {
@@ -401,10 +594,14 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
401
594
  for (const term of terms) {
402
595
  if (!term || typeof term !== 'object' || Array.isArray(term)) throw filterError(`Invalid term for ${fieldName}`);
403
596
  // An emptiness term whose path is omitted, null or "" tests the field itself.
597
+ // Only the terms of an object filter are constructs; a scalar filter is its own term.
404
598
  if (isEmptinessOperator(term.operator)) {
405
599
  const pathed = isObject && !isPathless(term.path);
406
600
  if (pathed) assertValidFilterPath(term.path);
407
- matchesClauses.push(buildEmptinessClause(gqltype, pathed ? `${fieldName}.${term.path}` : fieldName, term.operator, term.value));
601
+ matchesClauses.push(await buildEmptinessClause(
602
+ gqltype, pathed ? `${fieldName}.${term.path}` : fieldName, term.operator, term.value, isObject ? term : null,
603
+ aggregateClauses, aggregationsIncluded,
604
+ ));
408
605
  continue;
409
606
  }
410
607
  if (isObject) assertValidFilterPath(term.path);
@@ -412,6 +609,9 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
412
609
  const resolved = resolveQueryPath(gqltype, path, aggregationsIncluded);
413
610
  Object.assign(aggregateClauses, resolved.aggregateClauses);
414
611
  matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value, path));
612
+ if (isObject && hasScopeMarker(term)) {
613
+ throw detachedFilter(`${term.operator ?? 'EQ'} on ${path} carries a client scope marker, but is not an emptiness condition`);
614
+ }
415
615
  }
416
616
  return { aggregateClauses, matchesClauses };
417
617
  };
@@ -440,9 +640,12 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
440
640
  const condition = normalizeFilterCondition(rawCondition);
441
641
  // An emptiness condition without a path tests its field, also an object field; a dotted field
442
642
  // is resolved as a whole, as the query plan resolves it.
643
+ const path = rawCondition.path ? `${rawCondition.field}.${rawCondition.path}` : rawCondition.field;
443
644
  if (isEmptinessOperator(condition.operator)) {
444
- const path = rawCondition.path ? `${rawCondition.field}.${rawCondition.path}` : rawCondition.field;
445
- parts.push(buildEmptinessClause(gqltype, path, condition.operator, condition.value));
645
+ const lookups = {};
646
+ const match = await buildEmptinessClause(gqltype, path, condition.operator, condition.value, rawCondition, lookups, aggregationsIncluded);
647
+ appendLookups(aggregateClauses, aggregationsIncluded, lookups);
648
+ parts.push(match);
446
649
  continue;
447
650
  }
448
651
  const qlField = fields[condition.field];
@@ -466,6 +669,9 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
466
669
  }
467
670
 
468
671
  const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded);
672
+ if (hasScopeMarker(rawCondition)) {
673
+ throw detachedFilter(`${condition.operator ?? 'EQ'} on ${path} carries a client scope marker, but is not an emptiness condition`);
674
+ }
469
675
  appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
470
676
  for (const matchClause of Object.values(result.matchesClauses)) {
471
677
  for (const [matchKey, match] of Object.entries(matchClause)) {
package/types/index.d.ts CHANGED
@@ -92,13 +92,14 @@ export interface MongoAdapterOptions {
92
92
  export interface MongoAdapter extends DatabaseAdapter {
93
93
  readonly referentialIntegrity: 'off' | 'transactional';
94
94
  /**
95
- * Frozen; lists every `QLOperator` value. The literal text operators compile to escaped regular
96
- * expressions: `ILIKE` unanchored, `STARTS_WITH` and `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and
97
- * `IENDS_WITH` with `\z`, and `IEQ` with both, with the `i` option for the case-insensitive ones.
98
- * `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query operators on the record's own fields (see
99
- * {@link buildQuery}); declaring them does not lift the `UNSUPPORTED_QUERY` that emptiness through
100
- * references and collections gets in this release. A copy of the adapter without this member
101
- * supports only the operators of 3.7.0.
95
+ * Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true`. The literal text
96
+ * operators compile to escaped regular expressions: `ILIKE` unanchored, `STARTS_WITH` and
97
+ * `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z`, and `IEQ` with both, with
98
+ * the `i` option for the case-insensitive ones. `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query
99
+ * operators on the record's own fields, and through single references and at references and
100
+ * collections to joins and frames (see {@link buildQuery}). A copy of the adapter without this member
101
+ * supports only the operators of 3.7.0, and one whose declaration leaves out `referenceEmptiness`
102
+ * gets `UNSUPPORTED_QUERY` for emptiness through references and collections.
102
103
  */
103
104
  readonly queryFeatures: QueryFeatures;
104
105
  /**
@@ -259,9 +260,18 @@ export function preventCreatingCollection(prevent: boolean): void;
259
260
  export interface QueryLimitsOptions {
260
261
  /** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
261
262
  maxPageSize?: number;
263
+ /**
264
+ * Positive safe integer; defaults to 10. The most distinct scoped joins and frames that the
265
+ * client's reference and collection emptiness may need in one list, aggregate or collection-field
266
+ * call; more fail with `FILTER_DEPTH_EXCEEDED` (400).
267
+ */
268
+ maxReferenceFrames?: number;
262
269
  }
263
270
 
264
- /** Configure at startup. Invalid configuration throws INVALID_QUERY_LIMITS (400). */
271
+ /**
272
+ * Configure at startup. Invalid configuration throws INVALID_QUERY_LIMITS (400) and keeps both
273
+ * limits; an omitted option takes its default again.
274
+ */
265
275
  export function configureQueryLimits(options?: QueryLimitsOptions): void;
266
276
 
267
277
  /**
@@ -347,13 +357,34 @@ export interface FilterGroupInput {
347
357
  * without stored members is always empty (`{}` stands for the members). Embedded `id` members follow
348
358
  * the `.id` rule above, so an object of a supplied subdocument schema with an automatic `_id` is never
349
359
  * empty. A term without a path (omitted, null or `""`) and a group condition without a path test the
350
- * field itself. The checks, and their codes, are those of `createQueryPlan` in the same order:
351
- * `MISSING_FILTER_PATH` for an emptiness operator on an object filter without terms or beside terms,
352
- * `INVALID_FILTER_PATH` for a value inside an embedded list or a collection and for an interface or
353
- * union field or member, `INVALID_FILTER_OPERATOR` for an embedded type that reaches itself through
354
- * single embedded members, `INVALID_FILTER_VALUE` for a value that is not omitted or null, and
355
- * `UNSUPPORTED_QUERY` for a reference or collection on the path or among the members. Other operators
356
- * keep their pipelines.
360
+ * field itself.
361
+ *
362
+ * Their path may also cross single references and end at a reference or a collection, and an
363
+ * embedded object may have reference and collection members. A crossing reads its target through a
364
+ * `$lookup` and `$unwind` before the `$match`, and the target's `id` right after it is the joined
365
+ * `_id`, which takes the null rule. A reference or collection end or member is a frame, one `$lookup`
366
+ * with `pipeline: [{ $limit: 1 }, { $project: { _id: 1 } }]` under an alias `__sf_qN`, matched with
367
+ * `{ "__sf_qN.0": { $exists: false } }` (for a collection, `$or` a null local key, since `$lookup`
368
+ * matches a missing local key with every child whose connection field is null) and its `$nor`. A
369
+ * collection frame reads the child's connection field, and its local key is `<path>._id`. Arguments
370
+ * from this function, middleware and scopes read stored data: a reference is empty when no record it
371
+ * points to exists, so a dangling reference is empty, and leaves share the lookup aliases of the other
372
+ * operators. A term or group condition that the runtime marked for a client, `__scope: { id, scopes }`,
373
+ * reads through the target find scopes in `scopes`, compiled with this function into the `$lookup`
374
+ * pipelines: crossings become scoped joins (`__sf_jN`), frames take their target's scope, and a record
375
+ * that a scope hides reads as missing. A crossing or frame after a scoped crossing stays in that chain,
376
+ * also when its own scope is null. The aliases are unset at the end of find pipelines. A target type
377
+ * without a model fails with `INVALID_FILTER_PATH` (400), and a marker that is not filled for every
378
+ * path the construct reads, or that sits on a construct that reads none or on another operator, with
379
+ * `DETACHED_CLIENT_FILTER` (500), after the 400 checks. Supplied models on another database or
380
+ * connection and discriminator targets are not supported here, as for the other relation paths.
381
+ *
382
+ * The checks, and their codes, are those of `createQueryPlan` in the same order: `MISSING_FILTER_PATH`
383
+ * for an emptiness operator on an object filter without terms or beside terms, `INVALID_FILTER_PATH`
384
+ * for a value inside an embedded list or a collection and for an interface or union field or member,
385
+ * `INVALID_FILTER_OPERATOR` for an embedded type that reaches itself through single embedded members,
386
+ * and `INVALID_FILTER_VALUE` for a value that is not omitted or null. Other operators keep their
387
+ * pipelines.
357
388
  */
358
389
  export function buildQuery(
359
390
  input: Record<string, any>,
@@ -364,7 +395,9 @@ export function buildQuery(
364
395
  /**
365
396
  * Translate a QLFilterGroup into a MongoDB `$match` expression, collecting required lookups. A
366
397
  * condition path through a virtual field rejects with `INVALID_FILTER_FIELD` (400). `IS_EMPTY` and
367
- * `IS_NOT_EMPTY` conditions compile and reject as in {@link buildQuery}, and add no lookup.
398
+ * `IS_NOT_EMPTY` conditions compile and reject as in {@link buildQuery}: on the record's own fields
399
+ * they add no lookup, and through references and collections they append their joins and frames to
400
+ * `aggregateClauses` and record their aliases in `aggregationsIncluded`.
368
401
  */
369
402
  export function buildFilterGroupMatch(
370
403
  filterGroup: FilterGroupInput,