@simtlix/simfinity-core 3.8.0 → 3.9.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
@@ -20,6 +20,8 @@ The adapter supplies model creation, identifiers, transactions, record reads and
20
20
 
21
21
  An adapter can also define `findChildrenBatch(model, type, connectionField, parentIds, args, { context })`, the batch form of `findChildren` for generated collection fields, and a synchronous `batchesChildren(model, type, connectionField)` predicate. The runtime calls both as adapter methods and checks them on every read. When the method is missing or the predicate returns `false`, each field calls `findChildren` at once with the arguments object it built, as before. Otherwise, within one request (a context object), the runtime groups the collection reads of one related type and connection storage field whose effective arguments (after middleware, the scope and joined scopes) hold the same plain data and are requested in the same tick, that is before the promise jobs queued so far have run. It calls the method with 2 to 100 distinct parent IDs, each a raw ID that `castId` accepts without changing its string form, and with a copy of the arguments that the method may change; a group with one distinct parent calls `findChildren`. Return one array per parent ID, in order, each holding exactly the records, order and page that `findChildren` returns for that parent; records should show their parent under the connection field, or not show one. Fields of one parent share its records, each with its own array. After a rejection, or a result of another shape or with records of another parent, each field reads alone with `findChildren`; failures other than a `SimfinityError`, and results of another shape, are logged once per type. Pairing rule: the batch must reproduce `this.findChildren`, so `batchesChildren` must return `false` when `this.findChildren` is not the adapter's own function, as with a spread copy or an `Object.assign` override that changes how children are read. The bundled adapters follow this rule: both define the two members only with `batchedCollections: true`: the SQL adapter when the schema is prepared and its plugin's `compileChildrenQuery` is paired with its `compileQuery`, and the MongoDB adapter at creation. To opt out on one adapter instance, set `adapter.findChildrenBatch = undefined` (deleting it does nothing for a class whose method is on the prototype), or make `batchesChildren` return `false`. A copy that overrides `getById` still inherits the base `getByIds`, as before.
22
22
 
23
+ An adapter declares the filter operators it compiles as `queryFeatures: { operators }`, a list of `QLOperator` values that includes `EQ`, which a condition without an operator means; names this version does not know are ignored. `createRuntime()` reads it once and throws `INVALID_QUERY_FEATURES` (500) unless it is `undefined`, `null` or an object whose `operators` is an array of strings. Without a declaration the adapter supports the operators of 3.7.0, `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`, so it never receives a text operator such as `ILIKE` that it would drop or misread. A list, count, aggregate or collection read, or a by-ID read through a `get_by_id` scope, that uses another `QLOperator` value fails with `UNSUPPORTED_QUERY` (400): the runtime checks the client's arguments before middleware runs and the final arguments, with what middleware, scopes and joined scopes added, before it calls the adapter. Operators that `QLOperator` does not list still reach the adapter. An adapter that receives the text operators must apply their rules: literal values, a `String` field or list, and `INVALID_FILTER_VALUE` (400) for a value that is not a string or contains U+0000. `createQueryPlan(models, entity, args, { operators })` validates them and rejects operators outside `operators` with `UNSUPPORTED_QUERY`.
24
+
23
25
  Entity types, those registered with `connect()` and the targets of non-embedded relations, resolve `id` to `_id`, falling back to `id`, whatever the `createSchema()` allowlists include; types used only as embedded values keep their declared `id`. Embedded copies of an entity type resolve the same way, so they return their declared `id` unless the adapter stores an `_id` for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. `createSchema()` marks these registrations with `storedIdentity: true` before calling `adapter.prepare()`.
24
26
 
25
27
  When a runtime first creates a schema, it binds each GraphQL object type whose non-embedded relation fields receive its generated resolvers. It also reserves each reachable object type that still has a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a bound or reserved type in another runtime, or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, throws `TYPE_BOUND_TO_OTHER_RUNTIME` (409). `createSchema()` also rejects a type with a relation field copied, by `toConfig()` or a field spread, after another runtime generated that field's resolver. Create type objects once per runtime, for example with a factory function. Types without non-embedded relation fields, and types whose relation fields all have application resolvers, can be shared, and another runtime can use a `toConfig()` copy made before the first runtime built its schema. A shared custom mutation result type with an unresolved relation field is rejected.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -52,6 +52,7 @@
52
52
  "default": "./src/auth/rules.js"
53
53
  },
54
54
  "./internal/object-id": "./src/auth/object-id.js",
55
+ "./internal/query-operators": "./src/query-operators.js",
55
56
  "./internal/relation-storage": "./src/relation-storage.js",
56
57
  "./plugins": {
57
58
  "types": "./types/plugins.d.ts",
@@ -13,6 +13,12 @@ const QLOperator = new GraphQLEnumType({
13
13
  IN: { value: 'IN' },
14
14
  NIN: { value: 'NIN' },
15
15
  LIKE: { value: 'LIKE' },
16
+ ILIKE: { value: 'ILIKE' },
17
+ STARTS_WITH: { value: 'STARTS_WITH' },
18
+ ISTARTS_WITH: { value: 'ISTARTS_WITH' },
19
+ ENDS_WITH: { value: 'ENDS_WITH' },
20
+ IENDS_WITH: { value: 'IENDS_WITH' },
21
+ IEQ: { value: 'IEQ' },
16
22
  },
17
23
  });
18
24
 
@@ -0,0 +1,80 @@
1
+ import QLOperator from './const/QLOperator.js';
2
+ import SimfinityError from './errors/simfinity.error.js';
3
+
4
+ // Internal: the filter operators and the value rules every backend applies to them. They are shared
5
+ // by the core runtime, the query plan and the MongoDB compiler through the
6
+ // `./internal/query-operators` subpath, which is not public API.
7
+
8
+ /** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
9
+ export const QUERY_OPERATORS = Object.freeze(QLOperator.getValues().map((entry) => entry.value));
10
+
11
+ /**
12
+ * The operators of 3.7.0. An adapter that declares no `queryFeatures` supports exactly these, so a
13
+ * custom adapter written for 3.7 never receives an operator it would drop or misread.
14
+ */
15
+ export const LEGACY_QUERY_OPERATORS = Object.freeze(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
16
+
17
+ /**
18
+ * The literal text operators (#108, part 1) and what each one matches: `contains`, `prefix`,
19
+ * `suffix` or `exact` (the whole value), respecting case or ignoring it (`ignoreCase`). Values are
20
+ * literal: `%`, `_`, `.`, `*`, `\`, `^`, `$` and `(` match themselves. `LIKE`, the case-sensitive
21
+ * substring of 3.7.0, keeps its own validation and compilation and is not listed here.
22
+ */
23
+ export const TEXT_QUERY_OPERATORS = Object.freeze({
24
+ ILIKE: Object.freeze({ match: 'contains', ignoreCase: true }),
25
+ STARTS_WITH: Object.freeze({ match: 'prefix', ignoreCase: false }),
26
+ ISTARTS_WITH: Object.freeze({ match: 'prefix', ignoreCase: true }),
27
+ ENDS_WITH: Object.freeze({ match: 'suffix', ignoreCase: false }),
28
+ IENDS_WITH: Object.freeze({ match: 'suffix', ignoreCase: true }),
29
+ IEQ: Object.freeze({ match: 'exact', ignoreCase: true }),
30
+ });
31
+
32
+ /** Whether `operator` is a `QLOperator` value. */
33
+ export const isQueryOperator = (operator) => QUERY_OPERATORS.includes(operator);
34
+
35
+ /** The {@link TEXT_QUERY_OPERATORS} entry of `operator`, or null for any other operator. */
36
+ export const textQueryOperator = (operator) => (
37
+ typeof operator === 'string' && Object.hasOwn(TEXT_QUERY_OPERATORS, operator) ? TEXT_QUERY_OPERATORS[operator] : null
38
+ );
39
+
40
+ const invalidValue = (message) => { throw new SimfinityError(message, 'INVALID_FILTER_VALUE', 400); };
41
+
42
+ /**
43
+ * Validates the value of a text operator condition before any backend compiles it, so both backends
44
+ * reject the same input with the same code. `scalar` names the effective scalar of the path's last
45
+ * field: the base scalar of a validated scalar (`String` for an email scalar), `Enum` or the enum
46
+ * type's name for an enum, `ID` for identifiers and references. The field must be a `String` field
47
+ * or a `String` list, and the value a string without U+0000; the empty string is valid. Anything
48
+ * else fails with `INVALID_FILTER_VALUE` (400), as `LIKE` does. Returns the value unchanged: it is
49
+ * not passed through the field's scalar, since a validated scalar filters by its base scalar.
50
+ */
51
+ export const validateTextFilterValue = (operator, value, scalar, path) => {
52
+ const where = path == null ? '' : ` on ${Array.isArray(path) ? path.join('.') : path}`;
53
+ if (scalar !== 'String') invalidValue(`${operator} requires a String field${where}`);
54
+ if (typeof value !== 'string') invalidValue(`${operator} requires a non-null string value${where}`);
55
+ if (value.includes('\u0000')) invalidValue(`${operator} values cannot contain U+0000${where}`);
56
+ return value;
57
+ };
58
+
59
+ const invalidFeatures = (message) => {
60
+ throw new SimfinityError(`DatabaseAdapter queryFeatures ${message}`, 'INVALID_QUERY_FEATURES', 500);
61
+ };
62
+
63
+ /**
64
+ * Reads an adapter's `queryFeatures` declaration into the set of operators it compiles. Without a
65
+ * declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}. A declaration
66
+ * is an object whose `operators` is an array of operator names; names this version does not
67
+ * know are ignored, and other members are left for later features. Anything else throws
68
+ * `INVALID_QUERY_FEATURES` (500).
69
+ */
70
+ export const resolveQueryFeatures = (queryFeatures) => {
71
+ if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS) };
72
+ if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
73
+ invalidFeatures('must be an object with an operators array');
74
+ }
75
+ const { operators } = queryFeatures;
76
+ if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
77
+ invalidFeatures('operators must be an array of operator names');
78
+ }
79
+ return { operators: new Set(operators.filter(isQueryOperator)) };
80
+ };
package/src/query-plan.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { paginationStages } from './query-limits.js';
2
2
  import SimfinityError from './errors/simfinity.error.js';
3
+ import { QUERY_OPERATORS, textQueryOperator, validateTextFilterValue } from './query-operators.js';
3
4
 
4
5
  const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
5
- const operators = new Set(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
6
+ const operators = new Set(QUERY_OPERATORS);
6
7
  const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
7
8
  // `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
8
9
  // that name is filtered like any other.
@@ -47,6 +48,40 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
47
48
  return entries;
48
49
  };
49
50
 
51
+ /**
52
+ * Internal: the operator of every condition the arguments hold, as `{ operator, path }` entries: field
53
+ * filters, relation terms and AND/OR group conditions at any depth, with an omitted operator read as
54
+ * `EQ`. `path` names the condition for messages. Sort, pagination and aggregation hold no operators.
55
+ * Malformed entries are skipped and left to the backends; a group reached twice is read once.
56
+ */
57
+ export const collectQueryOperators = (args, operation = 'find') => {
58
+ const entries = [];
59
+ if (!args || typeof args !== 'object') return entries;
60
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
61
+ const add = (item, path) => {
62
+ if (isObject(item)) entries.push({ operator: item.operator ?? 'EQ', path });
63
+ };
64
+ const visited = new Set();
65
+ const visit = (group) => {
66
+ if (!isObject(group) || visited.has(group)) return;
67
+ visited.add(group);
68
+ for (const condition of listOf(group.conditions)) {
69
+ add(condition, condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field);
70
+ }
71
+ for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
72
+ };
73
+ const controls = controlArgs(operation);
74
+ for (const [name, value] of Object.entries(args)) {
75
+ if (controls.has(name) || !isObject(value)) continue;
76
+ // A relation filter's operators are in its terms; an operator beside them is read too, since a
77
+ // backend that reads the field as a scalar would compile it.
78
+ if (!Array.isArray(value.terms) || value.operator !== undefined) add(value, name);
79
+ if (Array.isArray(value.terms)) for (const term of value.terms) add(term, `${name}.${term?.path}`);
80
+ }
81
+ for (const item of [...listOf(args.AND), ...listOf(args.OR)]) visit(item);
82
+ return entries;
83
+ };
84
+
50
85
  /** Internal: the segment arrays of {@link collectQueryPathEntries}. */
51
86
  export const collectQueryPaths = (args, operation = 'find') => (
52
87
  collectQueryPathEntries(args, operation).map((entry) => entry.segments)
@@ -87,8 +122,14 @@ export const resolveModelPath = (models, entityName, path) => {
87
122
  return steps;
88
123
  };
89
124
 
90
- /** Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. */
91
- export const createQueryPlan = (models, entityName, input = {}, { mode = 'find' } = {}) => {
125
+ /**
126
+ * Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. Operators
127
+ * and values are validated here, text operators through validateTextFilterValue. With `operators`,
128
+ * the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
129
+ * without it every QLOperator value is planned.
130
+ */
131
+ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find', operators: supported } = {}) => {
132
+ const allowed = supported == null ? null : new Set(supported);
92
133
  const combine = (kind, terms) => {
93
134
  const active = terms.filter(Boolean);
94
135
  return active.length > 1 ? { kind, terms: active } : active[0] || null;
@@ -99,11 +140,13 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
99
140
  if (field.kind !== 'scalar' && !field.inferred) fail(`Filter on object field ${path} requires a scalar path`, 'MISSING_FILTER_PATH');
100
141
  const op = operator ?? 'EQ';
101
142
  if (!operators.has(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
143
+ if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
102
144
  const collection = ['IN', 'NIN', 'BTW'].includes(op);
103
145
  if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
104
146
  if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
105
147
  if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
106
148
  if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
149
+ if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
107
150
  return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
108
151
  };
109
152
  const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
package/src/runtime.js CHANGED
@@ -13,7 +13,10 @@ import { ForbiddenError } from './auth/errors.js';
13
13
  import QLOperator from './const/QLOperator.js';
14
14
  import QLValue from './const/QLValue.js';
15
15
  import QLSort from './const/QLSort.js';
16
- import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './query-plan.js';
16
+ import {
17
+ collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
18
+ } from './query-plan.js';
19
+ import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
17
20
  import { relationFieldOwners } from './relation-owners.js';
18
21
  import { isThenable } from './auth/thenable.js';
19
22
  import {
@@ -451,6 +454,10 @@ export const createRuntime = (adapter) => {
451
454
  if (!adapter) {
452
455
  throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
453
456
  }
457
+ // The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
458
+ // operators when it declares none.
459
+ const supportedOperators = resolveQueryFeatures(adapter.queryFeatures).operators;
460
+ const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
454
461
 
455
462
  const stateValue = adapter.stateValue || ((state) => state.name);
456
463
  const runtimeIdentity = {};
@@ -1787,6 +1794,21 @@ const assertStoredQueryPaths = (gqltype, args, operation) => {
1787
1794
  }
1788
1795
  };
1789
1796
 
1797
+ // Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
1798
+ // UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile.
1799
+ // Runs on the client's arguments before middleware and on the final arguments before the adapter
1800
+ // call, which covers what middleware, scopes and joined scopes added. Operators that QLOperator does
1801
+ // not list are left to the adapter, which reports them as before.
1802
+ const assertSupportedOperators = (gqltype, args, operation) => {
1803
+ if (supportsEveryOperator) return;
1804
+ for (const { operator, path } of collectQueryOperators(args, operation)) {
1805
+ if (isQueryOperator(operator) && !supportedOperators.has(operator)) {
1806
+ throw new SimfinityError(`The database adapter does not support the ${operator} filter operator, used on `
1807
+ + `${gqltype.name}.${path}`, 'UNSUPPORTED_QUERY', 400);
1808
+ }
1809
+ }
1810
+ };
1811
+
1790
1812
  // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
1791
1813
  // reach a non-embedded relation inside an embedded list, which is joined once per list item.
1792
1814
  const assertJoinedScopePaths = (groups, prefix, target) => {
@@ -1941,6 +1963,7 @@ const resolveById = async (type, args, context, requiredId) => {
1941
1963
  const queryArgs = { id: { operator: 'EQ', value: args.id } };
1942
1964
  await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
1943
1965
  assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
1966
+ assertSupportedOperators(type.gqltype, queryArgs, 'find');
1944
1967
  const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
1945
1968
  return results[0] || null;
1946
1969
  };
@@ -2448,6 +2471,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2448
2471
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
2449
2472
  async resolve(parent, args, context, info) {
2450
2473
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
2474
+ assertSupportedOperators(type.gqltype, args, 'find');
2451
2475
  const params = {
2452
2476
  type,
2453
2477
  args,
@@ -2458,6 +2482,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2458
2482
  await executeScope(params);
2459
2483
  await applyJoinedScopes(joinedScopes, params);
2460
2484
  assertStoredQueryPaths(type.gqltype, params.args, 'find');
2485
+ assertSupportedOperators(type.gqltype, params.args, 'find');
2461
2486
  const queryArgs = params.args;
2462
2487
  const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2463
2488
  && canReportCount(parent, info, context);
@@ -2494,6 +2519,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2494
2519
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
2495
2520
  async resolve(parent, args, context) {
2496
2521
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
2522
+ assertSupportedOperators(type.gqltype, args, 'aggregate');
2497
2523
  const params = {
2498
2524
  type,
2499
2525
  args,
@@ -2504,6 +2530,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2504
2530
  await executeScope(params);
2505
2531
  await applyJoinedScopes(joinedScopes, params);
2506
2532
  assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
2533
+ assertSupportedOperators(type.gqltype, params.args, 'aggregate');
2507
2534
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2508
2535
  },
2509
2536
  };
@@ -2953,11 +2980,13 @@ const autoGenerateResolvers = (gqltype) => {
2953
2980
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
2954
2981
  }
2955
2982
  const joinedScopes = inspectClientPaths(relatedType, args, 'find');
2983
+ assertSupportedOperators(relatedType, args, 'find');
2956
2984
  const params = { type: relatedTypeInfo, args, operation: 'find', context };
2957
2985
  await executeMiddleware(params);
2958
2986
  await executeScope(params);
2959
2987
  await applyJoinedScopes(joinedScopes, params);
2960
2988
  assertStoredQueryPaths(relatedType, params.args, 'find');
2989
+ assertSupportedOperators(relatedType, params.args, 'find');
2961
2990
  return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
2962
2991
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
2963
2992
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
package/types/index.d.ts CHANGED
@@ -8,10 +8,36 @@ import type {
8
8
  GraphQLSchema,
9
9
  } from 'graphql';
10
10
 
11
+ /** The filter operator enum; its values are the {@link QueryOperator} names. */
11
12
  export const QLOperator: GraphQLEnumType;
12
13
  export const QLSort: GraphQLInputObjectType;
13
14
  export const QLValue: GraphQLScalarType<unknown, unknown>;
14
15
 
16
+ /**
17
+ * A `QLOperator` value. The operators of 3.7.0 are `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`,
18
+ * `NIN` and `LIKE`. The literal text operators take a non-null string without U+0000 on a `String`
19
+ * field or `String` list (a validated scalar filters by its base scalar), and match it literally:
20
+ * `ILIKE` as a substring ignoring case, `STARTS_WITH` and `ISTARTS_WITH` as a prefix, `ENDS_WITH`
21
+ * and `IENDS_WITH` as a suffix, respecting or ignoring case, and `IEQ` as the whole value ignoring
22
+ * case. An empty value matches every non-null string, except for `IEQ`, where it matches only the
23
+ * empty string. On a list, some element must match.
24
+ */
25
+ export type QueryOperator =
26
+ | 'EQ' | 'NE' | 'LT' | 'LTE' | 'GT' | 'GTE' | 'BTW' | 'IN' | 'NIN' | 'LIKE'
27
+ | 'ILIKE' | 'STARTS_WITH' | 'ISTARTS_WITH' | 'ENDS_WITH' | 'IENDS_WITH' | 'IEQ';
28
+
29
+ /**
30
+ * The query language features a {@link DatabaseAdapter} compiles. Declare it so that the runtime
31
+ * sends the adapter only conditions it compiles; later releases add members for later features.
32
+ */
33
+ export interface QueryFeatures {
34
+ /**
35
+ * The `QLOperator` values the adapter compiles, including an omitted operator's `EQ`. Names this
36
+ * version of Simfinity does not know are ignored.
37
+ */
38
+ operators: readonly (QueryOperator | (string & {}))[];
39
+ }
40
+
15
41
  export interface ModelRegistration {
16
42
  gqltype: GraphQLObjectType;
17
43
  /** Defaults to true. False excludes embedded-only types until referenced. */
@@ -331,6 +357,18 @@ export interface Runtime<Model = any, Session = any> {
331
357
 
332
358
  /** Driver adapter contract. GraphQL generation and lifecycle live in createRuntime. */
333
359
  export interface DatabaseAdapter<Model = any, Session = any> {
360
+ /**
361
+ * Optional. The filter operators this adapter compiles. `createRuntime` reads it once and throws
362
+ * `INVALID_QUERY_FEATURES` (500) unless it is undefined, null or an object whose `operators` is an
363
+ * array of strings. Without it the adapter supports the operators of 3.7.0 (`EQ`, `NE`, `LT`,
364
+ * `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`). A list, aggregate or collection read, or a
365
+ * by-ID read through a `get_by_id` scope, whose arguments use a `QLOperator` value the adapter
366
+ * does not support fails with `UNSUPPORTED_QUERY` (400), before middleware runs for the client's
367
+ * arguments and before `find`, `count`, `aggregate`, `findChildren` or `findChildrenBatch` for
368
+ * conditions that middleware, scopes and joined scopes added. Operators outside `QLOperator` still
369
+ * reach the adapter, which reports them as before.
370
+ */
371
+ queryFeatures?: QueryFeatures | null | undefined;
334
372
  bind?(runtime: Pick<Runtime<Model, Session>, 'getModel' | 'getType' | 'getRegistrations'>): void;
335
373
  validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
336
374
  /**
@@ -448,6 +486,7 @@ export interface DatabaseAdapter<Model = any, Session = any> {
448
486
  }
449
487
  export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapter<Model, Session>): Runtime<Model, Session>;
450
488
 
489
+ /** `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. */
451
490
  export type QueryPredicate = { kind: 'predicate'; path: string[]; operator: string; value: any } | { kind: 'and' | 'or'; terms: QueryPredicate[] };
452
491
  export interface QueryPlan {
453
492
  entity: string;
@@ -457,7 +496,14 @@ export interface QueryPlan {
457
496
  pagination: { limit: number; offset: number } | null;
458
497
  aggregation?: { groupId: string[]; facts: Array<{ path: string[]; factName: string; operation: 'SUM' | 'COUNT' | 'AVG' | 'MIN' | 'MAX' }> };
459
498
  }
460
- export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode'] }): QueryPlan;
499
+ /**
500
+ * Validates the arguments into a plan: paths, operators and values, with `INVALID_FILTER_VALUE` (400)
501
+ * for a text operator on a field that is not a `String` field or list, or with a value that is not
502
+ * a string or contains U+0000. With `operators`, the names the caller compiles, any other
503
+ * `QLOperator` value fails with `UNSUPPORTED_QUERY` (400); unknown operators fail with
504
+ * `INVALID_FILTER_OPERATOR` (400) either way.
505
+ */
506
+ export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string> }): QueryPlan;
461
507
  export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
462
508
 
463
509
  export function configureQueryLimits(options?: { maxPageSize?: number }): void;