@simtlix/simfinity-js 3.10.0 → 3.12.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 +4 -2
- package/package.json +3 -3
- package/src/index.js +1 -0
- package/src/mongo/adapter.js +3 -2
- package/src/mongo/queries.js +262 -56
- package/types/index.d.ts +63 -16
package/README.md
CHANGED
|
@@ -28,9 +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
|
-
`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
|
|
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
32
|
|
|
33
|
-
The
|
|
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. 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).
|
|
34
36
|
|
|
35
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.
|
|
36
38
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.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.12.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.12.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export const getRegistrations = runtime.getRegistrations;
|
|
|
23
23
|
export const use = runtime.use;
|
|
24
24
|
export const registerMutation = runtime.registerMutation;
|
|
25
25
|
export const registerCustomQuery = runtime.registerCustomQuery;
|
|
26
|
+
export const registerView = runtime.registerView;
|
|
26
27
|
export const saveObject = runtime.saveObject;
|
|
27
28
|
export const preventCreatingCollection = runtime.preventCreatingCollection;
|
|
28
29
|
|
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
|
@@ -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,
|
|
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, {
|
|
80
|
+
for (const [alias, { stages }] of Object.entries(lookups)) {
|
|
56
81
|
if (!Object.hasOwn(aggregationsIncluded, alias)) {
|
|
57
|
-
aggregateClauses.push(
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
stages: [
|
|
92
|
+
{
|
|
93
|
+
$lookup: {
|
|
94
|
+
from: collectionName, foreignField, localField, as: alias,
|
|
95
|
+
},
|
|
69
96
|
},
|
|
70
|
-
|
|
71
|
-
|
|
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,
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
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
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
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
|
|
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
|
|
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 = (
|
|
256
|
-
|
|
303
|
+
const holdsArrays = (place, type) => (
|
|
304
|
+
place.schemaPath !== '_id' && isScalarType(type) && !SINGLE_VALUE_SCALARS.has(effectiveScalarName(type))
|
|
257
305
|
);
|
|
258
|
-
const nullForm = (
|
|
259
|
-
? { $and: [{ [key]: null }, { [key]: { $not: { $type: 'array' } } }] }
|
|
260
|
-
: { [key]: null });
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
));
|
|
273
|
-
|
|
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
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (
|
|
281
|
-
if (
|
|
282
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
449
|
+
let scoped = false;
|
|
292
450
|
for (const name of segments.slice(0, -1)) {
|
|
293
|
-
|
|
294
|
-
|
|
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(
|
|
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(
|
|
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
|
|
445
|
-
|
|
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
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
QueryFeatures,
|
|
20
20
|
RuntimeRegistration,
|
|
21
21
|
SimfinityError,
|
|
22
|
+
ViewOptions,
|
|
22
23
|
} from '@simtlix/simfinity-core';
|
|
23
24
|
export {
|
|
24
25
|
auth,
|
|
@@ -40,6 +41,8 @@ export type {
|
|
|
40
41
|
EnvelopSchemaPlugin,
|
|
41
42
|
FieldValidations,
|
|
42
43
|
FieldValidator,
|
|
44
|
+
FilterCondition,
|
|
45
|
+
FilterGroup,
|
|
43
46
|
ItemValidators,
|
|
44
47
|
MutationLimitsOptions,
|
|
45
48
|
PermissionSchema,
|
|
@@ -49,6 +52,9 @@ export type {
|
|
|
49
52
|
ScopeParams,
|
|
50
53
|
TypePermissions,
|
|
51
54
|
TypeScopes,
|
|
55
|
+
ViewOptions,
|
|
56
|
+
ViewParam,
|
|
57
|
+
ViewParamType,
|
|
52
58
|
VirtualFieldDeclaration,
|
|
53
59
|
VirtualFieldOptions,
|
|
54
60
|
} from '@simtlix/simfinity-core';
|
|
@@ -92,13 +98,14 @@ export interface MongoAdapterOptions {
|
|
|
92
98
|
export interface MongoAdapter extends DatabaseAdapter {
|
|
93
99
|
readonly referentialIntegrity: 'off' | 'transactional';
|
|
94
100
|
/**
|
|
95
|
-
* Frozen; lists every `QLOperator` value
|
|
96
|
-
* expressions: `ILIKE` unanchored, `STARTS_WITH` and
|
|
97
|
-
* `IENDS_WITH` with `\z`, and `IEQ` with both, with
|
|
98
|
-
* `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* supports only the operators of 3.7.0
|
|
101
|
+
* Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true`. The literal text
|
|
102
|
+
* operators compile to escaped regular expressions: `ILIKE` unanchored, `STARTS_WITH` and
|
|
103
|
+
* `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z`, and `IEQ` with both, with
|
|
104
|
+
* the `i` option for the case-insensitive ones. `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query
|
|
105
|
+
* operators on the record's own fields, and through single references and at references and
|
|
106
|
+
* collections to joins and frames (see {@link buildQuery}). A copy of the adapter without this member
|
|
107
|
+
* supports only the operators of 3.7.0, and one whose declaration leaves out `referenceEmptiness`
|
|
108
|
+
* gets `UNSUPPORTED_QUERY` for emptiness through references and collections.
|
|
102
109
|
*/
|
|
103
110
|
readonly queryFeatures: QueryFeatures;
|
|
104
111
|
/**
|
|
@@ -226,6 +233,14 @@ export function registerCustomQuery(
|
|
|
226
233
|
callback: (input: any, context: any) => any,
|
|
227
234
|
): void;
|
|
228
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Register a named view: a root query field that returns the type's list with the generated list
|
|
238
|
+
* arguments plus the view's params, and appends the groups of `where` after middleware. Throws
|
|
239
|
+
* `INVALID_VIEW` (500) for an invalid declaration and `QUERY_NAME_CONFLICT` (500) for a name already
|
|
240
|
+
* registered as a view (see the core `Runtime.registerView`).
|
|
241
|
+
*/
|
|
242
|
+
export function registerView(name: string, options: ViewOptions): void;
|
|
243
|
+
|
|
229
244
|
/** Operation context for root operations, generated non-embedded relation reads, and nested collection writes. */
|
|
230
245
|
export interface SimfinityMiddlewareContext {
|
|
231
246
|
args: any;
|
|
@@ -259,9 +274,18 @@ export function preventCreatingCollection(prevent: boolean): void;
|
|
|
259
274
|
export interface QueryLimitsOptions {
|
|
260
275
|
/** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
|
|
261
276
|
maxPageSize?: number;
|
|
277
|
+
/**
|
|
278
|
+
* Positive safe integer; defaults to 10. The most distinct scoped joins and frames that the
|
|
279
|
+
* client's reference and collection emptiness may need in one list, aggregate or collection-field
|
|
280
|
+
* call; more fail with `FILTER_DEPTH_EXCEEDED` (400).
|
|
281
|
+
*/
|
|
282
|
+
maxReferenceFrames?: number;
|
|
262
283
|
}
|
|
263
284
|
|
|
264
|
-
/**
|
|
285
|
+
/**
|
|
286
|
+
* Configure at startup. Invalid configuration throws INVALID_QUERY_LIMITS (400) and keeps both
|
|
287
|
+
* limits; an omitted option takes its default again.
|
|
288
|
+
*/
|
|
265
289
|
export function configureQueryLimits(options?: QueryLimitsOptions): void;
|
|
266
290
|
|
|
267
291
|
/**
|
|
@@ -347,13 +371,34 @@ export interface FilterGroupInput {
|
|
|
347
371
|
* without stored members is always empty (`{}` stands for the members). Embedded `id` members follow
|
|
348
372
|
* the `.id` rule above, so an object of a supplied subdocument schema with an automatic `_id` is never
|
|
349
373
|
* empty. A term without a path (omitted, null or `""`) and a group condition without a path test the
|
|
350
|
-
* field itself.
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
* `
|
|
356
|
-
*
|
|
374
|
+
* field itself.
|
|
375
|
+
*
|
|
376
|
+
* Their path may also cross single references and end at a reference or a collection, and an
|
|
377
|
+
* embedded object may have reference and collection members. A crossing reads its target through a
|
|
378
|
+
* `$lookup` and `$unwind` before the `$match`, and the target's `id` right after it is the joined
|
|
379
|
+
* `_id`, which takes the null rule. A reference or collection end or member is a frame, one `$lookup`
|
|
380
|
+
* with `pipeline: [{ $limit: 1 }, { $project: { _id: 1 } }]` under an alias `__sf_qN`, matched with
|
|
381
|
+
* `{ "__sf_qN.0": { $exists: false } }` (for a collection, `$or` a null local key, since `$lookup`
|
|
382
|
+
* matches a missing local key with every child whose connection field is null) and its `$nor`. A
|
|
383
|
+
* collection frame reads the child's connection field, and its local key is `<path>._id`. Arguments
|
|
384
|
+
* from this function, middleware and scopes read stored data: a reference is empty when no record it
|
|
385
|
+
* points to exists, so a dangling reference is empty, and leaves share the lookup aliases of the other
|
|
386
|
+
* operators. A term or group condition that the runtime marked for a client, `__scope: { id, scopes }`,
|
|
387
|
+
* reads through the target find scopes in `scopes`, compiled with this function into the `$lookup`
|
|
388
|
+
* pipelines: crossings become scoped joins (`__sf_jN`), frames take their target's scope, and a record
|
|
389
|
+
* that a scope hides reads as missing. A crossing or frame after a scoped crossing stays in that chain,
|
|
390
|
+
* also when its own scope is null. The aliases are unset at the end of find pipelines. A target type
|
|
391
|
+
* without a model fails with `INVALID_FILTER_PATH` (400), and a marker that is not filled for every
|
|
392
|
+
* path the construct reads, or that sits on a construct that reads none or on another operator, with
|
|
393
|
+
* `DETACHED_CLIENT_FILTER` (500), after the 400 checks. Supplied models on another database or
|
|
394
|
+
* connection and discriminator targets are not supported here, as for the other relation paths.
|
|
395
|
+
*
|
|
396
|
+
* The checks, and their codes, are those of `createQueryPlan` in the same order: `MISSING_FILTER_PATH`
|
|
397
|
+
* for an emptiness operator on an object filter without terms or beside terms, `INVALID_FILTER_PATH`
|
|
398
|
+
* for a value inside an embedded list or a collection and for an interface or union field or member,
|
|
399
|
+
* `INVALID_FILTER_OPERATOR` for an embedded type that reaches itself through single embedded members,
|
|
400
|
+
* and `INVALID_FILTER_VALUE` for a value that is not omitted or null. Other operators keep their
|
|
401
|
+
* pipelines.
|
|
357
402
|
*/
|
|
358
403
|
export function buildQuery(
|
|
359
404
|
input: Record<string, any>,
|
|
@@ -364,7 +409,9 @@ export function buildQuery(
|
|
|
364
409
|
/**
|
|
365
410
|
* Translate a QLFilterGroup into a MongoDB `$match` expression, collecting required lookups. A
|
|
366
411
|
* 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}
|
|
412
|
+
* `IS_NOT_EMPTY` conditions compile and reject as in {@link buildQuery}: on the record's own fields
|
|
413
|
+
* they add no lookup, and through references and collections they append their joins and frames to
|
|
414
|
+
* `aggregateClauses` and record their aliases in `aggregationsIncluded`.
|
|
368
415
|
*/
|
|
369
416
|
export function buildFilterGroupMatch(
|
|
370
417
|
filterGroup: FilterGroupInput,
|