@simtlix/simfinity-js 3.9.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 +5 -1
- package/package.json +3 -3
- package/src/index.js +1 -0
- package/src/mongo/adapter.js +3 -2
- package/src/mongo/queries.js +328 -17
- package/types/index.d.ts +82 -8
package/README.md
CHANGED
|
@@ -28,7 +28,11 @@ 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
|
-
|
|
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.
|
|
34
|
+
|
|
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).
|
|
32
36
|
|
|
33
37
|
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.
|
|
34
38
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
33
|
-
"@simtlix/simfinity-mcp": "3.
|
|
32
|
+
"@simtlix/simfinity-core": "3.11.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.11.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export const getInputType = runtime.getInputType;
|
|
|
22
22
|
export const getRegistrations = runtime.getRegistrations;
|
|
23
23
|
export const use = runtime.use;
|
|
24
24
|
export const registerMutation = runtime.registerMutation;
|
|
25
|
+
export const registerCustomQuery = runtime.registerCustomQuery;
|
|
25
26
|
export const saveObject = runtime.saveObject;
|
|
26
27
|
export const preventCreatingCollection = runtime.preventCreatingCollection;
|
|
27
28
|
|
package/src/mongo/adapter.js
CHANGED
|
@@ -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
|
|
16
|
-
|
|
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) => {
|
package/src/mongo/queries.js
CHANGED
|
@@ -2,14 +2,16 @@ import {
|
|
|
2
2
|
GraphQLEnumType,
|
|
3
3
|
GraphQLID,
|
|
4
4
|
GraphQLObjectType,
|
|
5
|
-
GraphQLScalarType,
|
|
6
5
|
getNamedType,
|
|
6
|
+
isAbstractType,
|
|
7
|
+
isScalarType,
|
|
7
8
|
} from 'graphql';
|
|
8
9
|
import mongoose from 'mongoose';
|
|
9
10
|
|
|
10
11
|
import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
|
|
11
12
|
import {
|
|
12
|
-
TEXT_QUERY_OPERATORS,
|
|
13
|
+
TEXT_QUERY_OPERATORS, describeEmptiness, effectiveScalarName, emptinessScopePaths, isEmptinessOperator,
|
|
14
|
+
textQueryOperator, validateTextFilterValue,
|
|
13
15
|
} from '@simtlix/simfinity-core/internal/query-operators';
|
|
14
16
|
import {
|
|
15
17
|
getFieldStorageName, getListShape, hasStoredIdentity, isVirtualField, normalizeConnectionField,
|
|
@@ -18,16 +20,15 @@ import {
|
|
|
18
20
|
import { castObjectId } from './ids.js';
|
|
19
21
|
import { resolveStorageScalar } from './models.js';
|
|
20
22
|
|
|
21
|
-
const getEffectiveTypeName = (type) => (
|
|
22
|
-
type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
|
|
23
|
-
);
|
|
24
|
-
|
|
25
23
|
const isGraphQLisoDate = (typeName) => (
|
|
26
24
|
typeName === 'DateTime' || typeName === 'Date' || typeName === 'Time'
|
|
27
25
|
);
|
|
28
26
|
|
|
29
27
|
// GraphQL reserves `__` names, so these aliases never replace a stored field.
|
|
30
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' };
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* The filter operators this compiler supports, which the adapter declares as its queryFeatures. They
|
|
@@ -36,6 +37,7 @@ const LOOKUP_ALIAS_PREFIX = '__sf_l';
|
|
|
36
37
|
export const MONGO_QUERY_OPERATORS = Object.freeze([
|
|
37
38
|
'EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE',
|
|
38
39
|
'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ',
|
|
40
|
+
'IS_EMPTY', 'IS_NOT_EMPTY',
|
|
39
41
|
]);
|
|
40
42
|
|
|
41
43
|
export const createMongoQueries = ({ getModel, getRegistrations }) => {
|
|
@@ -52,10 +54,32 @@ const allocateLookupAlias = (aggregationsIncluded, relationPath) => {
|
|
|
52
54
|
return aliases.get(relationPath);
|
|
53
55
|
};
|
|
54
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`.
|
|
55
79
|
const appendLookups = (aggregateClauses, aggregationsIncluded, lookups) => {
|
|
56
|
-
for (const [alias, {
|
|
80
|
+
for (const [alias, { stages }] of Object.entries(lookups)) {
|
|
57
81
|
if (!Object.hasOwn(aggregationsIncluded, alias)) {
|
|
58
|
-
aggregateClauses.push(
|
|
82
|
+
aggregateClauses.push(...stages);
|
|
59
83
|
aggregationsIncluded[alias] = true;
|
|
60
84
|
}
|
|
61
85
|
}
|
|
@@ -64,12 +88,14 @@ const appendLookups = (aggregateClauses, aggregationsIncluded, lookups) => {
|
|
|
64
88
|
const buildRelationLookup = ({
|
|
65
89
|
collectionName, localField, foreignField, alias,
|
|
66
90
|
}) => ({
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
stages: [
|
|
92
|
+
{
|
|
93
|
+
$lookup: {
|
|
94
|
+
from: collectionName, foreignField, localField, as: alias,
|
|
95
|
+
},
|
|
70
96
|
},
|
|
71
|
-
|
|
72
|
-
|
|
97
|
+
{ $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
|
|
98
|
+
],
|
|
73
99
|
});
|
|
74
100
|
|
|
75
101
|
const escapeRegex = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -231,6 +257,240 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
231
257
|
throw filterError(`Invalid query path: ${path}`, 'INVALID_FILTER_PATH');
|
|
232
258
|
};
|
|
233
259
|
|
|
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.
|
|
274
|
+
const isPathless = (path) => path == null || path === '';
|
|
275
|
+
const joinPath = (base, name) => (base ? `${base}.${name}` : name);
|
|
276
|
+
|
|
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) => {
|
|
289
|
+
const field = type.getFields()[name];
|
|
290
|
+
// A non-object `id` member follows identifierLeaf, as resolveQueryPath does; others keep their name.
|
|
291
|
+
const leaf = name === 'id' && !(getNamedType(field.type) instanceof GraphQLObjectType)
|
|
292
|
+
? identifierLeaf(model, parent.schemaPath, type)
|
|
293
|
+
: name;
|
|
294
|
+
return { key: joinPath(parent.key, leaf), schemaPath: joinPath(parent.schemaPath, leaf), path: joinPath(parent.path, name) };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// MongoDB stores the built-in scalars, dates and enums as single values, but keeps an application scalar
|
|
298
|
+
// without a supported base as written, arrays included, and `{ p: null }` also matches an array that
|
|
299
|
+
// holds a null. The null rule of such a scalar leaves arrays out, so `[null]` is a value, as GraphQL
|
|
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.
|
|
302
|
+
const SINGLE_VALUE_SCALARS = new Set(['String', 'ID', 'Int', 'Float', 'Boolean', 'DateTime', 'Date', 'Time']);
|
|
303
|
+
const holdsArrays = (place, type) => (
|
|
304
|
+
place.schemaPath !== '_id' && isScalarType(type) && !SINGLE_VALUE_SCALARS.has(effectiveScalarName(type))
|
|
305
|
+
);
|
|
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;
|
|
401
|
+
};
|
|
402
|
+
|
|
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;
|
|
419
|
+
};
|
|
420
|
+
|
|
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) => {
|
|
426
|
+
const segments = path.split('.');
|
|
427
|
+
const shape = describeEmptiness(gqltype, segments, { operator, value, path });
|
|
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: '' };
|
|
448
|
+
let type = gqltype;
|
|
449
|
+
let scoped = false;
|
|
450
|
+
for (const name of segments.slice(0, -1)) {
|
|
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;
|
|
489
|
+
}
|
|
490
|
+
const form = operator === 'IS_EMPTY' ? emptyForm : notEmptyForm;
|
|
491
|
+
return form(ctx, shape, parent, type, segments.at(-1));
|
|
492
|
+
};
|
|
493
|
+
|
|
234
494
|
const castFilterValue = (value, resolved) => {
|
|
235
495
|
if (value === null) return null;
|
|
236
496
|
if (value === undefined || Array.isArray(value)
|
|
@@ -245,7 +505,7 @@ const castFilterValue = (value, resolved) => {
|
|
|
245
505
|
// ID representation is a persistence concern, including String/Number/custom _id schemas.
|
|
246
506
|
if (schemaType && (scalar === GraphQLID || resolved.mongoPath.endsWith('._id') || resolved.mongoPath === '_id'
|
|
247
507
|
|| schemaType.instance === 'ObjectId')) return schemaType.cast(value);
|
|
248
|
-
const typeName =
|
|
508
|
+
const typeName = effectiveScalarName(scalar);
|
|
249
509
|
if (isGraphQLisoDate(typeName)) {
|
|
250
510
|
const date = value instanceof Date ? value : new Date(value);
|
|
251
511
|
if (Number.isNaN(date.getTime())) throw new Error('Invalid date');
|
|
@@ -275,14 +535,14 @@ const buildMatchesClause = (resolved, operator, value, path) => {
|
|
|
275
535
|
// Text operators take the value as is, once core's shared rule accepts it: a String field (a
|
|
276
536
|
// validated scalar by its base scalar) and a string without U+0000.
|
|
277
537
|
if (textQueryOperator(op)) {
|
|
278
|
-
const text = validateTextFilterValue(op, value,
|
|
538
|
+
const text = validateTextFilterValue(op, value, effectiveScalarName(resolved.fieldType), path);
|
|
279
539
|
return { [resolved.mongoPath]: OP_TO_MONGO[op](text, path) };
|
|
280
540
|
}
|
|
281
541
|
const collectionOperator = op === 'IN' || op === 'NIN' || op === 'BTW';
|
|
282
542
|
if (collectionOperator && (!Array.isArray(value) || (op === 'BTW' && value.length !== 2))) {
|
|
283
543
|
throw filterError(`${op} requires ${op === 'BTW' ? 'exactly two values' : 'an array value'}`);
|
|
284
544
|
}
|
|
285
|
-
if (op === 'LIKE' && (typeof value !== 'string' ||
|
|
545
|
+
if (op === 'LIKE' && (typeof value !== 'string' || effectiveScalarName(resolved.fieldType) !== 'String')) {
|
|
286
546
|
throw filterError('LIKE requires a string field and string value');
|
|
287
547
|
}
|
|
288
548
|
if (value === null && op !== 'EQ' && op !== 'NE') throw filterError(`${op} does not accept null`);
|
|
@@ -302,18 +562,56 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
|
|
|
302
562
|
if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
|
|
303
563
|
if (filterField == null) return { aggregateClauses, matchesClauses };
|
|
304
564
|
if (typeof filterField !== 'object' || Array.isArray(filterField)) throw filterError(`Invalid filter for ${fieldName}`);
|
|
305
|
-
const
|
|
565
|
+
const namedType = getNamedType(qlField.type);
|
|
566
|
+
const isObject = namedType instanceof GraphQLObjectType;
|
|
567
|
+
// Only trusted code writes an operator on an object filter, since QLTypeFilterExpression has none:
|
|
568
|
+
// an emptiness operator there fails instead of being dropped; other operators stay left out. Rule 1
|
|
569
|
+
// comes first, so a virtual field fails as it does in every other form.
|
|
570
|
+
if (isObject && isEmptinessOperator(filterField.operator)) {
|
|
571
|
+
const { operator } = filterField;
|
|
572
|
+
if (isVirtualField(qlField)) {
|
|
573
|
+
throw filterError(`Query path ${fieldName} uses the virtual field ${gqltype.name}.${fieldName}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
574
|
+
}
|
|
575
|
+
throw filterError(`Filter on ${fieldName} takes ${operator} only in a term; to test the field itself, use a term without a path: `
|
|
576
|
+
+ `${fieldName}: { terms: [{ operator: ${operator} }] }`, 'MISSING_FILTER_PATH');
|
|
577
|
+
}
|
|
306
578
|
if (isObject && (!Array.isArray(filterField.terms) || filterField.terms.length === 0)) {
|
|
307
579
|
throw filterError(`Filter on ${fieldName} requires non-empty terms`, 'MISSING_FILTER_PATH');
|
|
308
580
|
}
|
|
581
|
+
// QLTypeFilterExpression gives interface and union lists terms too. An emptiness operator beside
|
|
582
|
+
// them or in one of them tests the field itself, which rule 3 rejects, or a path through it, which
|
|
583
|
+
// rule 1 rejects: describeEmptiness throws both, in the order the gate reads them. Other operators
|
|
584
|
+
// keep reading the filter as a value.
|
|
585
|
+
if (isAbstractType(namedType) && Array.isArray(filterField.terms)) {
|
|
586
|
+
for (const term of [filterField, ...filterField.terms]) {
|
|
587
|
+
if (!isEmptinessOperator(term?.operator)) continue;
|
|
588
|
+
const pathed = term !== filterField && !isPathless(term.path);
|
|
589
|
+
if (pathed) assertValidFilterPath(term.path);
|
|
590
|
+
describeEmptiness(gqltype, pathed ? `${fieldName}.${term.path}` : fieldName, { operator: term.operator, value: term.value });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
309
593
|
const terms = isObject ? filterField.terms : [filterField];
|
|
310
594
|
for (const term of terms) {
|
|
311
595
|
if (!term || typeof term !== 'object' || Array.isArray(term)) throw filterError(`Invalid term for ${fieldName}`);
|
|
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.
|
|
598
|
+
if (isEmptinessOperator(term.operator)) {
|
|
599
|
+
const pathed = isObject && !isPathless(term.path);
|
|
600
|
+
if (pathed) assertValidFilterPath(term.path);
|
|
601
|
+
matchesClauses.push(await buildEmptinessClause(
|
|
602
|
+
gqltype, pathed ? `${fieldName}.${term.path}` : fieldName, term.operator, term.value, isObject ? term : null,
|
|
603
|
+
aggregateClauses, aggregationsIncluded,
|
|
604
|
+
));
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
312
607
|
if (isObject) assertValidFilterPath(term.path);
|
|
313
608
|
const path = isObject ? `${fieldName}.${term.path}` : fieldName;
|
|
314
609
|
const resolved = resolveQueryPath(gqltype, path, aggregationsIncluded);
|
|
315
610
|
Object.assign(aggregateClauses, resolved.aggregateClauses);
|
|
316
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
|
+
}
|
|
317
615
|
}
|
|
318
616
|
return { aggregateClauses, matchesClauses };
|
|
319
617
|
};
|
|
@@ -340,6 +638,16 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
340
638
|
for (const rawCondition of filterGroup.conditions) {
|
|
341
639
|
if (!rawCondition || typeof rawCondition.field !== 'string') throw filterError('Filter conditions require a field');
|
|
342
640
|
const condition = normalizeFilterCondition(rawCondition);
|
|
641
|
+
// An emptiness condition without a path tests its field, also an object field; a dotted field
|
|
642
|
+
// is resolved as a whole, as the query plan resolves it.
|
|
643
|
+
const path = rawCondition.path ? `${rawCondition.field}.${rawCondition.path}` : rawCondition.field;
|
|
644
|
+
if (isEmptinessOperator(condition.operator)) {
|
|
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);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
343
651
|
const qlField = fields[condition.field];
|
|
344
652
|
if (!qlField) {
|
|
345
653
|
throw new SimfinityError(`Unknown filter field: ${condition.field}`, 'INVALID_FILTER_FIELD', 400);
|
|
@@ -361,6 +669,9 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
361
669
|
}
|
|
362
670
|
|
|
363
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
|
+
}
|
|
364
675
|
appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
|
|
365
676
|
for (const matchClause of Object.values(result.matchesClauses)) {
|
|
366
677
|
for (const [matchKey, match] of Object.entries(matchClause)) {
|
package/types/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
GraphQLOutputType,
|
|
14
14
|
} from 'graphql';
|
|
15
15
|
import type {
|
|
16
|
+
CreateSchemaOptions,
|
|
16
17
|
DatabaseAdapter,
|
|
17
18
|
MutationLimitsOptions,
|
|
18
19
|
QueryFeatures,
|
|
@@ -35,6 +36,7 @@ export type {
|
|
|
35
36
|
AuthRule,
|
|
36
37
|
AuthRuleFunction,
|
|
37
38
|
AuthSchemaPlugin,
|
|
39
|
+
CreateSchemaOptions,
|
|
38
40
|
EnvelopSchemaPlugin,
|
|
39
41
|
FieldValidations,
|
|
40
42
|
FieldValidator,
|
|
@@ -90,10 +92,14 @@ export interface MongoAdapterOptions {
|
|
|
90
92
|
export interface MongoAdapter extends DatabaseAdapter {
|
|
91
93
|
readonly referentialIntegrity: 'off' | 'transactional';
|
|
92
94
|
/**
|
|
93
|
-
* Frozen; lists every `QLOperator` value
|
|
94
|
-
* expressions: `ILIKE` unanchored, `STARTS_WITH` and
|
|
95
|
-
* `IENDS_WITH` with `\z`, and `IEQ` with both, with
|
|
96
|
-
*
|
|
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.
|
|
97
103
|
*/
|
|
98
104
|
readonly queryFeatures: QueryFeatures;
|
|
99
105
|
/**
|
|
@@ -161,7 +167,7 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
|
|
|
161
167
|
/**
|
|
162
168
|
* Build the executable GraphQLSchema from every connected type. The first two
|
|
163
169
|
* allowlists are matched against the GraphQLObjectType INSTANCES passed to
|
|
164
|
-
* connect();
|
|
170
|
+
* connect(); custom queries and mutations are matched by name. Scopes are validated
|
|
165
171
|
* (INVALID_SCOPE), and reachable types bound or reserved by another runtime, or
|
|
166
172
|
* holding a field copied with toConfig() after another runtime generated its
|
|
167
173
|
* relation resolver, keeping the generated resolver or its extensions, are rejected (TYPE_BOUND_TO_OTHER_RUNTIME), before models
|
|
@@ -188,14 +194,19 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
|
|
|
188
194
|
* SimfinityRelationType) with a warning when it is in the process's first Simfinity schema. That
|
|
189
195
|
* schema fixes the names: in a later createSchema(), an application type with one of the current
|
|
190
196
|
* names (the original ones, or the fallback names after a rename) throws RESERVED_TYPE_NAME (500),
|
|
191
|
-
* before models are created when it is an included endpoint type, a registered
|
|
197
|
+
* before models are created when it is an included endpoint type, a registered custom operation's input or
|
|
192
198
|
* output type, an output type they reach or a field argument's type, and when the schema is
|
|
193
199
|
* constructed otherwise.
|
|
200
|
+
* options.includedCustomQueries selects custom queries by name (see CreateSchemaOptions); an
|
|
201
|
+
* invalid options argument throws INVALID_SCHEMA_OPTIONS (500), and an included custom query named
|
|
202
|
+
* like an included generated query throws QUERY_NAME_CONFLICT (500), before models are created. A
|
|
203
|
+
* schema without mutation fields has no Mutation root.
|
|
194
204
|
*/
|
|
195
205
|
export function createSchema(
|
|
196
206
|
includedQueryTypes?: GraphQLObjectType[] | null,
|
|
197
207
|
includedMutationTypes?: GraphQLObjectType[] | null,
|
|
198
208
|
includedCustomMutations?: string[] | null,
|
|
209
|
+
options?: CreateSchemaOptions,
|
|
199
210
|
): GraphQLSchema;
|
|
200
211
|
|
|
201
212
|
/** Register a custom mutation executed inside a transaction. */
|
|
@@ -207,6 +218,15 @@ export function registerMutation(
|
|
|
207
218
|
callback: (input: any, session: any, context: any) => any,
|
|
208
219
|
): void;
|
|
209
220
|
|
|
221
|
+
/** Register a custom query for a result that is not a stored record. No transaction or session wraps it. */
|
|
222
|
+
export function registerCustomQuery(
|
|
223
|
+
name: string,
|
|
224
|
+
description: string,
|
|
225
|
+
inputModel: GraphQLInputObjectType | null | undefined,
|
|
226
|
+
outputModel: GraphQLOutputType,
|
|
227
|
+
callback: (input: any, context: any) => any,
|
|
228
|
+
): void;
|
|
229
|
+
|
|
210
230
|
/** Operation context for root operations, generated non-embedded relation reads, and nested collection writes. */
|
|
211
231
|
export interface SimfinityMiddlewareContext {
|
|
212
232
|
args: any;
|
|
@@ -240,9 +260,18 @@ export function preventCreatingCollection(prevent: boolean): void;
|
|
|
240
260
|
export interface QueryLimitsOptions {
|
|
241
261
|
/** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
|
|
242
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;
|
|
243
269
|
}
|
|
244
270
|
|
|
245
|
-
/**
|
|
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
|
+
*/
|
|
246
275
|
export function configureQueryLimits(options?: QueryLimitsOptions): void;
|
|
247
276
|
|
|
248
277
|
/**
|
|
@@ -302,7 +331,9 @@ export interface FilterGroupInput {
|
|
|
302
331
|
conditions?: Array<{
|
|
303
332
|
field: string;
|
|
304
333
|
operator?: string;
|
|
334
|
+
/** Omitted or null for `IS_EMPTY` and `IS_NOT_EMPTY`. */
|
|
305
335
|
value?: any;
|
|
336
|
+
/** Required on an object field, except for `IS_EMPTY` and `IS_NOT_EMPTY`, where it may be omitted, null or `""` to test the field itself. */
|
|
306
337
|
path?: string;
|
|
307
338
|
}>;
|
|
308
339
|
[key: string]: any;
|
|
@@ -314,6 +345,46 @@ export interface FilterGroupInput {
|
|
|
314
345
|
* built here. An embedded `.id` path uses the declared `id` member, except for entity types embedded
|
|
315
346
|
* as lists and supplied subdocument schemas without an `id` path, which use the subdocument `_id`.
|
|
316
347
|
* A filter, condition or sort path through a virtual field rejects with `INVALID_FILTER_FIELD` (400).
|
|
348
|
+
*
|
|
349
|
+
* `IS_EMPTY` and `IS_NOT_EMPTY` take no value and test the queried document's own fields, inside the
|
|
350
|
+
* same `$match`: `{ p: { $in: [null, ""] } }` and `{ p: { $nin: [null, ""] } }` on a `String` field (a
|
|
351
|
+
* validated scalar by its base scalar), `{ p: null }` and `{ p: { $ne: null } }` on any other scalar
|
|
352
|
+
* or enum and on the record identity `_id`, `{ $and: [{ p: null }, { p: { $not: { $type: "array" } } }] }`
|
|
353
|
+
* and its `$nor` on an application scalar without a supported base, which MongoDB stores as written,
|
|
354
|
+
* so that an array, also `[null]`, is a value, `{ "p.0": { $exists: false } }` and
|
|
355
|
+
* `$exists: true` on a scalar list or an embedded list, and, on an embedded object,
|
|
356
|
+
* `{ $or: [{ p: null }, { $and: [<each stored member's IS_EMPTY form>] }] }` and its `$nor`. A type
|
|
357
|
+
* without stored members is always empty (`{}` stands for the members). Embedded `id` members follow
|
|
358
|
+
* the `.id` rule above, so an object of a supplied subdocument schema with an automatic `_id` is never
|
|
359
|
+
* empty. A term without a path (omitted, null or `""`) and a group condition without a path test the
|
|
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.
|
|
317
388
|
*/
|
|
318
389
|
export function buildQuery(
|
|
319
390
|
input: Record<string, any>,
|
|
@@ -323,7 +394,10 @@ export function buildQuery(
|
|
|
323
394
|
|
|
324
395
|
/**
|
|
325
396
|
* Translate a QLFilterGroup into a MongoDB `$match` expression, collecting required lookups. A
|
|
326
|
-
* condition path through a virtual field rejects with `INVALID_FILTER_FIELD` (400).
|
|
397
|
+
* condition path through a virtual field rejects with `INVALID_FILTER_FIELD` (400). `IS_EMPTY` and
|
|
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`.
|
|
327
401
|
*/
|
|
328
402
|
export function buildFilterGroupMatch(
|
|
329
403
|
filterGroup: FilterGroupInput,
|