@simtlix/simfinity-core 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/auth/index.js +5 -3
- package/src/const/QLOperator.js +2 -0
- package/src/metadata.js +24 -8
- package/src/query-emptiness.js +222 -0
- package/src/query-operators.js +8 -1
- package/src/query-plan.js +131 -24
- package/src/runtime.js +272 -58
- package/types/index.d.ts +60 -6
package/README.md
CHANGED
|
@@ -8,11 +8,12 @@ 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 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, 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
|
+
- `registerCustomQuery` for reads whose results are not stored records, such as previews, other sources or permission evaluations;
|
|
16
17
|
- `preventCreatingCollection` to pass validation-only storage setup to an adapter;
|
|
17
18
|
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
|
|
18
19
|
|
|
@@ -20,7 +21,7 @@ The adapter supplies model creation, identifiers, transactions, record reads and
|
|
|
20
21
|
|
|
21
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.
|
|
22
23
|
|
|
23
|
-
An adapter declares the filter operators it compiles as `queryFeatures: { operators }`, a list of `QLOperator` values that includes `EQ`, which a condition without an operator means; names this version does not know are ignored. `createRuntime()` reads it once and throws `INVALID_QUERY_FEATURES` (500) unless it is `undefined`, `null` or an object whose `operators` is an array of strings. Without a declaration the adapter supports the operators of 3.7.0, `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`, so it never receives a text operator such as `ILIKE` that it would drop or misread. A list, count, aggregate or collection read, or a by-ID read through a `get_by_id` scope, that uses another `QLOperator` value fails with `UNSUPPORTED_QUERY` (400): the runtime checks the client's arguments before middleware runs and the final arguments, with what middleware, scopes and joined scopes added, before it calls the adapter. Operators that `QLOperator` does not list still reach the adapter. An adapter that receives the text operators must apply their rules: literal values, a `String` field or list, and `INVALID_FILTER_VALUE` (400) for a value that is not a string or contains U+0000. `createQueryPlan(models, entity, args, { operators })` validates them and rejects operators outside `operators` with `UNSUPPORTED_QUERY`.
|
|
24
|
+
An adapter declares the filter operators it compiles as `queryFeatures: { operators }`, a list of `QLOperator` values that includes `EQ`, which a condition without an operator means; names this version does not know are ignored. `createRuntime()` reads it once and throws `INVALID_QUERY_FEATURES` (500) unless it is `undefined`, `null` or an object whose `operators` is an array of strings. Without a declaration the adapter supports the operators of 3.7.0, `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`, so it never receives a text operator such as `ILIKE`, 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`, a path inside a list, and emptiness through references and collections before it calls the adapter, 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).
|
|
24
25
|
|
|
25
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()`.
|
|
26
27
|
|
|
@@ -28,7 +29,7 @@ When a runtime first creates a schema, it binds each GraphQL object type whose n
|
|
|
28
29
|
|
|
29
30
|
Registration and `createSchema()` validate `extensions.scope` (`INVALID_SCOPE`) and reject list or embedded relations to unregistered types (`UNREGISTERED_RELATION_TARGET`); `use()` rejects non-functions (`INVALID_MIDDLEWARE`). Before `adapter.prepare()`, `createSchema()` rejects a writable non-embedded list relation without a non-empty child `connectionField` whose resolver it would generate (`INVALID_MODEL`, 400). A collection without `connectionField` that has an application resolver, or is `readOnly`, only logs a `Configuration issue` warning, unless it is both, and nested writes through it fail with `INVALID_MODEL` (500) before anything is written; adapters that describe the whole model, such as PostgreSQL and transactional MongoDB, still reject it in `prepare()`. Fields named like generated query arguments (`sort`, `pagination`, `AND`, `OR`, and `aggregation` on aggregate endpoints) also log a warning once per runtime. Virtual fields (`extensions: { virtual: true }` or `{ virtual: { mcp: 'tool' | 'select' | false } }`, with the field's own `resolve`) are never stored, get no generated input and no filter argument, and resolve wherever their type appears; `createSchema()` rejects an invalid declaration, a virtual field without its own resolver or with `relation`, `unique: true`, `validations`, `queryable: true` or `readOnly: false`, and a virtual identity, index, state-machine `state` or `connectionField` field (`INVALID_MODEL`, 400). A client filter, sort, `groupId` or fact path through a virtual field fails with `FORBIDDEN_FILTER_PATH` (403), and one that middleware or a scope adds fails with `INVALID_MODEL` (500). A `readOnly` field with arguments and its own resolver that is not virtual, a reference or a collection logs a `Configuration issue` warning once per runtime. Generated inputs that would have no fields are left out with a `Configuration issue` warning, with the collection operations, embedded fields and mutations that would take them; a non-null embedded field whose embedded type has no writable fields throws `INVALID_MODEL` (400) when generated mutations reach its owner. An application type named `FieldExtensionsType` or `RelationType` makes Simfinity rename its introspection metadata types to `SimfinityFieldExtensionsType` and `SimfinityRelationType` when it is in the process's first Simfinity schema. That schema fixes the names: in a later `createSchema()`, an application type with one of the current names, the original ones or the fallback names after a rename, throws `RESERVED_TYPE_NAME` (500), before `adapter.prepare()` when it is an included endpoint type, a registered mutation's input or output type, an output type they reach or a field argument's type, and when the schema is constructed otherwise, such as for a type inside an input object's fields.
|
|
30
31
|
|
|
31
|
-
Generated and custom mutations run through `adapter.withTransaction`. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
|
|
32
|
+
Generated and custom mutations run through `adapter.withTransaction`. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. Custom queries run without a transaction: their callback receives `(input, context)` and opens one through the engine's API when it needs it. `createSchema()` takes an optional fourth argument, an options object whose only key, `includedCustomQueries`, selects custom queries by name; any other value or key throws `INVALID_SCHEMA_OPTIONS` (500) before `adapter.prepare()`. A schema without mutation fields has no `Mutation` root. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
|
|
32
33
|
|
|
33
34
|
Importing the runtime installs Simfinity's `__Field.extensions` introspection field once per GraphQL module instance; its `readOnly` flag reads `true` for virtual fields. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
|
|
34
35
|
|
package/package.json
CHANGED
package/src/auth/index.js
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
import SimfinityError from '../errors/simfinity.error.js';
|
|
46
46
|
import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
|
|
47
47
|
import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
|
|
48
|
-
import {
|
|
48
|
+
import { collectQueryPathEntries, walkQueryPath } from '../query-plan.js';
|
|
49
49
|
import { relationFieldOwners } from '../relation-owners.js';
|
|
50
50
|
import { isThenable } from './thenable.js';
|
|
51
51
|
import {
|
|
@@ -246,13 +246,15 @@ const executeRule = async (rule, parent, args, ctx, info) => {
|
|
|
246
246
|
* field's filter, sort and aggregation arguments. Each segment needs its own `Type.field` rule
|
|
247
247
|
* (exact, wildcard, then default policy). Rules run without a parent, with empty args and the
|
|
248
248
|
* path field's identity in info, so parent-dependent rules such as isOwner deny query paths. A rule that
|
|
249
|
-
* throws anything other than a Simfinity or GraphQL error denies the path.
|
|
249
|
+
* throws anything other than a Simfinity or GraphQL error denies the path. An IS_EMPTY or IS_NOT_EMPTY
|
|
250
|
+
* condition on an embedded object reads its stored members, so each member path is authorized as a
|
|
251
|
+
* named path is, after it, with the same answer for a field that is both.
|
|
250
252
|
*/
|
|
251
253
|
const authorizeQueryPaths = async (permissions, defaultPolicy, schema, query, args, ctx, info) => {
|
|
252
254
|
const queriedType = query && schema?.getType?.(query.typeName);
|
|
253
255
|
if (!queriedType) return;
|
|
254
256
|
const checked = new Set();
|
|
255
|
-
for (const segments of
|
|
257
|
+
for (const { segments } of collectQueryPathEntries(args, query.operation, { gqltype: queriedType, expandMembers: true })) {
|
|
256
258
|
const steps = [];
|
|
257
259
|
walkQueryPath(queriedType, segments, (step) => steps.push(step));
|
|
258
260
|
for (const { type, fieldName, field } of steps) {
|
package/src/const/QLOperator.js
CHANGED
package/src/metadata.js
CHANGED
|
@@ -22,6 +22,26 @@ const unwrap = (type) => {
|
|
|
22
22
|
return { named, required, list, itemRequired };
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
// Follows a validated scalar's baseScalarType chain to the scalar it is stored as, stopping where the
|
|
26
|
+
// chain repeats, as MongoDB's resolveStorageScalar does.
|
|
27
|
+
const rootScalar = (type) => {
|
|
28
|
+
let base = type;
|
|
29
|
+
const visited = new Set();
|
|
30
|
+
while (base?.baseScalarType && !visited.has(base)) {
|
|
31
|
+
visited.add(base);
|
|
32
|
+
base = base.baseScalarType;
|
|
33
|
+
}
|
|
34
|
+
return base;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Internal: the name of the scalar a field type is stored as: the end of its baseScalarType chain, by
|
|
39
|
+
* its own name (`Date` stays `Date`). An enum or a scalar without a base gives its own name, and a
|
|
40
|
+
* chain that repeats stops where it repeats. Never throws; describeModels rejects cyclic chains and
|
|
41
|
+
* unsupported scalars on top of it.
|
|
42
|
+
*/
|
|
43
|
+
export const effectiveScalarName = (type) => rootScalar(type)?.name;
|
|
44
|
+
|
|
25
45
|
const scalarDescription = (type) => {
|
|
26
46
|
if (isEnumType(type)) {
|
|
27
47
|
const storedValues = new Map();
|
|
@@ -32,14 +52,10 @@ const scalarDescription = (type) => {
|
|
|
32
52
|
}
|
|
33
53
|
return { scalar: 'Enum', values: [...storedValues.keys()], enumValues: type.getValues().map(({ name, value }) => ({ name, value })) };
|
|
34
54
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
visited.add(base);
|
|
40
|
-
base = base.baseScalarType;
|
|
41
|
-
}
|
|
42
|
-
const scalar = ['DateTime', 'Date', 'Time'].includes(base.name) ? 'DateTime' : base.name;
|
|
55
|
+
// The walk stops on a type that still has a base only where the chain repeats.
|
|
56
|
+
if (rootScalar(type).baseScalarType) invalid(`Cyclic base scalar ${type.name}`);
|
|
57
|
+
const name = effectiveScalarName(type);
|
|
58
|
+
const scalar = ['DateTime', 'Date', 'Time'].includes(name) ? 'DateTime' : name;
|
|
43
59
|
if (!['String', 'ID', 'Int', 'Float', 'Boolean', 'DateTime'].includes(scalar)) {
|
|
44
60
|
invalid(`Unsupported scalar ${type.name}: declare a supported baseScalarType`);
|
|
45
61
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getNamedType, isInterfaceType, isObjectType, isScalarType, isUnionType,
|
|
3
|
+
} from 'graphql';
|
|
4
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
5
|
+
import { effectiveScalarName } from './metadata.js';
|
|
6
|
+
import { getListShape, isVirtualField } from './relation-storage.js';
|
|
7
|
+
|
|
8
|
+
// Internal: what the emptiness operators IS_EMPTY and IS_NOT_EMPTY (#108, part 2a) mean for a path,
|
|
9
|
+
// shared by the runtime gate, the query plan and the MongoDB compiler through the
|
|
10
|
+
// `./internal/query-operators` subpath, which re-exports it and is not public API. Each backend
|
|
11
|
+
// compiles the shape described here to its own forms.
|
|
12
|
+
|
|
13
|
+
/** The emptiness operators, in QLOperator order. IS_NOT_EMPTY is the two-valued complement of IS_EMPTY. */
|
|
14
|
+
export const EMPTINESS_QUERY_OPERATORS = Object.freeze(['IS_EMPTY', 'IS_NOT_EMPTY']);
|
|
15
|
+
|
|
16
|
+
/** Whether `operator` is IS_EMPTY or IS_NOT_EMPTY. */
|
|
17
|
+
export const isEmptinessOperator = (operator) => EMPTINESS_QUERY_OPERATORS.includes(operator);
|
|
18
|
+
|
|
19
|
+
const fail = (message, code) => { throw new SimfinityError(message, code, 400); };
|
|
20
|
+
const pathName = (path) => (Array.isArray(path) ? path.join('.') : path);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validates the value of an emptiness condition: it must be omitted (undefined) or null. Anything
|
|
24
|
+
* else, `""`, `[]`, `{}`, `false` and `0` included, fails with INVALID_FILTER_VALUE (400). `path`
|
|
25
|
+
* names the condition in the message. Returns null, the value the plan carries.
|
|
26
|
+
*/
|
|
27
|
+
export const validateEmptinessValue = (operator, value, path) => {
|
|
28
|
+
if (value !== undefined && value !== null) {
|
|
29
|
+
fail(`${operator} takes no value${path == null ? '' : ` on ${pathName(path)}`}: omit it or pass null`, 'INVALID_FILTER_VALUE');
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const PATH_SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
35
|
+
// The record identity, the queried entity's own `id` or `_id`, is never empty in practice: PostgreSQL
|
|
36
|
+
// stores it as a uuid primary key even when it is declared String.
|
|
37
|
+
const IDENTITY = ['id', '_id'];
|
|
38
|
+
const LISTS = { embeddedList: 'an embedded list', collection: 'a collection' };
|
|
39
|
+
|
|
40
|
+
// A path step or a member, classified as MongoDB reads paths: interface and union types first, then an
|
|
41
|
+
// object field without a relation or with `embedded: true` is embedded, and any other object field is
|
|
42
|
+
// 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
|
+
const graphqlStep = (name, field) => {
|
|
45
|
+
const type = getNamedType(field.type);
|
|
46
|
+
const list = !!getListShape(field.type);
|
|
47
|
+
if (isInterfaceType(type) || isUnionType(type)) return { name, kind: 'abstract' };
|
|
48
|
+
if (isObjectType(type)) {
|
|
49
|
+
const relation = field.extensions?.relation;
|
|
50
|
+
const embedded = !relation || relation.embedded;
|
|
51
|
+
return {
|
|
52
|
+
name, typeName: type.name, node: type, kind: embedded ? (list ? 'embeddedList' : 'embedded') : (list ? 'collection' : 'reference'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { name, kind: list ? 'scalarList' : 'scalar', string: isScalarType(type) && effectiveScalarName(type) === 'String' };
|
|
56
|
+
};
|
|
57
|
+
// Virtual members store nothing and are never read.
|
|
58
|
+
const graphqlMembers = (type) => Object.entries(type.getFields())
|
|
59
|
+
.filter(([, field]) => !isVirtualField(field)).map(([name, field]) => graphqlStep(name, field));
|
|
60
|
+
|
|
61
|
+
// 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.
|
|
63
|
+
const modelStep = (field) => {
|
|
64
|
+
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' };
|
|
67
|
+
return { name: field.name, kind: field.list ? 'scalarList' : 'scalar', string: field.scalar === 'String' };
|
|
68
|
+
};
|
|
69
|
+
const modelMembers = (fields) => fields.filter((field) => !field.private).map(modelStep);
|
|
70
|
+
|
|
71
|
+
const leafShape = (step, atRoot) => {
|
|
72
|
+
if (step.kind === 'scalarList' || step.kind === 'embeddedList') return { rule: 'list' };
|
|
73
|
+
if (atRoot && IDENTITY.includes(step.name)) return { rule: 'null' };
|
|
74
|
+
return { rule: step.string ? 'string' : 'null' };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Rules 3 to 6 of the order of checks, on a path whose fields resolved (rule 1): rule 3 throws as
|
|
78
|
+
// soon as it is found; a cycle (rule 4) and a reference or collection (rule 6) are recorded, are not
|
|
79
|
+
// entered, and throw only once the whole path and its members passed rule 3, with the value (rule 5)
|
|
80
|
+
// checked between them.
|
|
81
|
+
const describeSteps = (steps, { operator, value, label, membersOf }) => {
|
|
82
|
+
const segments = steps.map((step) => step.name);
|
|
83
|
+
const prefix = (index) => segments.slice(0, index + 1).join('.');
|
|
84
|
+
let cycle = null;
|
|
85
|
+
let unsupported = null;
|
|
86
|
+
const abstract = (path) => fail(`${operator} cannot test ${label}: ${path} is an interface or union field, whose emptiness is not defined`, 'INVALID_FILTER_PATH');
|
|
87
|
+
for (const [index, step] of steps.entries()) {
|
|
88
|
+
const list = index > 0 ? LISTS[steps[index - 1].kind] : null;
|
|
89
|
+
if (list) {
|
|
90
|
+
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
|
+
}
|
|
92
|
+
if (step.kind === 'abstract') abstract(prefix(index));
|
|
93
|
+
if (!unsupported && (step.kind === 'reference' || step.kind === 'collection')) {
|
|
94
|
+
unsupported = `${operator} on ${label} ${index === steps.length - 1 ? 'ends at' : 'crosses'} the ${step.kind} ${prefix(index)}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 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.
|
|
99
|
+
const expand = (node, base, ancestors) => {
|
|
100
|
+
const members = [];
|
|
101
|
+
for (const member of membersOf(node)) {
|
|
102
|
+
const memberPath = [...base, member.name].join('.');
|
|
103
|
+
if (member.kind === 'abstract') abstract(memberPath);
|
|
104
|
+
if (member.kind === 'reference' || member.kind === 'collection') {
|
|
105
|
+
unsupported ??= `${operator} on ${label} reads the ${member.kind} member ${memberPath}`;
|
|
106
|
+
} else if (member.kind !== 'embedded') {
|
|
107
|
+
members.push({ name: member.name, shape: leafShape(member, false) });
|
|
108
|
+
} else if (ancestors.includes(member.node)) {
|
|
109
|
+
cycle ??= `${operator} cannot test ${label}: the embedded type ${member.typeName ?? `of ${memberPath}`} reaches itself through the single embedded member ${memberPath}`;
|
|
110
|
+
} else {
|
|
111
|
+
members.push({ name: member.name, shape: { rule: 'object', members: expand(member.node, [...base, member.name], [...ancestors, member.node]) } });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return members;
|
|
115
|
+
};
|
|
116
|
+
const last = steps.at(-1);
|
|
117
|
+
const shape = last.kind === 'embedded'
|
|
118
|
+
? { rule: 'object', members: expand(last.node, segments, [last.node]) }
|
|
119
|
+
: leafShape(last, steps.length === 1);
|
|
120
|
+
if (cycle) fail(cycle, 'INVALID_FILTER_OPERATOR');
|
|
121
|
+
validateEmptinessValue(operator, value, label);
|
|
122
|
+
if (unsupported) {
|
|
123
|
+
fail(`${unsupported}: emptiness through references and collections arrives with reference emptiness (#108)`, 'UNSUPPORTED_QUERY');
|
|
124
|
+
}
|
|
125
|
+
return shape;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Rule 1 on GraphQL types: segment syntax and declared, stored fields, with resolveModelPath's codes
|
|
129
|
+
// and messages; nothing is traversed after a field that is not an object.
|
|
130
|
+
const graphqlSteps = (gqltype, segments, path) => {
|
|
131
|
+
const parts = typeof segments === 'string' ? segments.split('.') : segments;
|
|
132
|
+
if (!Array.isArray(parts) || !parts.length || parts.some((part) => typeof part !== 'string' || !PATH_SEGMENT.test(part))) {
|
|
133
|
+
fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
134
|
+
}
|
|
135
|
+
const label = path == null ? parts.join('.') : pathName(path);
|
|
136
|
+
const steps = [];
|
|
137
|
+
let type = gqltype;
|
|
138
|
+
for (const [index, name] of parts.entries()) {
|
|
139
|
+
const fields = isObjectType(type) ? type.getFields() : null;
|
|
140
|
+
if (!fields || !Object.hasOwn(fields, name)) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
141
|
+
if (isVirtualField(fields[name])) {
|
|
142
|
+
fail(`Query path ${label} uses the virtual field ${type.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
143
|
+
}
|
|
144
|
+
const step = graphqlStep(name, fields[name]);
|
|
145
|
+
if (index < parts.length - 1 && !step.node) fail(`Field ${name} cannot be traversed`, 'INVALID_FILTER_PATH');
|
|
146
|
+
steps.push(step);
|
|
147
|
+
type = step.node;
|
|
148
|
+
}
|
|
149
|
+
return { steps, label };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Describes what an emptiness condition tests on a path of `gqltype`, the queried type, and applies
|
|
154
|
+
* the checks of #108 part 2a in their order: rule 1 (segment syntax and declared, stored fields, with
|
|
155
|
+
* resolveModelPath's codes and messages; nothing is traversed after a field that is not an object),
|
|
156
|
+
* then rule 3 (INVALID_FILTER_PATH for a segment after an embedded list or a collection, and for an
|
|
157
|
+
* interface or union field on the path or among the members), rule 4 (INVALID_FILTER_OPERATOR when
|
|
158
|
+
* 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 when the path crosses or ends at a
|
|
160
|
+
* reference or a collection, or a member is one). Form, depth and the adapter's operators are the
|
|
161
|
+
* caller's checks. `segments` is an array or a dotted string; `path` names the condition in messages.
|
|
162
|
+
*
|
|
163
|
+
* Returns the shape: `{ rule: 'string' }` (missing, null or ""), `{ rule: 'null' }` (missing or
|
|
164
|
+
* null; the record identity always), `{ rule: 'list' }` (missing, null or []), or
|
|
165
|
+
* `{ rule: 'object', members: [{ name, shape }] }` (missing, null, or every stored member empty).
|
|
166
|
+
*/
|
|
167
|
+
export const describeEmptiness = (gqltype, segments, { operator = 'IS_EMPTY', value, path } = {}) => {
|
|
168
|
+
const { steps, label } = graphqlSteps(gqltype, segments, path);
|
|
169
|
+
return describeSteps(steps, { operator, value, label, membersOf: graphqlMembers });
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Rule 1 of {@link describeEmptiness} alone, with its codes and messages, for a caller that applies a
|
|
174
|
+
* check of its own between rule 1 and rules 3 to 6, as the runtime gate applies the adapter's
|
|
175
|
+
* operators (rule 2). Throws for a path that does not fully resolve on `gqltype`; returns nothing.
|
|
176
|
+
*/
|
|
177
|
+
export const assertEmptinessPath = (gqltype, segments, { path } = {}) => {
|
|
178
|
+
graphqlSteps(gqltype, segments, path);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The same description from model metadata, for a description whose entities carry no GraphQL type:
|
|
183
|
+
* `steps` are the field descriptions resolveModelPath returns for the path, so rule 1 is already
|
|
184
|
+
* done. Gives the shapes, codes and messages of {@link describeEmptiness} for the same types.
|
|
185
|
+
*/
|
|
186
|
+
export const describeModelEmptiness = (steps, { operator = 'IS_EMPTY', value, path } = {}) => {
|
|
187
|
+
if (!Array.isArray(steps) || !steps.length) fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
188
|
+
const classified = steps.map(modelStep);
|
|
189
|
+
const label = path == null ? classified.map((step) => step.name).join('.') : pathName(path);
|
|
190
|
+
return describeSteps(classified, { operator, value, label, membersOf: modelMembers });
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The stored member paths an emptiness condition reads when its path, on `gqltype`, ends at a single
|
|
195
|
+
* embedded object: absolute segment arrays, depth first in field order, entering single embedded
|
|
196
|
+
* members and nothing else. Virtual members are left out. A list, reference, collection, interface or
|
|
197
|
+
* union member is listed and not entered, and so is a single embedded member whose type is already on
|
|
198
|
+
* the chain being listed. A path that does not resolve to a single embedded object lists nothing. The
|
|
199
|
+
* path is followed as walkQueryPath follows it. Never throws; every code comes from the checks.
|
|
200
|
+
*/
|
|
201
|
+
export const emptinessMemberPaths = (gqltype, segments) => {
|
|
202
|
+
const paths = [];
|
|
203
|
+
if (!Array.isArray(segments) || !segments.length) return paths;
|
|
204
|
+
let type = gqltype;
|
|
205
|
+
let last = null;
|
|
206
|
+
for (const name of segments) {
|
|
207
|
+
const fields = typeof type?.getFields === 'function' ? type.getFields() : null;
|
|
208
|
+
if (!fields || typeof name !== 'string' || !Object.hasOwn(fields, name) || isVirtualField(fields[name])) return paths;
|
|
209
|
+
last = graphqlStep(name, fields[name]);
|
|
210
|
+
type = getNamedType(fields[name].type);
|
|
211
|
+
}
|
|
212
|
+
if (last.kind !== 'embedded') return paths;
|
|
213
|
+
const visit = (node, base, ancestors) => {
|
|
214
|
+
for (const member of graphqlMembers(node)) {
|
|
215
|
+
const memberPath = [...base, member.name];
|
|
216
|
+
paths.push(memberPath);
|
|
217
|
+
if (member.kind === 'embedded' && !ancestors.includes(member.node)) visit(member.node, memberPath, [...ancestors, member.node]);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
visit(last.node, segments, [last.node]);
|
|
221
|
+
return paths;
|
|
222
|
+
};
|
package/src/query-operators.js
CHANGED
|
@@ -3,7 +3,14 @@ import SimfinityError from './errors/simfinity.error.js';
|
|
|
3
3
|
|
|
4
4
|
// Internal: the filter operators and the value rules every backend applies to them. They are shared
|
|
5
5
|
// by the core runtime, the query plan and the MongoDB compiler through the
|
|
6
|
-
// `./internal/query-operators` subpath, which is not public API.
|
|
6
|
+
// `./internal/query-operators` subpath, which is not public API. The emptiness operators live in
|
|
7
|
+
// query-emptiness.js and are re-exported here, so the subpath stays the only one.
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
EMPTINESS_QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, isEmptinessOperator,
|
|
11
|
+
validateEmptinessValue,
|
|
12
|
+
} from './query-emptiness.js';
|
|
13
|
+
export { effectiveScalarName } from './metadata.js';
|
|
7
14
|
|
|
8
15
|
/** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
|
|
9
16
|
export const QUERY_OPERATORS = Object.freeze(QLOperator.getValues().map((entry) => entry.value));
|
package/src/query-plan.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { paginationStages } from './query-limits.js';
|
|
2
2
|
import SimfinityError from './errors/simfinity.error.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, isEmptinessOperator, textQueryOperator,
|
|
5
|
+
validateTextFilterValue,
|
|
6
|
+
} from './query-operators.js';
|
|
4
7
|
|
|
5
8
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
6
9
|
const operators = new Set(QUERY_OPERATORS);
|
|
@@ -10,6 +13,10 @@ const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
|
10
13
|
const listReserved = new Set(['AND', 'OR', 'sort', 'pagination']);
|
|
11
14
|
const controlArgs = (operation) => (operation === 'aggregate' ? reserved : listReserved);
|
|
12
15
|
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
16
|
+
const nestedGroups = (group) => [...listOf(group.AND), ...listOf(group.OR)];
|
|
17
|
+
// A term or condition path that names nothing: for the emptiness operators only, the field itself.
|
|
18
|
+
const isPathless = (path) => path == null || path === '';
|
|
19
|
+
const hasTermPath = (term) => typeof term?.path === 'string' && term.path !== '';
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
22
|
* Internal: the model paths named by generated query arguments, as `{ segments, group }` entries.
|
|
@@ -17,30 +24,59 @@ const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
|
17
24
|
* groupId and fact paths; aggregate sort terms name result keys. Outside aggregate mode a key
|
|
18
25
|
* named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
19
26
|
* object whose `conditions` hold the path, or null for the other forms. Malformed entries are
|
|
20
|
-
* left to the backends.
|
|
27
|
+
* left to the backends. An emptiness term without a path (omitted, null or "") names its field,
|
|
28
|
+
* also next to sibling terms that have paths.
|
|
29
|
+
*
|
|
30
|
+
* 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 `{ segments, group,
|
|
32
|
+
* member: true }` entries, so that the checks of client paths apply to them. Only client arguments
|
|
33
|
+
* are expanded.
|
|
21
34
|
*/
|
|
22
|
-
export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
35
|
+
export const collectQueryPathEntries = (args, operation = 'find', { gqltype, expandMembers = false } = {}) => {
|
|
23
36
|
const entries = [];
|
|
24
37
|
const add = (path, group = null) => {
|
|
25
38
|
if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group });
|
|
26
39
|
};
|
|
40
|
+
const addEmptiness = (path, group = null) => {
|
|
41
|
+
add(path, group);
|
|
42
|
+
if (!expandMembers || typeof path !== 'string' || !path) return;
|
|
43
|
+
for (const segments of emptinessMemberPaths(gqltype, path.split('.'))) entries.push({ segments, group, member: true });
|
|
44
|
+
};
|
|
27
45
|
if (!args || typeof args !== 'object') return entries;
|
|
28
46
|
const visit = (group) => {
|
|
29
47
|
if (!group || typeof group !== 'object') return;
|
|
30
48
|
for (const condition of listOf(group.conditions)) {
|
|
31
49
|
if (typeof condition?.field !== 'string') continue;
|
|
32
|
-
|
|
50
|
+
const path = isPathless(condition.path) ? condition.field : `${condition.field}.${condition.path}`;
|
|
51
|
+
if (isEmptinessOperator(condition.operator)) addEmptiness(path, group);
|
|
52
|
+
else add(path, group);
|
|
33
53
|
}
|
|
34
|
-
for (const item of
|
|
54
|
+
for (const item of nestedGroups(group)) visit(item);
|
|
35
55
|
};
|
|
36
56
|
const controls = controlArgs(operation);
|
|
37
57
|
for (const [name, value] of Object.entries(args)) {
|
|
38
58
|
if (controls.has(name) || value == null) continue;
|
|
39
|
-
const terms = listOf(value.terms)
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
const terms = listOf(value.terms);
|
|
60
|
+
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
61
|
+
if (!terms.some(hasTermPath)) {
|
|
62
|
+
if (isEmptinessOperator(value.operator) || terms.some(pathless)) addEmptiness(name);
|
|
63
|
+
else add(name);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let field = false;
|
|
67
|
+
for (const term of terms) {
|
|
68
|
+
if (hasTermPath(term)) {
|
|
69
|
+
if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}`);
|
|
70
|
+
else add(`${name}.${term.path}`);
|
|
71
|
+
} else if (!field && pathless(term)) {
|
|
72
|
+
addEmptiness(name);
|
|
73
|
+
field = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// An emptiness operator beside terms is rejected later; until then it reads the field too.
|
|
77
|
+
if (!field && isEmptinessOperator(value.operator)) addEmptiness(name);
|
|
42
78
|
}
|
|
43
|
-
for (const item of
|
|
79
|
+
for (const item of nestedGroups(args)) visit(item);
|
|
44
80
|
if (operation === 'aggregate') {
|
|
45
81
|
add(args.aggregation?.groupId);
|
|
46
82
|
for (const fact of listOf(args.aggregation?.facts)) add(fact?.path);
|
|
@@ -49,36 +85,75 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
|
49
85
|
};
|
|
50
86
|
|
|
51
87
|
/**
|
|
52
|
-
* Internal:
|
|
53
|
-
* filters, relation terms and AND/OR group
|
|
54
|
-
*
|
|
55
|
-
*
|
|
88
|
+
* Internal: every condition the arguments hold, as `{ operator, path, segments, form, field, rawPath,
|
|
89
|
+
* value, hasValue, ambiguous, depth }` entries: field filters, relation terms and AND/OR group
|
|
90
|
+
* conditions at any depth, with an omitted operator read as `EQ`.
|
|
91
|
+
* - `form` is 'filter' (an argument's own operator, without a terms array), 'beside-terms' (an
|
|
92
|
+
* operator written next to a terms array, which the compilers read only to reject an emptiness
|
|
93
|
+
* operator), 'term' or 'condition'.
|
|
94
|
+
* - `field` is the argument or condition field as written (a condition's may be dotted) and `rawPath`
|
|
95
|
+
* the term or condition path as written. `path`, for messages, and `segments` join them; a path that
|
|
96
|
+
* is omitted, null or "" names the field itself.
|
|
97
|
+
* - `value` is the condition's value, and `hasValue` whether it has an own `value` key.
|
|
98
|
+
* - `ambiguous` marks a condition with both a dotted field and a non-empty path.
|
|
99
|
+
* - `depth` is 0 for argument filters and for the groups listed in args.AND and args.OR, and one more
|
|
100
|
+
* for each nested group; a group reached at several depths reports the deepest.
|
|
101
|
+
* Sort, pagination and aggregation hold no operators. Malformed entries are skipped and left to the
|
|
102
|
+
* backends; a group reached twice is read once.
|
|
56
103
|
*/
|
|
57
104
|
export const collectQueryOperators = (args, operation = 'find') => {
|
|
58
105
|
const entries = [];
|
|
59
106
|
if (!args || typeof args !== 'object') return entries;
|
|
60
107
|
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
61
|
-
const add = (item,
|
|
62
|
-
if (isObject(item))
|
|
108
|
+
const add = (item, form, field, rawPath, depth, ambiguous = false) => {
|
|
109
|
+
if (!isObject(item)) return;
|
|
110
|
+
const path = isPathless(rawPath) ? field : `${field}.${rawPath}`;
|
|
111
|
+
entries.push({
|
|
112
|
+
operator: item.operator ?? 'EQ',
|
|
113
|
+
path,
|
|
114
|
+
segments: typeof path === 'string' && path ? path.split('.') : [],
|
|
115
|
+
form,
|
|
116
|
+
field,
|
|
117
|
+
rawPath,
|
|
118
|
+
value: item.value,
|
|
119
|
+
hasValue: Object.hasOwn(item, 'value'),
|
|
120
|
+
ambiguous,
|
|
121
|
+
depth,
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
// The deepest level of each group, as the backends' depth limit counts it; a group inside itself is
|
|
125
|
+
// not entered again.
|
|
126
|
+
const depths = new Map();
|
|
127
|
+
const open = new Set();
|
|
128
|
+
const measure = (group, depth) => {
|
|
129
|
+
if (!isObject(group) || open.has(group) || depths.get(group) >= depth) return;
|
|
130
|
+
depths.set(group, depth);
|
|
131
|
+
open.add(group);
|
|
132
|
+
for (const item of nestedGroups(group)) measure(item, depth + 1);
|
|
133
|
+
open.delete(group);
|
|
63
134
|
};
|
|
64
135
|
const visited = new Set();
|
|
65
136
|
const visit = (group) => {
|
|
66
137
|
if (!isObject(group) || visited.has(group)) return;
|
|
67
138
|
visited.add(group);
|
|
68
139
|
for (const condition of listOf(group.conditions)) {
|
|
69
|
-
|
|
140
|
+
const field = condition?.field;
|
|
141
|
+
const rawPath = condition?.path;
|
|
142
|
+
add(condition, 'condition', field, rawPath, depths.get(group), typeof field === 'string' && field.includes('.') && !isPathless(rawPath));
|
|
70
143
|
}
|
|
71
|
-
for (const item of
|
|
144
|
+
for (const item of nestedGroups(group)) visit(item);
|
|
72
145
|
};
|
|
73
146
|
const controls = controlArgs(operation);
|
|
74
147
|
for (const [name, value] of Object.entries(args)) {
|
|
75
148
|
if (controls.has(name) || !isObject(value)) continue;
|
|
76
149
|
// A relation filter's operators are in its terms; an operator beside them is read too, since a
|
|
77
150
|
// backend that reads the field as a scalar would compile it.
|
|
78
|
-
|
|
79
|
-
if (
|
|
151
|
+
const terms = Array.isArray(value.terms);
|
|
152
|
+
if (!terms || value.operator !== undefined) add(value, terms ? 'beside-terms' : 'filter', name, undefined, 0);
|
|
153
|
+
if (terms) for (const term of value.terms) add(term, 'term', name, term?.path, 0);
|
|
80
154
|
}
|
|
81
|
-
for (const item of
|
|
155
|
+
for (const item of nestedGroups(args)) measure(item, 0);
|
|
156
|
+
for (const item of nestedGroups(args)) visit(item);
|
|
82
157
|
return entries;
|
|
83
158
|
};
|
|
84
159
|
|
|
@@ -127,6 +202,14 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
127
202
|
* and values are validated here, text operators through validateTextFilterValue. With `operators`,
|
|
128
203
|
* the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
|
|
129
204
|
* without it every QLOperator value is planned.
|
|
205
|
+
*
|
|
206
|
+
* The emptiness operators plan `{ kind: 'predicate', path, operator, value: null }`, whose path may
|
|
207
|
+
* end at a scalar, a scalar list, an embedded list or an embedded object, after the checks of
|
|
208
|
+
* describeEmptiness in their order (on the entity's GraphQL type, or on the field descriptions when
|
|
209
|
+
* the description has none). A term or condition without a path tests the field itself; an emptiness
|
|
210
|
+
* operator on an object filter without terms, or beside terms, fails with MISSING_FILTER_PATH. A
|
|
211
|
+
* filter path of other operators that has more than one segment and ends at an object fails with
|
|
212
|
+
* INVALID_FILTER_PATH; a bare object field keeps MISSING_FILTER_PATH.
|
|
130
213
|
*/
|
|
131
214
|
export const createQueryPlan = (models, entityName, input = {}, { mode = 'find', operators: supported } = {}) => {
|
|
132
215
|
const allowed = supported == null ? null : new Set(supported);
|
|
@@ -134,11 +217,24 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
134
217
|
const active = terms.filter(Boolean);
|
|
135
218
|
return active.length > 1 ? { kind, terms: active } : active[0] || null;
|
|
136
219
|
};
|
|
220
|
+
// 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) => {
|
|
222
|
+
if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
223
|
+
const segments = Array.isArray(path) ? path : path.split('.');
|
|
224
|
+
const options = { operator, value, path: segments.join('.') };
|
|
225
|
+
const gqltype = models.entities.find((item) => item.name === entityName)?.gqltype;
|
|
226
|
+
if (gqltype) describeEmptiness(gqltype, segments, options);
|
|
227
|
+
else describeModelEmptiness(steps, options);
|
|
228
|
+
return { kind: 'predicate', path: segments, operator, value: null };
|
|
229
|
+
};
|
|
137
230
|
const predicate = (path, operator, value) => {
|
|
138
231
|
const steps = resolveModelPath(models, entityName, path);
|
|
139
|
-
const field = steps.at(-1);
|
|
140
|
-
if (field.kind !== 'scalar' && !field.inferred) fail(`Filter on object field ${path} requires a scalar path`, 'MISSING_FILTER_PATH');
|
|
141
232
|
const op = operator ?? 'EQ';
|
|
233
|
+
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value);
|
|
234
|
+
const field = steps.at(-1);
|
|
235
|
+
if (field.kind !== 'scalar' && !field.inferred) {
|
|
236
|
+
fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
237
|
+
}
|
|
142
238
|
if (!operators.has(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
143
239
|
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
144
240
|
const collection = ['IN', 'NIN', 'BTW'].includes(op);
|
|
@@ -174,10 +270,21 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
174
270
|
if (controls.has(name) || value == null) continue;
|
|
175
271
|
const field = resolveModelPath(models, entityName, name).at(-1);
|
|
176
272
|
if (typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter object');
|
|
177
|
-
|
|
178
|
-
|
|
273
|
+
const object = field.kind !== 'scalar' && !field.inferred;
|
|
274
|
+
// Only trusted code writes these forms, since QLTypeFilterExpression has no operator. Other
|
|
275
|
+
// operators beside terms are left out, as before.
|
|
276
|
+
if (object && isEmptinessOperator(value.operator)) {
|
|
277
|
+
fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term without a path: `
|
|
278
|
+
+ `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
|
|
279
|
+
}
|
|
280
|
+
if (object && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
281
|
+
if (!object) terms.push(predicate(name, value.operator, value.value));
|
|
179
282
|
else for (const term of value.terms) {
|
|
180
283
|
if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
|
|
284
|
+
if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
|
|
285
|
+
terms.push(predicate(name, term.operator, term.value));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
181
288
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
182
289
|
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value));
|
|
183
290
|
}
|