@simtlix/simfinity-core 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 -3
- package/package.json +1 -1
- package/src/query-emptiness.js +109 -34
- package/src/query-limits.js +14 -2
- package/src/query-operators.js +15 -10
- package/src/query-plan.js +103 -39
- package/src/runtime.js +553 -88
- package/types/index.d.ts +136 -14
package/README.md
CHANGED
|
@@ -8,20 +8,21 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
|
|
|
8
8
|
|
|
9
9
|
## Runtime
|
|
10
10
|
|
|
11
|
-
`createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom queries and mutations, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
|
|
11
|
+
`createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom queries and mutations, views, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
|
|
12
12
|
|
|
13
13
|
- `connect`, `addNoEndpointType`, and `createSchema` for registration and schema creation;
|
|
14
14
|
- `getModel`, `getType`, `getInputType`, and `getRegistrations` for runtime inspection;
|
|
15
15
|
- `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
|
|
16
16
|
- `registerCustomQuery` for reads whose results are not stored records, such as previews, other sources or permission evaluations;
|
|
17
|
+
- `registerView` for a named list read of one registered type, with params and a fixed predicate, run through the type's scope;
|
|
17
18
|
- `preventCreatingCollection` to pass validation-only storage setup to an adapter;
|
|
18
|
-
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
|
|
19
|
+
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size, reference-frame and nested-operation limits.
|
|
19
20
|
|
|
20
21
|
The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. The GraphQL types that `prepare()` and `createModel()` receive still contain virtual fields, which must never be stored: an adapter that builds storage from a type's fields skips each field for which `isVirtualField(field)`, exported by this package with `virtualFieldOptions(field)`, returns true, on entities and embedded types alike, or builds it from `describeModels()`, which leaves them out. Generated writes, filters, sorts and aggregations never name a virtual field, but a controller hook can still assign one. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. An adapter can define `readByIdIsInert(model)`, returning `true` when a `getById` of that model has no side effects and `update()` and `delete()` reject the same malformed IDs with `NOT_VALID_ID` (400) and return null for a missing record; the runtime then skips the reads before deletes without an `onDelete` hook and before updates that merge no embedded value and have neither `onUpdating` nor nested collection items. The SQL adapter always reports it, and the MongoDB adapter does for models without `find`, `findOne`, `findOneAndUpdate` or `findOneAndDelete` middleware and without application `_id` setters or overrides of the statics those reads and writes call. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError` (500), since the client did not send the value. `update()` resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its `_id`, else `id`, else the requested ID. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. Simfinity then reads as `null` an object that holds no data when a required member would read as `null`, not counting `id`, `readOnly` and virtual members, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same. It reads the stored members, never calling a getter, and counts a stored `_id` as `id`; it reads nothing from objects whose type has no member that counts, and an error while reading returns the object unchanged. An adapter whose hydrated records render members through getters can also define `rawEmbeddedValue(value)`, which returns a record's stored data as a plain object, or the record unchanged when that data does not show what it renders; a value that is not a plain object counts as data. The MongoDB adapter defines both.
|
|
21
22
|
|
|
22
23
|
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.
|
|
23
24
|
|
|
24
|
-
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
|
|
25
|
+
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 and whose optional `referenceEmptiness` is a boolean, `undefined` or `null`. 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`, or an emptiness operator, `IS_EMPTY` or `IS_NOT_EMPTY`, 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`. An adapter that declares `IS_EMPTY` and `IS_NOT_EMPTY` must compile their semantics on the queried record's own fields, where `IS_NOT_EMPTY` is the exact complement of `IS_EMPTY`: a `String` is empty when missing, `null` or `""`, any other scalar or enum when missing or `null`, a list when missing, `null` or `[]`, and an embedded object when missing, `null` or every stored member is empty. The runtime rejects a value other than `null` and a path inside a list before it calls the adapter, and also emptiness through references and collections unless the adapter declares `referenceEmptiness: true`, so the adapter receives conditions whose `value` may be absent and whose term or group `path` may be omitted, `null` or `""` to name the field itself; `createQueryPlan` plans them as `{ kind: 'predicate', path, operator, value: null }`, whose `path` may end at a scalar list, an embedded list or an embedded object. See [empty values](https://simtlix.github.io/simfinity.js/guide/queries.html#empty-values). An adapter that declares `referenceEmptiness: true` also receives emptiness conditions whose path crosses single references and ends at a reference or a collection, and embedded objects with reference and collection members. A client's such term or group condition carries the key `__scope: { id, scopes }`, where `scopes` holds, for each reference it crosses and each reference, collection or member it ends at or reads, keyed by the dotted path from the queried record, the arguments that the target type's `find` scope produced, or `null` when it added nothing. The adapter must join a crossed reference only where that scope admits the target, and count at an end or member only the records that the scope admits, so that a record the caller may not see reads as a missing one, also on a later hop with a `null` scope; a condition without `__scope` comes from middleware or a scope and reads stored data. `createQueryPlan(models, entity, args, { operators, referenceEmptiness: true })` lifts each filled `__scope` into the predicate's `scopes`, planning each scope on its target type, and throws `DETACHED_CLIENT_FILTER` (500) for a `__scope` that lacks a path; its `referenceEmptiness` option defaults to `true` when `operators` is omitted and to `false` otherwise. See [references and collections](https://simtlix.github.io/simfinity.js/guide/queries.html#references-and-collections).
|
|
25
26
|
|
|
26
27
|
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()`.
|
|
27
28
|
|
package/package.json
CHANGED
package/src/query-emptiness.js
CHANGED
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
} from 'graphql';
|
|
4
4
|
import SimfinityError from './errors/simfinity.error.js';
|
|
5
5
|
import { effectiveScalarName } from './metadata.js';
|
|
6
|
-
import { getListShape, isVirtualField } from './relation-storage.js';
|
|
6
|
+
import { getListShape, isVirtualField, normalizeConnectionField } from './relation-storage.js';
|
|
7
7
|
|
|
8
|
-
// Internal: what the emptiness operators IS_EMPTY and IS_NOT_EMPTY (#108,
|
|
9
|
-
// shared by the runtime gate, the query plan and the MongoDB compiler through the
|
|
8
|
+
// Internal: what the emptiness operators IS_EMPTY and IS_NOT_EMPTY (#108, parts 2a and 2b) mean for a
|
|
9
|
+
// path, shared by the runtime gate, the query plan and the MongoDB compiler through the
|
|
10
10
|
// `./internal/query-operators` subpath, which re-exports it and is not public API. Each backend
|
|
11
11
|
// compiles the shape described here to its own forms.
|
|
12
12
|
|
|
@@ -32,15 +32,19 @@ export const validateEmptinessValue = (operator, value, path) => {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
const PATH_SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
35
|
-
// The record identity,
|
|
36
|
-
//
|
|
35
|
+
// The record identity, an entity's own `id` or `_id`, is never empty in practice: PostgreSQL stores it
|
|
36
|
+
// as a uuid primary key even when it is declared String. It is the queried entity's at the root, and
|
|
37
|
+
// the target's right after a single reference.
|
|
37
38
|
const IDENTITY = ['id', '_id'];
|
|
38
39
|
const LISTS = { embeddedList: 'an embedded list', collection: 'a collection' };
|
|
39
40
|
|
|
40
41
|
// A path step or a member, classified as MongoDB reads paths: interface and union types first, then an
|
|
41
42
|
// object field without a relation or with `embedded: true` is embedded, and any other object field is
|
|
42
43
|
// a collection when it is a list and a reference otherwise. `node` is what members are read from (an
|
|
43
|
-
// object type, or a description's `fields`), and `string` tells a String scalar.
|
|
44
|
+
// object type, or a description's `fields`), and `string` tells a String scalar. A reference or a
|
|
45
|
+
// collection names its `target` type, and a collection the child's storage column that links it,
|
|
46
|
+
// `connectionField`, derived as MongoDB derives it; without a declared connectionField a collection
|
|
47
|
+
// reads through its own field name.
|
|
44
48
|
const graphqlStep = (name, field) => {
|
|
45
49
|
const type = getNamedType(field.type);
|
|
46
50
|
const list = !!getListShape(field.type);
|
|
@@ -48,9 +52,13 @@ const graphqlStep = (name, field) => {
|
|
|
48
52
|
if (isObjectType(type)) {
|
|
49
53
|
const relation = field.extensions?.relation;
|
|
50
54
|
const embedded = !relation || relation.embedded;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
const kind = embedded ? (list ? 'embeddedList' : 'embedded') : (list ? 'collection' : 'reference');
|
|
56
|
+
const step = { name, typeName: type.name, node: type, kind };
|
|
57
|
+
if (kind === 'reference') step.target = type.name;
|
|
58
|
+
if (kind === 'collection') {
|
|
59
|
+
Object.assign(step, { target: type.name, connectionField: normalizeConnectionField(type, relation.connectionField || name).storageFieldName });
|
|
60
|
+
}
|
|
61
|
+
return step;
|
|
54
62
|
}
|
|
55
63
|
return { name, kind: list ? 'scalarList' : 'scalar', string: isScalarType(type) && effectiveScalarName(type) === 'String' };
|
|
56
64
|
};
|
|
@@ -59,30 +67,40 @@ const graphqlMembers = (type) => Object.entries(type.getFields())
|
|
|
59
67
|
.filter(([, field]) => !isVirtualField(field)).map(([name, field]) => graphqlStep(name, field));
|
|
60
68
|
|
|
61
69
|
// The same classification of a describeModels field description: inferred inverse references are ID
|
|
62
|
-
// scalars, as GraphQL exposes them. Descriptions hold no interface or union fields
|
|
70
|
+
// scalars, as GraphQL exposes them. Descriptions hold no interface or union fields, and describeModels
|
|
71
|
+
// has already normalized a collection's connectionField to the child's storage column.
|
|
63
72
|
const modelStep = (field) => {
|
|
64
73
|
if (field.kind === 'embedded') return { name: field.name, kind: field.list ? 'embeddedList' : 'embedded', node: field.fields ?? [] };
|
|
65
|
-
if (field.kind === 'collection') return { name: field.name, kind: 'collection' };
|
|
66
|
-
if (field.kind === 'reference' && !field.inferred) return { name: field.name, kind: 'reference' };
|
|
74
|
+
if (field.kind === 'collection') return { name: field.name, kind: 'collection', target: field.target, connectionField: field.connectionField };
|
|
75
|
+
if (field.kind === 'reference' && !field.inferred) return { name: field.name, kind: 'reference', target: field.target };
|
|
67
76
|
return { name: field.name, kind: field.list ? 'scalarList' : 'scalar', string: field.scalar === 'String' };
|
|
68
77
|
};
|
|
69
78
|
const modelMembers = (fields) => fields.filter((field) => !field.private).map(modelStep);
|
|
70
79
|
|
|
71
|
-
const
|
|
80
|
+
const isRelation = (step) => step.kind === 'reference' || step.kind === 'collection';
|
|
81
|
+
const leafShape = (step, identity) => {
|
|
72
82
|
if (step.kind === 'scalarList' || step.kind === 'embeddedList') return { rule: 'list' };
|
|
73
|
-
if (
|
|
83
|
+
if (identity && IDENTITY.includes(step.name)) return { rule: 'null' };
|
|
74
84
|
return { rule: step.string ? 'string' : 'null' };
|
|
75
85
|
};
|
|
86
|
+
const relationShape = (step) => (step.kind === 'reference'
|
|
87
|
+
? { rule: 'reference', target: step.target }
|
|
88
|
+
: { rule: 'collection', target: step.target, connectionField: step.connectionField });
|
|
76
89
|
|
|
77
90
|
// Rules 3 to 6 of the order of checks, on a path whose fields resolved (rule 1): rule 3 throws as
|
|
78
91
|
// soon as it is found; a cycle (rule 4) and a reference or collection (rule 6) are recorded, are not
|
|
79
92
|
// entered, and throw only once the whole path and its members passed rule 3, with the value (rule 5)
|
|
80
|
-
// checked between them.
|
|
81
|
-
|
|
93
|
+
// checked between them. Rule 6 throws only when the caller does not support reference emptiness;
|
|
94
|
+
// otherwise a reference or collection end or member gets its own shape, and each single reference the
|
|
95
|
+
// path crosses is listed in `crossings`.
|
|
96
|
+
const describeSteps = (steps, {
|
|
97
|
+
operator, value, label, membersOf, referenceEmptiness,
|
|
98
|
+
}) => {
|
|
82
99
|
const segments = steps.map((step) => step.name);
|
|
83
100
|
const prefix = (index) => segments.slice(0, index + 1).join('.');
|
|
84
101
|
let cycle = null;
|
|
85
102
|
let unsupported = null;
|
|
103
|
+
const crossings = [];
|
|
86
104
|
const abstract = (path) => fail(`${operator} cannot test ${label}: ${path} is an interface or union field, whose emptiness is not defined`, 'INVALID_FILTER_PATH');
|
|
87
105
|
for (const [index, step] of steps.entries()) {
|
|
88
106
|
const list = index > 0 ? LISTS[steps[index - 1].kind] : null;
|
|
@@ -90,19 +108,23 @@ const describeSteps = (steps, { operator, value, label, membersOf }) => {
|
|
|
90
108
|
fail(`${operator} cannot test ${label}: emptiness of a value inside a list is not defined, and ${prefix(index - 1)} is ${list}`, 'INVALID_FILTER_PATH');
|
|
91
109
|
}
|
|
92
110
|
if (step.kind === 'abstract') abstract(prefix(index));
|
|
93
|
-
if (!unsupported && (step
|
|
111
|
+
if (!unsupported && isRelation(step)) {
|
|
94
112
|
unsupported = `${operator} on ${label} ${index === steps.length - 1 ? 'ends at' : 'crosses'} the ${step.kind} ${prefix(index)}`;
|
|
95
113
|
}
|
|
114
|
+
// Only a single reference can be crossed: rule 3 rejects any step after a collection.
|
|
115
|
+
if (step.kind === 'reference' && index < steps.length - 1) crossings.push({ path: prefix(index), target: step.target });
|
|
96
116
|
}
|
|
97
117
|
// The member rule: each stored member by its own rule, single embedded members recursively, lists
|
|
98
|
-
// by the list rule without looking inside their items
|
|
118
|
+
// by the list rule without looking inside their items, references and collections by their own
|
|
119
|
+
// rules without entering them.
|
|
99
120
|
const expand = (node, base, ancestors) => {
|
|
100
121
|
const members = [];
|
|
101
122
|
for (const member of membersOf(node)) {
|
|
102
123
|
const memberPath = [...base, member.name].join('.');
|
|
103
124
|
if (member.kind === 'abstract') abstract(memberPath);
|
|
104
|
-
if (member
|
|
125
|
+
if (isRelation(member)) {
|
|
105
126
|
unsupported ??= `${operator} on ${label} reads the ${member.kind} member ${memberPath}`;
|
|
127
|
+
members.push({ name: member.name, shape: relationShape(member) });
|
|
106
128
|
} else if (member.kind !== 'embedded') {
|
|
107
129
|
members.push({ name: member.name, shape: leafShape(member, false) });
|
|
108
130
|
} else if (ancestors.includes(member.node)) {
|
|
@@ -114,15 +136,16 @@ const describeSteps = (steps, { operator, value, label, membersOf }) => {
|
|
|
114
136
|
return members;
|
|
115
137
|
};
|
|
116
138
|
const last = steps.at(-1);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
let shape;
|
|
140
|
+
if (last.kind === 'embedded') shape = { rule: 'object', members: expand(last.node, segments, [last.node]) };
|
|
141
|
+
else if (isRelation(last)) shape = relationShape(last);
|
|
142
|
+
else shape = leafShape(last, steps.length === 1 || steps.at(-2).kind === 'reference');
|
|
120
143
|
if (cycle) fail(cycle, 'INVALID_FILTER_OPERATOR');
|
|
121
144
|
validateEmptinessValue(operator, value, label);
|
|
122
|
-
if (unsupported) {
|
|
123
|
-
fail(`${unsupported}: emptiness through references and collections
|
|
145
|
+
if (unsupported && !referenceEmptiness) {
|
|
146
|
+
fail(`${unsupported}: the database adapter does not support emptiness through references and collections`, 'UNSUPPORTED_QUERY');
|
|
124
147
|
}
|
|
125
|
-
return shape;
|
|
148
|
+
return crossings.length ? { ...shape, crossings } : shape;
|
|
126
149
|
};
|
|
127
150
|
|
|
128
151
|
// Rule 1 on GraphQL types: segment syntax and declared, stored fields, with resolveModelPath's codes
|
|
@@ -156,17 +179,29 @@ const graphqlSteps = (gqltype, segments, path) => {
|
|
|
156
179
|
* then rule 3 (INVALID_FILTER_PATH for a segment after an embedded list or a collection, and for an
|
|
157
180
|
* interface or union field on the path or among the members), rule 4 (INVALID_FILTER_OPERATOR when
|
|
158
181
|
* the members re-enter an embedded type through single embedded members), rule 5 (the value, as
|
|
159
|
-
* validateEmptinessValue checks it) and rule 6 (UNSUPPORTED_QUERY
|
|
160
|
-
* reference or a collection, or a member is one
|
|
161
|
-
*
|
|
182
|
+
* validateEmptinessValue checks it) and, with `referenceEmptiness: false`, rule 6 (UNSUPPORTED_QUERY
|
|
183
|
+
* when the path crosses or ends at a reference or a collection, or a member is one, for a caller whose
|
|
184
|
+
* database adapter does not support reference emptiness, #108 part 2b). Form, depth and the adapter's
|
|
185
|
+
* operators are the caller's checks. `segments` is an array or a dotted string; `path` names the
|
|
186
|
+
* condition in messages.
|
|
162
187
|
*
|
|
163
188
|
* Returns the shape: `{ rule: 'string' }` (missing, null or ""), `{ rule: 'null' }` (missing or
|
|
164
|
-
* null; the record identity
|
|
165
|
-
* `{ rule: '
|
|
189
|
+
* null; the record identity, at the root or right after a single reference, always, whatever scalar
|
|
190
|
+
* it is declared with), `{ rule: 'list' }` (missing, null or []), `{ rule: 'object', members: [{
|
|
191
|
+
* name, shape }] }` (missing, null, or every stored member empty), `{ rule: 'reference', target }`
|
|
192
|
+
* (no record the reference points to) or `{ rule: 'collection', target, connectionField }` (no child
|
|
193
|
+
* record whose `connectionField`, the child's storage column, points to the record); `target` is the
|
|
194
|
+
* type name. The members of an object take these shapes too. When the path crosses single references,
|
|
195
|
+
* the shape also lists them in path order as `crossings: [{ path, target }]`, with `path` the dotted
|
|
196
|
+
* path of the reference from the queried type; emptinessScopePaths reads them.
|
|
166
197
|
*/
|
|
167
|
-
export const describeEmptiness = (gqltype, segments, {
|
|
198
|
+
export const describeEmptiness = (gqltype, segments, {
|
|
199
|
+
operator = 'IS_EMPTY', value, path, referenceEmptiness = true,
|
|
200
|
+
} = {}) => {
|
|
168
201
|
const { steps, label } = graphqlSteps(gqltype, segments, path);
|
|
169
|
-
return describeSteps(steps, {
|
|
202
|
+
return describeSteps(steps, {
|
|
203
|
+
operator, value, label, membersOf: graphqlMembers, referenceEmptiness,
|
|
204
|
+
});
|
|
170
205
|
};
|
|
171
206
|
|
|
172
207
|
/**
|
|
@@ -183,13 +218,53 @@ export const assertEmptinessPath = (gqltype, segments, { path } = {}) => {
|
|
|
183
218
|
* `steps` are the field descriptions resolveModelPath returns for the path, so rule 1 is already
|
|
184
219
|
* done. Gives the shapes, codes and messages of {@link describeEmptiness} for the same types.
|
|
185
220
|
*/
|
|
186
|
-
export const describeModelEmptiness = (steps, {
|
|
221
|
+
export const describeModelEmptiness = (steps, {
|
|
222
|
+
operator = 'IS_EMPTY', value, path, referenceEmptiness = true,
|
|
223
|
+
} = {}) => {
|
|
187
224
|
if (!Array.isArray(steps) || !steps.length) fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
188
225
|
const classified = steps.map(modelStep);
|
|
189
226
|
const label = path == null ? classified.map((step) => step.name).join('.') : pathName(path);
|
|
190
|
-
return describeSteps(classified, {
|
|
227
|
+
return describeSteps(classified, {
|
|
228
|
+
operator, value, label, membersOf: modelMembers, referenceEmptiness,
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// The scoped joins and frames of one emptiness construct, as `[{ path, kind, target }]`: each crossing
|
|
233
|
+
// in path order, then the end and the reference and collection members, depth first in field order.
|
|
234
|
+
const scopeEntries = (shape, path) => {
|
|
235
|
+
const entries = (shape?.crossings ?? []).map(({ path: crossing, target }) => ({ path: crossing, kind: 'join', target }));
|
|
236
|
+
const visit = (node, at) => {
|
|
237
|
+
if (node?.rule === 'reference' || node?.rule === 'collection') entries.push({ path: at, kind: 'frame', target: node.target });
|
|
238
|
+
else if (node?.rule === 'object') for (const member of node.members) visit(member.shape, `${at}.${member.name}`);
|
|
239
|
+
};
|
|
240
|
+
visit(shape, pathName(path));
|
|
241
|
+
return entries;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The reference and collection paths that one emptiness construct reads, from the shape that
|
|
246
|
+
* describeEmptiness or describeModelEmptiness gave for its `path` (an array or a dotted string):
|
|
247
|
+
* `joins` are the single references the path crosses (one scoped join each, for a client construct),
|
|
248
|
+
* and `frames` the end when it is a reference or a collection, then each reference and collection
|
|
249
|
+
* member (one frame each). Every path is dotted and starts at the queried type. A construct on the
|
|
250
|
+
* record's own fields gives two empty lists.
|
|
251
|
+
*/
|
|
252
|
+
export const emptinessScopePaths = (shape, path) => {
|
|
253
|
+
const entries = scopeEntries(shape, path);
|
|
254
|
+
return {
|
|
255
|
+
joins: entries.filter((entry) => entry.kind === 'join').map((entry) => entry.path),
|
|
256
|
+
frames: entries.filter((entry) => entry.kind === 'frame').map((entry) => entry.path),
|
|
257
|
+
};
|
|
191
258
|
};
|
|
192
259
|
|
|
260
|
+
/**
|
|
261
|
+
* The target type name of every path {@link emptinessScopePaths} lists, as `{ [path]: target }`,
|
|
262
|
+
* joins first: the type whose `find` scope restricts that join or frame.
|
|
263
|
+
*/
|
|
264
|
+
export const emptinessScopeTargets = (shape, path) => Object.fromEntries(
|
|
265
|
+
scopeEntries(shape, path).map((entry) => [entry.path, entry.target]),
|
|
266
|
+
);
|
|
267
|
+
|
|
193
268
|
/**
|
|
194
269
|
* The stored member paths an emptiness condition reads when its path, on `gqltype`, ends at a single
|
|
195
270
|
* embedded object: absolute segment arrays, depth first in field order, entering single embedded
|
package/src/query-limits.js
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
import SimfinityError from './errors/simfinity.error.js';
|
|
2
2
|
|
|
3
3
|
let queryMaxPageSize = 1000;
|
|
4
|
+
let queryMaxReferenceFrames = 10;
|
|
4
5
|
|
|
6
|
+
const isPositiveSafeInteger = (value) => Number.isSafeInteger(value) && value >= 1;
|
|
7
|
+
|
|
8
|
+
// Process-wide. Each omitted option takes its default again, and every option is checked before any
|
|
9
|
+
// is assigned, so a call that fails keeps every current limit. `maxReferenceFrames` caps, per resolver
|
|
10
|
+
// call, the distinct scoped joins and frames of a client's reference emptiness (#108 part 2b).
|
|
5
11
|
export const configureQueryLimits = (options = {}) => {
|
|
6
12
|
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
7
13
|
|| ![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
|
|
8
14
|
throw new SimfinityError('Query limit options must be a plain object', 'INVALID_QUERY_LIMITS', 400);
|
|
9
15
|
}
|
|
10
|
-
const { maxPageSize = 1000 } = options;
|
|
11
|
-
if (!
|
|
16
|
+
const { maxPageSize = 1000, maxReferenceFrames = 10 } = options;
|
|
17
|
+
if (!isPositiveSafeInteger(maxPageSize)) {
|
|
12
18
|
throw new SimfinityError('maxPageSize must be a positive safe integer', 'INVALID_QUERY_LIMITS', 400);
|
|
13
19
|
}
|
|
20
|
+
if (!isPositiveSafeInteger(maxReferenceFrames)) {
|
|
21
|
+
throw new SimfinityError('maxReferenceFrames must be a positive safe integer', 'INVALID_QUERY_LIMITS', 400);
|
|
22
|
+
}
|
|
14
23
|
queryMaxPageSize = maxPageSize;
|
|
24
|
+
queryMaxReferenceFrames = maxReferenceFrames;
|
|
15
25
|
};
|
|
16
26
|
|
|
17
27
|
export const getQueryMaxPageSize = () => queryMaxPageSize;
|
|
18
28
|
|
|
29
|
+
export const getQueryMaxReferenceFrames = () => queryMaxReferenceFrames;
|
|
30
|
+
|
|
19
31
|
export const paginationStages = (pagination, withDefault) => {
|
|
20
32
|
if (pagination == null) return withDefault ? [{ $skip: 0 }, { $limit: Math.min(100, queryMaxPageSize) }] : [];
|
|
21
33
|
const { page, size } = pagination;
|
package/src/query-operators.js
CHANGED
|
@@ -7,8 +7,8 @@ import SimfinityError from './errors/simfinity.error.js';
|
|
|
7
7
|
// query-emptiness.js and are re-exported here, so the subpath stays the only one.
|
|
8
8
|
|
|
9
9
|
export {
|
|
10
|
-
EMPTINESS_QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths,
|
|
11
|
-
validateEmptinessValue,
|
|
10
|
+
EMPTINESS_QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopePaths,
|
|
11
|
+
emptinessScopeTargets, isEmptinessOperator, validateEmptinessValue,
|
|
12
12
|
} from './query-emptiness.js';
|
|
13
13
|
export { effectiveScalarName } from './metadata.js';
|
|
14
14
|
|
|
@@ -68,20 +68,25 @@ const invalidFeatures = (message) => {
|
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Reads an adapter's `queryFeatures` declaration into
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
71
|
+
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness }`: the set of
|
|
72
|
+
* operators it compiles, and whether it compiles emptiness through references and collections (#108
|
|
73
|
+
* part 2b). Without a declaration (undefined or null) the adapter supports {@link
|
|
74
|
+
* LEGACY_QUERY_OPERATORS} and no reference emptiness. A declaration is an object whose `operators`
|
|
75
|
+
* is an array of operator names, whose names this version does not know are ignored, and whose
|
|
76
|
+
* optional `referenceEmptiness` is a boolean, undefined or null (false); other members are left for
|
|
77
|
+
* later features. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
76
78
|
*/
|
|
77
79
|
export const resolveQueryFeatures = (queryFeatures) => {
|
|
78
|
-
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS) };
|
|
80
|
+
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false };
|
|
79
81
|
if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
|
|
80
82
|
invalidFeatures('must be an object with an operators array');
|
|
81
83
|
}
|
|
82
|
-
const { operators } = queryFeatures;
|
|
84
|
+
const { operators, referenceEmptiness } = queryFeatures;
|
|
83
85
|
if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
|
|
84
86
|
invalidFeatures('operators must be an array of operator names');
|
|
85
87
|
}
|
|
86
|
-
|
|
88
|
+
if (referenceEmptiness != null && typeof referenceEmptiness !== 'boolean') {
|
|
89
|
+
invalidFeatures('referenceEmptiness must be a boolean');
|
|
90
|
+
}
|
|
91
|
+
return { operators: new Set(operators.filter(isQueryOperator)), referenceEmptiness: referenceEmptiness === true };
|
|
87
92
|
};
|