@simtlix/simfinity-js 3.8.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 CHANGED
@@ -26,7 +26,11 @@ Generated models store enum internal values with their own type (numbers, boolea
26
26
 
27
27
  Virtual fields (`extensions: { virtual: true }`, with the field's own `resolve`) get no path, no `[]` default and no index in generated models, on entities and embedded types, and no entry in the transactional integrity registry, so they need no `relation` in either mode and documents look the same with or without them. Generated writes never set or unset them, and Mongoose's strict mode drops a value that a controller assigns to a generated document for one. An embedded type whose members are all virtual becomes a strict subdocument without paths rather than a Mixed path, so nothing is stored for it but the `_id` of list items. With a generated model, values stored before a field became virtual stay in the documents: Mongoose drops an `$unset` of a path the model does not declare, so remove them with a driver-level update, such as `getModel(Type).collection.updateMany({}, { $unset: { busyRanges: '' } })`, or with `updateMany(filter, update, { strict: false })`; a generated update that sets an embedded parent rewrites it, which drops the stored keys of its virtual members. A supplied model keeps the paths it declares: documents stored earlier keep their values for the field, which nothing reads or writes until `$unset` removes them, and an index on such a path stays until you drop it. `buildQuery()` rejects a path through a virtual field with `INVALID_FILTER_FIELD` (400).
28
28
 
29
- 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. `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).
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
+
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).
30
34
 
31
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.
32
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "3.8.0",
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.8.0",
33
- "@simtlix/simfinity-mcp": "3.8.0"
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
 
@@ -3,7 +3,7 @@ import { SimfinityError } from '@simtlix/simfinity-core';
3
3
  import { getListShape, normalizeConnectionField } from '@simtlix/simfinity-core/internal/relation-storage';
4
4
 
5
5
  import { createMongoModel } from './models.js';
6
- import { createMongoQueries } from './queries.js';
6
+ import { MONGO_QUERY_OPERATORS, createMongoQueries } from './queries.js';
7
7
  import { withMongoTransaction } from './transactions.js';
8
8
  import { createMongoIntegrity } from './integrity.js';
9
9
  import {
@@ -12,6 +12,9 @@ import {
12
12
 
13
13
  const withSession = (query, session) => (session ? query.session(session) : query);
14
14
 
15
+ // Every filter operator the query compiler supports; the runtime rejects any other one.
16
+ const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS });
17
+
15
18
  // A write that fails only on malformed ObjectId values reports NOT_VALID_ID, not a driver error.
16
19
  const mapIdCastErrors = async (write) => {
17
20
  try {
@@ -216,6 +219,7 @@ export const createMongoAdapter = (options) => {
216
219
  };
217
220
 
218
221
  const adapter = {
222
+ queryFeatures: QUERY_FEATURES,
219
223
  bind(binding) {
220
224
  integrity.bind(binding);
221
225
  getRegistrations = binding.getRegistrations;
@@ -2,12 +2,17 @@ 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';
12
+ import {
13
+ TEXT_QUERY_OPERATORS, describeEmptiness, effectiveScalarName, isEmptinessOperator, textQueryOperator,
14
+ validateTextFilterValue,
15
+ } from '@simtlix/simfinity-core/internal/query-operators';
11
16
  import {
12
17
  getFieldStorageName, getListShape, hasStoredIdentity, isVirtualField, normalizeConnectionField,
13
18
  } from '@simtlix/simfinity-core/internal/relation-storage';
@@ -15,10 +20,6 @@ import {
15
20
  import { castObjectId } from './ids.js';
16
21
  import { resolveStorageScalar } from './models.js';
17
22
 
18
- const getEffectiveTypeName = (type) => (
19
- type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
20
- );
21
-
22
23
  const isGraphQLisoDate = (typeName) => (
23
24
  typeName === 'DateTime' || typeName === 'Date' || typeName === 'Time'
24
25
  );
@@ -26,6 +27,16 @@ const isGraphQLisoDate = (typeName) => (
26
27
  // GraphQL reserves `__` names, so these aliases never replace a stored field.
27
28
  const LOOKUP_ALIAS_PREFIX = '__sf_l';
28
29
 
30
+ /**
31
+ * The filter operators this compiler supports, which the adapter declares as its queryFeatures. They
32
+ * are listed explicitly, so that a QLOperator value added later stays unsupported until it compiles.
33
+ */
34
+ export const MONGO_QUERY_OPERATORS = Object.freeze([
35
+ 'EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE',
36
+ 'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ',
37
+ 'IS_EMPTY', 'IS_NOT_EMPTY',
38
+ ]);
39
+
29
40
  export const createMongoQueries = ({ getModel, getRegistrations }) => {
30
41
  // One alias per relation path for each query build, keyed by the build's `included` accumulator.
31
42
  const lookupAliases = new WeakMap();
@@ -62,6 +73,34 @@ const buildRelationLookup = ({
62
73
 
63
74
  const escapeRegex = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
75
 
76
+ // MongoDB rejects a $regex pattern longer than 32,764 UTF-8 bytes on the server (Location51091),
77
+ // which would report a client's long value as an internal error. Escaping adds a byte to each
78
+ // metacharacter and the operators add their anchors, so the limit applies to the compiled pattern.
79
+ const MAX_REGEX_PATTERN_BYTES = 32764;
80
+ // A $regex match, rejected with INVALID_FILTER_VALUE before the query runs when MongoDB would reject
81
+ // its pattern. `path` is the condition's GraphQL path, for the message.
82
+ const regexMatch = (operator, pattern, ignoreCase, path) => {
83
+ const bytes = Buffer.byteLength(pattern, 'utf8');
84
+ if (bytes > MAX_REGEX_PATTERN_BYTES) {
85
+ const where = path == null ? '' : ` on ${path}`;
86
+ throw filterError(`${operator} value${where} is too long: MongoDB accepts regular expressions of at most ${MAX_REGEX_PATTERN_BYTES} bytes, and this one has ${bytes}`);
87
+ }
88
+ return ignoreCase ? { $regex: pattern, $options: 'i' } : { $regex: pattern };
89
+ };
90
+
91
+ // The literal text operators: the escaped value, anchored with `^` at the start of the string and
92
+ // `\z` at its very end (`$` would also match before a final newline), with the `i` option to ignore
93
+ // case. Without the `m` option, `^` never matches after a newline.
94
+ const TEXT_PATTERNS = {
95
+ contains: (escaped) => escaped,
96
+ prefix: (escaped) => `^${escaped}`,
97
+ suffix: (escaped) => `${escaped}\\z`,
98
+ exact: (escaped) => `^${escaped}\\z`,
99
+ };
100
+ const textRegex = (operator, { match, ignoreCase }) => (value, path) => (
101
+ regexMatch(operator, TEXT_PATTERNS[match](escapeRegex(value)), ignoreCase, path)
102
+ );
103
+
65
104
  const PATH_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
66
105
 
67
106
  const assertValidFilterPath = (path) => {
@@ -101,7 +140,8 @@ const OP_TO_MONGO = {
101
140
  GTE: (v) => ({ $gte: v }),
102
141
  NE: (v) => ({ $ne: v }),
103
142
  BTW: (v) => ({ $gte: v[0], $lte: v[1] }),
104
- LIKE: (v) => ({ $regex: `.*${escapeRegex(v)}.*` }),
143
+ LIKE: (v, path) => regexMatch('LIKE', `.*${escapeRegex(v)}.*`, false, path),
144
+ ...Object.fromEntries(Object.entries(TEXT_QUERY_OPERATORS).map(([op, text]) => [op, textRegex(op, text)])),
105
145
  };
106
146
 
107
147
  const AGG_OP_TO_MONGO = {
@@ -190,6 +230,74 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
190
230
  throw filterError(`Invalid query path: ${path}`, 'INVALID_FILTER_PATH');
191
231
  };
192
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
+
193
301
  const castFilterValue = (value, resolved) => {
194
302
  if (value === null) return null;
195
303
  if (value === undefined || Array.isArray(value)
@@ -204,7 +312,7 @@ const castFilterValue = (value, resolved) => {
204
312
  // ID representation is a persistence concern, including String/Number/custom _id schemas.
205
313
  if (schemaType && (scalar === GraphQLID || resolved.mongoPath.endsWith('._id') || resolved.mongoPath === '_id'
206
314
  || schemaType.instance === 'ObjectId')) return schemaType.cast(value);
207
- const typeName = getEffectiveTypeName(scalar);
315
+ const typeName = effectiveScalarName(scalar);
208
316
  if (isGraphQLisoDate(typeName)) {
209
317
  const date = value instanceof Date ? value : new Date(value);
210
318
  if (Number.isNaN(date.getTime())) throw new Error('Invalid date');
@@ -225,16 +333,23 @@ const castFilterValue = (value, resolved) => {
225
333
  }
226
334
  };
227
335
 
228
- const buildMatchesClause = (resolved, operator, value) => {
336
+ // `path` is the condition's GraphQL path, for messages.
337
+ const buildMatchesClause = (resolved, operator, value, path) => {
229
338
  const op = operator ?? 'EQ';
230
339
  if (!QLOperator.getValues().some((entry) => entry.value === op)) {
231
340
  throw filterError(`Unsupported filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
232
341
  }
342
+ // Text operators take the value as is, once core's shared rule accepts it: a String field (a
343
+ // validated scalar by its base scalar) and a string without U+0000.
344
+ if (textQueryOperator(op)) {
345
+ const text = validateTextFilterValue(op, value, effectiveScalarName(resolved.fieldType), path);
346
+ return { [resolved.mongoPath]: OP_TO_MONGO[op](text, path) };
347
+ }
233
348
  const collectionOperator = op === 'IN' || op === 'NIN' || op === 'BTW';
234
349
  if (collectionOperator && (!Array.isArray(value) || (op === 'BTW' && value.length !== 2))) {
235
350
  throw filterError(`${op} requires ${op === 'BTW' ? 'exactly two values' : 'an array value'}`);
236
351
  }
237
- if (op === 'LIKE' && (typeof value !== 'string' || getEffectiveTypeName(resolved.fieldType) !== 'String')) {
352
+ if (op === 'LIKE' && (typeof value !== 'string' || effectiveScalarName(resolved.fieldType) !== 'String')) {
238
353
  throw filterError('LIKE requires a string field and string value');
239
354
  }
240
355
  if (value === null && op !== 'EQ' && op !== 'NE') throw filterError(`${op} does not accept null`);
@@ -245,7 +360,7 @@ const buildMatchesClause = (resolved, operator, value) => {
245
360
  const coerced = collectionOperator ? value.map(cast) : op === 'LIKE' ? value : cast(value);
246
361
  if (op === 'EQ') return { [resolved.mongoPath]: coerced };
247
362
  if (op === 'IN' || op === 'NIN') return { [resolved.mongoPath]: { [op === 'IN' ? '$in' : '$nin']: coerced } };
248
- return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced) };
363
+ return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced, path) };
249
364
  };
250
365
 
251
366
  const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded) => {
@@ -254,17 +369,49 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
254
369
  if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
255
370
  if (filterField == null) return { aggregateClauses, matchesClauses };
256
371
  if (typeof filterField !== 'object' || Array.isArray(filterField)) throw filterError(`Invalid filter for ${fieldName}`);
257
- const isObject = getNamedType(qlField.type) instanceof GraphQLObjectType;
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
+ }
258
385
  if (isObject && (!Array.isArray(filterField.terms) || filterField.terms.length === 0)) {
259
386
  throw filterError(`Filter on ${fieldName} requires non-empty terms`, 'MISSING_FILTER_PATH');
260
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
+ }
261
400
  const terms = isObject ? filterField.terms : [filterField];
262
401
  for (const term of terms) {
263
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
+ }
264
410
  if (isObject) assertValidFilterPath(term.path);
265
- const resolved = resolveQueryPath(gqltype, isObject ? `${fieldName}.${term.path}` : fieldName, aggregationsIncluded);
411
+ const path = isObject ? `${fieldName}.${term.path}` : fieldName;
412
+ const resolved = resolveQueryPath(gqltype, path, aggregationsIncluded);
266
413
  Object.assign(aggregateClauses, resolved.aggregateClauses);
267
- matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value));
414
+ matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value, path));
268
415
  }
269
416
  return { aggregateClauses, matchesClauses };
270
417
  };
@@ -291,6 +438,13 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
291
438
  for (const rawCondition of filterGroup.conditions) {
292
439
  if (!rawCondition || typeof rawCondition.field !== 'string') throw filterError('Filter conditions require a field');
293
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
+ }
294
448
  const qlField = fields[condition.field];
295
449
  if (!qlField) {
296
450
  throw new SimfinityError(`Unknown filter field: ${condition.field}`, 'INVALID_FILTER_FIELD', 400);
package/types/index.d.ts CHANGED
@@ -13,8 +13,10 @@ import type {
13
13
  GraphQLOutputType,
14
14
  } from 'graphql';
15
15
  import type {
16
+ CreateSchemaOptions,
16
17
  DatabaseAdapter,
17
18
  MutationLimitsOptions,
19
+ QueryFeatures,
18
20
  RuntimeRegistration,
19
21
  SimfinityError,
20
22
  } from '@simtlix/simfinity-core';
@@ -34,6 +36,7 @@ export type {
34
36
  AuthRule,
35
37
  AuthRuleFunction,
36
38
  AuthSchemaPlugin,
39
+ CreateSchemaOptions,
37
40
  EnvelopSchemaPlugin,
38
41
  FieldValidations,
39
42
  FieldValidator,
@@ -88,6 +91,16 @@ export interface MongoAdapterOptions {
88
91
  */
89
92
  export interface MongoAdapter extends DatabaseAdapter {
90
93
  readonly referentialIntegrity: 'off' | 'transactional';
94
+ /**
95
+ * Frozen; lists every `QLOperator` value. The literal text operators compile to escaped regular
96
+ * expressions: `ILIKE` unanchored, `STARTS_WITH` and `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and
97
+ * `IENDS_WITH` with `\z`, and `IEQ` with both, with the `i` option for the case-insensitive ones.
98
+ * `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query operators on the record's own fields (see
99
+ * {@link buildQuery}); declaring them does not lift the `UNSUPPORTED_QUERY` that emptiness through
100
+ * references and collections gets in this release. A copy of the adapter without this member
101
+ * supports only the operators of 3.7.0.
102
+ */
103
+ readonly queryFeatures: QueryFeatures;
91
104
  /**
92
105
  * Accepts only an ObjectId or a 24-character hexadecimal string, normalized to lowercase, and
93
106
  * throws `NOT_VALID_ID` (400) for anything else, including undefined, null and numbers. It never
@@ -153,7 +166,7 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
153
166
  /**
154
167
  * Build the executable GraphQLSchema from every connected type. The first two
155
168
  * allowlists are matched against the GraphQLObjectType INSTANCES passed to
156
- * connect(); only custom mutations are matched by name. Scopes are validated
169
+ * connect(); custom queries and mutations are matched by name. Scopes are validated
157
170
  * (INVALID_SCOPE), and reachable types bound or reserved by another runtime, or
158
171
  * holding a field copied with toConfig() after another runtime generated its
159
172
  * relation resolver, keeping the generated resolver or its extensions, are rejected (TYPE_BOUND_TO_OTHER_RUNTIME), before models
@@ -180,14 +193,19 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
180
193
  * SimfinityRelationType) with a warning when it is in the process's first Simfinity schema. That
181
194
  * schema fixes the names: in a later createSchema(), an application type with one of the current
182
195
  * names (the original ones, or the fallback names after a rename) throws RESERVED_TYPE_NAME (500),
183
- * before models are created when it is an included endpoint type, a registered mutation's input or
196
+ * before models are created when it is an included endpoint type, a registered custom operation's input or
184
197
  * output type, an output type they reach or a field argument's type, and when the schema is
185
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.
186
203
  */
187
204
  export function createSchema(
188
205
  includedQueryTypes?: GraphQLObjectType[] | null,
189
206
  includedMutationTypes?: GraphQLObjectType[] | null,
190
207
  includedCustomMutations?: string[] | null,
208
+ options?: CreateSchemaOptions,
191
209
  ): GraphQLSchema;
192
210
 
193
211
  /** Register a custom mutation executed inside a transaction. */
@@ -199,6 +217,15 @@ export function registerMutation(
199
217
  callback: (input: any, session: any, context: any) => any,
200
218
  ): void;
201
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
+
202
229
  /** Operation context for root operations, generated non-embedded relation reads, and nested collection writes. */
203
230
  export interface SimfinityMiddlewareContext {
204
231
  args: any;
@@ -294,7 +321,9 @@ export interface FilterGroupInput {
294
321
  conditions?: Array<{
295
322
  field: string;
296
323
  operator?: string;
324
+ /** Omitted or null for `IS_EMPTY` and `IS_NOT_EMPTY`. */
297
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. */
298
327
  path?: string;
299
328
  }>;
300
329
  [key: string]: any;
@@ -306,6 +335,25 @@ export interface FilterGroupInput {
306
335
  * built here. An embedded `.id` path uses the declared `id` member, except for entity types embedded
307
336
  * as lists and supplied subdocument schemas without an `id` path, which use the subdocument `_id`.
308
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.
309
357
  */
310
358
  export function buildQuery(
311
359
  input: Record<string, any>,
@@ -315,7 +363,8 @@ export function buildQuery(
315
363
 
316
364
  /**
317
365
  * Translate a QLFilterGroup into a MongoDB `$match` expression, collecting required lookups. A
318
- * 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.
319
368
  */
320
369
  export function buildFilterGroupMatch(
321
370
  filterGroup: FilterGroupInput,