@simtlix/simfinity-core 3.10.0 → 3.11.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 +2 -2
- 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 +276 -39
- package/types/index.d.ts +67 -14
package/README.md
CHANGED
|
@@ -15,13 +15,13 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
|
|
|
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
17
|
- `preventCreatingCollection` to pass validation-only storage setup to an adapter;
|
|
18
|
-
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
|
|
18
|
+
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size, reference-frame and nested-operation limits.
|
|
19
19
|
|
|
20
20
|
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
21
|
|
|
22
22
|
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
23
|
|
|
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
|
|
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 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
25
|
|
|
26
26
|
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
27
|
|
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
|
};
|
package/src/query-plan.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { paginationStages } from './query-limits.js';
|
|
2
2
|
import SimfinityError from './errors/simfinity.error.js';
|
|
3
3
|
import {
|
|
4
|
-
QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths,
|
|
5
|
-
validateTextFilterValue,
|
|
4
|
+
QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator,
|
|
5
|
+
textQueryOperator, validateTextFilterValue,
|
|
6
6
|
} from './query-operators.js';
|
|
7
7
|
|
|
8
8
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
9
|
+
// A client scope marker that cannot be honored: only the runtime writes `__scope` on a term or group
|
|
10
|
+
// condition, and fills it after middleware (#108 part 2b).
|
|
11
|
+
const detached = (message) => {
|
|
12
|
+
throw new SimfinityError(`${message}; only the runtime writes and fills client scope markers (docs/guide/middleware.md)`, 'DETACHED_CLIENT_FILTER', 500);
|
|
13
|
+
};
|
|
9
14
|
const operators = new Set(QUERY_OPERATORS);
|
|
10
15
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
11
16
|
// `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
|
|
@@ -19,37 +24,45 @@ const isPathless = (path) => path == null || path === '';
|
|
|
19
24
|
const hasTermPath = (term) => typeof term?.path === 'string' && term.path !== '';
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
|
-
* Internal: the model paths named by generated query arguments, as `{ segments, group
|
|
23
|
-
* Covers filter keys and relation terms, AND/OR conditions, list sort terms,
|
|
24
|
-
* groupId and fact paths; aggregate sort terms name result keys. Outside aggregate
|
|
25
|
-
* named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
26
|
-
* object whose `conditions` hold the path, or null for the other forms.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Internal: the model paths named by generated query arguments, as `{ segments, group, operator,
|
|
28
|
+
* construct }` entries. Covers filter keys and relation terms, AND/OR conditions, list sort terms,
|
|
29
|
+
* and aggregation groupId and fact paths; aggregate sort terms name result keys. Outside aggregate
|
|
30
|
+
* mode a key named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
31
|
+
* object whose `conditions` hold the path, or null for the other forms. `operator` is the operator
|
|
32
|
+
* that reads the path, an omitted one read as `EQ`, or null for sort, groupId and fact paths.
|
|
33
|
+
* `construct` is the term or group condition object that names the path, or null for a field
|
|
34
|
+
* filter's own operator and for sort, groupId and fact paths. Malformed entries are left to the
|
|
35
|
+
* backends. An emptiness term without a path (omitted, null or "") names its field, also next to
|
|
36
|
+
* sibling terms that have paths.
|
|
29
37
|
*
|
|
30
38
|
* With `expandMembers` and the queried `gqltype`, the path of each emptiness condition is followed by
|
|
31
|
-
* the stored member paths that condition reads (emptinessMemberPaths), as
|
|
32
|
-
* member: true
|
|
33
|
-
* are expanded.
|
|
39
|
+
* the stored member paths that condition reads (emptinessMemberPaths), as entries with the same
|
|
40
|
+
* group, operator and construct and `member: true`, so that the checks of client paths apply to them.
|
|
41
|
+
* Only client arguments are expanded.
|
|
34
42
|
*/
|
|
35
43
|
export const collectQueryPathEntries = (args, operation = 'find', { gqltype, expandMembers = false } = {}) => {
|
|
36
44
|
const entries = [];
|
|
37
|
-
const add = (path, group = null) => {
|
|
38
|
-
if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group });
|
|
45
|
+
const add = (path, group = null, operator = null, construct = null) => {
|
|
46
|
+
if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group, operator, construct });
|
|
39
47
|
};
|
|
40
|
-
const addEmptiness = (path, group
|
|
41
|
-
add(path, group);
|
|
48
|
+
const addEmptiness = (path, group, operator, construct) => {
|
|
49
|
+
add(path, group, operator, construct);
|
|
42
50
|
if (!expandMembers || typeof path !== 'string' || !path) return;
|
|
43
|
-
for (const segments of emptinessMemberPaths(gqltype, path.split('.')))
|
|
51
|
+
for (const segments of emptinessMemberPaths(gqltype, path.split('.'))) {
|
|
52
|
+
entries.push({
|
|
53
|
+
segments, group, operator, construct, member: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
44
56
|
};
|
|
45
57
|
if (!args || typeof args !== 'object') return entries;
|
|
58
|
+
const operatorOf = (item) => item?.operator ?? 'EQ';
|
|
46
59
|
const visit = (group) => {
|
|
47
60
|
if (!group || typeof group !== 'object') return;
|
|
48
61
|
for (const condition of listOf(group.conditions)) {
|
|
49
62
|
if (typeof condition?.field !== 'string') continue;
|
|
50
63
|
const path = isPathless(condition.path) ? condition.field : `${condition.field}.${condition.path}`;
|
|
51
|
-
if (isEmptinessOperator(condition.operator)) addEmptiness(path, group);
|
|
52
|
-
else add(path, group);
|
|
64
|
+
if (isEmptinessOperator(condition.operator)) addEmptiness(path, group, condition.operator, condition);
|
|
65
|
+
else add(path, group, operatorOf(condition), condition);
|
|
53
66
|
}
|
|
54
67
|
for (const item of nestedGroups(group)) visit(item);
|
|
55
68
|
};
|
|
@@ -59,22 +72,24 @@ export const collectQueryPathEntries = (args, operation = 'find', { gqltype, exp
|
|
|
59
72
|
const terms = listOf(value.terms);
|
|
60
73
|
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
61
74
|
if (!terms.some(hasTermPath)) {
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
const term = terms.find(pathless);
|
|
76
|
+
if (term) addEmptiness(name, null, term.operator, term);
|
|
77
|
+
else if (isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
|
|
78
|
+
else add(name, null, operatorOf(value));
|
|
64
79
|
continue;
|
|
65
80
|
}
|
|
66
81
|
let field = false;
|
|
67
82
|
for (const term of terms) {
|
|
68
83
|
if (hasTermPath(term)) {
|
|
69
|
-
if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}
|
|
70
|
-
else add(`${name}.${term.path}
|
|
84
|
+
if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}`, null, term.operator, term);
|
|
85
|
+
else add(`${name}.${term.path}`, null, operatorOf(term), term);
|
|
71
86
|
} else if (!field && pathless(term)) {
|
|
72
|
-
addEmptiness(name);
|
|
87
|
+
addEmptiness(name, null, term.operator, term);
|
|
73
88
|
field = true;
|
|
74
89
|
}
|
|
75
90
|
}
|
|
76
91
|
// An emptiness operator beside terms is rejected later; until then it reads the field too.
|
|
77
|
-
if (!field && isEmptinessOperator(value.operator)) addEmptiness(name);
|
|
92
|
+
if (!field && isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
|
|
78
93
|
}
|
|
79
94
|
for (const item of nestedGroups(args)) visit(item);
|
|
80
95
|
if (operation === 'aggregate') {
|
|
@@ -86,8 +101,8 @@ export const collectQueryPathEntries = (args, operation = 'find', { gqltype, exp
|
|
|
86
101
|
|
|
87
102
|
/**
|
|
88
103
|
* Internal: every condition the arguments hold, as `{ operator, path, segments, form, field, rawPath,
|
|
89
|
-
* value, hasValue, ambiguous, depth }` entries: field filters, relation terms
|
|
90
|
-
* conditions at any depth, with an omitted operator read as `EQ`.
|
|
104
|
+
* value, hasValue, ambiguous, depth, construct, emptiness }` entries: field filters, relation terms
|
|
105
|
+
* and AND/OR group conditions at any depth, with an omitted operator read as `EQ`.
|
|
91
106
|
* - `form` is 'filter' (an argument's own operator, without a terms array), 'beside-terms' (an
|
|
92
107
|
* operator written next to a terms array, which the compilers read only to reject an emptiness
|
|
93
108
|
* operator), 'term' or 'condition'.
|
|
@@ -98,6 +113,10 @@ export const collectQueryPathEntries = (args, operation = 'find', { gqltype, exp
|
|
|
98
113
|
* - `ambiguous` marks a condition with both a dotted field and a non-empty path.
|
|
99
114
|
* - `depth` is 0 for argument filters and for the groups listed in args.AND and args.OR, and one more
|
|
100
115
|
* for each nested group; a group reached at several depths reports the deepest.
|
|
116
|
+
* - `construct` is the term or group condition object itself, or null for a field filter's own
|
|
117
|
+
* operator ('filter' and 'beside-terms'). These objects are the only ones that carry a client scope
|
|
118
|
+
* marker, `__scope` (#108 part 2b).
|
|
119
|
+
* - `emptiness` tells whether the operator is IS_EMPTY or IS_NOT_EMPTY.
|
|
101
120
|
* Sort, pagination and aggregation hold no operators. Malformed entries are skipped and left to the
|
|
102
121
|
* backends; a group reached twice is read once.
|
|
103
122
|
*/
|
|
@@ -108,8 +127,9 @@ export const collectQueryOperators = (args, operation = 'find') => {
|
|
|
108
127
|
const add = (item, form, field, rawPath, depth, ambiguous = false) => {
|
|
109
128
|
if (!isObject(item)) return;
|
|
110
129
|
const path = isPathless(rawPath) ? field : `${field}.${rawPath}`;
|
|
130
|
+
const operator = item.operator ?? 'EQ';
|
|
111
131
|
entries.push({
|
|
112
|
-
operator
|
|
132
|
+
operator,
|
|
113
133
|
path,
|
|
114
134
|
segments: typeof path === 'string' && path ? path.split('.') : [],
|
|
115
135
|
form,
|
|
@@ -119,6 +139,8 @@ export const collectQueryOperators = (args, operation = 'find') => {
|
|
|
119
139
|
hasValue: Object.hasOwn(item, 'value'),
|
|
120
140
|
ambiguous,
|
|
121
141
|
depth,
|
|
142
|
+
construct: form === 'term' || form === 'condition' ? item : null,
|
|
143
|
+
emptiness: isEmptinessOperator(operator),
|
|
122
144
|
});
|
|
123
145
|
};
|
|
124
146
|
// The deepest level of each group, as the backends' depth limit counts it; a group inside itself is
|
|
@@ -210,27 +232,68 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
210
232
|
* operator on an object filter without terms, or beside terms, fails with MISSING_FILTER_PATH. A
|
|
211
233
|
* filter path of other operators that has more than one segment and ends at an object fails with
|
|
212
234
|
* INVALID_FILTER_PATH; a bare object field keeps MISSING_FILTER_PATH.
|
|
235
|
+
*
|
|
236
|
+
* With `referenceEmptiness` (#108 part 2b; by default true when `operators` is omitted and false
|
|
237
|
+
* when it is given), an emptiness path may also cross single references, end at a reference or a
|
|
238
|
+
* collection, or read reference and collection members; without it they fail with UNSUPPORTED_QUERY.
|
|
239
|
+
* Such a predicate is trusted, with stored semantics, unless its term or group condition carries the
|
|
240
|
+
* client scope marker the runtime fills, `__scope: { id, scopes }`. Then the predicate gets `scopes:
|
|
241
|
+
* { [path]: where | null }`, with one key for each path emptinessScopePaths lists for it: the plan of
|
|
242
|
+
* that path's target find scope arguments, planned on the target type in its own frame, or null when
|
|
243
|
+
* the scope added nothing. A `__scope` without filled `scopes`, without one of those keys, or on a
|
|
244
|
+
* construct that reads no reference or collection fails with DETACHED_CLIENT_FILTER (500) after the
|
|
245
|
+
* checks above; a `__scope` anywhere else is never read.
|
|
213
246
|
*/
|
|
214
|
-
export const createQueryPlan = (models, entityName, input = {}, {
|
|
247
|
+
export const createQueryPlan = (models, entityName, input = {}, {
|
|
248
|
+
mode = 'find', operators: supported, referenceEmptiness = supported == null,
|
|
249
|
+
} = {}) => {
|
|
215
250
|
const allowed = supported == null ? null : new Set(supported);
|
|
251
|
+
const references = referenceEmptiness === true;
|
|
216
252
|
const combine = (kind, terms) => {
|
|
217
253
|
const active = terms.filter(Boolean);
|
|
218
254
|
return active.length > 1 ? { kind, terms: active } : active[0] || null;
|
|
219
255
|
};
|
|
256
|
+
// The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
|
|
257
|
+
// arguments are planned on the target type, as trusted find arguments in their own frame, whatever
|
|
258
|
+
// the query's mode, with the operator names already read (`operators` may be a one-time iterable).
|
|
259
|
+
const liftScopes = (marker, shape, segments, operator) => {
|
|
260
|
+
const label = `${operator} on ${segments.join('.')}`;
|
|
261
|
+
const targets = emptinessScopeTargets(shape, segments);
|
|
262
|
+
const paths = Object.keys(targets);
|
|
263
|
+
if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
|
|
264
|
+
const scopes = marker?.scopes;
|
|
265
|
+
if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled`);
|
|
266
|
+
const lifted = {};
|
|
267
|
+
for (const path of paths) {
|
|
268
|
+
if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
|
|
269
|
+
lifted[path] = scopes[path] === null
|
|
270
|
+
? null
|
|
271
|
+
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references }).where;
|
|
272
|
+
}
|
|
273
|
+
return lifted;
|
|
274
|
+
};
|
|
275
|
+
const hasMarker = (construct) => construct != null && typeof construct === 'object' && Object.hasOwn(construct, '__scope');
|
|
220
276
|
// Rule 1 has resolved the path; rule 2 is the compiler's operators, rules 3 to 6 describeEmptiness's.
|
|
221
|
-
const emptiness = (path, steps, operator, value) => {
|
|
277
|
+
const emptiness = (path, steps, operator, value, construct) => {
|
|
222
278
|
if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
223
279
|
const segments = Array.isArray(path) ? path : path.split('.');
|
|
224
|
-
const options = {
|
|
280
|
+
const options = {
|
|
281
|
+
operator, value, path: segments.join('.'), referenceEmptiness: references,
|
|
282
|
+
};
|
|
225
283
|
const gqltype = models.entities.find((item) => item.name === entityName)?.gqltype;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
284
|
+
const shape = gqltype ? describeEmptiness(gqltype, segments, options) : describeModelEmptiness(steps, options);
|
|
285
|
+
const node = {
|
|
286
|
+
kind: 'predicate', path: segments, operator, value: null,
|
|
287
|
+
};
|
|
288
|
+
if (hasMarker(construct)) node.scopes = liftScopes(construct.__scope, shape, segments, operator);
|
|
289
|
+
return node;
|
|
229
290
|
};
|
|
230
|
-
|
|
291
|
+
// `construct` is the term or group condition that holds the predicate, the only objects whose
|
|
292
|
+
// `__scope` is read; null for a field filter's own operator.
|
|
293
|
+
const predicate = (path, operator, value, construct = null) => {
|
|
231
294
|
const steps = resolveModelPath(models, entityName, path);
|
|
232
295
|
const op = operator ?? 'EQ';
|
|
233
|
-
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value);
|
|
296
|
+
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct);
|
|
234
297
|
const field = steps.at(-1);
|
|
235
298
|
if (field.kind !== 'scalar' && !field.inferred) {
|
|
236
299
|
fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
@@ -243,6 +306,7 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
243
306
|
if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
|
|
244
307
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
245
308
|
if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
|
|
309
|
+
if (hasMarker(construct)) detached(`${op} on ${Array.isArray(path) ? path.join('.') : path} carries a client scope marker, but is not an emptiness condition`);
|
|
246
310
|
return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
247
311
|
};
|
|
248
312
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
@@ -256,7 +320,7 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
256
320
|
for (const condition of value.conditions || []) {
|
|
257
321
|
if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
258
322
|
if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
259
|
-
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value));
|
|
323
|
+
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition));
|
|
260
324
|
}
|
|
261
325
|
for (const item of value.AND || []) terms.push(group(item, depth + 1));
|
|
262
326
|
terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
|
|
@@ -282,11 +346,11 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
282
346
|
else for (const term of value.terms) {
|
|
283
347
|
if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
|
|
284
348
|
if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
|
|
285
|
-
terms.push(predicate(name, term.operator, term.value));
|
|
349
|
+
terms.push(predicate(name, term.operator, term.value, term));
|
|
286
350
|
continue;
|
|
287
351
|
}
|
|
288
352
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
289
|
-
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value));
|
|
353
|
+
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term));
|
|
290
354
|
}
|
|
291
355
|
}
|
|
292
356
|
for (const item of input.AND || []) terms.push(group(item));
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
configureQueryLimits, getQueryMaxPageSize, getQueryMaxReferenceFrames, paginationStages,
|
|
3
|
+
} from './query-limits.js';
|
|
2
4
|
import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
|
|
3
5
|
import {
|
|
4
6
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
@@ -17,7 +19,9 @@ import {
|
|
|
17
19
|
collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
|
|
18
20
|
} from './query-plan.js';
|
|
19
21
|
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
20
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
assertEmptinessPath, describeEmptiness, emptinessScopePaths, emptinessScopeTargets, isEmptinessOperator,
|
|
24
|
+
} from './query-emptiness.js';
|
|
21
25
|
import { relationFieldOwners } from './relation-owners.js';
|
|
22
26
|
import { isThenable } from './auth/thenable.js';
|
|
23
27
|
import {
|
|
@@ -468,8 +472,12 @@ export const createRuntime = (adapter) => {
|
|
|
468
472
|
throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
|
|
469
473
|
}
|
|
470
474
|
// The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
|
|
471
|
-
// operators when it declares none.
|
|
472
|
-
|
|
475
|
+
// operators when it declares none. With queryFeatures.referenceEmptiness, it also compiles
|
|
476
|
+
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
477
|
+
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
478
|
+
const {
|
|
479
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
|
|
480
|
+
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
473
481
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
474
482
|
|
|
475
483
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
@@ -1750,31 +1758,69 @@ const executeScope = async (params) => {
|
|
|
1750
1758
|
return scopeFunction({ type, args, operation, context });
|
|
1751
1759
|
};
|
|
1752
1760
|
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
+
// The arguments of a list, aggregate or collection field call that are not filters. On aggregate
|
|
1762
|
+
// endpoints `aggregation` is one; elsewhere a field of that name is filtered like any other.
|
|
1763
|
+
const LIST_CONTROL_ARGS = new Set(['sort', 'pagination']);
|
|
1764
|
+
const AGGREGATE_CONTROL_ARGS = new Set(['sort', 'pagination', 'aggregation']);
|
|
1765
|
+
const isFilterObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1766
|
+
// A new object with the same prototype and own enumerable properties: graphql-js builds inline literals
|
|
1767
|
+
// with a null prototype and variables with Object's, and childArgsKey tells them apart.
|
|
1768
|
+
const copyFilterObject = (value) => Object.assign(Object.create(Object.getPrototypeOf(value)), value);
|
|
1769
|
+
// A term, a group condition or a field filter: a value array becomes a new Array with the same items.
|
|
1770
|
+
const copyFilterCondition = (condition) => {
|
|
1771
|
+
if (!isFilterObject(condition)) return condition;
|
|
1772
|
+
const copy = copyFilterObject(condition);
|
|
1773
|
+
if (Array.isArray(condition.value)) copy.value = [...condition.value];
|
|
1774
|
+
return copy;
|
|
1775
|
+
};
|
|
1776
|
+
const copyFilterGroup = (group) => {
|
|
1777
|
+
if (!isFilterObject(group)) return group;
|
|
1778
|
+
const copy = copyFilterObject(group);
|
|
1779
|
+
if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
|
|
1780
|
+
if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
|
|
1781
|
+
if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
|
|
1782
|
+
return copy;
|
|
1783
|
+
};
|
|
1784
|
+
const copyFieldFilter = (filter) => {
|
|
1785
|
+
const copy = copyFilterCondition(filter);
|
|
1786
|
+
if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
|
|
1761
1787
|
return copy;
|
|
1762
|
-
}
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
// A tree copy of the query-language structure of one call's filter arguments, onto that call's args:
|
|
1791
|
+
// field filters and their terms, AND/OR groups and their conditions. Every place gets its own object,
|
|
1792
|
+
// also where graphql-js hands one variable object to several arguments or list items, so that a
|
|
1793
|
+
// middleware edit or a client scope marker stays within the call. Value arrays are copied shallowly,
|
|
1794
|
+
// and every other value, sort, pagination and aggregation included, is kept as it is. GraphQL input
|
|
1795
|
+
// has no cycles, so nothing is memoized.
|
|
1796
|
+
const copyClientFilters = (args, operation) => {
|
|
1797
|
+
const controls = operation === 'aggregate' ? AGGREGATE_CONTROL_ARGS : LIST_CONTROL_ARGS;
|
|
1798
|
+
for (const [name, value] of Object.entries(args)) {
|
|
1799
|
+
if (name === 'AND' || name === 'OR') {
|
|
1800
|
+
if (Array.isArray(value)) args[name] = value.map(copyFilterGroup);
|
|
1801
|
+
} else if (!controls.has(name)) args[name] = copyFieldFilter(value);
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1763
1804
|
|
|
1764
1805
|
// Checks the filter, sort and aggregation paths a client supplied, before middleware and scopes
|
|
1765
1806
|
// add trusted paths. Returns the scoped relations those paths enter, keyed by path, with the
|
|
1766
1807
|
// client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
|
|
1767
1808
|
// graphql-js passes the same variable values to every resolver call of a request, so the client's
|
|
1768
|
-
//
|
|
1809
|
+
// filters are first copied onto this call's args (copyClientFilters), and joined scopes are only
|
|
1810
|
+
// placed in those copies.
|
|
1769
1811
|
// An emptiness condition on an embedded object reads its stored members, so their paths are checked
|
|
1770
1812
|
// as client paths too (#108 part 2a); a member path only gets the 403 checks, and enters no joined
|
|
1771
|
-
// scope.
|
|
1813
|
+
// scope. When the adapter compiles reference emptiness (#108 part 2b), a client's emptiness term or
|
|
1814
|
+
// condition reads its references and collections through scoped joins and frames instead, so it
|
|
1815
|
+
// enters no joined scope either; every other entry, beside it or on the same path, records its own.
|
|
1772
1816
|
const inspectClientPaths = (gqltype, args, operation) => {
|
|
1773
|
-
|
|
1774
|
-
if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
|
|
1775
|
-
}
|
|
1817
|
+
copyClientFilters(args, operation);
|
|
1776
1818
|
const joinedScopes = new Map();
|
|
1777
|
-
|
|
1819
|
+
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
1820
|
+
for (const {
|
|
1821
|
+
segments, group, member, operator,
|
|
1822
|
+
} of entries) {
|
|
1823
|
+
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator));
|
|
1778
1824
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1779
1825
|
const path = segments.slice(0, index + 1).join('.');
|
|
1780
1826
|
if (isVirtualField(field)) {
|
|
@@ -1784,7 +1830,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1784
1830
|
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
1785
1831
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1786
1832
|
}
|
|
1787
|
-
if (
|
|
1833
|
+
if (!joins) return;
|
|
1788
1834
|
const relation = field.extensions?.relation;
|
|
1789
1835
|
if (!relation || relation.embedded) return;
|
|
1790
1836
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
@@ -1870,18 +1916,28 @@ const assertEmptinessForm = (gqltype, {
|
|
|
1870
1916
|
// Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
|
|
1871
1917
|
// UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile,
|
|
1872
1918
|
// and applies to every IS_EMPTY or IS_NOT_EMPTY condition the order of checks of #108 part 2a,
|
|
1873
|
-
// whatever the adapter declares
|
|
1874
|
-
// arguments before
|
|
1875
|
-
//
|
|
1876
|
-
//
|
|
1877
|
-
// adapter, which reports them as before.
|
|
1919
|
+
// whatever the adapter declares, rule 6 only when it does not declare reference emptiness. Runs on
|
|
1920
|
+
// the client's arguments before middleware and on the final arguments before the adapter call,
|
|
1921
|
+
// which covers what middleware, scopes and joined scopes added, so that an emptiness condition gets
|
|
1922
|
+
// the code that a direct createQueryPlan call gives, whoever wrote it and whatever the caller may
|
|
1923
|
+
// read. Operators that QLOperator does not list are left to the adapter, which reports them as before.
|
|
1924
|
+
// Returns every condition as `{ entry, shape }`: its collectQueryOperators entry, and the shape of an
|
|
1925
|
+
// emptiness condition that the backends read as one, or null.
|
|
1878
1926
|
const assertQueryConditions = (gqltype, args, operation) => {
|
|
1927
|
+
const checked = [];
|
|
1879
1928
|
for (const entry of collectQueryOperators(args, operation)) {
|
|
1880
1929
|
const { operator, path } = entry;
|
|
1930
|
+
let shape = null;
|
|
1881
1931
|
if (isEmptinessOperator(operator)) {
|
|
1882
|
-
if (assertEmptinessForm(gqltype, entry))
|
|
1932
|
+
if (assertEmptinessForm(gqltype, entry)) {
|
|
1933
|
+
shape = describeEmptiness(gqltype, entry.segments, {
|
|
1934
|
+
operator, value: entry.value, path, referenceEmptiness: supportsReferenceEmptiness,
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1883
1937
|
} else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
|
|
1938
|
+
checked.push({ entry, shape });
|
|
1884
1939
|
}
|
|
1940
|
+
return checked;
|
|
1885
1941
|
};
|
|
1886
1942
|
|
|
1887
1943
|
// Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
|
|
@@ -1894,6 +1950,8 @@ const assertQueryConditions = (gqltype, args, operation) => {
|
|
|
1894
1950
|
// adds more than a reference crossing. After its form and operator, each prefixed condition is checked
|
|
1895
1951
|
// on `rootType`, the queried type, where rule 3, which comes before rules 4 to 6, rejects it with the
|
|
1896
1952
|
// permanent INVALID_FILTER_PATH that the final gate and createQueryPlan give it wherever it is written.
|
|
1953
|
+
// An adapter that compiles reference emptiness lifts decision 3 (#108 part 2b): after the same checks,
|
|
1954
|
+
// the prefixed conditions are applied as trusted ones, which read stored data.
|
|
1897
1955
|
const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
|
|
1898
1956
|
const { gqltype } = target;
|
|
1899
1957
|
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
|
|
@@ -1909,17 +1967,21 @@ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target
|
|
|
1909
1967
|
if (!(error instanceof SimfinityError) || error.getCode() !== 'UNSUPPORTED_QUERY') throw error;
|
|
1910
1968
|
}
|
|
1911
1969
|
};
|
|
1970
|
+
const referenceEmptiness = supportsReferenceEmptiness;
|
|
1912
1971
|
for (const entry of entries) {
|
|
1913
1972
|
if (!assertEmptinessForm(gqltype, entry)) continue;
|
|
1914
1973
|
const { operator, value } = entry;
|
|
1915
|
-
if (throughList) exceptRule6(() => describeEmptiness(rootType, `${prefix}.${entry.path}`, { operator, value }));
|
|
1916
|
-
exceptRule6(() => describeEmptiness(gqltype, entry.segments, {
|
|
1974
|
+
if (throughList) exceptRule6(() => describeEmptiness(rootType, `${prefix}.${entry.path}`, { operator, value, referenceEmptiness }));
|
|
1975
|
+
exceptRule6(() => describeEmptiness(gqltype, entry.segments, {
|
|
1976
|
+
operator, value, path: entry.path, referenceEmptiness,
|
|
1977
|
+
}));
|
|
1917
1978
|
}
|
|
1979
|
+
if (referenceEmptiness) return;
|
|
1918
1980
|
const prefixed = collectQueryOperators({ AND: groups }).find(({ operator }) => isEmptinessOperator(operator))
|
|
1919
1981
|
?? { operator: entries[0].operator, path: `${prefix}.${entries[0].path}` };
|
|
1920
1982
|
throw new SimfinityError(`Query path ${prefix} enters ${gqltype.name}, whose find scope tests ${prefixed.path} with `
|
|
1921
|
-
+ `${prefixed.operator}: emptiness through references and collections
|
|
1922
|
-
+ `
|
|
1983
|
+
+ `${prefixed.operator}: the database adapter does not support emptiness through references and collections; `
|
|
1984
|
+
+ `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
|
|
1923
1985
|
};
|
|
1924
1986
|
|
|
1925
1987
|
// A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
|
|
@@ -1989,6 +2051,181 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
1989
2051
|
}
|
|
1990
2052
|
};
|
|
1991
2053
|
|
|
2054
|
+
// Client scope markers (#108 part 2b). When the adapter compiles reference emptiness, each IS_EMPTY or
|
|
2055
|
+
// IS_NOT_EMPTY term or group condition that a client sends and that crosses or ends at a reference or a
|
|
2056
|
+
// collection, or reads one as a member, gets `__scope: { id }` on this call's copy, before middleware.
|
|
2057
|
+
// After middleware, the query's own scope and joined scopes, the runtime checks that every marker it
|
|
2058
|
+
// issued is still there and that no other one is, gates the final arguments, then fills each marker
|
|
2059
|
+
// with the raw find scope arguments of every type the construct reads through, `__scope: { id, scopes:
|
|
2060
|
+
// { [path]: args | null } }`, which the compilers apply as scoped joins and frames, so that a record
|
|
2061
|
+
// those scopes hide reads as missing. Constructs without a marker are trusted and read stored data.
|
|
2062
|
+
// Markers live only on the terms and group conditions collectQueryOperators returns; a `__scope`
|
|
2063
|
+
// anywhere else, inside a value for one, is never read.
|
|
2064
|
+
const SCOPE_MARKER = '__scope';
|
|
2065
|
+
const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
|
|
2066
|
+
+ 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
|
|
2067
|
+
const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
|
|
2068
|
+
const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
|
|
2069
|
+
|
|
2070
|
+
// Marks the client's constructs that read references or collections, from the gate's conditions on the
|
|
2071
|
+
// client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
|
|
2072
|
+
// scoped joins and frames in this call, so every 2a code comes first. Returns the issued markers, as
|
|
2073
|
+
// id -> the client's operator and path, or null when the adapter does not compile reference
|
|
2074
|
+
// emptiness. Ids are ordinals in the order of the conditions, so identical arguments get identical
|
|
2075
|
+
// markers and sibling collection reads keep one batch key.
|
|
2076
|
+
const markClientConstructs = (checked) => {
|
|
2077
|
+
if (!supportsReferenceEmptiness) return null;
|
|
2078
|
+
const issued = new Map();
|
|
2079
|
+
const pairs = new Set();
|
|
2080
|
+
for (const { entry, shape } of checked) {
|
|
2081
|
+
const { construct } = entry;
|
|
2082
|
+
if (!shape || !construct || hasScopeMarker(construct)) continue;
|
|
2083
|
+
const { joins, frames } = emptinessScopePaths(shape, entry.segments);
|
|
2084
|
+
if (!joins.length && !frames.length) continue;
|
|
2085
|
+
const id = issued.size;
|
|
2086
|
+
construct[SCOPE_MARKER] = { id };
|
|
2087
|
+
issued.set(id, { operator: entry.operator, path: entry.path });
|
|
2088
|
+
for (const path of joins) pairs.add(`join ${path}`);
|
|
2089
|
+
for (const path of frames) pairs.add(`frame ${path}`);
|
|
2090
|
+
}
|
|
2091
|
+
const limit = getQueryMaxReferenceFrames();
|
|
2092
|
+
if (pairs.size > limit) {
|
|
2093
|
+
throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
|
|
2094
|
+
+ `frames in one query field, more than the limit of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2095
|
+
}
|
|
2096
|
+
return issued;
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
// The detach check: every marker issued in this call sits, well formed (an object whose only own key
|
|
2100
|
+
// is its id), on a term or group condition of the final arguments, possibly on several when middleware
|
|
2101
|
+
// copied or duplicated the construct; no other marker sits on one.
|
|
2102
|
+
const assertClientMarkers = (args, operation, issued) => {
|
|
2103
|
+
const found = new Set();
|
|
2104
|
+
for (const { construct, operator, path } of collectQueryOperators(args, operation)) {
|
|
2105
|
+
if (!hasScopeMarker(construct)) continue;
|
|
2106
|
+
const marker = construct[SCOPE_MARKER];
|
|
2107
|
+
const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
|
|
2108
|
+
if (!isFilterObject(marker) || Reflect.ownKeys(marker).length !== 1 || !Object.hasOwn(marker, 'id')) {
|
|
2109
|
+
const named = origin ? `, the client's ${origin.operator} condition on ${origin.path},` : '';
|
|
2110
|
+
throw detachedClientFilter(`The ${operator} condition on ${path}${named} carries a malformed client scope marker`);
|
|
2111
|
+
}
|
|
2112
|
+
if (!origin) {
|
|
2113
|
+
throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
|
|
2114
|
+
}
|
|
2115
|
+
found.add(marker.id);
|
|
2116
|
+
}
|
|
2117
|
+
for (const [id, origin] of issued) {
|
|
2118
|
+
if (!found.has(id)) {
|
|
2119
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is no longer in the arguments `
|
|
2120
|
+
+ 'after middleware and scopes');
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
};
|
|
2124
|
+
|
|
2125
|
+
// The first reference or collection a path crosses or ends at, as its dotted path, or null.
|
|
2126
|
+
const relationOnPath = (gqltype, segments) => {
|
|
2127
|
+
let found = null;
|
|
2128
|
+
walkQueryPath(gqltype, segments, ({ field, index }) => {
|
|
2129
|
+
const relation = field.extensions?.relation;
|
|
2130
|
+
if (!found && relation && !relation.embedded) found = segments.slice(0, index + 1).join('.');
|
|
2131
|
+
});
|
|
2132
|
+
return found;
|
|
2133
|
+
};
|
|
2134
|
+
|
|
2135
|
+
// The find scope arguments of a type that a client's constructs read through, once per call and type
|
|
2136
|
+
// (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
|
|
2137
|
+
// joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
|
|
2138
|
+
// gate on the type, where everything is trusted. Null when the type has no find scope or the scope
|
|
2139
|
+
// added nothing.
|
|
2140
|
+
const clientScopeArgs = async (typeName, context, cache) => {
|
|
2141
|
+
if (cache.has(typeName)) return cache.get(typeName);
|
|
2142
|
+
const target = typesDict.types[typeName];
|
|
2143
|
+
let result = null;
|
|
2144
|
+
if (getScope(target?.gqltype, 'find')) {
|
|
2145
|
+
const scopeArgs = {};
|
|
2146
|
+
await executeScope({ type: target, args: scopeArgs, operation: 'find', context });
|
|
2147
|
+
const kept = {};
|
|
2148
|
+
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2149
|
+
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2150
|
+
else if (value != null) assertScopeControlArg(name, value);
|
|
2151
|
+
}
|
|
2152
|
+
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2153
|
+
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2154
|
+
if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
|
|
2155
|
+
}
|
|
2156
|
+
cache.set(typeName, result);
|
|
2157
|
+
return result;
|
|
2158
|
+
};
|
|
2159
|
+
|
|
2160
|
+
// The fill, on the final arguments the gate passed (`checked`). Each marked object is read at each of
|
|
2161
|
+
// its final positions, with its final operator and path:
|
|
2162
|
+
// - an emptiness condition that crosses, ends at or has as members references or collections gets a
|
|
2163
|
+
// fresh `__scope: { id, scopes }`, one key per scoped join and frame path, with the union of the
|
|
2164
|
+
// keys of every position when middleware placed the object at several;
|
|
2165
|
+
// - an emptiness condition narrowed to the record's own fields loses its marker, and is trusted;
|
|
2166
|
+
// - another operator loses it too, unless its path still crosses or ends at a reference or a
|
|
2167
|
+
// collection: no joined scope was recorded for it, so it fails with DETACHED_CLIENT_FILTER.
|
|
2168
|
+
// An object that reads references at one position and none at another fails the same way, since one
|
|
2169
|
+
// marker cannot serve both. Every check comes before any target scope runs.
|
|
2170
|
+
const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
2171
|
+
const marked = new Map();
|
|
2172
|
+
for (const item of checked) {
|
|
2173
|
+
const { construct } = item.entry;
|
|
2174
|
+
if (!hasScopeMarker(construct)) continue;
|
|
2175
|
+
if (!marked.has(construct)) marked.set(construct, []);
|
|
2176
|
+
marked.get(construct).push(item);
|
|
2177
|
+
}
|
|
2178
|
+
const fills = [];
|
|
2179
|
+
for (const [construct, positions] of marked) {
|
|
2180
|
+
const { id } = construct[SCOPE_MARKER];
|
|
2181
|
+
const origin = issued.get(id);
|
|
2182
|
+
const targets = {};
|
|
2183
|
+
const unread = [];
|
|
2184
|
+
for (const { entry, shape } of positions) {
|
|
2185
|
+
if (!entry.emptiness) {
|
|
2186
|
+
const relation = relationOnPath(gqltype, entry.segments);
|
|
2187
|
+
if (relation) {
|
|
2188
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2189
|
+
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
2190
|
+
}
|
|
2191
|
+
unread.push(entry.path);
|
|
2192
|
+
} else {
|
|
2193
|
+
const keys = shape ? emptinessScopeTargets(shape, entry.segments) : {};
|
|
2194
|
+
if (Object.keys(keys).length) Object.assign(targets, keys);
|
|
2195
|
+
else unread.push(entry.path);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
const paths = Object.keys(targets);
|
|
2199
|
+
if (paths.length && unread.length) {
|
|
2200
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
|
|
2201
|
+
+ `references or collections and where it reads none (${unread.join(', ')})`);
|
|
2202
|
+
}
|
|
2203
|
+
fills.push({ construct, id, targets: paths.length ? targets : null });
|
|
2204
|
+
}
|
|
2205
|
+
const cache = new Map();
|
|
2206
|
+
for (const { construct, id, targets } of fills) {
|
|
2207
|
+
if (!targets) {
|
|
2208
|
+
delete construct[SCOPE_MARKER];
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2211
|
+
const scopes = {};
|
|
2212
|
+
for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
|
|
2213
|
+
construct[SCOPE_MARKER] = { id, scopes };
|
|
2214
|
+
}
|
|
2215
|
+
};
|
|
2216
|
+
|
|
2217
|
+
// After middleware, the query's own scope and joined scopes, and before the adapter call: the detach
|
|
2218
|
+
// check, the final gate (stored paths, then every condition), then the fill. Markers exist only when
|
|
2219
|
+
// the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
|
|
2220
|
+
// Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
|
|
2221
|
+
// the adapter after as many promise jobs as before.
|
|
2222
|
+
const settleClientArgs = (gqltype, params, operation, issued) => {
|
|
2223
|
+
if (issued) assertClientMarkers(params.args, operation, issued);
|
|
2224
|
+
assertStoredQueryPaths(gqltype, params.args, operation);
|
|
2225
|
+
const checked = assertQueryConditions(gqltype, params.args, operation);
|
|
2226
|
+
return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
|
|
2227
|
+
};
|
|
2228
|
+
|
|
1992
2229
|
// Unscoped relation reads of one request, grouped by related type until the promise jobs queued so
|
|
1993
2230
|
// far have run, and keyed by cast ID. Nothing is cached: each batch is read once and dropped.
|
|
1994
2231
|
const referenceBatches = new WeakMap();
|
|
@@ -2595,7 +2832,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2595
2832
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
|
|
2596
2833
|
async resolve(parent, args, context, info) {
|
|
2597
2834
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
|
|
2598
|
-
assertQueryConditions(type.gqltype, args, 'find');
|
|
2835
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'find'));
|
|
2599
2836
|
const params = {
|
|
2600
2837
|
type,
|
|
2601
2838
|
args,
|
|
@@ -2605,8 +2842,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2605
2842
|
await executeMiddleware(params);
|
|
2606
2843
|
await executeScope(params);
|
|
2607
2844
|
await applyJoinedScopes(joinedScopes, params);
|
|
2608
|
-
|
|
2609
|
-
|
|
2845
|
+
const filling = settleClientArgs(type.gqltype, params, 'find', issued);
|
|
2846
|
+
if (filling) await filling;
|
|
2610
2847
|
const queryArgs = params.args;
|
|
2611
2848
|
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2612
2849
|
&& canReportCount(parent, info, context);
|
|
@@ -2643,7 +2880,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2643
2880
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
2644
2881
|
async resolve(parent, args, context) {
|
|
2645
2882
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
|
|
2646
|
-
assertQueryConditions(type.gqltype, args, 'aggregate');
|
|
2883
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'aggregate'));
|
|
2647
2884
|
const params = {
|
|
2648
2885
|
type,
|
|
2649
2886
|
args,
|
|
@@ -2653,8 +2890,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2653
2890
|
await executeMiddleware(params);
|
|
2654
2891
|
await executeScope(params);
|
|
2655
2892
|
await applyJoinedScopes(joinedScopes, params);
|
|
2656
|
-
|
|
2657
|
-
|
|
2893
|
+
const filling = settleClientArgs(type.gqltype, params, 'aggregate', issued);
|
|
2894
|
+
if (filling) await filling;
|
|
2658
2895
|
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
2659
2896
|
},
|
|
2660
2897
|
};
|
|
@@ -3193,13 +3430,13 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
3193
3430
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
3194
3431
|
}
|
|
3195
3432
|
const joinedScopes = inspectClientPaths(relatedType, args, 'find');
|
|
3196
|
-
assertQueryConditions(relatedType, args, 'find');
|
|
3433
|
+
const issued = markClientConstructs(assertQueryConditions(relatedType, args, 'find'));
|
|
3197
3434
|
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
3198
3435
|
await executeMiddleware(params);
|
|
3199
3436
|
await executeScope(params);
|
|
3200
3437
|
await applyJoinedScopes(joinedScopes, params);
|
|
3201
|
-
|
|
3202
|
-
|
|
3438
|
+
const filling = settleClientArgs(relatedType, params, 'find', issued);
|
|
3439
|
+
if (filling) await filling;
|
|
3203
3440
|
return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
|
|
3204
3441
|
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
3205
3442
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
package/types/index.d.ts
CHANGED
|
@@ -27,11 +27,15 @@ export const QLValue: GraphQLScalarType<unknown, unknown>;
|
|
|
27
27
|
* missing, null or `""` String (`" "` is not empty), a missing or null value of any other scalar or
|
|
28
28
|
* enum, a missing, null or `[]` list (`[null]` and `[""]` are not empty), and a missing or null
|
|
29
29
|
* embedded object or one whose stored members are all empty by these rules; the record identity is
|
|
30
|
-
* never empty.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* never empty. The target's `id` right after a reference is empty exactly when the reference is,
|
|
31
|
+
* including when the target is missing or hidden by its `find` scope. A path that continues inside an
|
|
32
|
+
* embedded list or a collection fails with `INVALID_FILTER_PATH`. With an adapter that declares
|
|
33
|
+
* `queryFeatures.referenceEmptiness: true`, a path may also cross single references and end at a
|
|
34
|
+
* reference or a collection, and an embedded object may have reference and collection members: a
|
|
35
|
+
* reference is empty when no record it points to exists that the target's `find` scope admits for
|
|
36
|
+
* the caller, a collection when no such child exists, and a hidden record on the way reads as
|
|
37
|
+
* missing; constructs that middleware and scopes add read stored data. With any other adapter they
|
|
38
|
+
* fail with `UNSUPPORTED_QUERY`. A term or group condition without a path tests the field itself.
|
|
35
39
|
*/
|
|
36
40
|
export type QueryOperator =
|
|
37
41
|
| 'EQ' | 'NE' | 'LT' | 'LTE' | 'GT' | 'GTE' | 'BTW' | 'IN' | 'NIN' | 'LIKE'
|
|
@@ -48,6 +52,15 @@ export interface QueryFeatures {
|
|
|
48
52
|
* version of Simfinity does not know are ignored.
|
|
49
53
|
*/
|
|
50
54
|
operators: readonly (QueryOperator | (string & {}))[];
|
|
55
|
+
/**
|
|
56
|
+
* Whether the adapter compiles `IS_EMPTY` and `IS_NOT_EMPTY` through single references and at
|
|
57
|
+
* references and collections, including reference and collection members of embedded objects.
|
|
58
|
+
* Undefined or null means false; any other value that is not a boolean fails `createRuntime` with
|
|
59
|
+
* `INVALID_QUERY_FEATURES` (500). An adapter that declares it receives client constructs that carry
|
|
60
|
+
* a filled client scope marker, `__scope: { id, scopes }`, on their term or group condition, and
|
|
61
|
+
* must apply each target scope in `scopes`; constructs without a marker read stored data.
|
|
62
|
+
*/
|
|
63
|
+
referenceEmptiness?: boolean | null | undefined;
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
export interface ModelRegistration {
|
|
@@ -303,8 +316,20 @@ export interface CreateSchemaOptions {
|
|
|
303
316
|
includedCustomQueries?: string[] | null;
|
|
304
317
|
}
|
|
305
318
|
|
|
319
|
+
export interface QueryLimitsOptions {
|
|
320
|
+
/** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
|
|
321
|
+
maxPageSize?: number;
|
|
322
|
+
/**
|
|
323
|
+
* Positive safe integer; defaults to 10. The most distinct scoped joins and frames that the
|
|
324
|
+
* client's reference and collection emptiness may need in one list, aggregate or collection-field
|
|
325
|
+
* call; more fail with `FILTER_DEPTH_EXCEEDED` (400).
|
|
326
|
+
*/
|
|
327
|
+
maxReferenceFrames?: number;
|
|
328
|
+
}
|
|
329
|
+
|
|
306
330
|
export interface Runtime<Model = any, Session = any> {
|
|
307
|
-
|
|
331
|
+
/** The same process-wide setting as the root `configureQueryLimits`. */
|
|
332
|
+
configureQueryLimits(options?: QueryLimitsOptions): void;
|
|
308
333
|
/**
|
|
309
334
|
* Process-wide; calling it without options restores the unlimited default. Invalid options,
|
|
310
335
|
* including unknown or misspelled option keys, throw `INVALID_MUTATION_LIMITS` (400) and keep
|
|
@@ -395,15 +420,16 @@ export interface DatabaseAdapter<Model = any, Session = any> {
|
|
|
395
420
|
/**
|
|
396
421
|
* Optional. The filter operators this adapter compiles. `createRuntime` reads it once and throws
|
|
397
422
|
* `INVALID_QUERY_FEATURES` (500) unless it is undefined, null or an object whose `operators` is an
|
|
398
|
-
* array of strings
|
|
399
|
-
* `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN`
|
|
423
|
+
* array of strings and whose `referenceEmptiness`, if present, is a boolean or null. Without it the
|
|
424
|
+
* adapter supports the operators of 3.7.0 (`EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN`
|
|
425
|
+
* and `LIKE`) and no reference emptiness. A list, aggregate or collection read, or a
|
|
400
426
|
* by-ID read through a `get_by_id` scope, whose arguments use a `QLOperator` value the adapter
|
|
401
427
|
* does not support fails with `UNSUPPORTED_QUERY` (400), before middleware runs for the client's
|
|
402
428
|
* arguments and before `find`, `count`, `aggregate`, `findChildren` or `findChildrenBatch` for
|
|
403
429
|
* conditions that middleware, scopes and joined scopes added. Operators outside `QLOperator` still
|
|
404
430
|
* reach the adapter, which reports them as before. Declaring `IS_EMPTY` and `IS_NOT_EMPTY` means
|
|
405
|
-
* compiling their semantics on the record's own fields;
|
|
406
|
-
*
|
|
431
|
+
* compiling their semantics on the record's own fields; emptiness through references and
|
|
432
|
+
* collections also needs `referenceEmptiness: true`, and fails with `UNSUPPORTED_QUERY` without it.
|
|
407
433
|
*/
|
|
408
434
|
queryFeatures?: QueryFeatures | null | undefined;
|
|
409
435
|
bind?(runtime: Pick<Runtime<Model, Session>, 'getModel' | 'getType' | 'getRegistrations'>): void;
|
|
@@ -527,9 +553,21 @@ export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapt
|
|
|
527
553
|
* `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. For
|
|
528
554
|
* `IS_EMPTY` and `IS_NOT_EMPTY` the value is null, and `path` may end at an embedded object, an
|
|
529
555
|
* embedded list or a scalar list as well as at a scalar; `IS_NOT_EMPTY` is the exact complement,
|
|
530
|
-
* which the compiler builds.
|
|
556
|
+
* which the compiler builds. With `referenceEmptiness: true`, their `path` may also cross single
|
|
557
|
+
* references and end at a reference or a collection.
|
|
558
|
+
*
|
|
559
|
+
* `scopes` exists only on such an emptiness predicate from a client, and only for a compiler that
|
|
560
|
+
* declares `referenceEmptiness: true`. It has one key per dotted path from the queried record: each
|
|
561
|
+
* single reference the path crosses (a scoped join, which admits the target only where the scope
|
|
562
|
+
* admits it), the end when it is a reference or a collection, and each reference and collection
|
|
563
|
+
* member of an embedded object (frames, which keep only the records the scope admits). Each value is
|
|
564
|
+
* the target's `find` scope as a predicate in the target's own frame, or null when the scope added
|
|
565
|
+
* nothing; after a scoped crossing, a null hop still correlates through that scoped join. Without
|
|
566
|
+
* `scopes` the predicate reads stored data, through plain joins.
|
|
531
567
|
*/
|
|
532
|
-
export type QueryPredicate =
|
|
568
|
+
export type QueryPredicate =
|
|
569
|
+
| { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null> }
|
|
570
|
+
| { kind: 'and' | 'or'; terms: QueryPredicate[] };
|
|
533
571
|
export interface QueryPlan {
|
|
534
572
|
entity: string;
|
|
535
573
|
where: QueryPredicate | null;
|
|
@@ -556,11 +594,26 @@ export interface QueryPlan {
|
|
|
556
594
|
* the field itself. For other operators, a filter path of more than one segment that ends at an
|
|
557
595
|
* object fails with `INVALID_FILTER_PATH`, as on MongoDB; a bare object field keeps
|
|
558
596
|
* `MISSING_FILTER_PATH`.
|
|
597
|
+
*
|
|
598
|
+
* `referenceEmptiness` defaults to true when `operators` is omitted and to false when it is given.
|
|
599
|
+
* When it is false, an emptiness condition that crosses or ends at a reference or a collection, or
|
|
600
|
+
* reads a reference or collection member, fails with `UNSUPPORTED_QUERY` where the order above has
|
|
601
|
+
* `UNSUPPORTED_QUERY` for references; when it is true, such a condition plans with stored semantics,
|
|
602
|
+
* unless its term or group condition carries a client scope marker that the runtime filled, which
|
|
603
|
+
* becomes the predicate's `scopes`. A marker that is not filled for every path of the condition fails
|
|
604
|
+
* with `DETACHED_CLIENT_FILTER` (500).
|
|
559
605
|
*/
|
|
560
|
-
export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string
|
|
606
|
+
export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean }): QueryPlan;
|
|
561
607
|
export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
|
|
562
608
|
|
|
563
|
-
|
|
609
|
+
/**
|
|
610
|
+
* Process-wide. `maxPageSize` (default 1000) caps page sizes. `maxReferenceFrames` (default 10) caps,
|
|
611
|
+
* per list, aggregate or collection-field call, the distinct scoped joins and frames that a client's
|
|
612
|
+
* reference and collection emptiness needs; more fail with `FILTER_DEPTH_EXCEEDED` (400). Each must be
|
|
613
|
+
* a positive safe integer, or the call throws `INVALID_QUERY_LIMITS` (400) and keeps both limits; an
|
|
614
|
+
* omitted option takes its default again.
|
|
615
|
+
*/
|
|
616
|
+
export function configureQueryLimits(options?: QueryLimitsOptions): void;
|
|
564
617
|
/**
|
|
565
618
|
* Same process-wide setting as `Runtime.configureMutationLimits`. Unknown or misspelled options
|
|
566
619
|
* throw `INVALID_MUTATION_LIMITS` (400) and keep the current limit.
|