@simtlix/simfinity-js 3.9.0 → 3.10.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 +3 -1
- package/package.json +3 -3
- package/src/index.js +1 -0
- package/src/mongo/queries.js +115 -10
- package/types/index.d.ts +46 -5
package/README.md
CHANGED
|
@@ -28,7 +28,9 @@ Virtual fields (`extensions: { virtual: true }`, with the field's own `resolve`)
|
|
|
28
28
|
|
|
29
29
|
Text filters compile to escaped regular expressions, so a value never becomes regex syntax: `LIKE` and `ILIKE` match anywhere, `STARTS_WITH` and `ISTARTS_WITH` are anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z` (so a trailing newline is part of the value), and `IEQ` with both. The `I` operators add the `i` option, which applies the Unicode simple case folding of the server's PCRE2 build (`É`/`é`, `ẞ`/`ß`, `σ`/`ς`, `µ`/`μ`, `ſ`/`s`), never a length-changing or Turkic folding (`ß`/`ss`, `İ`/`i`), and does not fold characters newer than that build, such as U+A7DC; the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) lists where PostgreSQL differs. Only `STARTS_WITH` reads a prefix range of an index on the field, a String list or an embedded path; the other text operators test every key of such an index, or every document without one, and `$regex` ignores collations, so a case-insensitive collation index does not serve them. A value whose escaped pattern is longer than MongoDB's 32,764-byte pattern limit fails with `INVALID_FILTER_VALUE` (400) before the query runs, also for `LIKE`. `createMongoAdapter()` declares every operator it compiles in `queryFeatures`; a copy of the adapter without it supports only the operators of 3.7.0.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
`IS_EMPTY` and `IS_NOT_EMPTY` take no value and compile to plain query operators inside the same `$match`, so they also filter counts, aggregates and batched collection reads (`$unionWith`): `$in: [null, ""]` and `$nin` on `String` fields, which an index on the field serves; `null` and `$ne: null` on other scalars, enums and the record identity `_id`; on an application scalar without a supported base, such as a `JSON` scalar, which MongoDB stores as written, `null` without arrays, `{ $and: [{ p: null }, { p: { $not: { $type: "array" } } }] }`, and its `$nor`, so that an array, also `[null]`, is a value, as reads return it; `"p.0": { $exists: false }` and `$exists: true` on scalar lists and embedded lists, which never look inside the items; and, on an embedded object, an `$or` of `null` and the `IS_EMPTY` form of every stored member, with its `$nor` for `IS_NOT_EMPTY`, so an object whose stored members are all empty, such as `{ w: null, label: "" }`, is empty. A term or group condition without a `path` tests the object or list field itself. Embedded `id` members are read where other filters read them, so an object of a supplied subdocument schema with an automatic `_id` is never empty, unlike on PostgreSQL. A path that continues inside an embedded list or a collection, such as `lines.qty` or `seasons.year`, fails with the permanent `INVALID_FILTER_PATH` (400), since emptiness of a value inside a list is not defined. In this release, any other path that crosses or ends at a reference, a path that ends at a collection, such as `seasons`, and an embedded object with a reference or collection member fail with `UNSUPPORTED_QUERY` (400); the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html#empty-values) has the rules and the other codes.
|
|
32
|
+
|
|
33
|
+
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
34
|
|
|
33
35
|
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
36
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.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.10.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.10.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/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, isEmptinessOperator, textQueryOperator,
|
|
14
|
+
validateTextFilterValue,
|
|
13
15
|
} from '@simtlix/simfinity-core/internal/query-operators';
|
|
14
16
|
import {
|
|
15
17
|
getFieldStorageName, getListShape, hasStoredIdentity, isVirtualField, normalizeConnectionField,
|
|
@@ -18,10 +20,6 @@ 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
|
);
|
|
@@ -36,6 +34,7 @@ const LOOKUP_ALIAS_PREFIX = '__sf_l';
|
|
|
36
34
|
export const MONGO_QUERY_OPERATORS = Object.freeze([
|
|
37
35
|
'EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE',
|
|
38
36
|
'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ',
|
|
37
|
+
'IS_EMPTY', 'IS_NOT_EMPTY',
|
|
39
38
|
]);
|
|
40
39
|
|
|
41
40
|
export const createMongoQueries = ({ getModel, getRegistrations }) => {
|
|
@@ -231,6 +230,74 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
231
230
|
throw filterError(`Invalid query path: ${path}`, 'INVALID_FILTER_PATH');
|
|
232
231
|
};
|
|
233
232
|
|
|
233
|
+
// The emptiness operators IS_EMPTY and IS_NOT_EMPTY (#108, part 2a). Core's describeEmptiness applies
|
|
234
|
+
// their checks in the order every caller uses and describes what "empty" means for the path. It
|
|
235
|
+
// rejects references, collections, interface and union fields and anything inside a list, so the
|
|
236
|
+
// path and every member it reads are stored in the queried document, through single embedded objects.
|
|
237
|
+
// The forms are plain query operators that need no lookup, valid wherever the $match runs.
|
|
238
|
+
const isPathless = (path) => path == null || path === '';
|
|
239
|
+
|
|
240
|
+
// The stored key of the member `name` of `type`, an object stored at `schemaPath` ('' at the root):
|
|
241
|
+
// a non-object `id` member follows identifierLeaf, as resolveQueryPath does; others keep their name.
|
|
242
|
+
const emptinessKey = (model, type, schemaPath, name) => {
|
|
243
|
+
const field = type.getFields()[name];
|
|
244
|
+
const leaf = name === 'id' && !(getNamedType(field.type) instanceof GraphQLObjectType)
|
|
245
|
+
? identifierLeaf(model, schemaPath, type)
|
|
246
|
+
: name;
|
|
247
|
+
return schemaPath ? `${schemaPath}.${leaf}` : leaf;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// MongoDB stores the built-in scalars, dates and enums as single values, but keeps an application scalar
|
|
251
|
+
// without a supported base as written, arrays included, and `{ p: null }` also matches an array that
|
|
252
|
+
// holds a null. The null rule of such a scalar leaves arrays out, so `[null]` is a value, as GraphQL
|
|
253
|
+
// reads it. The record identity `_id` is never an array, so it keeps the plain form.
|
|
254
|
+
const SINGLE_VALUE_SCALARS = new Set(['String', 'ID', 'Int', 'Float', 'Boolean', 'DateTime', 'Date', 'Time']);
|
|
255
|
+
const holdsArrays = (key, type) => (
|
|
256
|
+
key !== '_id' && isScalarType(type) && !SINGLE_VALUE_SCALARS.has(effectiveScalarName(type))
|
|
257
|
+
);
|
|
258
|
+
const nullForm = (key, type) => (holdsArrays(key, type)
|
|
259
|
+
? { $and: [{ [key]: null }, { [key]: { $not: { $type: 'array' } } }] }
|
|
260
|
+
: { [key]: null });
|
|
261
|
+
|
|
262
|
+
// The IS_EMPTY form of `shape` for the value stored at `key`; `type` is the named type of its field.
|
|
263
|
+
// An object is missing, null, or has every stored member empty. Without stored members it is always
|
|
264
|
+
// empty: MongoDB rejects an empty $and, so the empty query, which matches every document, stands in.
|
|
265
|
+
const emptyForm = (model, shape, key, type) => {
|
|
266
|
+
if (shape.rule === 'string') return { [key]: { $in: [null, ''] } };
|
|
267
|
+
if (shape.rule === 'null') return nullForm(key, type);
|
|
268
|
+
if (shape.rule === 'list') return { [`${key}.0`]: { $exists: false } };
|
|
269
|
+
const fields = type.getFields();
|
|
270
|
+
const members = shape.members.map((member) => emptyForm(
|
|
271
|
+
model, member.shape, emptinessKey(model, type, key, member.name), getNamedType(fields[member.name].type),
|
|
272
|
+
));
|
|
273
|
+
return { $or: [{ [key]: null }, members.length ? { $and: members } : {}] };
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// IS_NOT_EMPTY is the exact complement: the positive form of a leaf, which an index can serve, or
|
|
277
|
+
// the $nor of the IS_EMPTY form of an object or of a scalar that may hold arrays.
|
|
278
|
+
const notEmptyForm = (model, shape, key, type) => {
|
|
279
|
+
if (shape.rule === 'string') return { [key]: { $nin: [null, ''] } };
|
|
280
|
+
if (shape.rule === 'null') return holdsArrays(key, type) ? { $nor: [nullForm(key, type)] } : { [key]: { $ne: null } };
|
|
281
|
+
if (shape.rule === 'list') return { [`${key}.0`]: { $exists: true } };
|
|
282
|
+
return { $nor: [emptyForm(model, shape, key, type)] };
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// The match clause of one emptiness condition on `path`, the field itself or a dotted path from it.
|
|
286
|
+
const buildEmptinessClause = (gqltype, path, operator, value) => {
|
|
287
|
+
const segments = path.split('.');
|
|
288
|
+
const shape = describeEmptiness(gqltype, segments, { operator, value, path });
|
|
289
|
+
const model = getModel(gqltype);
|
|
290
|
+
let type = gqltype;
|
|
291
|
+
let schemaPath = '';
|
|
292
|
+
for (const name of segments.slice(0, -1)) {
|
|
293
|
+
schemaPath = schemaPath ? `${schemaPath}.${name}` : name;
|
|
294
|
+
type = getNamedType(type.getFields()[name].type);
|
|
295
|
+
}
|
|
296
|
+
const name = segments.at(-1);
|
|
297
|
+
const form = operator === 'IS_EMPTY' ? emptyForm : notEmptyForm;
|
|
298
|
+
return form(model, shape, emptinessKey(model, type, schemaPath, name), getNamedType(type.getFields()[name].type));
|
|
299
|
+
};
|
|
300
|
+
|
|
234
301
|
const castFilterValue = (value, resolved) => {
|
|
235
302
|
if (value === null) return null;
|
|
236
303
|
if (value === undefined || Array.isArray(value)
|
|
@@ -245,7 +312,7 @@ const castFilterValue = (value, resolved) => {
|
|
|
245
312
|
// ID representation is a persistence concern, including String/Number/custom _id schemas.
|
|
246
313
|
if (schemaType && (scalar === GraphQLID || resolved.mongoPath.endsWith('._id') || resolved.mongoPath === '_id'
|
|
247
314
|
|| schemaType.instance === 'ObjectId')) return schemaType.cast(value);
|
|
248
|
-
const typeName =
|
|
315
|
+
const typeName = effectiveScalarName(scalar);
|
|
249
316
|
if (isGraphQLisoDate(typeName)) {
|
|
250
317
|
const date = value instanceof Date ? value : new Date(value);
|
|
251
318
|
if (Number.isNaN(date.getTime())) throw new Error('Invalid date');
|
|
@@ -275,14 +342,14 @@ const buildMatchesClause = (resolved, operator, value, path) => {
|
|
|
275
342
|
// Text operators take the value as is, once core's shared rule accepts it: a String field (a
|
|
276
343
|
// validated scalar by its base scalar) and a string without U+0000.
|
|
277
344
|
if (textQueryOperator(op)) {
|
|
278
|
-
const text = validateTextFilterValue(op, value,
|
|
345
|
+
const text = validateTextFilterValue(op, value, effectiveScalarName(resolved.fieldType), path);
|
|
279
346
|
return { [resolved.mongoPath]: OP_TO_MONGO[op](text, path) };
|
|
280
347
|
}
|
|
281
348
|
const collectionOperator = op === 'IN' || op === 'NIN' || op === 'BTW';
|
|
282
349
|
if (collectionOperator && (!Array.isArray(value) || (op === 'BTW' && value.length !== 2))) {
|
|
283
350
|
throw filterError(`${op} requires ${op === 'BTW' ? 'exactly two values' : 'an array value'}`);
|
|
284
351
|
}
|
|
285
|
-
if (op === 'LIKE' && (typeof value !== 'string' ||
|
|
352
|
+
if (op === 'LIKE' && (typeof value !== 'string' || effectiveScalarName(resolved.fieldType) !== 'String')) {
|
|
286
353
|
throw filterError('LIKE requires a string field and string value');
|
|
287
354
|
}
|
|
288
355
|
if (value === null && op !== 'EQ' && op !== 'NE') throw filterError(`${op} does not accept null`);
|
|
@@ -302,13 +369,44 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
|
|
|
302
369
|
if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
|
|
303
370
|
if (filterField == null) return { aggregateClauses, matchesClauses };
|
|
304
371
|
if (typeof filterField !== 'object' || Array.isArray(filterField)) throw filterError(`Invalid filter for ${fieldName}`);
|
|
305
|
-
const
|
|
372
|
+
const namedType = getNamedType(qlField.type);
|
|
373
|
+
const isObject = namedType instanceof GraphQLObjectType;
|
|
374
|
+
// Only trusted code writes an operator on an object filter, since QLTypeFilterExpression has none:
|
|
375
|
+
// an emptiness operator there fails instead of being dropped; other operators stay left out. Rule 1
|
|
376
|
+
// comes first, so a virtual field fails as it does in every other form.
|
|
377
|
+
if (isObject && isEmptinessOperator(filterField.operator)) {
|
|
378
|
+
const { operator } = filterField;
|
|
379
|
+
if (isVirtualField(qlField)) {
|
|
380
|
+
throw filterError(`Query path ${fieldName} uses the virtual field ${gqltype.name}.${fieldName}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
381
|
+
}
|
|
382
|
+
throw filterError(`Filter on ${fieldName} takes ${operator} only in a term; to test the field itself, use a term without a path: `
|
|
383
|
+
+ `${fieldName}: { terms: [{ operator: ${operator} }] }`, 'MISSING_FILTER_PATH');
|
|
384
|
+
}
|
|
306
385
|
if (isObject && (!Array.isArray(filterField.terms) || filterField.terms.length === 0)) {
|
|
307
386
|
throw filterError(`Filter on ${fieldName} requires non-empty terms`, 'MISSING_FILTER_PATH');
|
|
308
387
|
}
|
|
388
|
+
// QLTypeFilterExpression gives interface and union lists terms too. An emptiness operator beside
|
|
389
|
+
// them or in one of them tests the field itself, which rule 3 rejects, or a path through it, which
|
|
390
|
+
// rule 1 rejects: describeEmptiness throws both, in the order the gate reads them. Other operators
|
|
391
|
+
// keep reading the filter as a value.
|
|
392
|
+
if (isAbstractType(namedType) && Array.isArray(filterField.terms)) {
|
|
393
|
+
for (const term of [filterField, ...filterField.terms]) {
|
|
394
|
+
if (!isEmptinessOperator(term?.operator)) continue;
|
|
395
|
+
const pathed = term !== filterField && !isPathless(term.path);
|
|
396
|
+
if (pathed) assertValidFilterPath(term.path);
|
|
397
|
+
describeEmptiness(gqltype, pathed ? `${fieldName}.${term.path}` : fieldName, { operator: term.operator, value: term.value });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
309
400
|
const terms = isObject ? filterField.terms : [filterField];
|
|
310
401
|
for (const term of terms) {
|
|
311
402
|
if (!term || typeof term !== 'object' || Array.isArray(term)) throw filterError(`Invalid term for ${fieldName}`);
|
|
403
|
+
// An emptiness term whose path is omitted, null or "" tests the field itself.
|
|
404
|
+
if (isEmptinessOperator(term.operator)) {
|
|
405
|
+
const pathed = isObject && !isPathless(term.path);
|
|
406
|
+
if (pathed) assertValidFilterPath(term.path);
|
|
407
|
+
matchesClauses.push(buildEmptinessClause(gqltype, pathed ? `${fieldName}.${term.path}` : fieldName, term.operator, term.value));
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
312
410
|
if (isObject) assertValidFilterPath(term.path);
|
|
313
411
|
const path = isObject ? `${fieldName}.${term.path}` : fieldName;
|
|
314
412
|
const resolved = resolveQueryPath(gqltype, path, aggregationsIncluded);
|
|
@@ -340,6 +438,13 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
340
438
|
for (const rawCondition of filterGroup.conditions) {
|
|
341
439
|
if (!rawCondition || typeof rawCondition.field !== 'string') throw filterError('Filter conditions require a field');
|
|
342
440
|
const condition = normalizeFilterCondition(rawCondition);
|
|
441
|
+
// An emptiness condition without a path tests its field, also an object field; a dotted field
|
|
442
|
+
// is resolved as a whole, as the query plan resolves it.
|
|
443
|
+
if (isEmptinessOperator(condition.operator)) {
|
|
444
|
+
const path = rawCondition.path ? `${rawCondition.field}.${rawCondition.path}` : rawCondition.field;
|
|
445
|
+
parts.push(buildEmptinessClause(gqltype, path, condition.operator, condition.value));
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
343
448
|
const qlField = fields[condition.field];
|
|
344
449
|
if (!qlField) {
|
|
345
450
|
throw new SimfinityError(`Unknown filter field: ${condition.field}`, 'INVALID_FILTER_FIELD', 400);
|
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,
|
|
@@ -92,8 +94,11 @@ export interface MongoAdapter extends DatabaseAdapter {
|
|
|
92
94
|
/**
|
|
93
95
|
* Frozen; lists every `QLOperator` value. The literal text operators compile to escaped regular
|
|
94
96
|
* expressions: `ILIKE` unanchored, `STARTS_WITH` and `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and
|
|
95
|
-
* `IENDS_WITH` with `\z`, and `IEQ` with both, with the `i` option for the case-insensitive ones.
|
|
96
|
-
*
|
|
97
|
+
* `IENDS_WITH` with `\z`, and `IEQ` with both, with the `i` option for the case-insensitive ones.
|
|
98
|
+
* `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query operators on the record's own fields (see
|
|
99
|
+
* {@link buildQuery}); declaring them does not lift the `UNSUPPORTED_QUERY` that emptiness through
|
|
100
|
+
* references and collections gets in this release. A copy of the adapter without this member
|
|
101
|
+
* supports only the operators of 3.7.0.
|
|
97
102
|
*/
|
|
98
103
|
readonly queryFeatures: QueryFeatures;
|
|
99
104
|
/**
|
|
@@ -161,7 +166,7 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
|
|
|
161
166
|
/**
|
|
162
167
|
* Build the executable GraphQLSchema from every connected type. The first two
|
|
163
168
|
* allowlists are matched against the GraphQLObjectType INSTANCES passed to
|
|
164
|
-
* connect();
|
|
169
|
+
* connect(); custom queries and mutations are matched by name. Scopes are validated
|
|
165
170
|
* (INVALID_SCOPE), and reachable types bound or reserved by another runtime, or
|
|
166
171
|
* holding a field copied with toConfig() after another runtime generated its
|
|
167
172
|
* relation resolver, keeping the generated resolver or its extensions, are rejected (TYPE_BOUND_TO_OTHER_RUNTIME), before models
|
|
@@ -188,14 +193,19 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
|
|
|
188
193
|
* SimfinityRelationType) with a warning when it is in the process's first Simfinity schema. That
|
|
189
194
|
* schema fixes the names: in a later createSchema(), an application type with one of the current
|
|
190
195
|
* 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
|
|
196
|
+
* before models are created when it is an included endpoint type, a registered custom operation's input or
|
|
192
197
|
* output type, an output type they reach or a field argument's type, and when the schema is
|
|
193
198
|
* constructed otherwise.
|
|
199
|
+
* options.includedCustomQueries selects custom queries by name (see CreateSchemaOptions); an
|
|
200
|
+
* invalid options argument throws INVALID_SCHEMA_OPTIONS (500), and an included custom query named
|
|
201
|
+
* like an included generated query throws QUERY_NAME_CONFLICT (500), before models are created. A
|
|
202
|
+
* schema without mutation fields has no Mutation root.
|
|
194
203
|
*/
|
|
195
204
|
export function createSchema(
|
|
196
205
|
includedQueryTypes?: GraphQLObjectType[] | null,
|
|
197
206
|
includedMutationTypes?: GraphQLObjectType[] | null,
|
|
198
207
|
includedCustomMutations?: string[] | null,
|
|
208
|
+
options?: CreateSchemaOptions,
|
|
199
209
|
): GraphQLSchema;
|
|
200
210
|
|
|
201
211
|
/** Register a custom mutation executed inside a transaction. */
|
|
@@ -207,6 +217,15 @@ export function registerMutation(
|
|
|
207
217
|
callback: (input: any, session: any, context: any) => any,
|
|
208
218
|
): void;
|
|
209
219
|
|
|
220
|
+
/** Register a custom query for a result that is not a stored record. No transaction or session wraps it. */
|
|
221
|
+
export function registerCustomQuery(
|
|
222
|
+
name: string,
|
|
223
|
+
description: string,
|
|
224
|
+
inputModel: GraphQLInputObjectType | null | undefined,
|
|
225
|
+
outputModel: GraphQLOutputType,
|
|
226
|
+
callback: (input: any, context: any) => any,
|
|
227
|
+
): void;
|
|
228
|
+
|
|
210
229
|
/** Operation context for root operations, generated non-embedded relation reads, and nested collection writes. */
|
|
211
230
|
export interface SimfinityMiddlewareContext {
|
|
212
231
|
args: any;
|
|
@@ -302,7 +321,9 @@ export interface FilterGroupInput {
|
|
|
302
321
|
conditions?: Array<{
|
|
303
322
|
field: string;
|
|
304
323
|
operator?: string;
|
|
324
|
+
/** Omitted or null for `IS_EMPTY` and `IS_NOT_EMPTY`. */
|
|
305
325
|
value?: any;
|
|
326
|
+
/** 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
327
|
path?: string;
|
|
307
328
|
}>;
|
|
308
329
|
[key: string]: any;
|
|
@@ -314,6 +335,25 @@ export interface FilterGroupInput {
|
|
|
314
335
|
* built here. An embedded `.id` path uses the declared `id` member, except for entity types embedded
|
|
315
336
|
* as lists and supplied subdocument schemas without an `id` path, which use the subdocument `_id`.
|
|
316
337
|
* A filter, condition or sort path through a virtual field rejects with `INVALID_FILTER_FIELD` (400).
|
|
338
|
+
*
|
|
339
|
+
* `IS_EMPTY` and `IS_NOT_EMPTY` take no value and test the queried document's own fields, inside the
|
|
340
|
+
* same `$match`: `{ p: { $in: [null, ""] } }` and `{ p: { $nin: [null, ""] } }` on a `String` field (a
|
|
341
|
+
* validated scalar by its base scalar), `{ p: null }` and `{ p: { $ne: null } }` on any other scalar
|
|
342
|
+
* or enum and on the record identity `_id`, `{ $and: [{ p: null }, { p: { $not: { $type: "array" } } }] }`
|
|
343
|
+
* and its `$nor` on an application scalar without a supported base, which MongoDB stores as written,
|
|
344
|
+
* so that an array, also `[null]`, is a value, `{ "p.0": { $exists: false } }` and
|
|
345
|
+
* `$exists: true` on a scalar list or an embedded list, and, on an embedded object,
|
|
346
|
+
* `{ $or: [{ p: null }, { $and: [<each stored member's IS_EMPTY form>] }] }` and its `$nor`. A type
|
|
347
|
+
* without stored members is always empty (`{}` stands for the members). Embedded `id` members follow
|
|
348
|
+
* the `.id` rule above, so an object of a supplied subdocument schema with an automatic `_id` is never
|
|
349
|
+
* empty. A term without a path (omitted, null or `""`) and a group condition without a path test the
|
|
350
|
+
* field itself. The checks, and their codes, are those of `createQueryPlan` in the same order:
|
|
351
|
+
* `MISSING_FILTER_PATH` for an emptiness operator on an object filter without terms or beside terms,
|
|
352
|
+
* `INVALID_FILTER_PATH` for a value inside an embedded list or a collection and for an interface or
|
|
353
|
+
* union field or member, `INVALID_FILTER_OPERATOR` for an embedded type that reaches itself through
|
|
354
|
+
* single embedded members, `INVALID_FILTER_VALUE` for a value that is not omitted or null, and
|
|
355
|
+
* `UNSUPPORTED_QUERY` for a reference or collection on the path or among the members. Other operators
|
|
356
|
+
* keep their pipelines.
|
|
317
357
|
*/
|
|
318
358
|
export function buildQuery(
|
|
319
359
|
input: Record<string, any>,
|
|
@@ -323,7 +363,8 @@ export function buildQuery(
|
|
|
323
363
|
|
|
324
364
|
/**
|
|
325
365
|
* 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).
|
|
366
|
+
* condition path through a virtual field rejects with `INVALID_FILTER_FIELD` (400). `IS_EMPTY` and
|
|
367
|
+
* `IS_NOT_EMPTY` conditions compile and reject as in {@link buildQuery}, and add no lookup.
|
|
327
368
|
*/
|
|
328
369
|
export function buildFilterGroupMatch(
|
|
329
370
|
filterGroup: FilterGroupInput,
|