@simtlix/simfinity-core 3.7.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/package.json +2 -1
- package/src/const/QLOperator.js +6 -0
- package/src/index.js +1 -0
- package/src/introspection.js +10 -1
- package/src/metadata.js +17 -2
- package/src/query-operators.js +80 -0
- package/src/query-plan.js +46 -3
- package/src/relation-storage.js +53 -3
- package/src/runtime.js +139 -12
- package/src/virtual-fields.js +135 -0
- package/types/index.d.ts +120 -4
package/README.md
CHANGED
|
@@ -16,26 +16,30 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
|
|
|
16
16
|
- `preventCreatingCollection` to pass validation-only storage setup to an adapter;
|
|
17
17
|
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
|
|
18
18
|
|
|
19
|
-
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. 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
|
|
19
|
+
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.
|
|
20
20
|
|
|
21
21
|
An adapter can also define `findChildrenBatch(model, type, connectionField, parentIds, args, { context })`, the batch form of `findChildren` for generated collection fields, and a synchronous `batchesChildren(model, type, connectionField)` predicate. The runtime calls both as adapter methods and checks them on every read. When the method is missing or the predicate returns `false`, each field calls `findChildren` at once with the arguments object it built, as before. Otherwise, within one request (a context object), the runtime groups the collection reads of one related type and connection storage field whose effective arguments (after middleware, the scope and joined scopes) hold the same plain data and are requested in the same tick, that is before the promise jobs queued so far have run. It calls the method with 2 to 100 distinct parent IDs, each a raw ID that `castId` accepts without changing its string form, and with a copy of the arguments that the method may change; a group with one distinct parent calls `findChildren`. Return one array per parent ID, in order, each holding exactly the records, order and page that `findChildren` returns for that parent; records should show their parent under the connection field, or not show one. Fields of one parent share its records, each with its own array. After a rejection, or a result of another shape or with records of another parent, each field reads alone with `findChildren`; failures other than a `SimfinityError`, and results of another shape, are logged once per type. Pairing rule: the batch must reproduce `this.findChildren`, so `batchesChildren` must return `false` when `this.findChildren` is not the adapter's own function, as with a spread copy or an `Object.assign` override that changes how children are read. The bundled adapters follow this rule: both define the two members only with `batchedCollections: true`: the SQL adapter when the schema is prepared and its plugin's `compileChildrenQuery` is paired with its `compileQuery`, and the MongoDB adapter at creation. To opt out on one adapter instance, set `adapter.findChildrenBatch = undefined` (deleting it does nothing for a class whose method is on the prototype), or make `batchesChildren` return `false`. A copy that overrides `getById` still inherits the base `getByIds`, as before.
|
|
22
22
|
|
|
23
|
+
An adapter declares the filter operators it compiles as `queryFeatures: { operators }`, a list of `QLOperator` values that includes `EQ`, which a condition without an operator means; names this version does not know are ignored. `createRuntime()` reads it once and throws `INVALID_QUERY_FEATURES` (500) unless it is `undefined`, `null` or an object whose `operators` is an array of strings. Without a declaration the adapter supports the operators of 3.7.0, `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`, so it never receives a text operator such as `ILIKE` that it would drop or misread. A list, count, aggregate or collection read, or a by-ID read through a `get_by_id` scope, that uses another `QLOperator` value fails with `UNSUPPORTED_QUERY` (400): the runtime checks the client's arguments before middleware runs and the final arguments, with what middleware, scopes and joined scopes added, before it calls the adapter. Operators that `QLOperator` does not list still reach the adapter. An adapter that receives the text operators must apply their rules: literal values, a `String` field or list, and `INVALID_FILTER_VALUE` (400) for a value that is not a string or contains U+0000. `createQueryPlan(models, entity, args, { operators })` validates them and rejects operators outside `operators` with `UNSUPPORTED_QUERY`.
|
|
24
|
+
|
|
23
25
|
Entity types, those registered with `connect()` and the targets of non-embedded relations, resolve `id` to `_id`, falling back to `id`, whatever the `createSchema()` allowlists include; types used only as embedded values keep their declared `id`. Embedded copies of an entity type resolve the same way, so they return their declared `id` unless the adapter stores an `_id` for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. `createSchema()` marks these registrations with `storedIdentity: true` before calling `adapter.prepare()`.
|
|
24
26
|
|
|
25
27
|
When a runtime first creates a schema, it binds each GraphQL object type whose non-embedded relation fields receive its generated resolvers. It also reserves each reachable object type that still has a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a bound or reserved type in another runtime, or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, throws `TYPE_BOUND_TO_OTHER_RUNTIME` (409). `createSchema()` also rejects a type with a relation field copied, by `toConfig()` or a field spread, after another runtime generated that field's resolver. Create type objects once per runtime, for example with a factory function. Types without non-embedded relation fields, and types whose relation fields all have application resolvers, can be shared, and another runtime can use a `toConfig()` copy made before the first runtime built its schema. A shared custom mutation result type with an unresolved relation field is rejected.
|
|
26
28
|
|
|
27
|
-
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. 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.
|
|
29
|
+
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.
|
|
28
30
|
|
|
29
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.
|
|
30
32
|
|
|
31
|
-
Importing the runtime installs Simfinity's `__Field.extensions` introspection field once per GraphQL module instance. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
|
|
33
|
+
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.
|
|
32
34
|
|
|
33
35
|
## Shared metadata and helpers
|
|
34
36
|
|
|
35
|
-
`describeModels([{ gqltype, endpoint }])` discovers the persistent entity graph from `GraphQLObjectType` definitions and `extensions.relation
|
|
37
|
+
`describeModels([{ gqltype, endpoint }])` discovers the persistent entity graph from `GraphQLObjectType` definitions and `extensions.relation`, leaving virtual fields out and describing no type that only virtual fields reach. Types registered with `endpoint: false` are still included when referenced. Reciprocal one-to-many definitions resolve to one child reference; explicit linking entities represent many-to-many relationships.
|
|
36
38
|
|
|
37
39
|
`createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
|
|
38
40
|
|
|
41
|
+
`isVirtualField(field)` tells whether a GraphQL field is virtual, and `virtualFieldOptions(field)` returns its normalized options, `{ mcp }`, or `null` for any other field. Adapters that build storage from GraphQL types use them to skip virtual fields.
|
|
42
|
+
|
|
39
43
|
The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `UnauthenticatedError` and `ForbiddenError` (the same classes as `auth.UnauthenticatedError` and `auth.ForbiddenError`), `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
|
|
40
44
|
|
|
41
45
|
The same helpers are available from the `@simtlix/simfinity-core/auth`, `/auth/errors`, `/auth/expressions`, `/auth/rules`, `/plugins`, `/scalars` and `/validators` subpaths, each with TypeScript declarations whose types match the root namespaces. `moduleResolution` `node16`, `nodenext` and `bundler` read them through the export map, and `node10` through `typesVersions`. The `internal/*` subpaths are not public API and have no declarations.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "Driver-free GraphQL runtime and model metadata for Simfinity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"default": "./src/auth/rules.js"
|
|
53
53
|
},
|
|
54
54
|
"./internal/object-id": "./src/auth/object-id.js",
|
|
55
|
+
"./internal/query-operators": "./src/query-operators.js",
|
|
55
56
|
"./internal/relation-storage": "./src/relation-storage.js",
|
|
56
57
|
"./plugins": {
|
|
57
58
|
"types": "./types/plugins.d.ts",
|
package/src/const/QLOperator.js
CHANGED
|
@@ -13,6 +13,12 @@ const QLOperator = new GraphQLEnumType({
|
|
|
13
13
|
IN: { value: 'IN' },
|
|
14
14
|
NIN: { value: 'NIN' },
|
|
15
15
|
LIKE: { value: 'LIKE' },
|
|
16
|
+
ILIKE: { value: 'ILIKE' },
|
|
17
|
+
STARTS_WITH: { value: 'STARTS_WITH' },
|
|
18
|
+
ISTARTS_WITH: { value: 'ISTARTS_WITH' },
|
|
19
|
+
ENDS_WITH: { value: 'ENDS_WITH' },
|
|
20
|
+
IENDS_WITH: { value: 'IENDS_WITH' },
|
|
21
|
+
IEQ: { value: 'IEQ' },
|
|
16
22
|
},
|
|
17
23
|
});
|
|
18
24
|
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { describeModels } from './metadata.js';
|
|
2
|
+
export { isVirtualField, virtualFieldOptions } from './relation-storage.js';
|
|
2
3
|
export { default as SimfinityError } from './errors/simfinity.error.js';
|
|
3
4
|
export { createValidatedScalar } from './scalars/factory.js';
|
|
4
5
|
export {
|
package/src/introspection.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
GraphQLString,
|
|
5
5
|
__Field,
|
|
6
6
|
} from 'graphql';
|
|
7
|
+
import { isVirtualField } from './relation-storage.js';
|
|
7
8
|
|
|
8
9
|
// The object types of `__Field.extensions`: FieldExtensionsType and the RelationType of its
|
|
9
10
|
// `relation` field, under the given names.
|
|
@@ -27,6 +28,14 @@ const createFieldMetadataType = (extensionsName, relationName) => {
|
|
|
27
28
|
});
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
// The metadata `__Field.extensions` returns: the field's own extensions, except that a virtual field
|
|
32
|
+
// reads `readOnly: true`, which virtual implies. The metadata types keep their fields, so schemas
|
|
33
|
+
// without virtual fields introspect exactly as before; client metadata about virtual fields belongs
|
|
34
|
+
// in a descriptor of its own (#101), not in this process-wide extension.
|
|
35
|
+
const fieldMetadataOf = (field) => (isVirtualField(field)
|
|
36
|
+
? { ...field.extensions, readOnly: true }
|
|
37
|
+
: field.extensions);
|
|
38
|
+
|
|
30
39
|
// getFields() supports both lazy and already materialized introspection fields.
|
|
31
40
|
// Reuse the shared field on repeated module evaluation to preserve type identity.
|
|
32
41
|
const introspectionFields = __Field.getFields();
|
|
@@ -34,7 +43,7 @@ if (!introspectionFields.extensions) {
|
|
|
34
43
|
introspectionFields.extensions = {
|
|
35
44
|
type: createFieldMetadataType('FieldExtensionsType', 'RelationType'),
|
|
36
45
|
name: 'extensions',
|
|
37
|
-
resolve:
|
|
46
|
+
resolve: fieldMetadataOf,
|
|
38
47
|
args: [],
|
|
39
48
|
isDeprecated: false,
|
|
40
49
|
};
|
package/src/metadata.js
CHANGED
|
@@ -2,6 +2,10 @@ import {
|
|
|
2
2
|
isObjectType, isScalarType, isEnumType, isNonNullType, isListType,
|
|
3
3
|
} from 'graphql';
|
|
4
4
|
import SimfinityError from './errors/simfinity.error.js';
|
|
5
|
+
import {
|
|
6
|
+
assertVirtualConnection, assertVirtualDeclaration, assertVirtualIndexes, assertVirtualStateField,
|
|
7
|
+
} from './virtual-fields.js';
|
|
8
|
+
import { assertPlainConnectionField } from './relation-storage.js';
|
|
5
9
|
|
|
6
10
|
const invalid = (message) => {
|
|
7
11
|
throw new SimfinityError(message, 'INVALID_MODEL', 400);
|
|
@@ -42,7 +46,11 @@ const scalarDescription = (type) => {
|
|
|
42
46
|
return { scalar };
|
|
43
47
|
};
|
|
44
48
|
|
|
45
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Describe persistent entities without importing a database driver or mutating GraphQL types.
|
|
51
|
+
* Virtual fields (`extensions.virtual`) store nothing, so they are left out of every entity and
|
|
52
|
+
* embedded type, and their result types are neither described nor required to declare a relation.
|
|
53
|
+
*/
|
|
46
54
|
export const describeModels = (registrations) => {
|
|
47
55
|
if (!Array.isArray(registrations)) invalid('registrations must be an array');
|
|
48
56
|
const knownTypes = new Map();
|
|
@@ -61,12 +69,16 @@ export const describeModels = (registrations) => {
|
|
|
61
69
|
}
|
|
62
70
|
};
|
|
63
71
|
for (const registration of registrations) registerType(registration.gqltype);
|
|
72
|
+
for (const registration of registrations) assertVirtualStateField(registration.gqltype, registration.stateMachine);
|
|
64
73
|
for (const registration of registrations) if (registration.endpoint !== false) addEntity(registration.gqltype);
|
|
65
74
|
|
|
66
75
|
const fieldsOf = (type, ancestors, path, embedded = false) => {
|
|
67
76
|
if (ancestors.includes(type)) invalid(`Embedded cycle at ${path}`);
|
|
68
77
|
registerType(type);
|
|
69
|
-
const
|
|
78
|
+
const stored = Object.values(type.getFields()).filter((field) => !assertVirtualDeclaration(
|
|
79
|
+
type.name, field.name, field, () => typeof field.resolve === 'function',
|
|
80
|
+
));
|
|
81
|
+
const fields = stored.map((field) => {
|
|
70
82
|
const { named, ...wrappers } = unwrap(field.type);
|
|
71
83
|
const extensions = field.extensions || {};
|
|
72
84
|
const descriptor = {
|
|
@@ -79,12 +91,14 @@ export const describeModels = (registrations) => {
|
|
|
79
91
|
if (!isObjectType(named)) invalid(`Unsupported field type at ${path}.${field.name}`);
|
|
80
92
|
const relation = extensions.relation;
|
|
81
93
|
if (!relation) invalid(`${path}.${field.name} requires extensions.relation`);
|
|
94
|
+
assertVirtualConnection(type, field.name, field);
|
|
82
95
|
if (relation.embedded) {
|
|
83
96
|
return {
|
|
84
97
|
...descriptor, kind: 'embedded',
|
|
85
98
|
fields: fieldsOf(named, [...ancestors, type], `${path}.${field.name}`, true),
|
|
86
99
|
};
|
|
87
100
|
}
|
|
101
|
+
assertPlainConnectionField(type.name, field.name, field);
|
|
88
102
|
addEntity(named);
|
|
89
103
|
if (wrappers.list) {
|
|
90
104
|
if (!relation.connectionField) invalid(`${path}.${field.name} requires a child connectionField`);
|
|
@@ -111,6 +125,7 @@ export const describeModels = (registrations) => {
|
|
|
111
125
|
}
|
|
112
126
|
return { fields: [...index.fields], unique: index.unique === true };
|
|
113
127
|
});
|
|
128
|
+
assertVirtualIndexes(type);
|
|
114
129
|
}
|
|
115
130
|
for (const entity of entities.values()) {
|
|
116
131
|
for (const field of entity.fields.filter((item) => item.kind === 'collection')) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import QLOperator from './const/QLOperator.js';
|
|
2
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
3
|
+
|
|
4
|
+
// Internal: the filter operators and the value rules every backend applies to them. They are shared
|
|
5
|
+
// by the core runtime, the query plan and the MongoDB compiler through the
|
|
6
|
+
// `./internal/query-operators` subpath, which is not public API.
|
|
7
|
+
|
|
8
|
+
/** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
|
|
9
|
+
export const QUERY_OPERATORS = Object.freeze(QLOperator.getValues().map((entry) => entry.value));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The operators of 3.7.0. An adapter that declares no `queryFeatures` supports exactly these, so a
|
|
13
|
+
* custom adapter written for 3.7 never receives an operator it would drop or misread.
|
|
14
|
+
*/
|
|
15
|
+
export const LEGACY_QUERY_OPERATORS = Object.freeze(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The literal text operators (#108, part 1) and what each one matches: `contains`, `prefix`,
|
|
19
|
+
* `suffix` or `exact` (the whole value), respecting case or ignoring it (`ignoreCase`). Values are
|
|
20
|
+
* literal: `%`, `_`, `.`, `*`, `\`, `^`, `$` and `(` match themselves. `LIKE`, the case-sensitive
|
|
21
|
+
* substring of 3.7.0, keeps its own validation and compilation and is not listed here.
|
|
22
|
+
*/
|
|
23
|
+
export const TEXT_QUERY_OPERATORS = Object.freeze({
|
|
24
|
+
ILIKE: Object.freeze({ match: 'contains', ignoreCase: true }),
|
|
25
|
+
STARTS_WITH: Object.freeze({ match: 'prefix', ignoreCase: false }),
|
|
26
|
+
ISTARTS_WITH: Object.freeze({ match: 'prefix', ignoreCase: true }),
|
|
27
|
+
ENDS_WITH: Object.freeze({ match: 'suffix', ignoreCase: false }),
|
|
28
|
+
IENDS_WITH: Object.freeze({ match: 'suffix', ignoreCase: true }),
|
|
29
|
+
IEQ: Object.freeze({ match: 'exact', ignoreCase: true }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Whether `operator` is a `QLOperator` value. */
|
|
33
|
+
export const isQueryOperator = (operator) => QUERY_OPERATORS.includes(operator);
|
|
34
|
+
|
|
35
|
+
/** The {@link TEXT_QUERY_OPERATORS} entry of `operator`, or null for any other operator. */
|
|
36
|
+
export const textQueryOperator = (operator) => (
|
|
37
|
+
typeof operator === 'string' && Object.hasOwn(TEXT_QUERY_OPERATORS, operator) ? TEXT_QUERY_OPERATORS[operator] : null
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const invalidValue = (message) => { throw new SimfinityError(message, 'INVALID_FILTER_VALUE', 400); };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validates the value of a text operator condition before any backend compiles it, so both backends
|
|
44
|
+
* reject the same input with the same code. `scalar` names the effective scalar of the path's last
|
|
45
|
+
* field: the base scalar of a validated scalar (`String` for an email scalar), `Enum` or the enum
|
|
46
|
+
* type's name for an enum, `ID` for identifiers and references. The field must be a `String` field
|
|
47
|
+
* or a `String` list, and the value a string without U+0000; the empty string is valid. Anything
|
|
48
|
+
* else fails with `INVALID_FILTER_VALUE` (400), as `LIKE` does. Returns the value unchanged: it is
|
|
49
|
+
* not passed through the field's scalar, since a validated scalar filters by its base scalar.
|
|
50
|
+
*/
|
|
51
|
+
export const validateTextFilterValue = (operator, value, scalar, path) => {
|
|
52
|
+
const where = path == null ? '' : ` on ${Array.isArray(path) ? path.join('.') : path}`;
|
|
53
|
+
if (scalar !== 'String') invalidValue(`${operator} requires a String field${where}`);
|
|
54
|
+
if (typeof value !== 'string') invalidValue(`${operator} requires a non-null string value${where}`);
|
|
55
|
+
if (value.includes('\u0000')) invalidValue(`${operator} values cannot contain U+0000${where}`);
|
|
56
|
+
return value;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const invalidFeatures = (message) => {
|
|
60
|
+
throw new SimfinityError(`DatabaseAdapter queryFeatures ${message}`, 'INVALID_QUERY_FEATURES', 500);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads an adapter's `queryFeatures` declaration into the set of operators it compiles. Without a
|
|
65
|
+
* declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}. A declaration
|
|
66
|
+
* is an object whose `operators` is an array of operator names; names this version does not
|
|
67
|
+
* know are ignored, and other members are left for later features. Anything else throws
|
|
68
|
+
* `INVALID_QUERY_FEATURES` (500).
|
|
69
|
+
*/
|
|
70
|
+
export const resolveQueryFeatures = (queryFeatures) => {
|
|
71
|
+
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS) };
|
|
72
|
+
if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
|
|
73
|
+
invalidFeatures('must be an object with an operators array');
|
|
74
|
+
}
|
|
75
|
+
const { operators } = queryFeatures;
|
|
76
|
+
if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
|
|
77
|
+
invalidFeatures('operators must be an array of operator names');
|
|
78
|
+
}
|
|
79
|
+
return { operators: new Set(operators.filter(isQueryOperator)) };
|
|
80
|
+
};
|
package/src/query-plan.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { paginationStages } from './query-limits.js';
|
|
2
2
|
import SimfinityError from './errors/simfinity.error.js';
|
|
3
|
+
import { QUERY_OPERATORS, textQueryOperator, validateTextFilterValue } from './query-operators.js';
|
|
3
4
|
|
|
4
5
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
5
|
-
const operators = new Set(
|
|
6
|
+
const operators = new Set(QUERY_OPERATORS);
|
|
6
7
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
7
8
|
// `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
|
|
8
9
|
// that name is filtered like any other.
|
|
@@ -47,6 +48,40 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
|
47
48
|
return entries;
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Internal: the operator of every condition the arguments hold, as `{ operator, path }` entries: field
|
|
53
|
+
* filters, relation terms and AND/OR group conditions at any depth, with an omitted operator read as
|
|
54
|
+
* `EQ`. `path` names the condition for messages. Sort, pagination and aggregation hold no operators.
|
|
55
|
+
* Malformed entries are skipped and left to the backends; a group reached twice is read once.
|
|
56
|
+
*/
|
|
57
|
+
export const collectQueryOperators = (args, operation = 'find') => {
|
|
58
|
+
const entries = [];
|
|
59
|
+
if (!args || typeof args !== 'object') return entries;
|
|
60
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
61
|
+
const add = (item, path) => {
|
|
62
|
+
if (isObject(item)) entries.push({ operator: item.operator ?? 'EQ', path });
|
|
63
|
+
};
|
|
64
|
+
const visited = new Set();
|
|
65
|
+
const visit = (group) => {
|
|
66
|
+
if (!isObject(group) || visited.has(group)) return;
|
|
67
|
+
visited.add(group);
|
|
68
|
+
for (const condition of listOf(group.conditions)) {
|
|
69
|
+
add(condition, condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field);
|
|
70
|
+
}
|
|
71
|
+
for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
|
|
72
|
+
};
|
|
73
|
+
const controls = controlArgs(operation);
|
|
74
|
+
for (const [name, value] of Object.entries(args)) {
|
|
75
|
+
if (controls.has(name) || !isObject(value)) continue;
|
|
76
|
+
// A relation filter's operators are in its terms; an operator beside them is read too, since a
|
|
77
|
+
// backend that reads the field as a scalar would compile it.
|
|
78
|
+
if (!Array.isArray(value.terms) || value.operator !== undefined) add(value, name);
|
|
79
|
+
if (Array.isArray(value.terms)) for (const term of value.terms) add(term, `${name}.${term?.path}`);
|
|
80
|
+
}
|
|
81
|
+
for (const item of [...listOf(args.AND), ...listOf(args.OR)]) visit(item);
|
|
82
|
+
return entries;
|
|
83
|
+
};
|
|
84
|
+
|
|
50
85
|
/** Internal: the segment arrays of {@link collectQueryPathEntries}. */
|
|
51
86
|
export const collectQueryPaths = (args, operation = 'find') => (
|
|
52
87
|
collectQueryPathEntries(args, operation).map((entry) => entry.segments)
|
|
@@ -87,8 +122,14 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
87
122
|
return steps;
|
|
88
123
|
};
|
|
89
124
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. Operators
|
|
127
|
+
* and values are validated here, text operators through validateTextFilterValue. With `operators`,
|
|
128
|
+
* the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
|
|
129
|
+
* without it every QLOperator value is planned.
|
|
130
|
+
*/
|
|
131
|
+
export const createQueryPlan = (models, entityName, input = {}, { mode = 'find', operators: supported } = {}) => {
|
|
132
|
+
const allowed = supported == null ? null : new Set(supported);
|
|
92
133
|
const combine = (kind, terms) => {
|
|
93
134
|
const active = terms.filter(Boolean);
|
|
94
135
|
return active.length > 1 ? { kind, terms: active } : active[0] || null;
|
|
@@ -99,11 +140,13 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
|
|
|
99
140
|
if (field.kind !== 'scalar' && !field.inferred) fail(`Filter on object field ${path} requires a scalar path`, 'MISSING_FILTER_PATH');
|
|
100
141
|
const op = operator ?? 'EQ';
|
|
101
142
|
if (!operators.has(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
143
|
+
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
102
144
|
const collection = ['IN', 'NIN', 'BTW'].includes(op);
|
|
103
145
|
if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
|
|
104
146
|
if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
|
|
105
147
|
if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
|
|
106
148
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
149
|
+
if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
|
|
107
150
|
return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
108
151
|
};
|
|
109
152
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
package/src/relation-storage.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { GraphQLList, GraphQLNonNull } from 'graphql';
|
|
2
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
2
3
|
|
|
3
|
-
// Internal helpers that decide where a relation stores its link
|
|
4
|
-
// identity. They are shared by the core runtime and
|
|
5
|
-
// `./internal/relation-storage` subpath, which is not public API.
|
|
4
|
+
// Internal helpers that decide where a relation stores its link, which types keep a stored
|
|
5
|
+
// identity and which fields store nothing (virtual fields). They are shared by the core runtime and
|
|
6
|
+
// the other packages through the `./internal/relation-storage` subpath, which is not public API.
|
|
7
|
+
// isVirtualField and virtualFieldOptions are also public, as root exports of the core package, for
|
|
8
|
+
// custom adapters that build storage from GraphQL fields.
|
|
6
9
|
|
|
7
10
|
const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
|
|
8
11
|
|
|
@@ -18,6 +21,53 @@ export const getListShape = (type) => {
|
|
|
18
21
|
};
|
|
19
22
|
};
|
|
20
23
|
|
|
24
|
+
export const VIRTUAL_MCP_MODES = Object.freeze(['tool', 'select', false]);
|
|
25
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
26
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
27
|
+
|
|
28
|
+
// A virtual field (`extensions.virtual`) is resolved by its own resolver and stored nowhere: it has
|
|
29
|
+
// no storage, no generated input and no query path. Only `true` and a plain options object with
|
|
30
|
+
// known options declare one, and this returns its normalized options, `{ mcp }`, with `mcp`
|
|
31
|
+
// defaulting to 'tool'; it returns null for any other field. createSchema() and describeModels()
|
|
32
|
+
// reject other `virtual` values with INVALID_MODEL, so in their schemas a field with
|
|
33
|
+
// `extensions.virtual` is always virtual.
|
|
34
|
+
export const virtualFieldOptions = (field) => {
|
|
35
|
+
const virtual = field?.extensions?.virtual;
|
|
36
|
+
if (virtual === true) return { mcp: 'tool' };
|
|
37
|
+
if (!isPlainObject(virtual) || Object.keys(virtual).some((key) => key !== 'mcp')) return null;
|
|
38
|
+
if (virtual.mcp !== undefined && !VIRTUAL_MCP_MODES.includes(virtual.mcp)) return null;
|
|
39
|
+
return { mcp: virtual.mcp ?? 'tool' };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const isVirtualField = (field) => virtualFieldOptions(field) !== null;
|
|
43
|
+
|
|
44
|
+
// A relation's connectionField is a field of the record that stores the link: the reference's own
|
|
45
|
+
// type, or a collection's item type. It is a field name, never a path into an embedded value, which
|
|
46
|
+
// would put the link inside another value: MongoDB would store it in that value's member and read it
|
|
47
|
+
// back as missing, and PostgreSQL would name a column after the whole path. A link that belongs to an
|
|
48
|
+
// embedded value is declared there, as a reference of the embedded type. Throws INVALID_MODEL (400)
|
|
49
|
+
// for a connectionField that is not a string, or that contains a dot; an omitted or empty one keeps
|
|
50
|
+
// its existing meaning.
|
|
51
|
+
export const assertPlainConnectionField = (typeName, fieldName, field) => {
|
|
52
|
+
const relation = field?.extensions?.relation;
|
|
53
|
+
if (!relation || relation.embedded) return;
|
|
54
|
+
const { connectionField } = relation;
|
|
55
|
+
if (connectionField === undefined || connectionField === null || connectionField === '') return;
|
|
56
|
+
const name = `${typeName}.${fieldName}`;
|
|
57
|
+
const listShape = getListShape(field.type);
|
|
58
|
+
const holder = listShape ? listShape.itemType.name : typeName;
|
|
59
|
+
if (typeof connectionField !== 'string') {
|
|
60
|
+
throw new SimfinityError(`${name} extensions.relation.connectionField must be the name of a field of ${holder}`,
|
|
61
|
+
'INVALID_MODEL', 400);
|
|
62
|
+
}
|
|
63
|
+
if (connectionField.includes('.')) {
|
|
64
|
+
throw new SimfinityError(`${name} extensions.relation.connectionField ${connectionField} must name a field of ${holder}, `
|
|
65
|
+
+ 'not a path into an embedded value'
|
|
66
|
+
+ (listShape ? '' : '; declare a reference that belongs to an embedded value inside the embedded type'),
|
|
67
|
+
'INVALID_MODEL', 400);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
21
71
|
export const getFieldStorageName = (fieldName, field) => {
|
|
22
72
|
const relation = field.extensions?.relation;
|
|
23
73
|
return relation && !relation.embedded && !getListShape(field.type)
|
package/src/runtime.js
CHANGED
|
@@ -13,12 +13,18 @@ import { ForbiddenError } from './auth/errors.js';
|
|
|
13
13
|
import QLOperator from './const/QLOperator.js';
|
|
14
14
|
import QLValue from './const/QLValue.js';
|
|
15
15
|
import QLSort from './const/QLSort.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
|
|
18
|
+
} from './query-plan.js';
|
|
19
|
+
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
17
20
|
import { relationFieldOwners } from './relation-owners.js';
|
|
18
21
|
import { isThenable } from './auth/thenable.js';
|
|
19
22
|
import {
|
|
20
|
-
getFieldStorageName, getListShape, markStoredIdentity, normalizeConnectionField,
|
|
23
|
+
assertPlainConnectionField, getFieldStorageName, getListShape, isVirtualField, markStoredIdentity, normalizeConnectionField,
|
|
21
24
|
} from './relation-storage.js';
|
|
25
|
+
import {
|
|
26
|
+
assertVirtualConnection, assertVirtualDeclaration, assertVirtualIndexes, assertVirtualStateField, virtualConflicts,
|
|
27
|
+
} from './virtual-fields.js';
|
|
22
28
|
import {
|
|
23
29
|
fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
|
|
24
30
|
} from './introspection.js';
|
|
@@ -71,6 +77,8 @@ const hasApplicationResolver = (gqltype, fieldName) => (
|
|
|
71
77
|
// so a bound type cannot be registered in, or reached from, another runtime's schema.
|
|
72
78
|
const runtimeBoundTypes = new WeakMap();
|
|
73
79
|
const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER_PATH', 403);
|
|
80
|
+
// Generated inputs never write a readOnly field, nor a virtual one, which virtual implies.
|
|
81
|
+
const isReadOnlyField = (field) => !!field.extensions?.readOnly || isVirtualField(field);
|
|
74
82
|
|
|
75
83
|
// Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
|
|
76
84
|
const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
|
|
@@ -446,6 +454,10 @@ export const createRuntime = (adapter) => {
|
|
|
446
454
|
if (!adapter) {
|
|
447
455
|
throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
|
|
448
456
|
}
|
|
457
|
+
// The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
|
|
458
|
+
// operators when it declares none.
|
|
459
|
+
const supportedOperators = resolveQueryFeatures(adapter.queryFeatures).operators;
|
|
460
|
+
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
449
461
|
|
|
450
462
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
451
463
|
const runtimeIdentity = {};
|
|
@@ -816,7 +828,7 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
|
|
|
816
828
|
const selfCollectionFieldNames = (gqltype) => {
|
|
817
829
|
const hasStateMachine = !!typesDict.types[gqltype.name]?.stateMachine;
|
|
818
830
|
return Object.entries(gqltype.getFields())
|
|
819
|
-
.filter(([fieldName, field]) => !field
|
|
831
|
+
.filter(([fieldName, field]) => !isReadOnlyField(field)
|
|
820
832
|
&& !(fieldName === 'state' && hasStateMachine)
|
|
821
833
|
&& getListShape(field.type)?.itemType === gqltype)
|
|
822
834
|
.map(([fieldName]) => fieldName);
|
|
@@ -896,7 +908,7 @@ const buildInputType = (gqltype, reachable) => {
|
|
|
896
908
|
const fieldArg = {};
|
|
897
909
|
const fieldArgForUpdate = {};
|
|
898
910
|
|
|
899
|
-
if (!fieldEntry
|
|
911
|
+
if (!isReadOnlyField(fieldEntry)) {
|
|
900
912
|
const hasStateMachine = !!typesDict.types[gqltype.name].stateMachine;
|
|
901
913
|
const stateFieldManagedByStateMachine = !!(fieldEntryName === 'state' && hasStateMachine);
|
|
902
914
|
|
|
@@ -1037,7 +1049,7 @@ const typesWithReachableInputs = (includedMutationTypes) => {
|
|
|
1037
1049
|
if (reached.has(gqltype)) return;
|
|
1038
1050
|
reached.add(gqltype);
|
|
1039
1051
|
for (const field of Object.values(gqltype.getFields())) {
|
|
1040
|
-
if (field
|
|
1052
|
+
if (isReadOnlyField(field)) continue;
|
|
1041
1053
|
const listShape = getListShape(field.type);
|
|
1042
1054
|
const target = listShape ? listShape.itemType : unwrapNonNull(field.type);
|
|
1043
1055
|
// A single reference is an IdInputType, so it reaches no input of its target.
|
|
@@ -1181,6 +1193,8 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
1181
1193
|
const collectionFields = {};
|
|
1182
1194
|
|
|
1183
1195
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
1196
|
+
// A virtual field stores nothing, whatever middleware or saveObject() passes for it.
|
|
1197
|
+
if (isVirtualField(fieldEntry)) continue;
|
|
1184
1198
|
const fieldValue = ownValue(args, fieldEntryName);
|
|
1185
1199
|
if (fieldEntry.extensions && fieldEntry.extensions.validations
|
|
1186
1200
|
&& fieldEntry.extensions.validations[operation]) {
|
|
@@ -1339,7 +1353,7 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
|
|
|
1339
1353
|
for (const [memberName, member] of Object.entries(embeddedType.getFields())) {
|
|
1340
1354
|
const relation = member.extensions?.relation;
|
|
1341
1355
|
const memberList = getListShape(member.type);
|
|
1342
|
-
if (relation && !relation.embedded && memberList) continue;
|
|
1356
|
+
if ((relation && !relation.embedded && memberList) || isVirtualField(member)) continue;
|
|
1343
1357
|
const storageName = getFieldStorageName(memberName, member);
|
|
1344
1358
|
if (ownValue(item, storageName) === undefined && memberList) item[storageName] = [];
|
|
1345
1359
|
if (memberName === 'id' || member.extensions?.readOnly) continue;
|
|
@@ -1366,7 +1380,7 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
|
|
|
1366
1380
|
const members = unwrapNonNull(fieldEntry.type).getFields();
|
|
1367
1381
|
for (const [memberName, value] of Object.entries(patch)) {
|
|
1368
1382
|
const member = members[memberName];
|
|
1369
|
-
if (value !== null || !member || member.type instanceof GraphQLNonNull) continue;
|
|
1383
|
+
if (value !== null || !member || member.type instanceof GraphQLNonNull || isVirtualField(member)) continue;
|
|
1370
1384
|
const relation = member.extensions?.relation;
|
|
1371
1385
|
const listShape = getListShape(member.type);
|
|
1372
1386
|
if (relation && !relation.embedded && listShape) continue;
|
|
@@ -1429,7 +1443,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1429
1443
|
}
|
|
1430
1444
|
|
|
1431
1445
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
1432
|
-
if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull)) {
|
|
1446
|
+
if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)) {
|
|
1433
1447
|
materializedModel.modelArgs.$unset = {
|
|
1434
1448
|
...materializedModel.modelArgs.$unset,
|
|
1435
1449
|
[getFieldStorageName(fieldEntryName, fieldEntry)]: '',
|
|
@@ -1735,6 +1749,9 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1735
1749
|
for (const { segments, group } of collectQueryPathEntries(args, operation)) {
|
|
1736
1750
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1737
1751
|
const path = segments.slice(0, index + 1).join('.');
|
|
1752
|
+
if (isVirtualField(field)) {
|
|
1753
|
+
throw forbiddenPath(`Query path ${path} uses the virtual field ${type.name}.${fieldName}, which is not stored`);
|
|
1754
|
+
}
|
|
1738
1755
|
const queryable = field.extensions?.queryable;
|
|
1739
1756
|
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
1740
1757
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
@@ -1759,6 +1776,39 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1759
1776
|
return joinedScopes;
|
|
1760
1777
|
};
|
|
1761
1778
|
|
|
1779
|
+
// Whether a schema of this runtime declared a virtual field, so that query paths may name one.
|
|
1780
|
+
let virtualFieldsDeclared = false;
|
|
1781
|
+
|
|
1782
|
+
// Client paths through a virtual field were rejected before middleware and scopes ran
|
|
1783
|
+
// (inspectClientPaths), so a path through one now is a trusted path that middleware or a scope added.
|
|
1784
|
+
// It would compile against storage that does not exist, so the operation fails with INVALID_MODEL
|
|
1785
|
+
// (500), the code of model declarations that cannot be honored at request time.
|
|
1786
|
+
const assertStoredQueryPaths = (gqltype, args, operation) => {
|
|
1787
|
+
if (!virtualFieldsDeclared) return;
|
|
1788
|
+
for (const { segments } of collectQueryPathEntries(args, operation)) {
|
|
1789
|
+
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1790
|
+
if (!isVirtualField(field)) return;
|
|
1791
|
+
throw new SimfinityError(`Query path ${segments.slice(0, index + 1).join('.')}, added by middleware or a `
|
|
1792
|
+
+ `scope, uses the virtual field ${type.name}.${fieldName}, which is not stored`, 'INVALID_MODEL', 500);
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
// Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
|
|
1798
|
+
// UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile.
|
|
1799
|
+
// Runs on the client's arguments before middleware and on the final arguments before the adapter
|
|
1800
|
+
// call, which covers what middleware, scopes and joined scopes added. Operators that QLOperator does
|
|
1801
|
+
// not list are left to the adapter, which reports them as before.
|
|
1802
|
+
const assertSupportedOperators = (gqltype, args, operation) => {
|
|
1803
|
+
if (supportsEveryOperator) return;
|
|
1804
|
+
for (const { operator, path } of collectQueryOperators(args, operation)) {
|
|
1805
|
+
if (isQueryOperator(operator) && !supportedOperators.has(operator)) {
|
|
1806
|
+
throw new SimfinityError(`The database adapter does not support the ${operator} filter operator, used on `
|
|
1807
|
+
+ `${gqltype.name}.${path}`, 'UNSUPPORTED_QUERY', 400);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
|
|
1762
1812
|
// A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
|
|
1763
1813
|
// reach a non-embedded relation inside an embedded list, which is joined once per list item.
|
|
1764
1814
|
const assertJoinedScopePaths = (groups, prefix, target) => {
|
|
@@ -1912,6 +1962,8 @@ const resolveById = async (type, args, context, requiredId) => {
|
|
|
1912
1962
|
}
|
|
1913
1963
|
const queryArgs = { id: { operator: 'EQ', value: args.id } };
|
|
1914
1964
|
await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
|
|
1965
|
+
assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
|
|
1966
|
+
assertSupportedOperators(type.gqltype, queryArgs, 'find');
|
|
1915
1967
|
const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
|
|
1916
1968
|
return results[0] || null;
|
|
1917
1969
|
};
|
|
@@ -2419,6 +2471,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2419
2471
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
|
|
2420
2472
|
async resolve(parent, args, context, info) {
|
|
2421
2473
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
|
|
2474
|
+
assertSupportedOperators(type.gqltype, args, 'find');
|
|
2422
2475
|
const params = {
|
|
2423
2476
|
type,
|
|
2424
2477
|
args,
|
|
@@ -2428,6 +2481,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2428
2481
|
await executeMiddleware(params);
|
|
2429
2482
|
await executeScope(params);
|
|
2430
2483
|
await applyJoinedScopes(joinedScopes, params);
|
|
2484
|
+
assertStoredQueryPaths(type.gqltype, params.args, 'find');
|
|
2485
|
+
assertSupportedOperators(type.gqltype, params.args, 'find');
|
|
2431
2486
|
const queryArgs = params.args;
|
|
2432
2487
|
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2433
2488
|
&& canReportCount(parent, info, context);
|
|
@@ -2448,7 +2503,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2448
2503
|
};
|
|
2449
2504
|
|
|
2450
2505
|
const aggregateArgsObject = { ...argsObject };
|
|
2451
|
-
|
|
2506
|
+
const fields = type.gqltype.getFields();
|
|
2507
|
+
if (Object.hasOwn(fields, 'aggregation') && !isVirtualField(fields.aggregation)) {
|
|
2452
2508
|
warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
|
|
2453
2509
|
+ `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
|
|
2454
2510
|
+ 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
|
|
@@ -2463,6 +2519,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2463
2519
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
2464
2520
|
async resolve(parent, args, context) {
|
|
2465
2521
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
|
|
2522
|
+
assertSupportedOperators(type.gqltype, args, 'aggregate');
|
|
2466
2523
|
const params = {
|
|
2467
2524
|
type,
|
|
2468
2525
|
args,
|
|
@@ -2472,6 +2529,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2472
2529
|
await executeMiddleware(params);
|
|
2473
2530
|
await executeScope(params);
|
|
2474
2531
|
await applyJoinedScopes(joinedScopes, params);
|
|
2532
|
+
assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
|
|
2533
|
+
assertSupportedOperators(type.gqltype, params.args, 'aggregate');
|
|
2475
2534
|
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
2476
2535
|
},
|
|
2477
2536
|
};
|
|
@@ -2576,7 +2635,7 @@ const holdsNoData = (stored, type, ancestors = []) => !ancestors.includes(stored
|
|
|
2576
2635
|
// which buildInputType leaves out; nor do referenced collections or members with an application
|
|
2577
2636
|
// resolver, which may compute a value.
|
|
2578
2637
|
const countsAsRequired = (type, memberName, member) => member.type instanceof GraphQLNonNull
|
|
2579
|
-
&& !isReferencedCollection(member) && memberName !== 'id' && !member
|
|
2638
|
+
&& !isReferencedCollection(member) && memberName !== 'id' && !isReadOnlyField(member)
|
|
2580
2639
|
&& !isAbstractOutputType(unwrapListAndNonNull(member.type)) && !hasApplicationResolver(type, memberName);
|
|
2581
2640
|
const hasRequiredMember = (type) => Object.entries(type.getFields())
|
|
2582
2641
|
.some(([memberName, member]) => countsAsRequired(type, memberName, member));
|
|
@@ -2671,6 +2730,23 @@ const warnOnce = (gqltype, key, message) => {
|
|
|
2671
2730
|
console.warn(message);
|
|
2672
2731
|
};
|
|
2673
2732
|
|
|
2733
|
+
// A connectionField names a field, never a path (assertPlainConnectionField), in every type a model
|
|
2734
|
+
// stores: the registered types and the embedded types they store, at any depth, whether or not an
|
|
2735
|
+
// embedded type is registered itself, as describeModels and MongoDB's generated models reach them.
|
|
2736
|
+
const assertPlainConnectionFields = () => {
|
|
2737
|
+
const visited = new Set();
|
|
2738
|
+
const visit = (gqltype) => {
|
|
2739
|
+
if (visited.has(gqltype)) return;
|
|
2740
|
+
visited.add(gqltype);
|
|
2741
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
2742
|
+
assertPlainConnectionField(gqltype.name, fieldName, field);
|
|
2743
|
+
const named = field.extensions?.relation?.embedded && getNamedType(field.type);
|
|
2744
|
+
if (named instanceof GraphQLObjectType) visit(named);
|
|
2745
|
+
}
|
|
2746
|
+
};
|
|
2747
|
+
for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
|
|
2748
|
+
};
|
|
2749
|
+
|
|
2674
2750
|
// A writable referenced collection without a child connectionField would store unlinked children
|
|
2675
2751
|
// through generated nested writes and read none back, so it is rejected before anything is built.
|
|
2676
2752
|
// One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
|
|
@@ -2691,6 +2767,50 @@ const assertCollectionConnections = () => {
|
|
|
2691
2767
|
}
|
|
2692
2768
|
};
|
|
2693
2769
|
|
|
2770
|
+
// Virtual fields (`extensions.virtual`) of the object types a schema reaches, registered or not, and
|
|
2771
|
+
// the uses of them that registered types declare, are checked before anything is built
|
|
2772
|
+
// (INVALID_MODEL, 400). Returns whether any field is virtual.
|
|
2773
|
+
const assertVirtualFields = (schemaTypes) => {
|
|
2774
|
+
let declared = false;
|
|
2775
|
+
for (const type of schemaTypes) {
|
|
2776
|
+
if (!(type instanceof GraphQLObjectType)) continue;
|
|
2777
|
+
for (const [fieldName, field] of Object.entries(type.getFields())) {
|
|
2778
|
+
if (assertVirtualDeclaration(type.name, fieldName, field, () => hasApplicationResolver(type, fieldName))) {
|
|
2779
|
+
declared = true;
|
|
2780
|
+
}
|
|
2781
|
+
assertVirtualConnection(type, fieldName, field, () => hasApplicationResolver(type, fieldName));
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
for (const { gqltype, stateMachine } of Object.values(typesDict.types)) {
|
|
2785
|
+
assertVirtualIndexes(gqltype);
|
|
2786
|
+
assertVirtualStateField(gqltype, stateMachine);
|
|
2787
|
+
}
|
|
2788
|
+
return declared;
|
|
2789
|
+
};
|
|
2790
|
+
|
|
2791
|
+
// A readOnly field with arguments and its own resolver is almost always computed, yet unless it is
|
|
2792
|
+
// virtual it is stored and filterable like any other field. References and collections, whose
|
|
2793
|
+
// storage is a link rather than the field's value and which cannot be virtual, are not reported.
|
|
2794
|
+
// The advice also names the settings it declares that a virtual field cannot, such as the
|
|
2795
|
+
// `relation: { embedded: true }` of a computed embedded value.
|
|
2796
|
+
const warnUndeclaredVirtualFields = () => {
|
|
2797
|
+
for (const { gqltype } of Object.values(typesDict.types)) {
|
|
2798
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
2799
|
+
const relation = field.extensions?.relation;
|
|
2800
|
+
if (!field.extensions?.readOnly || !field.args.length || isVirtualField(field)
|
|
2801
|
+
|| (relation && !relation.embedded) || !hasApplicationResolver(gqltype, fieldName)) continue;
|
|
2802
|
+
const conflicts = virtualConflicts(field.extensions);
|
|
2803
|
+
const removal = conflicts.length
|
|
2804
|
+
? ` and remove ${conflicts.length < 3 ? conflicts.join(' and ') : `${conflicts.slice(0, -1).join(', ')} and ${conflicts.at(-1)}`}`
|
|
2805
|
+
: '';
|
|
2806
|
+
warnOnce(gqltype, `undeclaredVirtual:${fieldName}`, `Configuration issue: ${gqltype.name}.${fieldName} is `
|
|
2807
|
+
+ 'readOnly and has arguments and its own resolver, but it is not virtual, so Simfinity describes it as '
|
|
2808
|
+
+ 'stored data: backends may store it, and list queries get a filter argument for it. If its resolver '
|
|
2809
|
+
+ `computes it, declare extensions: { virtual: true }${removal}.`);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
};
|
|
2813
|
+
|
|
2694
2814
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
2695
2815
|
|
|
2696
2816
|
// Application types that a schema with these allowlists contains, whatever else it reaches: the
|
|
@@ -2713,6 +2833,9 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2713
2833
|
const schemaTypes = collectSchemaOutputTypes();
|
|
2714
2834
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
2715
2835
|
schemaTypes.forEach(assertFieldsNotBoundElsewhere);
|
|
2836
|
+
if (assertVirtualFields(schemaTypes)) virtualFieldsDeclared = true;
|
|
2837
|
+
warnUndeclaredVirtualFields();
|
|
2838
|
+
assertPlainConnectionFields();
|
|
2716
2839
|
assertCollectionConnections();
|
|
2717
2840
|
// An application type with the name of a field metadata type that an earlier schema fixed; one that
|
|
2718
2841
|
// only generated types or the fields of an input object reach is found when the schema is
|
|
@@ -2857,10 +2980,13 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
2857
2980
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
2858
2981
|
}
|
|
2859
2982
|
const joinedScopes = inspectClientPaths(relatedType, args, 'find');
|
|
2983
|
+
assertSupportedOperators(relatedType, args, 'find');
|
|
2860
2984
|
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
2861
2985
|
await executeMiddleware(params);
|
|
2862
2986
|
await executeScope(params);
|
|
2863
2987
|
await applyJoinedScopes(joinedScopes, params);
|
|
2988
|
+
assertStoredQueryPaths(relatedType, params.args, 'find');
|
|
2989
|
+
assertSupportedOperators(relatedType, params.args, 'find');
|
|
2864
2990
|
return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
|
|
2865
2991
|
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
2866
2992
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
@@ -2961,11 +3087,12 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
|
|
|
2961
3087
|
|
|
2962
3088
|
// `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
|
|
2963
3089
|
// A field named like a generated argument gets no top-level filter either; it is reported once.
|
|
3090
|
+
// A virtual field is not part of the query language, so it gets no filter at all.
|
|
2964
3091
|
const createArgsForQuery = (gqltype, omit = null) => {
|
|
2965
3092
|
const argsObject = {};
|
|
2966
3093
|
const argTypes = gqltype.getFields();
|
|
2967
3094
|
for (const name of LIST_QUERY_ARGS) {
|
|
2968
|
-
if (name !== omit && Object.hasOwn(argTypes, name)) {
|
|
3095
|
+
if (name !== omit && Object.hasOwn(argTypes, name) && !isVirtualField(argTypes[name])) {
|
|
2969
3096
|
warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
|
|
2970
3097
|
+ 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
|
|
2971
3098
|
+ 'filter it in AND/OR group conditions or rename the field.');
|
|
@@ -2973,7 +3100,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
|
|
|
2973
3100
|
}
|
|
2974
3101
|
|
|
2975
3102
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
2976
|
-
if (fieldEntryName === omit) continue;
|
|
3103
|
+
if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
|
|
2977
3104
|
let filterType = null;
|
|
2978
3105
|
|
|
2979
3106
|
if (fieldEntry.type instanceof GraphQLScalarType
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { getNamedType } from 'graphql';
|
|
2
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
3
|
+
import { getListShape, isVirtualField, VIRTUAL_MCP_MODES } from './relation-storage.js';
|
|
4
|
+
|
|
5
|
+
// Internal checks of virtual field declarations (`extensions.virtual`), shared by createSchema() and
|
|
6
|
+
// describeModels(). A virtual field is resolved by its own resolver and stored nowhere, so every
|
|
7
|
+
// setting or use that needs storage, a generated input or a query path is rejected.
|
|
8
|
+
|
|
9
|
+
const invalid = (message) => {
|
|
10
|
+
throw new SimfinityError(message, 'INVALID_MODEL', 400);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
14
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
15
|
+
|
|
16
|
+
// Settings that describe storage, writes or the query language, which a virtual field has none of.
|
|
17
|
+
// `readOnly: true` and `queryable: false` restate what virtual implies, so they are allowed.
|
|
18
|
+
const CONFLICTING_SETTINGS = [
|
|
19
|
+
['relation', (value) => !!value, 'extensions.relation'],
|
|
20
|
+
['unique', (value) => value === true, 'unique: true'],
|
|
21
|
+
['validations', (value) => !!value, 'extensions.validations'],
|
|
22
|
+
['queryable', (value) => value === true, 'queryable: true'],
|
|
23
|
+
['readOnly', (value) => value === false, 'readOnly: false'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The settings of a field's extensions that a virtual field cannot declare, as its INVALID_MODEL message
|
|
28
|
+
* names them (such as 'extensions.relation'), in the order they are checked. Empty when it could be virtual.
|
|
29
|
+
*/
|
|
30
|
+
export const virtualConflicts = (extensions = {}) => CONFLICTING_SETTINGS
|
|
31
|
+
.filter(([key, conflicts]) => conflicts(extensions[key])).map(([, , setting]) => setting);
|
|
32
|
+
|
|
33
|
+
const ownField = (type, name) => {
|
|
34
|
+
const fields = typeof type?.getFields === 'function' ? type.getFields() : null;
|
|
35
|
+
return fields && typeof name === 'string' && Object.hasOwn(fields, name) ? fields[name] : undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// The virtual field that a stored path names or passes through, with the type that holds it, or null.
|
|
39
|
+
// A MongoDB connectionField or index key may be a dotted path into embedded values, such as
|
|
40
|
+
// `meta.key`, so each segment is a member of the embedded type that the previous segment holds. A
|
|
41
|
+
// path stops at a segment that is not a declared field or not an embedded value: storage then has
|
|
42
|
+
// no member to name there, and a reference stores only its own link, not its target's fields.
|
|
43
|
+
const virtualFieldOnPath = (type, path) => {
|
|
44
|
+
let holder = type;
|
|
45
|
+
const segments = path.split('.');
|
|
46
|
+
for (const [index, segment] of segments.entries()) {
|
|
47
|
+
const field = ownField(holder, segment);
|
|
48
|
+
if (!field) return null;
|
|
49
|
+
if (isVirtualField(field)) return { holder, name: segment };
|
|
50
|
+
if (index === segments.length - 1 || !field.extensions?.relation?.embedded) return null;
|
|
51
|
+
holder = getNamedType(field.type);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ` (through meta.key)` for a dotted path, so the message names the path that was declared.
|
|
57
|
+
const throughPath = (path, found) => (path === found.name ? '' : ` (through ${path})`);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Checks the `extensions.virtual` declaration of `typeName.fieldName` and returns whether the field is
|
|
61
|
+
* virtual. `hasOwnResolver()` tells whether the field has its own resolver; it is called only for a
|
|
62
|
+
* field that declares `virtual`. Throws INVALID_MODEL (400), naming the type and field, for a value
|
|
63
|
+
* other than `true` or a plain options object, an unknown option, an `mcp` option other than 'tool',
|
|
64
|
+
* 'select' or false, a missing resolver, a conflicting setting, and an identity field (`id` or `_id`).
|
|
65
|
+
*/
|
|
66
|
+
export const assertVirtualDeclaration = (typeName, fieldName, field, hasOwnResolver) => {
|
|
67
|
+
const extensions = field.extensions || {};
|
|
68
|
+
const { virtual } = extensions;
|
|
69
|
+
if (virtual === undefined) return false;
|
|
70
|
+
const name = `${typeName}.${fieldName}`;
|
|
71
|
+
if (virtual !== true) {
|
|
72
|
+
if (!isPlainObject(virtual)) invalid(`${name} extensions.virtual must be true or an options object such as { mcp: 'select' }`);
|
|
73
|
+
const unknown = Object.keys(virtual).find((key) => key !== 'mcp');
|
|
74
|
+
if (unknown !== undefined) invalid(`${name} extensions.virtual has the unknown option ${unknown}; its only option is mcp`);
|
|
75
|
+
if (virtual.mcp !== undefined && !VIRTUAL_MCP_MODES.includes(virtual.mcp)) {
|
|
76
|
+
invalid(`${name} extensions.virtual.mcp must be 'tool', 'select' or false`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!hasOwnResolver()) invalid(`${name} is virtual, so it requires its own resolve function`);
|
|
80
|
+
const [setting] = virtualConflicts(extensions);
|
|
81
|
+
if (setting) invalid(`${name} is virtual, which is never stored, so it cannot declare ${setting}`);
|
|
82
|
+
if (fieldName === 'id' || fieldName === '_id') invalid(`${name} is the identity field, so it cannot be virtual`);
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A reference stores its link under its connectionField, on its own type, and a collection reads its
|
|
88
|
+
* items by their type's connectionField, or by their own field name without one. Throws INVALID_MODEL
|
|
89
|
+
* (400) when that field is virtual, or when a dotted connectionField passes through or ends at a
|
|
90
|
+
* virtual member of an embedded type. `hasOwnResolver()` tells whether the application resolves the
|
|
91
|
+
* field itself, so that Simfinity never reads its items; it defaults to the field having a resolver.
|
|
92
|
+
*/
|
|
93
|
+
export const assertVirtualConnection = (type, fieldName, field, hasOwnResolver = () => typeof field.resolve === 'function') => {
|
|
94
|
+
const relation = field.extensions?.relation;
|
|
95
|
+
if (!relation || relation.embedded) return;
|
|
96
|
+
const listShape = getListShape(field.type);
|
|
97
|
+
const holder = listShape ? listShape.itemType : type;
|
|
98
|
+
const { connectionField } = relation;
|
|
99
|
+
// Storage keys the link by the declared value as a property name, so a non-string value reaches
|
|
100
|
+
// the same path as its string form. A collection without one, whose items Simfinity reads, reads
|
|
101
|
+
// them by its own field name.
|
|
102
|
+
let path = null;
|
|
103
|
+
if (connectionField !== undefined && connectionField !== null && connectionField !== '') path = String(connectionField);
|
|
104
|
+
else if (listShape && !hasOwnResolver()) path = fieldName;
|
|
105
|
+
const found = path ? virtualFieldOnPath(holder, path) : null;
|
|
106
|
+
if (found) {
|
|
107
|
+
invalid(`${type.name}.${fieldName} uses the virtual field ${found.holder.name}.${found.name} as its connectionField`
|
|
108
|
+
+ `${throughPath(path, found)}; a virtual field is never stored`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Throws INVALID_MODEL (400) when an entry of the type's `extensions.indexes` names a virtual field, or a
|
|
114
|
+
* dotted key that passes through or ends at a virtual member of an embedded type.
|
|
115
|
+
*/
|
|
116
|
+
export const assertVirtualIndexes = (type) => {
|
|
117
|
+
const indexes = type.extensions?.indexes;
|
|
118
|
+
if (!Array.isArray(indexes)) return;
|
|
119
|
+
for (const index of indexes) {
|
|
120
|
+
for (const name of Array.isArray(index?.fields) ? index.fields : []) {
|
|
121
|
+
const found = typeof name === 'string' ? virtualFieldOnPath(type, name) : null;
|
|
122
|
+
if (found) {
|
|
123
|
+
invalid(`An index of ${type.name} names the virtual field ${found.holder.name}.${found.name}`
|
|
124
|
+
+ `${throughPath(name, found)}, which is never stored`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Throws INVALID_MODEL (400) when the `state` field of a type with a state machine is virtual. */
|
|
131
|
+
export const assertVirtualStateField = (type, stateMachine) => {
|
|
132
|
+
if (stateMachine && isVirtualField(ownField(type, 'state'))) {
|
|
133
|
+
invalid(`${type.name}.state holds the state of its state machine, so it cannot be virtual`);
|
|
134
|
+
}
|
|
135
|
+
};
|
package/types/index.d.ts
CHANGED
|
@@ -8,10 +8,36 @@ import type {
|
|
|
8
8
|
GraphQLSchema,
|
|
9
9
|
} from 'graphql';
|
|
10
10
|
|
|
11
|
+
/** The filter operator enum; its values are the {@link QueryOperator} names. */
|
|
11
12
|
export const QLOperator: GraphQLEnumType;
|
|
12
13
|
export const QLSort: GraphQLInputObjectType;
|
|
13
14
|
export const QLValue: GraphQLScalarType<unknown, unknown>;
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* A `QLOperator` value. The operators of 3.7.0 are `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`,
|
|
18
|
+
* `NIN` and `LIKE`. The literal text operators take a non-null string without U+0000 on a `String`
|
|
19
|
+
* field or `String` list (a validated scalar filters by its base scalar), and match it literally:
|
|
20
|
+
* `ILIKE` as a substring ignoring case, `STARTS_WITH` and `ISTARTS_WITH` as a prefix, `ENDS_WITH`
|
|
21
|
+
* and `IENDS_WITH` as a suffix, respecting or ignoring case, and `IEQ` as the whole value ignoring
|
|
22
|
+
* case. An empty value matches every non-null string, except for `IEQ`, where it matches only the
|
|
23
|
+
* empty string. On a list, some element must match.
|
|
24
|
+
*/
|
|
25
|
+
export type QueryOperator =
|
|
26
|
+
| 'EQ' | 'NE' | 'LT' | 'LTE' | 'GT' | 'GTE' | 'BTW' | 'IN' | 'NIN' | 'LIKE'
|
|
27
|
+
| 'ILIKE' | 'STARTS_WITH' | 'ISTARTS_WITH' | 'ENDS_WITH' | 'IENDS_WITH' | 'IEQ';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The query language features a {@link DatabaseAdapter} compiles. Declare it so that the runtime
|
|
31
|
+
* sends the adapter only conditions it compiles; later releases add members for later features.
|
|
32
|
+
*/
|
|
33
|
+
export interface QueryFeatures {
|
|
34
|
+
/**
|
|
35
|
+
* The `QLOperator` values the adapter compiles, including an omitted operator's `EQ`. Names this
|
|
36
|
+
* version of Simfinity does not know are ignored.
|
|
37
|
+
*/
|
|
38
|
+
operators: readonly (QueryOperator | (string & {}))[];
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
export interface ModelRegistration {
|
|
16
42
|
gqltype: GraphQLObjectType;
|
|
17
43
|
/** Defaults to true. False excludes embedded-only types until referenced. */
|
|
@@ -43,7 +69,65 @@ export interface ModelDescription {
|
|
|
43
69
|
indexes: Array<{ fields: string[]; unique: boolean }>;
|
|
44
70
|
}>;
|
|
45
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Describes the stored data of the registered entities and the embedded types they reach. Virtual
|
|
74
|
+
* fields are left out of every entity and embedded type, and the types only they reach are not
|
|
75
|
+
* described. Throws `INVALID_MODEL` (400) for an invalid `extensions.virtual` declaration, as
|
|
76
|
+
* `createSchema()` does (see {@link VirtualFieldOptions}).
|
|
77
|
+
*/
|
|
46
78
|
export function describeModels(registrations: ModelRegistration[]): ModelDescription;
|
|
79
|
+
/**
|
|
80
|
+
* Options of a virtual field. A field declares itself virtual with `extensions: { virtual: true }` or
|
|
81
|
+
* `extensions: { virtual: { mcp } }` (see {@link VirtualFieldDeclaration}), and must have its own
|
|
82
|
+
* `resolve`, which receives the record, the field's arguments and the request context. No backend
|
|
83
|
+
* stores it, generated inputs leave it out, and it is not part of the query language: list,
|
|
84
|
+
* aggregate and collection fields have no filter argument for it, a client filter, sort, `groupId`
|
|
85
|
+
* or fact path through it fails with `FORBIDDEN_FILTER_PATH` (403), and a path that middleware or a
|
|
86
|
+
* scope adds through it fails with `INVALID_MODEL` (500). Virtual implies `readOnly`, and
|
|
87
|
+
* introspection reads `extensions { readOnly }` as true for it. Any output type may be its
|
|
88
|
+
* result, registered or not; a registered type needs no `relation`, and gets no reference or
|
|
89
|
+
* collection storage and no inferred inverse reference.
|
|
90
|
+
*
|
|
91
|
+
* `createSchema()` and `describeModels()` throw `INVALID_MODEL` (400), naming the type and field, for
|
|
92
|
+
* a value other than `true` or a plain options object, an unknown option, an invalid `mcp` value, a
|
|
93
|
+
* field without its own `resolve`, a field that also declares `relation`, `unique: true`,
|
|
94
|
+
* `validations`, `queryable: true` or `readOnly: false`, an identity field (`id` or `_id`), and for a
|
|
95
|
+
* virtual field named in the type's `extensions.indexes`, used as the `state` field of its state
|
|
96
|
+
* machine, or used as the `connectionField` of a reference or a collection.
|
|
97
|
+
*/
|
|
98
|
+
export interface VirtualFieldOptions {
|
|
99
|
+
/**
|
|
100
|
+
* How generated MCP tools read the field. `'tool'`, the default, generates a
|
|
101
|
+
* `<byIdQuery>_<field>` read tool for a field with a required argument on a type with a generated
|
|
102
|
+
* by-ID query. `'select'` also lets tools select a field without required arguments below a list
|
|
103
|
+
* position, as in the records of list tools and the items of nested collections, where each item
|
|
104
|
+
* runs its resolver; elsewhere, such as at the top of by-ID results, tools always select it. A
|
|
105
|
+
* field with a required argument still gets its tool. `false` generates no per-field tool, and
|
|
106
|
+
* changes nothing else: by-ID and mutation tools still select a field without required
|
|
107
|
+
* arguments, so for such a field `'tool'` and `false` behave the same.
|
|
108
|
+
*/
|
|
109
|
+
mcp?: 'tool' | 'select' | false;
|
|
110
|
+
}
|
|
111
|
+
/** The value of a virtual field's `extensions.virtual`. */
|
|
112
|
+
export type VirtualFieldDeclaration = true | VirtualFieldOptions;
|
|
113
|
+
/** A GraphQL field, or its configuration, as far as the virtual field helpers read it. */
|
|
114
|
+
export interface VirtualFieldCandidate {
|
|
115
|
+
readonly extensions?: Readonly<{ [name: string]: unknown }> | null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Whether a field is virtual: its `extensions.virtual` is `true`, or a plain object whose only key is
|
|
119
|
+
* a valid `mcp`. The GraphQL types that `DatabaseAdapter.prepare()` and `createModel()` receive still
|
|
120
|
+
* contain virtual fields, so an adapter that builds storage from a type's fields skips the fields for
|
|
121
|
+
* which this returns true, on entities and embedded types alike. In a schema that `createSchema()`
|
|
122
|
+
* accepted, every field that declares `extensions.virtual` (other than `undefined`) is virtual,
|
|
123
|
+
* because it rejects every other value.
|
|
124
|
+
*/
|
|
125
|
+
export function isVirtualField(field: VirtualFieldCandidate | null | undefined): boolean;
|
|
126
|
+
/**
|
|
127
|
+
* The normalized options of a virtual field, `{ mcp }` with `mcp` defaulting to `'tool'`, or null
|
|
128
|
+
* when the field is not virtual (see {@link isVirtualField}).
|
|
129
|
+
*/
|
|
130
|
+
export function virtualFieldOptions(field: VirtualFieldCandidate | null | undefined): Required<VirtualFieldOptions> | null;
|
|
47
131
|
/**
|
|
48
132
|
* Creates the scalar `${name}_${baseScalarType.name}`. `validate` throws to reject a value and
|
|
49
133
|
* receives the base scalar's internal value: the base's `parseValue` result for variables, the
|
|
@@ -222,7 +306,10 @@ export interface Runtime<Model = any, Session = any> {
|
|
|
222
306
|
* non-empty child `connectionField`, whose resolver this runtime would generate, throws
|
|
223
307
|
* `INVALID_MODEL` (400) before `adapter.prepare()`; one with an application resolver or
|
|
224
308
|
* `readOnly` only logs a `Configuration issue` warning, and its nested writes fail with
|
|
225
|
-
* `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning.
|
|
309
|
+
* `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning. Invalid
|
|
310
|
+
* virtual field declarations throw `INVALID_MODEL` (400) before `adapter.prepare()` (see
|
|
311
|
+
* {@link VirtualFieldOptions}), and a `readOnly` field with arguments and its own resolver that is
|
|
312
|
+
* not virtual, and is not a reference or collection, logs a `Configuration issue` warning once. The
|
|
226
313
|
* first schema binds the types whose relation resolvers it generates to this runtime and
|
|
227
314
|
* reserves reachable types that still have a
|
|
228
315
|
* non-embedded relation field without a resolver, such as an unregistered custom mutation result.
|
|
@@ -270,9 +357,30 @@ export interface Runtime<Model = any, Session = any> {
|
|
|
270
357
|
|
|
271
358
|
/** Driver adapter contract. GraphQL generation and lifecycle live in createRuntime. */
|
|
272
359
|
export interface DatabaseAdapter<Model = any, Session = any> {
|
|
360
|
+
/**
|
|
361
|
+
* Optional. The filter operators this adapter compiles. `createRuntime` reads it once and throws
|
|
362
|
+
* `INVALID_QUERY_FEATURES` (500) unless it is undefined, null or an object whose `operators` is an
|
|
363
|
+
* array of strings. Without it the adapter supports the operators of 3.7.0 (`EQ`, `NE`, `LT`,
|
|
364
|
+
* `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`). A list, aggregate or collection read, or a
|
|
365
|
+
* by-ID read through a `get_by_id` scope, whose arguments use a `QLOperator` value the adapter
|
|
366
|
+
* does not support fails with `UNSUPPORTED_QUERY` (400), before middleware runs for the client's
|
|
367
|
+
* arguments and before `find`, `count`, `aggregate`, `findChildren` or `findChildrenBatch` for
|
|
368
|
+
* conditions that middleware, scopes and joined scopes added. Operators outside `QLOperator` still
|
|
369
|
+
* reach the adapter, which reports them as before.
|
|
370
|
+
*/
|
|
371
|
+
queryFeatures?: QueryFeatures | null | undefined;
|
|
273
372
|
bind?(runtime: Pick<Runtime<Model, Session>, 'getModel' | 'getType' | 'getRegistrations'>): void;
|
|
274
373
|
validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
|
|
374
|
+
/**
|
|
375
|
+
* Optional. Receives every registration before models are created. Their GraphQL types, like the
|
|
376
|
+
* type that `createModel()` receives, still contain virtual fields (`extensions.virtual`), which
|
|
377
|
+
* must never be stored: an adapter that builds storage from a type's fields skips each field for
|
|
378
|
+
* which {@link isVirtualField} returns true, on the types its embedded fields reach as well, or
|
|
379
|
+
* builds it from `describeModels(registrations)`, which leaves them out. Generated writes, filters,
|
|
380
|
+
* sorts and aggregations never name a virtual field; a controller hook can still assign one.
|
|
381
|
+
*/
|
|
275
382
|
prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
|
|
383
|
+
/** Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. */
|
|
276
384
|
createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
|
|
277
385
|
/**
|
|
278
386
|
* Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
|
|
@@ -296,8 +404,8 @@ export interface DatabaseAdapter<Model = any, Session = any> {
|
|
|
296
404
|
* value unchanged. It must depend only on the value: these resolvers read no data and do not bind
|
|
297
405
|
* their types, so a runtime that reaches a shared type may read through another runtime's hook.
|
|
298
406
|
* Simfinity then reads as null an object that holds no data when a non-null member would read as
|
|
299
|
-
* null, whatever the selection. These members do not count: `id`, readOnly members,
|
|
300
|
-
* union members, members with their own resolver and referenced collections. An object whose type
|
|
407
|
+
* null, whatever the selection. These members do not count: `id`, readOnly and virtual members,
|
|
408
|
+
* interface and union members, members with their own resolver and referenced collections. An object whose type
|
|
301
409
|
* has no member that counts is returned untouched. Otherwise Simfinity reads the stored members
|
|
302
410
|
* (see `rawEmbeddedValue`), never calling a getter, and counts a stored `_id` as `id`; an error
|
|
303
411
|
* while reading them returns the object unchanged. Runtimes that share the type read through the
|
|
@@ -378,6 +486,7 @@ export interface DatabaseAdapter<Model = any, Session = any> {
|
|
|
378
486
|
}
|
|
379
487
|
export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapter<Model, Session>): Runtime<Model, Session>;
|
|
380
488
|
|
|
489
|
+
/** `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. */
|
|
381
490
|
export type QueryPredicate = { kind: 'predicate'; path: string[]; operator: string; value: any } | { kind: 'and' | 'or'; terms: QueryPredicate[] };
|
|
382
491
|
export interface QueryPlan {
|
|
383
492
|
entity: string;
|
|
@@ -387,7 +496,14 @@ export interface QueryPlan {
|
|
|
387
496
|
pagination: { limit: number; offset: number } | null;
|
|
388
497
|
aggregation?: { groupId: string[]; facts: Array<{ path: string[]; factName: string; operation: 'SUM' | 'COUNT' | 'AVG' | 'MIN' | 'MAX' }> };
|
|
389
498
|
}
|
|
390
|
-
|
|
499
|
+
/**
|
|
500
|
+
* Validates the arguments into a plan: paths, operators and values, with `INVALID_FILTER_VALUE` (400)
|
|
501
|
+
* for a text operator on a field that is not a `String` field or list, or with a value that is not
|
|
502
|
+
* a string or contains U+0000. With `operators`, the names the caller compiles, any other
|
|
503
|
+
* `QLOperator` value fails with `UNSUPPORTED_QUERY` (400); unknown operators fail with
|
|
504
|
+
* `INVALID_FILTER_OPERATOR` (400) either way.
|
|
505
|
+
*/
|
|
506
|
+
export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string> }): QueryPlan;
|
|
391
507
|
export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
|
|
392
508
|
|
|
393
509
|
export function configureQueryLimits(options?: { maxPageSize?: number }): void;
|