@simtlix/simfinity-core 3.5.3 → 3.5.5
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 +231 -71
- package/types/index.d.ts +37 -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.5",
|
|
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,10 +1,10 @@
|
|
|
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,
|
|
5
5
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
6
6
|
GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
|
|
7
|
-
Kind, getNamedType,
|
|
7
|
+
Kind, defaultFieldResolver, getNamedType,
|
|
8
8
|
} from 'graphql';
|
|
9
9
|
|
|
10
10
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -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);
|
|
@@ -475,46 +502,6 @@ const unwrapListAndNonNull = (type) => {
|
|
|
475
502
|
return unwrapped;
|
|
476
503
|
};
|
|
477
504
|
|
|
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
505
|
const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
|
|
519
506
|
const wrappedItem = listShape.itemNonNull ? new GraphQLNonNull(itemType) : itemType;
|
|
520
507
|
const listType = new GraphQLList(wrappedItem);
|
|
@@ -821,8 +808,7 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
821
808
|
|| isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
|
|
822
809
|
if (fieldEntry.extensions && fieldEntry.extensions.relation) {
|
|
823
810
|
if (!fieldEntry.extensions.relation.embedded) {
|
|
824
|
-
|
|
825
|
-
modelArgs[connectionField] = adapter.castId(args[fieldEntryName].id);
|
|
811
|
+
modelArgs[getFieldStorageName(fieldEntryName, fieldEntry)] = adapter.castId(args[fieldEntryName].id);
|
|
826
812
|
} else {
|
|
827
813
|
const fieldType = fieldEntry.type instanceof GraphQLNonNull
|
|
828
814
|
? fieldEntry.type.ofType : fieldEntry.type;
|
|
@@ -959,6 +945,25 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
|
|
|
959
945
|
}
|
|
960
946
|
};
|
|
961
947
|
|
|
948
|
+
// An explicit null for a nullable member of a singular embedded patch clears that member, as a
|
|
949
|
+
// top-level null clears a field. Lists become empty lists; other members, including nested embedded
|
|
950
|
+
// objects and references (under their storage name), are stored as null rather than removed, since
|
|
951
|
+
// a removed nested object is materialized again from its list defaults. A null for a non-null member
|
|
952
|
+
// keeps the stored value, also as at the top level. Nested objects in the patch replace the stored
|
|
953
|
+
// ones wholesale.
|
|
954
|
+
const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
|
|
955
|
+
if (!patch || typeof patch !== 'object') return;
|
|
956
|
+
const members = unwrapNonNull(fieldEntry.type).getFields();
|
|
957
|
+
for (const [memberName, value] of Object.entries(patch)) {
|
|
958
|
+
const member = members[memberName];
|
|
959
|
+
if (value !== null || !member || member.type instanceof GraphQLNonNull) continue;
|
|
960
|
+
const relation = member.extensions?.relation;
|
|
961
|
+
const listShape = getListShape(member.type);
|
|
962
|
+
if (relation && !relation.embedded && listShape) continue;
|
|
963
|
+
merged[getFieldStorageName(memberName, member)] = listShape ? [] : null;
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
|
|
962
967
|
const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
|
|
963
968
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
|
|
964
969
|
const objectId = args.id;
|
|
@@ -982,6 +987,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
982
987
|
completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
|
|
983
988
|
} else {
|
|
984
989
|
materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
|
|
990
|
+
clearNullEmbeddedMembers(materializedModel.modelArgs[fieldEntryName],
|
|
991
|
+
argTypes[fieldEntryName], args[fieldEntryName]);
|
|
985
992
|
completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
|
|
986
993
|
materializedModel.modelArgs[fieldEntryName], newObjectData);
|
|
987
994
|
}
|
|
@@ -992,13 +999,9 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
992
999
|
|
|
993
1000
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
994
1001
|
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
1002
|
materializedModel.modelArgs.$unset = {
|
|
1000
1003
|
...materializedModel.modelArgs.$unset,
|
|
1001
|
-
[
|
|
1004
|
+
[getFieldStorageName(fieldEntryName, fieldEntry)]: '',
|
|
1002
1005
|
};
|
|
1003
1006
|
}
|
|
1004
1007
|
}
|
|
@@ -1079,6 +1082,7 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
|
|
|
1079
1082
|
|
|
1080
1083
|
const saveObject = async (typeName, args, session, context) => {
|
|
1081
1084
|
const type = typesDict.types[typeName];
|
|
1085
|
+
assertCollectionWrites(type.gqltype, args);
|
|
1082
1086
|
const snapshot = cloneInput(args, type.inputType);
|
|
1083
1087
|
return adapter.withTransaction(session, (transaction) => onSaveObject(
|
|
1084
1088
|
type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
|
|
@@ -1109,10 +1113,50 @@ const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) =>
|
|
|
1109
1113
|
}
|
|
1110
1114
|
};
|
|
1111
1115
|
|
|
1116
|
+
// A referenced collection links each child to its parent through the child's connectionField.
|
|
1117
|
+
const hasConnectionField = (relation) => typeof relation.connectionField === 'string'
|
|
1118
|
+
&& relation.connectionField !== '';
|
|
1119
|
+
const isUnlinkedCollection = (field) => {
|
|
1120
|
+
const relation = field.extensions?.relation;
|
|
1121
|
+
const listShape = getListShape(field.type);
|
|
1122
|
+
return !!relation && !relation.embedded && !!listShape
|
|
1123
|
+
&& listShape.itemType instanceof GraphQLObjectType && !hasConnectionField(relation);
|
|
1124
|
+
};
|
|
1125
|
+
// Null entries are skipped by every nested operation, so a list of only nulls writes nothing.
|
|
1126
|
+
const hasNestedItems = (items) => !Array.isArray(items) || items.some((item) => item !== null);
|
|
1127
|
+
const unlinkedCollectionError = (gqltype, fieldName) => new SimfinityError(
|
|
1128
|
+
`${gqltype.name}.${fieldName} cannot store nested items because it has no connectionField`,
|
|
1129
|
+
'INVALID_MODEL',
|
|
1130
|
+
500,
|
|
1131
|
+
);
|
|
1132
|
+
|
|
1133
|
+
// Rejects nested items for a collection without a connectionField, at every nesting level that
|
|
1134
|
+
// iterateOnCollectionFields executes, before anything is written or any controller hook runs. Such a
|
|
1135
|
+
// collection only exists with its own resolver or as readOnly; createSchema rejects the others.
|
|
1136
|
+
const assertCollectionWrites = (gqltype, input) => {
|
|
1137
|
+
if (!input || typeof input !== 'object') return;
|
|
1138
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
1139
|
+
const value = input[fieldName];
|
|
1140
|
+
const relation = field.extensions?.relation;
|
|
1141
|
+
const listShape = getListShape(field.type);
|
|
1142
|
+
if (value == null || !relation || relation.embedded || !listShape
|
|
1143
|
+
|| !(listShape.itemType instanceof GraphQLObjectType)) continue;
|
|
1144
|
+
for (const key of ['added', 'updated', 'deleted']) {
|
|
1145
|
+
const items = value[key];
|
|
1146
|
+
if (!items) continue;
|
|
1147
|
+
if (!hasConnectionField(relation) && hasNestedItems(items)) throw unlinkedCollectionError(gqltype, fieldName);
|
|
1148
|
+
if (key !== 'deleted' && Array.isArray(items)) {
|
|
1149
|
+
for (const item of items) assertCollectionWrites(listShape.itemType, item);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1112
1155
|
const executeOperation = (Model, gqltype, controller, args, operation, actionField,
|
|
1113
1156
|
session, context, inputType) => {
|
|
1114
1157
|
const limit = getMaxNestedOperations();
|
|
1115
1158
|
if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
|
|
1159
|
+
if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
|
|
1116
1160
|
const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
|
|
1117
1161
|
return adapter.withTransaction(
|
|
1118
1162
|
session,
|
|
@@ -1142,10 +1186,12 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
|
|
|
1142
1186
|
collectionFieldsList, operationType, context) => {
|
|
1143
1187
|
const argTypes = gqltype.getFields();
|
|
1144
1188
|
const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1189
|
+
const { relation } = argTypes[collectionField].extensions;
|
|
1190
|
+
// Checked before the operation too; this also covers items that nested middleware added.
|
|
1191
|
+
if (!hasConnectionField(relation) && hasNestedItems(collectionFieldsList)) {
|
|
1192
|
+
throw unlinkedCollectionError(gqltype, collectionField);
|
|
1193
|
+
}
|
|
1194
|
+
const connection = normalizeConnectionField(collectionGQLType, relation.connectionField);
|
|
1149
1195
|
|
|
1150
1196
|
const type = operationType === operations.UPDATE
|
|
1151
1197
|
? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
|
|
@@ -1568,10 +1614,6 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1568
1614
|
if (!shouldNotBeIncludedInSchema(includedTypes, type.gqltype)) {
|
|
1569
1615
|
const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
|
|
1570
1616
|
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
1617
|
rootQueryArgs.fields[type.simpleEntityEndpointName] = {
|
|
1576
1618
|
type: type.gqltype,
|
|
1577
1619
|
args: { id: { type: GraphQLID } },
|
|
@@ -1580,9 +1622,7 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1580
1622
|
},
|
|
1581
1623
|
};
|
|
1582
1624
|
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
const argsObject = createArgsForQuery(argTypes);
|
|
1625
|
+
const argsObject = createArgsForQuery(type.gqltype);
|
|
1586
1626
|
|
|
1587
1627
|
rootQueryArgs.fields[type.listEntitiesEndpointName] = {
|
|
1588
1628
|
type: new GraphQLList(type.gqltype),
|
|
@@ -1616,6 +1656,11 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1616
1656
|
};
|
|
1617
1657
|
|
|
1618
1658
|
const aggregateArgsObject = { ...argsObject };
|
|
1659
|
+
if (Object.hasOwn(type.gqltype.getFields(), 'aggregation')) {
|
|
1660
|
+
warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
|
|
1661
|
+
+ `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
|
|
1662
|
+
+ 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
|
|
1663
|
+
}
|
|
1619
1664
|
aggregateArgsObject.aggregation = {
|
|
1620
1665
|
type: new GraphQLNonNull(QLTypeAggregationExpression),
|
|
1621
1666
|
};
|
|
@@ -1645,7 +1690,9 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1645
1690
|
return new GraphQLObjectType(rootQueryArgs);
|
|
1646
1691
|
};
|
|
1647
1692
|
|
|
1693
|
+
// Returns the registrations that a non-embedded relation of a registered type targets.
|
|
1648
1694
|
const markReferencedTypesForModelGeneration = () => {
|
|
1695
|
+
const referenced = new Set();
|
|
1649
1696
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
1650
1697
|
Object.values(typeInfo.gqltype.getFields()).forEach((fieldEntry) => {
|
|
1651
1698
|
const relation = fieldEntry.extensions?.relation;
|
|
@@ -1653,11 +1700,79 @@ const markReferencedTypesForModelGeneration = () => {
|
|
|
1653
1700
|
|
|
1654
1701
|
const relatedType = unwrapListAndNonNull(fieldEntry.type);
|
|
1655
1702
|
const relatedTypeInfo = typesDict.types[relatedType.name];
|
|
1703
|
+
if (relatedTypeInfo) referenced.add(relatedTypeInfo);
|
|
1656
1704
|
if (relatedTypeInfo && !relatedTypeInfo.endpoint) {
|
|
1657
1705
|
relatedTypeInfo.needsModel = true;
|
|
1658
1706
|
}
|
|
1659
1707
|
});
|
|
1660
1708
|
});
|
|
1709
|
+
return referenced;
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
// Entity records keep their identity in `_id`; MongoDB records have no `id` value, while the SQL
|
|
1713
|
+
// store and other adapters may set both. Embedded-only types keep the default resolver, which
|
|
1714
|
+
// returns their declared `id` member.
|
|
1715
|
+
const installIdResolver = (gqltype) => {
|
|
1716
|
+
const idField = gqltype.getFields().id;
|
|
1717
|
+
if (idField && !idField.resolve) idField.resolve = markGenerated((parent) => parent._id ?? parent.id);
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
// An adapter that hydrates stored records may render an explicitly null singular embedded object as
|
|
1721
|
+
// an object (MongoDB nested paths do); readEmbeddedValue returns null for it. Nullable singular
|
|
1722
|
+
// embedded fields of registered types, and of the embedded types they reach, read through it. The
|
|
1723
|
+
// hook depends only on the value and reads no data, so these resolvers do not bind their types to
|
|
1724
|
+
// this runtime: another runtime that reaches a shared type reads the same value through it.
|
|
1725
|
+
// The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
|
|
1726
|
+
// custom mutation result supplies, is still called; a promise it returns is read once it settles.
|
|
1727
|
+
const readEmbeddedField = markGenerated((parent, args, context, info) => {
|
|
1728
|
+
const value = defaultFieldResolver(parent, args, context, info);
|
|
1729
|
+
return isThenable(value)
|
|
1730
|
+
? value.then((resolved) => adapter.readEmbeddedValue(resolved))
|
|
1731
|
+
: adapter.readEmbeddedValue(value);
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
const installEmbeddedValueResolvers = (gqltype, visited) => {
|
|
1735
|
+
if (!(gqltype instanceof GraphQLObjectType) || visited.has(gqltype)) return;
|
|
1736
|
+
visited.add(gqltype);
|
|
1737
|
+
for (const fieldEntry of Object.values(gqltype.getFields())) {
|
|
1738
|
+
if (fieldEntry.extensions?.relation?.embedded !== true) continue;
|
|
1739
|
+
const listShape = getListShape(fieldEntry.type);
|
|
1740
|
+
if (!listShape && !(fieldEntry.type instanceof GraphQLNonNull) && !fieldEntry.resolve) {
|
|
1741
|
+
fieldEntry.resolve = readEmbeddedField;
|
|
1742
|
+
}
|
|
1743
|
+
installEmbeddedValueResolvers(listShape ? listShape.itemType : unwrapNonNull(fieldEntry.type), visited);
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
|
|
1747
|
+
// Configuration issues this runtime already reported, per type, so a repeated createSchema call or a
|
|
1748
|
+
// type reached from several places logs each one once.
|
|
1749
|
+
const reportedIssues = new WeakMap();
|
|
1750
|
+
const warnOnce = (gqltype, key, message) => {
|
|
1751
|
+
if (!reportedIssues.has(gqltype)) reportedIssues.set(gqltype, new Set());
|
|
1752
|
+
const reported = reportedIssues.get(gqltype);
|
|
1753
|
+
if (reported.has(key)) return;
|
|
1754
|
+
reported.add(key);
|
|
1755
|
+
console.warn(message);
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
// A writable referenced collection without a child connectionField would store unlinked children
|
|
1759
|
+
// through generated nested writes and read none back, so it is rejected before anything is built.
|
|
1760
|
+
// One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
|
|
1761
|
+
// one warns unless it has its own resolver.
|
|
1762
|
+
const assertCollectionConnections = () => {
|
|
1763
|
+
for (const { gqltype } of Object.values(typesDict.types)) {
|
|
1764
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
1765
|
+
if (!isUnlinkedCollection(field)) continue;
|
|
1766
|
+
const problem = `${gqltype.name}.${fieldName} requires a child connectionField`;
|
|
1767
|
+
const readOnly = !!field.extensions.readOnly;
|
|
1768
|
+
const ownResolver = hasApplicationResolver(gqltype, fieldName);
|
|
1769
|
+
if (!readOnly && !ownResolver) throw new SimfinityError(problem, 'INVALID_MODEL', 400);
|
|
1770
|
+
if (!readOnly || !ownResolver) {
|
|
1771
|
+
warnOnce(gqltype, `connectionField:${fieldName}`,
|
|
1772
|
+
`Configuration issue: ${problem}; nested writes through it are rejected`);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1661
1776
|
};
|
|
1662
1777
|
|
|
1663
1778
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
@@ -1668,7 +1783,12 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
1668
1783
|
const schemaTypes = collectSchemaOutputTypes();
|
|
1669
1784
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
1670
1785
|
schemaTypes.forEach(assertFieldsNotBoundElsewhere);
|
|
1671
|
-
|
|
1786
|
+
assertCollectionConnections();
|
|
1787
|
+
const referencedTypes = markReferencedTypesForModelGeneration();
|
|
1788
|
+
// Entities, which keep their own stored identity: connect() types and relation targets.
|
|
1789
|
+
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
1790
|
+
typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
|
|
1791
|
+
});
|
|
1672
1792
|
if (adapter.prepare) {
|
|
1673
1793
|
adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
|
|
1674
1794
|
}
|
|
@@ -1688,12 +1808,23 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
1688
1808
|
Object.keys(typesDict.types).forEach((typeName) => {
|
|
1689
1809
|
if (typesDictForUpdate.types[typeName]) {
|
|
1690
1810
|
typesDictForUpdate.types[typeName].model = typesDict.types[typeName].model;
|
|
1811
|
+
typesDictForUpdate.types[typeName].storedIdentity = typesDict.types[typeName].storedIdentity;
|
|
1691
1812
|
}
|
|
1692
1813
|
});
|
|
1693
1814
|
|
|
1815
|
+
// Every entity resolves `id`, whatever the query and mutation allowlists include. The type object
|
|
1816
|
+
// is marked too, so query paths of any runtime that shares it follow the same identity.
|
|
1817
|
+
const embeddedValueTypes = new Set();
|
|
1694
1818
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
1695
1819
|
if (typeInfo.gqltype) {
|
|
1820
|
+
if (typeInfo.storedIdentity) {
|
|
1821
|
+
markStoredIdentity(typeInfo.gqltype);
|
|
1822
|
+
installIdResolver(typeInfo.gqltype);
|
|
1823
|
+
}
|
|
1696
1824
|
autoGenerateResolvers(typeInfo.gqltype);
|
|
1825
|
+
if (typeof adapter.readEmbeddedValue === 'function') {
|
|
1826
|
+
installEmbeddedValueResolvers(typeInfo.gqltype, embeddedValueTypes);
|
|
1827
|
+
}
|
|
1697
1828
|
}
|
|
1698
1829
|
});
|
|
1699
1830
|
|
|
@@ -1749,6 +1880,16 @@ const claimRelationField = (fieldEntry, resolve, extensions = {}) => {
|
|
|
1749
1880
|
relationFieldOwners.set(fieldEntry.resolve, runtimeIdentity);
|
|
1750
1881
|
};
|
|
1751
1882
|
|
|
1883
|
+
// The identifier a single reference reads is stored, not sent by the client, so one the adapter
|
|
1884
|
+
// rejects as malformed, such as a value written outside Simfinity, is a data error rather than a bad
|
|
1885
|
+
// request. Missing records and other errors keep their own classification.
|
|
1886
|
+
const storedReferenceError = (error, gqltype, fieldName) => {
|
|
1887
|
+
if (!(error instanceof SimfinityError) || error.getCode() !== 'NOT_VALID_ID' || error.getStatus() !== 400) {
|
|
1888
|
+
return error;
|
|
1889
|
+
}
|
|
1890
|
+
return new InternalServerError(`${gqltype.name}.${fieldName} stores an invalid identifier`, error);
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1752
1893
|
const autoGenerateResolvers = (gqltype) => {
|
|
1753
1894
|
assertFieldsNotBoundElsewhere(gqltype);
|
|
1754
1895
|
const fields = gqltype.getFields();
|
|
@@ -1766,8 +1907,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1766
1907
|
relation.connectionField || fieldName,
|
|
1767
1908
|
);
|
|
1768
1909
|
const relatedTypeInfo = requireRegisteredType(typesDict, relatedType, fieldName, gqltype.name);
|
|
1769
|
-
const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.
|
|
1770
|
-
if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
|
|
1910
|
+
const argsObject = createArgsForQuery(relatedTypeInfo.gqltype, connection.graphqlFieldName);
|
|
1771
1911
|
|
|
1772
1912
|
fieldEntry.args = formatArgs(Object.entries(argsObject));
|
|
1773
1913
|
claimRelationField(fieldEntry, async (parent, args, context) => {
|
|
@@ -1783,7 +1923,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1783
1923
|
relatedTypeInfo.model,
|
|
1784
1924
|
relatedTypeInfo.gqltype,
|
|
1785
1925
|
connection.storageFieldName,
|
|
1786
|
-
parent.
|
|
1926
|
+
parent._id ?? parent.id,
|
|
1787
1927
|
params.args,
|
|
1788
1928
|
null,
|
|
1789
1929
|
);
|
|
@@ -1792,16 +1932,22 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1792
1932
|
} else if (fieldEntry.type instanceof GraphQLObjectType
|
|
1793
1933
|
|| (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
|
|
1794
1934
|
const relatedType = unwrapNonNull(fieldEntry.type);
|
|
1795
|
-
const connectionField =
|
|
1935
|
+
const connectionField = getFieldStorageName(fieldName, fieldEntry);
|
|
1796
1936
|
|
|
1797
1937
|
claimRelationField(fieldEntry, async (parent, args, context) => {
|
|
1798
1938
|
const relatedTypeInfo = typesDict.types[relatedType.name];
|
|
1799
1939
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
1800
1940
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
1801
1941
|
}
|
|
1802
|
-
|
|
1803
|
-
const
|
|
1804
|
-
|
|
1942
|
+
// Any stored identifier except null or an empty string, so numeric IDs such as 0 resolve.
|
|
1943
|
+
const relatedId = parent[connectionField] ?? parent[fieldName];
|
|
1944
|
+
const id = relatedId?._id ?? relatedId;
|
|
1945
|
+
if (id == null || id === '') return null;
|
|
1946
|
+
try {
|
|
1947
|
+
return await resolveById(relatedTypeInfo, { id: String(id) }, context, id);
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
throw storedReferenceError(error, gqltype, fieldName);
|
|
1950
|
+
}
|
|
1805
1951
|
});
|
|
1806
1952
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
1807
1953
|
}
|
|
@@ -1858,10 +2004,24 @@ const addNoEndpointType = (gqltype) => {
|
|
|
1858
2004
|
typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
|
|
1859
2005
|
};
|
|
1860
2006
|
|
|
1861
|
-
|
|
2007
|
+
// Arguments that every list query and collection field generates, after the field filters.
|
|
2008
|
+
const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
|
|
2009
|
+
|
|
2010
|
+
// `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
|
|
2011
|
+
// A field named like a generated argument gets no top-level filter either; it is reported once.
|
|
2012
|
+
const createArgsForQuery = (gqltype, omit = null) => {
|
|
1862
2013
|
const argsObject = {};
|
|
2014
|
+
const argTypes = gqltype.getFields();
|
|
2015
|
+
for (const name of LIST_QUERY_ARGS) {
|
|
2016
|
+
if (name !== omit && Object.hasOwn(argTypes, name)) {
|
|
2017
|
+
warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
|
|
2018
|
+
+ 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
|
|
2019
|
+
+ 'filter it in AND/OR group conditions or rename the field.');
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
1863
2022
|
|
|
1864
2023
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
2024
|
+
if (fieldEntryName === omit) continue;
|
|
1865
2025
|
argsObject[fieldEntryName] = {};
|
|
1866
2026
|
|
|
1867
2027
|
if (fieldEntry.type instanceof GraphQLScalarType
|
package/types/index.d.ts
CHANGED
|
@@ -66,6 +66,10 @@ export class SimfinityError extends Error {
|
|
|
66
66
|
getCode(): string | undefined;
|
|
67
67
|
getStatus(): number | undefined;
|
|
68
68
|
getTimestamp(): string;
|
|
69
|
+
/** Set on errors that wrap a driver error, such as MongoDB's TRANSACTION_RETRY_EXCEEDED. */
|
|
70
|
+
cause?: unknown;
|
|
71
|
+
/** Set on errors that wrap a driver error, such as MongoDB's TRANSACTION_RETRY_EXCEEDED; returns `cause`. */
|
|
72
|
+
getCause?(): unknown;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export class InternalServerError extends SimfinityError {
|
|
@@ -129,6 +133,14 @@ export interface RuntimeRegistration<Model = any, Session = any> extends ModelRe
|
|
|
129
133
|
controller?: EntityController<Session> | null;
|
|
130
134
|
onModelCreated?: ((model: Model) => void) | null;
|
|
131
135
|
stateMachine?: StateMachine<Session> | null;
|
|
136
|
+
/**
|
|
137
|
+
* Set by `createSchema()` before `adapter.prepare()`: true for entity types, those registered
|
|
138
|
+
* with `connect()` and the targets of non-embedded relations, whose `id` resolves to the stored
|
|
139
|
+
* `_id`, falling back to `id`. False for types this runtime uses only as embedded values. The `id`
|
|
140
|
+
* rule follows the type object, so a type that another runtime sharing it treats as an entity
|
|
141
|
+
* resolves `id` the same way here.
|
|
142
|
+
*/
|
|
143
|
+
storedIdentity?: boolean;
|
|
132
144
|
}
|
|
133
145
|
export interface MiddlewareContext {
|
|
134
146
|
args: any;
|
|
@@ -194,9 +206,15 @@ export interface Runtime<Model = any, Session = any> {
|
|
|
194
206
|
* (`TYPE_BOUND_TO_OTHER_RUNTIME`) any reachable type that another runtime bound or reserved, or
|
|
195
207
|
* whose field was copied with `toConfig()` after another runtime generated its relation
|
|
196
208
|
* resolver, keeping the generated resolver or its extensions. A list or embedded relation to an unregistered type throws
|
|
197
|
-
* `UNREGISTERED_RELATION_TARGET` (500).
|
|
198
|
-
*
|
|
209
|
+
* `UNREGISTERED_RELATION_TARGET` (500). A writable non-embedded list relation without a
|
|
210
|
+
* non-empty child `connectionField`, whose resolver this runtime would generate, throws
|
|
211
|
+
* `INVALID_MODEL` (400) before `adapter.prepare()`; one with an application resolver or
|
|
212
|
+
* `readOnly` only logs a `Configuration issue` warning, and its nested writes fail with
|
|
213
|
+
* `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning. The
|
|
214
|
+
* first schema binds the types whose relation resolvers it generates to this runtime and
|
|
215
|
+
* reserves reachable types that still have a
|
|
199
216
|
* non-embedded relation field without a resolver, such as an unregistered custom mutation result.
|
|
217
|
+
* Every entity type resolves `id` to `_id ?? id`, whatever the allowlists include.
|
|
200
218
|
*/
|
|
201
219
|
createSchema(includedQueryTypes?: GraphQLObjectType[] | null, includedMutationTypes?: GraphQLObjectType[] | null, includedCustomMutations?: string[] | null): GraphQLSchema;
|
|
202
220
|
getModel(type: GraphQLObjectType | { name: string }): Model | null | undefined;
|
|
@@ -225,12 +243,29 @@ export interface DatabaseAdapter<Model = any, Session = any> {
|
|
|
225
243
|
validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
|
|
226
244
|
prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
|
|
227
245
|
createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
|
|
246
|
+
/**
|
|
247
|
+
* Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
|
|
248
|
+
* code `NOT_VALID_ID` (400) for a malformed value; never create a new identifier. A batched
|
|
249
|
+
* reference read whose ID throws or is normalized reads that ID alone with `getById`. When
|
|
250
|
+
* `getById` rejects a stored reference with that error, the generated reference field fails with
|
|
251
|
+
* an `InternalServerError` whose cause is that error, since the client did not send the value.
|
|
252
|
+
*/
|
|
228
253
|
castId(value: any): any;
|
|
229
254
|
stateValue?(state: { name: string; value: any }): any;
|
|
230
255
|
withTransaction<T>(session: Session | null | undefined, callback: (session: Session) => Promise<T> | T, model?: Model): Promise<T>;
|
|
231
256
|
newRecord(model: Model, data: any, session?: Session): any;
|
|
232
257
|
saveRecord(model: Model, record: any, session?: Session): any;
|
|
233
258
|
toObject(record: any): any;
|
|
259
|
+
/**
|
|
260
|
+
* Optional. When defined, every nullable, singular embedded object field without a resolver
|
|
261
|
+
* passes its value through `readEmbeddedValue(value)`. The field is read as graphql's default
|
|
262
|
+
* resolver reads it: a method on the parent is called with (args, context, info), and the hook
|
|
263
|
+
* receives its result once a returned promise settles. Return null for a value that a hydrated
|
|
264
|
+
* record renders as an object although the stored value is an explicit null; return any other
|
|
265
|
+
* value unchanged. It must depend only on the value: these resolvers read no data and do not bind
|
|
266
|
+
* their types, so a runtime that reaches a shared type may read through another runtime's hook.
|
|
267
|
+
*/
|
|
268
|
+
readEmbeddedValue?(value: any): any;
|
|
234
269
|
getById(model: Model, id: any, session?: Session | null, options?: { projection?: Record<string, number>; plain?: boolean; lock?: boolean; requiredId?: any; context?: any }): any;
|
|
235
270
|
/** 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
271
|
getByIds?(model: Model, ids: any[], options?: { context?: any }): any;
|