@simtlix/simfinity-core 3.5.3 → 3.5.6
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 -2
- package/package.json +2 -1
- package/src/query-plan.js +14 -6
- package/src/relation-storage.js +58 -0
- package/src/runtime.js +296 -81
- package/types/index.d.ts +40 -2
package/README.md
CHANGED
|
@@ -16,11 +16,13 @@ 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 it.
|
|
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. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError`, since the client did not send the value. 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. The MongoDB adapter defines it.
|
|
20
|
+
|
|
21
|
+
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()`.
|
|
20
22
|
|
|
21
23
|
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.
|
|
22
24
|
|
|
23
|
-
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`).
|
|
25
|
+
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.
|
|
24
26
|
|
|
25
27
|
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.
|
|
26
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-core",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.6",
|
|
4
4
|
"description": "Driver-free GraphQL runtime and model metadata for Simfinity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"./auth/expressions": "./src/auth/expressions.js",
|
|
16
16
|
"./auth/rules": "./src/auth/rules.js",
|
|
17
17
|
"./internal/object-id": "./src/auth/object-id.js",
|
|
18
|
+
"./internal/relation-storage": "./src/relation-storage.js",
|
|
18
19
|
"./plugins": "./src/plugins.js",
|
|
19
20
|
"./scalars": "./src/scalars.js",
|
|
20
21
|
"./validators": "./src/validators.js"
|
package/src/query-plan.js
CHANGED
|
@@ -4,12 +4,17 @@ import SimfinityError from './errors/simfinity.error.js';
|
|
|
4
4
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
5
5
|
const operators = new Set(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
|
|
6
6
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
7
|
+
// `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
|
|
8
|
+
// that name is filtered like any other.
|
|
9
|
+
const listReserved = new Set(['AND', 'OR', 'sort', 'pagination']);
|
|
10
|
+
const controlArgs = (operation) => (operation === 'aggregate' ? reserved : listReserved);
|
|
7
11
|
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Internal: the model paths named by generated query arguments, as `{ segments, group }` entries.
|
|
11
15
|
* Covers filter keys and relation terms, AND/OR conditions, list sort terms, and aggregation
|
|
12
|
-
* groupId and fact paths; aggregate sort terms name result keys.
|
|
16
|
+
* groupId and fact paths; aggregate sort terms name result keys. Outside aggregate mode a key
|
|
17
|
+
* named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
13
18
|
* object whose `conditions` hold the path, or null for the other forms. Malformed entries are
|
|
14
19
|
* left to the backends.
|
|
15
20
|
*/
|
|
@@ -27,8 +32,9 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
|
27
32
|
}
|
|
28
33
|
for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
|
|
29
34
|
};
|
|
35
|
+
const controls = controlArgs(operation);
|
|
30
36
|
for (const [name, value] of Object.entries(args)) {
|
|
31
|
-
if (
|
|
37
|
+
if (controls.has(name) || value == null) continue;
|
|
32
38
|
const terms = listOf(value.terms).filter((term) => typeof term?.path === 'string' && term.path);
|
|
33
39
|
if (terms.length) for (const term of terms) add(`${name}.${term.path}`);
|
|
34
40
|
else add(name);
|
|
@@ -100,9 +106,9 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
|
|
|
100
106
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
101
107
|
return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
102
108
|
};
|
|
103
|
-
const validateGroup = (value) => {
|
|
109
|
+
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
104
110
|
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
|
|
105
|
-
for (const key of
|
|
111
|
+
for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
|
|
106
112
|
};
|
|
107
113
|
const group = (value, depth = 0) => {
|
|
108
114
|
validateGroup(value);
|
|
@@ -117,10 +123,12 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
|
|
|
117
123
|
terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
|
|
118
124
|
return combine('and', terms);
|
|
119
125
|
};
|
|
120
|
-
|
|
126
|
+
// Top-level arguments are field filters plus AND/OR; `conditions` only lists conditions inside groups.
|
|
127
|
+
validateGroup(input, ['AND', 'OR']);
|
|
128
|
+
const controls = controlArgs(mode);
|
|
121
129
|
const terms = [];
|
|
122
130
|
for (const [name, value] of Object.entries(input)) {
|
|
123
|
-
if (
|
|
131
|
+
if (controls.has(name) || value == null) continue;
|
|
124
132
|
const field = resolveModelPath(models, entityName, name).at(-1);
|
|
125
133
|
if (typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter object');
|
|
126
134
|
if (field.kind !== 'scalar' && !field.inferred && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { GraphQLList, GraphQLNonNull } from 'graphql';
|
|
2
|
+
|
|
3
|
+
// Internal helpers that decide where a relation stores its link and which types keep a stored
|
|
4
|
+
// identity. They are shared by the core runtime and the MongoDB adapter through the
|
|
5
|
+
// `./internal/relation-storage` subpath, which is not public API.
|
|
6
|
+
|
|
7
|
+
const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
|
|
8
|
+
|
|
9
|
+
export const getListShape = (type) => {
|
|
10
|
+
const outerNonNull = type instanceof GraphQLNonNull;
|
|
11
|
+
const listType = unwrapNonNull(type);
|
|
12
|
+
if (!(listType instanceof GraphQLList)) return null;
|
|
13
|
+
const itemNonNull = listType.ofType instanceof GraphQLNonNull;
|
|
14
|
+
return {
|
|
15
|
+
outerNonNull,
|
|
16
|
+
itemNonNull,
|
|
17
|
+
itemType: unwrapNonNull(listType.ofType),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const getFieldStorageName = (fieldName, field) => {
|
|
22
|
+
const relation = field.extensions?.relation;
|
|
23
|
+
return relation && !relation.embedded && !getListShape(field.type)
|
|
24
|
+
? relation.connectionField || fieldName
|
|
25
|
+
: fieldName;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Entity types, which keep their own stored identity. The generated id resolver lives on the type
|
|
29
|
+
// object, which runtimes may share, so the identity rule is per type object, not per runtime
|
|
30
|
+
// registration: a store path for `id` follows the resolver any runtime installed on the type.
|
|
31
|
+
const storedIdentityTypes = new WeakSet();
|
|
32
|
+
|
|
33
|
+
export const markStoredIdentity = (gqltype) => {
|
|
34
|
+
storedIdentityTypes.add(gqltype);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const hasStoredIdentity = (gqltype) => storedIdentityTypes.has(gqltype);
|
|
38
|
+
|
|
39
|
+
export const normalizeConnectionField = (childType, declaredFieldName) => {
|
|
40
|
+
if (!declaredFieldName) {
|
|
41
|
+
return { declaredFieldName, graphqlFieldName: null, storageFieldName: null };
|
|
42
|
+
}
|
|
43
|
+
const childFields = childType.getFields();
|
|
44
|
+
let graphqlFieldName = childFields[declaredFieldName] ? declaredFieldName : null;
|
|
45
|
+
if (!graphqlFieldName) {
|
|
46
|
+
const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
|
|
47
|
+
getFieldStorageName(fieldName, field) === declaredFieldName
|
|
48
|
+
));
|
|
49
|
+
graphqlFieldName = aliasedField?.[0] || null;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
declaredFieldName,
|
|
53
|
+
graphqlFieldName,
|
|
54
|
+
storageFieldName: graphqlFieldName
|
|
55
|
+
? getFieldStorageName(graphqlFieldName, childFields[graphqlFieldName])
|
|
56
|
+
: declaredFieldName,
|
|
57
|
+
};
|
|
58
|
+
};
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { configureQueryLimits, getQueryMaxPageSize } from './query-limits.js';
|
|
1
|
+
import { configureQueryLimits, getQueryMaxPageSize, paginationStages } from './query-limits.js';
|
|
2
2
|
import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
|
|
3
3
|
import {
|
|
4
4
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
@@ -14,6 +14,10 @@ import QLValue from './const/QLValue.js';
|
|
|
14
14
|
import QLSort from './const/QLSort.js';
|
|
15
15
|
import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './query-plan.js';
|
|
16
16
|
import { relationFieldOwners } from './relation-owners.js';
|
|
17
|
+
import { isThenable } from './auth/thenable.js';
|
|
18
|
+
import {
|
|
19
|
+
getFieldStorageName, getListShape, markStoredIdentity, normalizeConnectionField,
|
|
20
|
+
} from './relation-storage.js';
|
|
17
21
|
import './introspection.js';
|
|
18
22
|
|
|
19
23
|
// Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
|
|
@@ -78,7 +82,26 @@ const requireRegisteredType = (dict, type, fieldName, ownerName) => {
|
|
|
78
82
|
}
|
|
79
83
|
return registration;
|
|
80
84
|
};
|
|
81
|
-
|
|
85
|
+
// A joined scope is a find scope, so `aggregation` is a field filter there, as on list queries.
|
|
86
|
+
const SCOPE_IGNORED_ARGS = new Set(['sort', 'pagination']);
|
|
87
|
+
|
|
88
|
+
// A joined scope cannot sort or paginate the records it restricts. Its sort and pagination values are
|
|
89
|
+
// checked for shape only (INVALID_SORT: non-empty terms with a field and an ASC or DESC order;
|
|
90
|
+
// INVALID_PAGINATION: as on direct queries), then ignored; sort field names are not resolved.
|
|
91
|
+
const assertScopeControlArg = (name, value) => {
|
|
92
|
+
if (name === 'pagination') {
|
|
93
|
+
paginationStages(value, false);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!Array.isArray(value.terms) || !value.terms.length) {
|
|
97
|
+
throw new SimfinityError('Sort requires non-empty terms', 'INVALID_SORT', 400);
|
|
98
|
+
}
|
|
99
|
+
for (const term of value.terms) {
|
|
100
|
+
if (typeof term?.field !== 'string' || !['ASC', 'DESC'].includes(term.order)) {
|
|
101
|
+
throw new SimfinityError('Sort terms require a field and an ASC or DESC order', 'INVALID_SORT', 400);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
82
105
|
|
|
83
106
|
// Moves a joined type's scope group below the relation path that entered it.
|
|
84
107
|
const prefixScopeGroup = (group, prefix) => {
|
|
@@ -102,7 +125,11 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
|
102
125
|
const conditions = [];
|
|
103
126
|
const groups = [];
|
|
104
127
|
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
105
|
-
if (value == null
|
|
128
|
+
if (value == null) continue;
|
|
129
|
+
if (SCOPE_IGNORED_ARGS.has(name)) {
|
|
130
|
+
assertScopeControlArg(name, value);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
106
133
|
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
|
|
107
134
|
else if (name === 'OR') {
|
|
108
135
|
const branches = [].concat(value);
|
|
@@ -237,6 +264,29 @@ export const buildErrorFormatter = (callback) => (error) => {
|
|
|
237
264
|
|
|
238
265
|
export { SimfinityError, InternalServerError };
|
|
239
266
|
|
|
267
|
+
// A field named like an Object.prototype member (`constructor`, `toString`, ...) that reads as that
|
|
268
|
+
// very member holds no value: graphql input objects and stored records without the field inherit
|
|
269
|
+
// it, and MongoDB nested paths return it from their own accessors. Other values, overriding members
|
|
270
|
+
// such as class methods and getters, and every other name read as usual. The property is read once,
|
|
271
|
+
// so a getter runs once, as with a plain read.
|
|
272
|
+
const isObjectMember = (key, read) => key in Object.prototype && read === Object.prototype[key];
|
|
273
|
+
const ownValue = (value, key) => {
|
|
274
|
+
if (value == null) return undefined;
|
|
275
|
+
const read = value[key];
|
|
276
|
+
return isObjectMember(key, read) ? undefined : read;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Output fields with such names read through this resolver instead of graphql's default one, which
|
|
280
|
+
// would call the inherited member. Otherwise it resolves as the default one does, calling a method
|
|
281
|
+
// with the parent as receiver, but reads the property only once. It reads no data, so installing it
|
|
282
|
+
// binds no type to a runtime.
|
|
283
|
+
const readOwnField = markGenerated((parent, args, context, info) => {
|
|
284
|
+
if (parent === null || (typeof parent !== 'object' && typeof parent !== 'function')) return undefined;
|
|
285
|
+
const property = parent[info.fieldName];
|
|
286
|
+
if (isObjectMember(info.fieldName, property)) return null;
|
|
287
|
+
return typeof property === 'function' ? property.call(parent, args, context, info) : property;
|
|
288
|
+
});
|
|
289
|
+
|
|
240
290
|
const cloneInput = (value, type) => {
|
|
241
291
|
if (value === null || value === undefined || !type) return value;
|
|
242
292
|
if (type instanceof GraphQLNonNull) return cloneInput(value, type.ofType);
|
|
@@ -475,46 +525,6 @@ const unwrapListAndNonNull = (type) => {
|
|
|
475
525
|
return unwrapped;
|
|
476
526
|
};
|
|
477
527
|
|
|
478
|
-
const getListShape = (type) => {
|
|
479
|
-
const outerNonNull = type instanceof GraphQLNonNull;
|
|
480
|
-
const listType = unwrapNonNull(type);
|
|
481
|
-
if (!(listType instanceof GraphQLList)) return null;
|
|
482
|
-
const itemNonNull = listType.ofType instanceof GraphQLNonNull;
|
|
483
|
-
return {
|
|
484
|
-
outerNonNull,
|
|
485
|
-
itemNonNull,
|
|
486
|
-
itemType: unwrapNonNull(listType.ofType),
|
|
487
|
-
};
|
|
488
|
-
};
|
|
489
|
-
|
|
490
|
-
const getFieldStorageName = (fieldName, field) => {
|
|
491
|
-
const relation = field.extensions?.relation;
|
|
492
|
-
return relation && !relation.embedded && !getListShape(field.type)
|
|
493
|
-
? relation.connectionField || fieldName
|
|
494
|
-
: fieldName;
|
|
495
|
-
};
|
|
496
|
-
|
|
497
|
-
const normalizeConnectionField = (childType, declaredFieldName) => {
|
|
498
|
-
if (!declaredFieldName) {
|
|
499
|
-
return { declaredFieldName, graphqlFieldName: null, storageFieldName: null };
|
|
500
|
-
}
|
|
501
|
-
const childFields = childType.getFields();
|
|
502
|
-
let graphqlFieldName = childFields[declaredFieldName] ? declaredFieldName : null;
|
|
503
|
-
if (!graphqlFieldName) {
|
|
504
|
-
const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
|
|
505
|
-
getFieldStorageName(fieldName, field) === declaredFieldName
|
|
506
|
-
));
|
|
507
|
-
graphqlFieldName = aliasedField?.[0] || null;
|
|
508
|
-
}
|
|
509
|
-
return {
|
|
510
|
-
declaredFieldName,
|
|
511
|
-
graphqlFieldName,
|
|
512
|
-
storageFieldName: graphqlFieldName
|
|
513
|
-
? getFieldStorageName(graphqlFieldName, childFields[graphqlFieldName])
|
|
514
|
-
: declaredFieldName,
|
|
515
|
-
};
|
|
516
|
-
};
|
|
517
|
-
|
|
518
528
|
const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
|
|
519
529
|
const wrappedItem = listShape.itemNonNull ? new GraphQLNonNull(itemType) : itemType;
|
|
520
530
|
const listType = new GraphQLList(wrappedItem);
|
|
@@ -804,29 +814,29 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
804
814
|
const collectionFields = {};
|
|
805
815
|
|
|
806
816
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
817
|
+
const fieldValue = ownValue(args, fieldEntryName);
|
|
807
818
|
if (fieldEntry.extensions && fieldEntry.extensions.validations
|
|
808
819
|
&& fieldEntry.extensions.validations[operation]) {
|
|
809
820
|
for (const validator of fieldEntry.extensions.validations[operation]) {
|
|
810
|
-
await validator.validate(gqltype.name, fieldEntryName,
|
|
821
|
+
await validator.validate(gqltype.name, fieldEntryName, fieldValue, session);
|
|
811
822
|
}
|
|
812
823
|
}
|
|
813
824
|
|
|
814
|
-
if (
|
|
825
|
+
if (fieldValue !== undefined && fieldValue !== null) {
|
|
815
826
|
if (fieldEntry.type instanceof GraphQLScalarType
|
|
816
827
|
|| fieldEntry.type instanceof GraphQLEnumType
|
|
817
828
|
|| isNonNullOfType(fieldEntry.type, GraphQLScalarType)
|
|
818
829
|
|| isNonNullOfType(fieldEntry.type, GraphQLEnumType)) {
|
|
819
|
-
modelArgs[fieldEntryName] =
|
|
830
|
+
modelArgs[fieldEntryName] = fieldValue;
|
|
820
831
|
} else if (fieldEntry.type instanceof GraphQLObjectType
|
|
821
832
|
|| isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
|
|
822
833
|
if (fieldEntry.extensions && fieldEntry.extensions.relation) {
|
|
823
834
|
if (!fieldEntry.extensions.relation.embedded) {
|
|
824
|
-
|
|
825
|
-
modelArgs[connectionField] = adapter.castId(args[fieldEntryName].id);
|
|
835
|
+
modelArgs[getFieldStorageName(fieldEntryName, fieldEntry)] = adapter.castId(fieldValue.id);
|
|
826
836
|
} else {
|
|
827
837
|
const fieldType = fieldEntry.type instanceof GraphQLNonNull
|
|
828
838
|
? fieldEntry.type.ofType : fieldEntry.type;
|
|
829
|
-
modelArgs[fieldEntryName] = (await materializeModel(
|
|
839
|
+
modelArgs[fieldEntryName] = (await materializeModel(fieldValue, fieldType,
|
|
830
840
|
null, operation, session)).modelArgs;
|
|
831
841
|
}
|
|
832
842
|
} else {
|
|
@@ -841,11 +851,11 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
841
851
|
if (itemType instanceof GraphQLObjectType && fieldEntry.extensions
|
|
842
852
|
&& fieldEntry.extensions.relation) {
|
|
843
853
|
if (!fieldEntry.extensions.relation.embedded) {
|
|
844
|
-
collectionFields[fieldEntryName] =
|
|
854
|
+
collectionFields[fieldEntryName] = fieldValue;
|
|
845
855
|
} else if (fieldEntry.extensions.relation.embedded) {
|
|
846
856
|
const collectionEntries = [];
|
|
847
857
|
|
|
848
|
-
for (const element of
|
|
858
|
+
for (const element of fieldValue) {
|
|
849
859
|
if (element === null) {
|
|
850
860
|
collectionEntries.push(null);
|
|
851
861
|
} else {
|
|
@@ -857,9 +867,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
857
867
|
modelArgs[fieldEntryName] = collectionEntries;
|
|
858
868
|
}
|
|
859
869
|
} else if (itemType instanceof GraphQLScalarType || itemType instanceof GraphQLEnumType) {
|
|
860
|
-
modelArgs[fieldEntryName] =
|
|
870
|
+
modelArgs[fieldEntryName] = fieldValue;
|
|
861
871
|
}
|
|
862
872
|
}
|
|
873
|
+
} else if (operation === 'CREATE' && fieldEntryName in Object.prototype && getListShape(fieldEntry.type)
|
|
874
|
+
&& !(fieldEntry.extensions?.relation && !fieldEntry.extensions.relation.embedded)) {
|
|
875
|
+
// A create without a value stores [] for a list: SQL backends store it for every list, and
|
|
876
|
+
// Mongoose defaults to it, except for a list named like an Object.prototype member, where it
|
|
877
|
+
// finds the inherited member. Collections are not stored on the record.
|
|
878
|
+
modelArgs[fieldEntryName] = [];
|
|
863
879
|
}
|
|
864
880
|
}
|
|
865
881
|
|
|
@@ -945,9 +961,9 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
|
|
|
945
961
|
const memberList = getListShape(member.type);
|
|
946
962
|
if (relation && !relation.embedded && memberList) continue;
|
|
947
963
|
const storageName = getFieldStorageName(memberName, member);
|
|
948
|
-
if (item
|
|
964
|
+
if (ownValue(item, storageName) === undefined && memberList) item[storageName] = [];
|
|
949
965
|
if (memberName === 'id' || member.extensions?.readOnly) continue;
|
|
950
|
-
const memberValue = item
|
|
966
|
+
const memberValue = ownValue(item, storageName);
|
|
951
967
|
if (memberValue === null || memberValue === undefined) {
|
|
952
968
|
if (member.type instanceof GraphQLNonNull) {
|
|
953
969
|
throw new SimfinityError(`Required value ${memberName} is missing`, 'REQUIRED_VALUE', 400);
|
|
@@ -959,6 +975,25 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
|
|
|
959
975
|
}
|
|
960
976
|
};
|
|
961
977
|
|
|
978
|
+
// An explicit null for a nullable member of a singular embedded patch clears that member, as a
|
|
979
|
+
// top-level null clears a field. Lists become empty lists; other members, including nested embedded
|
|
980
|
+
// objects and references (under their storage name), are stored as null rather than removed, since
|
|
981
|
+
// a removed nested object is materialized again from its list defaults. A null for a non-null member
|
|
982
|
+
// keeps the stored value, also as at the top level. Nested objects in the patch replace the stored
|
|
983
|
+
// ones wholesale.
|
|
984
|
+
const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
|
|
985
|
+
if (!patch || typeof patch !== 'object') return;
|
|
986
|
+
const members = unwrapNonNull(fieldEntry.type).getFields();
|
|
987
|
+
for (const [memberName, value] of Object.entries(patch)) {
|
|
988
|
+
const member = members[memberName];
|
|
989
|
+
if (value !== null || !member || member.type instanceof GraphQLNonNull) continue;
|
|
990
|
+
const relation = member.extensions?.relation;
|
|
991
|
+
const listShape = getListShape(member.type);
|
|
992
|
+
if (relation && !relation.embedded && listShape) continue;
|
|
993
|
+
merged[getFieldStorageName(memberName, member)] = listShape ? [] : null;
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
|
|
962
997
|
const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
|
|
963
998
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
|
|
964
999
|
const objectId = args.id;
|
|
@@ -974,14 +1009,16 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
974
1009
|
});
|
|
975
1010
|
if (currentObject) {
|
|
976
1011
|
for (const fieldEntryName of embeddedFieldNames) {
|
|
977
|
-
const oldObjectData = currentObject
|
|
978
|
-
const newObjectData = materializedModel.modelArgs
|
|
1012
|
+
const oldObjectData = ownValue(currentObject, fieldEntryName);
|
|
1013
|
+
const newObjectData = ownValue(materializedModel.modelArgs, fieldEntryName);
|
|
979
1014
|
if (newObjectData) {
|
|
980
1015
|
if (Array.isArray(newObjectData)) {
|
|
981
1016
|
materializedModel.modelArgs[fieldEntryName] = newObjectData;
|
|
982
1017
|
completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
|
|
983
1018
|
} else {
|
|
984
1019
|
materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
|
|
1020
|
+
clearNullEmbeddedMembers(materializedModel.modelArgs[fieldEntryName],
|
|
1021
|
+
argTypes[fieldEntryName], ownValue(args, fieldEntryName));
|
|
985
1022
|
completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
|
|
986
1023
|
materializedModel.modelArgs[fieldEntryName], newObjectData);
|
|
987
1024
|
}
|
|
@@ -992,13 +1029,9 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
992
1029
|
|
|
993
1030
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
994
1031
|
if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull)) {
|
|
995
|
-
const relation = fieldEntry.extensions?.relation;
|
|
996
|
-
const storageFieldName = relation && !relation.embedded && !(unwrapNonNull(fieldEntry.type) instanceof GraphQLList)
|
|
997
|
-
? relation.connectionField || fieldEntryName
|
|
998
|
-
: fieldEntryName;
|
|
999
1032
|
materializedModel.modelArgs.$unset = {
|
|
1000
1033
|
...materializedModel.modelArgs.$unset,
|
|
1001
|
-
[
|
|
1034
|
+
[getFieldStorageName(fieldEntryName, fieldEntry)]: '',
|
|
1002
1035
|
};
|
|
1003
1036
|
}
|
|
1004
1037
|
}
|
|
@@ -1079,6 +1112,7 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
|
|
|
1079
1112
|
|
|
1080
1113
|
const saveObject = async (typeName, args, session, context) => {
|
|
1081
1114
|
const type = typesDict.types[typeName];
|
|
1115
|
+
assertCollectionWrites(type.gqltype, args);
|
|
1082
1116
|
const snapshot = cloneInput(args, type.inputType);
|
|
1083
1117
|
return adapter.withTransaction(session, (transaction) => onSaveObject(
|
|
1084
1118
|
type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
|
|
@@ -1109,10 +1143,50 @@ const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) =>
|
|
|
1109
1143
|
}
|
|
1110
1144
|
};
|
|
1111
1145
|
|
|
1146
|
+
// A referenced collection links each child to its parent through the child's connectionField.
|
|
1147
|
+
const hasConnectionField = (relation) => typeof relation.connectionField === 'string'
|
|
1148
|
+
&& relation.connectionField !== '';
|
|
1149
|
+
const isUnlinkedCollection = (field) => {
|
|
1150
|
+
const relation = field.extensions?.relation;
|
|
1151
|
+
const listShape = getListShape(field.type);
|
|
1152
|
+
return !!relation && !relation.embedded && !!listShape
|
|
1153
|
+
&& listShape.itemType instanceof GraphQLObjectType && !hasConnectionField(relation);
|
|
1154
|
+
};
|
|
1155
|
+
// Null entries are skipped by every nested operation, so a list of only nulls writes nothing.
|
|
1156
|
+
const hasNestedItems = (items) => !Array.isArray(items) || items.some((item) => item !== null);
|
|
1157
|
+
const unlinkedCollectionError = (gqltype, fieldName) => new SimfinityError(
|
|
1158
|
+
`${gqltype.name}.${fieldName} cannot store nested items because it has no connectionField`,
|
|
1159
|
+
'INVALID_MODEL',
|
|
1160
|
+
500,
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
// Rejects nested items for a collection without a connectionField, at every nesting level that
|
|
1164
|
+
// iterateOnCollectionFields executes, before anything is written or any controller hook runs. Such a
|
|
1165
|
+
// collection only exists with its own resolver or as readOnly; createSchema rejects the others.
|
|
1166
|
+
const assertCollectionWrites = (gqltype, input) => {
|
|
1167
|
+
if (!input || typeof input !== 'object') return;
|
|
1168
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
1169
|
+
const value = input[fieldName];
|
|
1170
|
+
const relation = field.extensions?.relation;
|
|
1171
|
+
const listShape = getListShape(field.type);
|
|
1172
|
+
if (value == null || !relation || relation.embedded || !listShape
|
|
1173
|
+
|| !(listShape.itemType instanceof GraphQLObjectType)) continue;
|
|
1174
|
+
for (const key of ['added', 'updated', 'deleted']) {
|
|
1175
|
+
const items = value[key];
|
|
1176
|
+
if (!items) continue;
|
|
1177
|
+
if (!hasConnectionField(relation) && hasNestedItems(items)) throw unlinkedCollectionError(gqltype, fieldName);
|
|
1178
|
+
if (key !== 'deleted' && Array.isArray(items)) {
|
|
1179
|
+
for (const item of items) assertCollectionWrites(listShape.itemType, item);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1112
1185
|
const executeOperation = (Model, gqltype, controller, args, operation, actionField,
|
|
1113
1186
|
session, context, inputType) => {
|
|
1114
1187
|
const limit = getMaxNestedOperations();
|
|
1115
1188
|
if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
|
|
1189
|
+
if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
|
|
1116
1190
|
const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
|
|
1117
1191
|
return adapter.withTransaction(
|
|
1118
1192
|
session,
|
|
@@ -1142,10 +1216,12 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
|
|
|
1142
1216
|
collectionFieldsList, operationType, context) => {
|
|
1143
1217
|
const argTypes = gqltype.getFields();
|
|
1144
1218
|
const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1219
|
+
const { relation } = argTypes[collectionField].extensions;
|
|
1220
|
+
// Checked before the operation too; this also covers items that nested middleware added.
|
|
1221
|
+
if (!hasConnectionField(relation) && hasNestedItems(collectionFieldsList)) {
|
|
1222
|
+
throw unlinkedCollectionError(gqltype, collectionField);
|
|
1223
|
+
}
|
|
1224
|
+
const connection = normalizeConnectionField(collectionGQLType, relation.connectionField);
|
|
1149
1225
|
|
|
1150
1226
|
const type = operationType === operations.UPDATE
|
|
1151
1227
|
? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
|
|
@@ -1568,10 +1644,6 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1568
1644
|
if (!shouldNotBeIncludedInSchema(includedTypes, type.gqltype)) {
|
|
1569
1645
|
const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
|
|
1570
1646
|
if (!wasAddedAsNoEnpointType) {
|
|
1571
|
-
if (type.gqltype.getFields().id && !type.gqltype.getFields().id.resolve) {
|
|
1572
|
-
type.gqltype.getFields().id.resolve = markGenerated((parent) => parent._id);
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
1647
|
rootQueryArgs.fields[type.simpleEntityEndpointName] = {
|
|
1576
1648
|
type: type.gqltype,
|
|
1577
1649
|
args: { id: { type: GraphQLID } },
|
|
@@ -1580,9 +1652,7 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1580
1652
|
},
|
|
1581
1653
|
};
|
|
1582
1654
|
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
const argsObject = createArgsForQuery(argTypes);
|
|
1655
|
+
const argsObject = createArgsForQuery(type.gqltype);
|
|
1586
1656
|
|
|
1587
1657
|
rootQueryArgs.fields[type.listEntitiesEndpointName] = {
|
|
1588
1658
|
type: new GraphQLList(type.gqltype),
|
|
@@ -1616,6 +1686,11 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1616
1686
|
};
|
|
1617
1687
|
|
|
1618
1688
|
const aggregateArgsObject = { ...argsObject };
|
|
1689
|
+
if (Object.hasOwn(type.gqltype.getFields(), 'aggregation')) {
|
|
1690
|
+
warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
|
|
1691
|
+
+ `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
|
|
1692
|
+
+ 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
|
|
1693
|
+
}
|
|
1619
1694
|
aggregateArgsObject.aggregation = {
|
|
1620
1695
|
type: new GraphQLNonNull(QLTypeAggregationExpression),
|
|
1621
1696
|
};
|
|
@@ -1645,7 +1720,9 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1645
1720
|
return new GraphQLObjectType(rootQueryArgs);
|
|
1646
1721
|
};
|
|
1647
1722
|
|
|
1723
|
+
// Returns the registrations that a non-embedded relation of a registered type targets.
|
|
1648
1724
|
const markReferencedTypesForModelGeneration = () => {
|
|
1725
|
+
const referenced = new Set();
|
|
1649
1726
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
1650
1727
|
Object.values(typeInfo.gqltype.getFields()).forEach((fieldEntry) => {
|
|
1651
1728
|
const relation = fieldEntry.extensions?.relation;
|
|
@@ -1653,11 +1730,94 @@ const markReferencedTypesForModelGeneration = () => {
|
|
|
1653
1730
|
|
|
1654
1731
|
const relatedType = unwrapListAndNonNull(fieldEntry.type);
|
|
1655
1732
|
const relatedTypeInfo = typesDict.types[relatedType.name];
|
|
1733
|
+
if (relatedTypeInfo) referenced.add(relatedTypeInfo);
|
|
1656
1734
|
if (relatedTypeInfo && !relatedTypeInfo.endpoint) {
|
|
1657
1735
|
relatedTypeInfo.needsModel = true;
|
|
1658
1736
|
}
|
|
1659
1737
|
});
|
|
1660
1738
|
});
|
|
1739
|
+
return referenced;
|
|
1740
|
+
};
|
|
1741
|
+
|
|
1742
|
+
// Entity records keep their identity in `_id`; MongoDB records have no `id` value, while the SQL
|
|
1743
|
+
// store and other adapters may set both. Embedded-only types keep the default resolver, which
|
|
1744
|
+
// returns their declared `id` member.
|
|
1745
|
+
const installIdResolver = (gqltype) => {
|
|
1746
|
+
const idField = gqltype.getFields().id;
|
|
1747
|
+
if (idField && !idField.resolve) idField.resolve = markGenerated((parent) => parent._id ?? parent.id);
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
// An adapter that hydrates stored records may render an explicitly null singular embedded object as
|
|
1751
|
+
// an object (MongoDB nested paths do); readEmbeddedValue returns null for it. Nullable singular
|
|
1752
|
+
// embedded fields of registered types, and of the embedded types they reach, read through it. The
|
|
1753
|
+
// hook depends only on the value and reads no data, so these resolvers do not bind their types to
|
|
1754
|
+
// this runtime: another runtime that reaches a shared type reads the same value through it.
|
|
1755
|
+
// The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
|
|
1756
|
+
// custom mutation result supplies, is still called; a promise it returns is read once it settles.
|
|
1757
|
+
// Only a field named like an Object.prototype member that reads as that member reads as null.
|
|
1758
|
+
const readEmbeddedField = markGenerated((parent, args, context, info) => {
|
|
1759
|
+
const value = readOwnField(parent, args, context, info);
|
|
1760
|
+
return isThenable(value)
|
|
1761
|
+
? value.then((resolved) => adapter.readEmbeddedValue(resolved))
|
|
1762
|
+
: adapter.readEmbeddedValue(value);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
const installEmbeddedValueResolvers = (gqltype, visited) => {
|
|
1766
|
+
if (!(gqltype instanceof GraphQLObjectType) || visited.has(gqltype)) return;
|
|
1767
|
+
visited.add(gqltype);
|
|
1768
|
+
for (const fieldEntry of Object.values(gqltype.getFields())) {
|
|
1769
|
+
if (fieldEntry.extensions?.relation?.embedded !== true) continue;
|
|
1770
|
+
const listShape = getListShape(fieldEntry.type);
|
|
1771
|
+
if (!listShape && !(fieldEntry.type instanceof GraphQLNonNull) && !fieldEntry.resolve) {
|
|
1772
|
+
fieldEntry.resolve = readEmbeddedField;
|
|
1773
|
+
}
|
|
1774
|
+
installEmbeddedValueResolvers(listShape ? listShape.itemType : unwrapNonNull(fieldEntry.type), visited);
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
// Fields named like Object.prototype members, of registered types and the embedded types they reach,
|
|
1779
|
+
// read through readOwnField when they have no resolver yet; references and collections keep the ones
|
|
1780
|
+
// autoGenerateResolvers gives them.
|
|
1781
|
+
const installObjectMemberResolvers = (gqltype, visited) => {
|
|
1782
|
+
if (!(gqltype instanceof GraphQLObjectType) || visited.has(gqltype)) return;
|
|
1783
|
+
visited.add(gqltype);
|
|
1784
|
+
for (const [fieldName, fieldEntry] of Object.entries(gqltype.getFields())) {
|
|
1785
|
+
const relation = fieldEntry.extensions?.relation;
|
|
1786
|
+
if (relation && !relation.embedded) continue;
|
|
1787
|
+
if (fieldName in Object.prototype && !fieldEntry.resolve) fieldEntry.resolve = readOwnField;
|
|
1788
|
+
if (relation) installObjectMemberResolvers(unwrapListAndNonNull(fieldEntry.type), visited);
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1792
|
+
// Configuration issues this runtime already reported, per type, so a repeated createSchema call or a
|
|
1793
|
+
// type reached from several places logs each one once.
|
|
1794
|
+
const reportedIssues = new WeakMap();
|
|
1795
|
+
const warnOnce = (gqltype, key, message) => {
|
|
1796
|
+
if (!reportedIssues.has(gqltype)) reportedIssues.set(gqltype, new Set());
|
|
1797
|
+
const reported = reportedIssues.get(gqltype);
|
|
1798
|
+
if (reported.has(key)) return;
|
|
1799
|
+
reported.add(key);
|
|
1800
|
+
console.warn(message);
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
// A writable referenced collection without a child connectionField would store unlinked children
|
|
1804
|
+
// through generated nested writes and read none back, so it is rejected before anything is built.
|
|
1805
|
+
// One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
|
|
1806
|
+
// one warns unless it has its own resolver.
|
|
1807
|
+
const assertCollectionConnections = () => {
|
|
1808
|
+
for (const { gqltype } of Object.values(typesDict.types)) {
|
|
1809
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
1810
|
+
if (!isUnlinkedCollection(field)) continue;
|
|
1811
|
+
const problem = `${gqltype.name}.${fieldName} requires a child connectionField`;
|
|
1812
|
+
const readOnly = !!field.extensions.readOnly;
|
|
1813
|
+
const ownResolver = hasApplicationResolver(gqltype, fieldName);
|
|
1814
|
+
if (!readOnly && !ownResolver) throw new SimfinityError(problem, 'INVALID_MODEL', 400);
|
|
1815
|
+
if (!readOnly || !ownResolver) {
|
|
1816
|
+
warnOnce(gqltype, `connectionField:${fieldName}`,
|
|
1817
|
+
`Configuration issue: ${problem}; nested writes through it are rejected`);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1661
1821
|
};
|
|
1662
1822
|
|
|
1663
1823
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
@@ -1668,7 +1828,12 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
1668
1828
|
const schemaTypes = collectSchemaOutputTypes();
|
|
1669
1829
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
1670
1830
|
schemaTypes.forEach(assertFieldsNotBoundElsewhere);
|
|
1671
|
-
|
|
1831
|
+
assertCollectionConnections();
|
|
1832
|
+
const referencedTypes = markReferencedTypesForModelGeneration();
|
|
1833
|
+
// Entities, which keep their own stored identity: connect() types and relation targets.
|
|
1834
|
+
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
1835
|
+
typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
|
|
1836
|
+
});
|
|
1672
1837
|
if (adapter.prepare) {
|
|
1673
1838
|
adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
|
|
1674
1839
|
}
|
|
@@ -1688,14 +1853,28 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
1688
1853
|
Object.keys(typesDict.types).forEach((typeName) => {
|
|
1689
1854
|
if (typesDictForUpdate.types[typeName]) {
|
|
1690
1855
|
typesDictForUpdate.types[typeName].model = typesDict.types[typeName].model;
|
|
1856
|
+
typesDictForUpdate.types[typeName].storedIdentity = typesDict.types[typeName].storedIdentity;
|
|
1691
1857
|
}
|
|
1692
1858
|
});
|
|
1693
1859
|
|
|
1860
|
+
// Every entity resolves `id`, whatever the query and mutation allowlists include. The type object
|
|
1861
|
+
// is marked too, so query paths of any runtime that shares it follow the same identity.
|
|
1862
|
+
const embeddedValueTypes = new Set();
|
|
1694
1863
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
1695
1864
|
if (typeInfo.gqltype) {
|
|
1865
|
+
if (typeInfo.storedIdentity) {
|
|
1866
|
+
markStoredIdentity(typeInfo.gqltype);
|
|
1867
|
+
installIdResolver(typeInfo.gqltype);
|
|
1868
|
+
}
|
|
1696
1869
|
autoGenerateResolvers(typeInfo.gqltype);
|
|
1870
|
+
if (typeof adapter.readEmbeddedValue === 'function') {
|
|
1871
|
+
installEmbeddedValueResolvers(typeInfo.gqltype, embeddedValueTypes);
|
|
1872
|
+
}
|
|
1697
1873
|
}
|
|
1698
1874
|
});
|
|
1875
|
+
// After every type has its relation and embedded value resolvers, which these fields keep.
|
|
1876
|
+
const objectMemberTypes = new Set();
|
|
1877
|
+
Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
|
|
1699
1878
|
|
|
1700
1879
|
const query = buildRootQuery('RootQueryType', includedQueryTypes);
|
|
1701
1880
|
Object.values(typesDict.types).forEach(({ gqltype }) => {
|
|
@@ -1749,6 +1928,16 @@ const claimRelationField = (fieldEntry, resolve, extensions = {}) => {
|
|
|
1749
1928
|
relationFieldOwners.set(fieldEntry.resolve, runtimeIdentity);
|
|
1750
1929
|
};
|
|
1751
1930
|
|
|
1931
|
+
// The identifier a single reference reads is stored, not sent by the client, so one the adapter
|
|
1932
|
+
// rejects as malformed, such as a value written outside Simfinity, is a data error rather than a bad
|
|
1933
|
+
// request. Missing records and other errors keep their own classification.
|
|
1934
|
+
const storedReferenceError = (error, gqltype, fieldName) => {
|
|
1935
|
+
if (!(error instanceof SimfinityError) || error.getCode() !== 'NOT_VALID_ID' || error.getStatus() !== 400) {
|
|
1936
|
+
return error;
|
|
1937
|
+
}
|
|
1938
|
+
return new InternalServerError(`${gqltype.name}.${fieldName} stores an invalid identifier`, error);
|
|
1939
|
+
};
|
|
1940
|
+
|
|
1752
1941
|
const autoGenerateResolvers = (gqltype) => {
|
|
1753
1942
|
assertFieldsNotBoundElsewhere(gqltype);
|
|
1754
1943
|
const fields = gqltype.getFields();
|
|
@@ -1766,8 +1955,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1766
1955
|
relation.connectionField || fieldName,
|
|
1767
1956
|
);
|
|
1768
1957
|
const relatedTypeInfo = requireRegisteredType(typesDict, relatedType, fieldName, gqltype.name);
|
|
1769
|
-
const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.
|
|
1770
|
-
if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
|
|
1958
|
+
const argsObject = createArgsForQuery(relatedTypeInfo.gqltype, connection.graphqlFieldName);
|
|
1771
1959
|
|
|
1772
1960
|
fieldEntry.args = formatArgs(Object.entries(argsObject));
|
|
1773
1961
|
claimRelationField(fieldEntry, async (parent, args, context) => {
|
|
@@ -1783,7 +1971,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1783
1971
|
relatedTypeInfo.model,
|
|
1784
1972
|
relatedTypeInfo.gqltype,
|
|
1785
1973
|
connection.storageFieldName,
|
|
1786
|
-
parent.
|
|
1974
|
+
parent._id ?? parent.id,
|
|
1787
1975
|
params.args,
|
|
1788
1976
|
null,
|
|
1789
1977
|
);
|
|
@@ -1792,16 +1980,29 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1792
1980
|
} else if (fieldEntry.type instanceof GraphQLObjectType
|
|
1793
1981
|
|| (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
|
|
1794
1982
|
const relatedType = unwrapNonNull(fieldEntry.type);
|
|
1795
|
-
const connectionField =
|
|
1983
|
+
const connectionField = getFieldStorageName(fieldName, fieldEntry);
|
|
1796
1984
|
|
|
1797
1985
|
claimRelationField(fieldEntry, async (parent, args, context) => {
|
|
1798
1986
|
const relatedTypeInfo = typesDict.types[relatedType.name];
|
|
1799
1987
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
1800
1988
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
1801
1989
|
}
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1990
|
+
// Any stored identifier except null or an empty string, so numeric IDs such as 0 resolve. A
|
|
1991
|
+
// function under the field name is a member, such as the class a MongoDB document inherits
|
|
1992
|
+
// `constructor` from, not an identifier.
|
|
1993
|
+
// The field name is read only when the stored identifier is missing.
|
|
1994
|
+
let relatedId = ownValue(parent, connectionField);
|
|
1995
|
+
if (relatedId == null) {
|
|
1996
|
+
const linked = ownValue(parent, fieldName);
|
|
1997
|
+
relatedId = typeof linked === 'function' ? undefined : linked;
|
|
1998
|
+
}
|
|
1999
|
+
const id = relatedId?._id ?? relatedId;
|
|
2000
|
+
if (id == null || id === '') return null;
|
|
2001
|
+
try {
|
|
2002
|
+
return await resolveById(relatedTypeInfo, { id: String(id) }, context, id);
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
throw storedReferenceError(error, gqltype, fieldName);
|
|
2005
|
+
}
|
|
1805
2006
|
});
|
|
1806
2007
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
1807
2008
|
}
|
|
@@ -1858,10 +2059,24 @@ const addNoEndpointType = (gqltype) => {
|
|
|
1858
2059
|
typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
|
|
1859
2060
|
};
|
|
1860
2061
|
|
|
1861
|
-
|
|
2062
|
+
// Arguments that every list query and collection field generates, after the field filters.
|
|
2063
|
+
const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
|
|
2064
|
+
|
|
2065
|
+
// `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
|
|
2066
|
+
// A field named like a generated argument gets no top-level filter either; it is reported once.
|
|
2067
|
+
const createArgsForQuery = (gqltype, omit = null) => {
|
|
1862
2068
|
const argsObject = {};
|
|
2069
|
+
const argTypes = gqltype.getFields();
|
|
2070
|
+
for (const name of LIST_QUERY_ARGS) {
|
|
2071
|
+
if (name !== omit && Object.hasOwn(argTypes, name)) {
|
|
2072
|
+
warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
|
|
2073
|
+
+ 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
|
|
2074
|
+
+ 'filter it in AND/OR group conditions or rename the field.');
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
1863
2077
|
|
|
1864
2078
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
2079
|
+
if (fieldEntryName === omit) continue;
|
|
1865
2080
|
argsObject[fieldEntryName] = {};
|
|
1866
2081
|
|
|
1867
2082
|
if (fieldEntry.type instanceof GraphQLScalarType
|
package/types/index.d.ts
CHANGED
|
@@ -66,6 +66,13 @@ export class SimfinityError extends Error {
|
|
|
66
66
|
getCode(): string | undefined;
|
|
67
67
|
getStatus(): number | undefined;
|
|
68
68
|
getTimestamp(): string;
|
|
69
|
+
/**
|
|
70
|
+
* Set on errors that wrap a driver error, such as TRANSACTION_RETRY_EXCEEDED, and on the PostgreSQL database errors
|
|
71
|
+
* that a SQL runtime maps or reports as DATABASE_ERROR; on those PostgreSQL errors it is not enumerable.
|
|
72
|
+
*/
|
|
73
|
+
cause?: unknown;
|
|
74
|
+
/** Set on errors that wrap a driver error, such as TRANSACTION_RETRY_EXCEEDED, and on PostgreSQL database errors; returns `cause`. */
|
|
75
|
+
getCause?(): unknown;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
export class InternalServerError extends SimfinityError {
|
|
@@ -129,6 +136,14 @@ export interface RuntimeRegistration<Model = any, Session = any> extends ModelRe
|
|
|
129
136
|
controller?: EntityController<Session> | null;
|
|
130
137
|
onModelCreated?: ((model: Model) => void) | null;
|
|
131
138
|
stateMachine?: StateMachine<Session> | null;
|
|
139
|
+
/**
|
|
140
|
+
* Set by `createSchema()` before `adapter.prepare()`: true for entity types, those registered
|
|
141
|
+
* with `connect()` and the targets of non-embedded relations, whose `id` resolves to the stored
|
|
142
|
+
* `_id`, falling back to `id`. False for types this runtime uses only as embedded values. The `id`
|
|
143
|
+
* rule follows the type object, so a type that another runtime sharing it treats as an entity
|
|
144
|
+
* resolves `id` the same way here.
|
|
145
|
+
*/
|
|
146
|
+
storedIdentity?: boolean;
|
|
132
147
|
}
|
|
133
148
|
export interface MiddlewareContext {
|
|
134
149
|
args: any;
|
|
@@ -194,9 +209,15 @@ export interface Runtime<Model = any, Session = any> {
|
|
|
194
209
|
* (`TYPE_BOUND_TO_OTHER_RUNTIME`) any reachable type that another runtime bound or reserved, or
|
|
195
210
|
* whose field was copied with `toConfig()` after another runtime generated its relation
|
|
196
211
|
* resolver, keeping the generated resolver or its extensions. A list or embedded relation to an unregistered type throws
|
|
197
|
-
* `UNREGISTERED_RELATION_TARGET` (500).
|
|
198
|
-
*
|
|
212
|
+
* `UNREGISTERED_RELATION_TARGET` (500). A writable non-embedded list relation without a
|
|
213
|
+
* non-empty child `connectionField`, whose resolver this runtime would generate, throws
|
|
214
|
+
* `INVALID_MODEL` (400) before `adapter.prepare()`; one with an application resolver or
|
|
215
|
+
* `readOnly` only logs a `Configuration issue` warning, and its nested writes fail with
|
|
216
|
+
* `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning. The
|
|
217
|
+
* first schema binds the types whose relation resolvers it generates to this runtime and
|
|
218
|
+
* reserves reachable types that still have a
|
|
199
219
|
* non-embedded relation field without a resolver, such as an unregistered custom mutation result.
|
|
220
|
+
* Every entity type resolves `id` to `_id ?? id`, whatever the allowlists include.
|
|
200
221
|
*/
|
|
201
222
|
createSchema(includedQueryTypes?: GraphQLObjectType[] | null, includedMutationTypes?: GraphQLObjectType[] | null, includedCustomMutations?: string[] | null): GraphQLSchema;
|
|
202
223
|
getModel(type: GraphQLObjectType | { name: string }): Model | null | undefined;
|
|
@@ -225,12 +246,29 @@ export interface DatabaseAdapter<Model = any, Session = any> {
|
|
|
225
246
|
validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
|
|
226
247
|
prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
|
|
227
248
|
createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
|
|
249
|
+
/**
|
|
250
|
+
* Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
|
|
251
|
+
* code `NOT_VALID_ID` (400) for a malformed value; never create a new identifier. A batched
|
|
252
|
+
* reference read whose ID throws or is normalized reads that ID alone with `getById`. When
|
|
253
|
+
* `getById` rejects a stored reference with that error, the generated reference field fails with
|
|
254
|
+
* an `InternalServerError` whose cause is that error, since the client did not send the value.
|
|
255
|
+
*/
|
|
228
256
|
castId(value: any): any;
|
|
229
257
|
stateValue?(state: { name: string; value: any }): any;
|
|
230
258
|
withTransaction<T>(session: Session | null | undefined, callback: (session: Session) => Promise<T> | T, model?: Model): Promise<T>;
|
|
231
259
|
newRecord(model: Model, data: any, session?: Session): any;
|
|
232
260
|
saveRecord(model: Model, record: any, session?: Session): any;
|
|
233
261
|
toObject(record: any): any;
|
|
262
|
+
/**
|
|
263
|
+
* Optional. When defined, every nullable, singular embedded object field without a resolver
|
|
264
|
+
* passes its value through `readEmbeddedValue(value)`. The field is read as graphql's default
|
|
265
|
+
* resolver reads it: a method on the parent is called with (args, context, info), and the hook
|
|
266
|
+
* receives its result once a returned promise settles. Return null for a value that a hydrated
|
|
267
|
+
* record renders as an object although the stored value is an explicit null; return any other
|
|
268
|
+
* value unchanged. It must depend only on the value: these resolvers read no data and do not bind
|
|
269
|
+
* their types, so a runtime that reaches a shared type may read through another runtime's hook.
|
|
270
|
+
*/
|
|
271
|
+
readEmbeddedValue?(value: any): any;
|
|
234
272
|
getById(model: Model, id: any, session?: Session | null, options?: { projection?: Record<string, number>; plain?: boolean; lock?: boolean; requiredId?: any; context?: any }): any;
|
|
235
273
|
/** Optional batch read: the records found for `ids`, in getById shape and any order. After a failure, the runtime reads each ID with getById. */
|
|
236
274
|
getByIds?(model: Model, ids: any[], options?: { context?: any }): any;
|