@simtlix/simfinity-core 3.5.2 → 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 +11 -4
- package/package.json +2 -1
- package/src/auth/expressions.js +77 -16
- package/src/auth/index.js +44 -8
- package/src/auth/rules.js +54 -10
- package/src/auth/thenable.js +17 -0
- package/src/index.js +1 -0
- package/src/mutation-limits.js +24 -0
- package/src/query-plan.js +14 -6
- package/src/relation-owners.js +5 -0
- package/src/relation-storage.js +58 -0
- package/src/runtime.js +523 -127
- package/src/scalars/factory.js +30 -10
- package/src/scalars/pattern.js +16 -0
- package/src/scalars.js +3 -2
- package/src/validators.js +4 -4
- package/types/index.d.ts +179 -7
package/src/runtime.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { configureQueryLimits, getQueryMaxPageSize } from './query-limits.js';
|
|
1
|
+
import { configureQueryLimits, getQueryMaxPageSize, paginationStages } from './query-limits.js';
|
|
2
|
+
import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
|
|
2
3
|
import {
|
|
3
4
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
4
5
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
5
|
-
GraphQLInt, GraphQLEnumType, GraphQLBoolean,
|
|
6
|
+
GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
|
|
7
|
+
Kind, defaultFieldResolver, getNamedType,
|
|
6
8
|
} from 'graphql';
|
|
7
9
|
|
|
8
10
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -11,6 +13,11 @@ import QLOperator from './const/QLOperator.js';
|
|
|
11
13
|
import QLValue from './const/QLValue.js';
|
|
12
14
|
import QLSort from './const/QLSort.js';
|
|
13
15
|
import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './query-plan.js';
|
|
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';
|
|
14
21
|
import './introspection.js';
|
|
15
22
|
|
|
16
23
|
// Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
|
|
@@ -28,8 +35,73 @@ const listApplicationResolvedFields = (gqltype) => new Set(Object.entries(gqltyp
|
|
|
28
35
|
const hasApplicationResolver = (gqltype, fieldName) => (
|
|
29
36
|
applicationResolvedFields.get(gqltype) || listApplicationResolvedFields(gqltype)
|
|
30
37
|
).has(fieldName);
|
|
38
|
+
// Object types whose relation fields hold resolvers and filter arguments generated by one runtime,
|
|
39
|
+
// mapped to that runtime. Those resolvers read through its adapter, registrations and middleware,
|
|
40
|
+
// so a bound type cannot be registered in, or reached from, another runtime's schema.
|
|
41
|
+
const runtimeBoundTypes = new WeakMap();
|
|
31
42
|
const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER_PATH', 403);
|
|
32
|
-
|
|
43
|
+
|
|
44
|
+
// Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
|
|
45
|
+
const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
|
|
46
|
+
const invalidScope = (typeName, detail) => new SimfinityError(
|
|
47
|
+
`${typeName} extensions.scope ${detail}`, 'INVALID_SCOPE', 500,
|
|
48
|
+
);
|
|
49
|
+
const validateScope = (gqltype) => {
|
|
50
|
+
const extensions = gqltype.extensions || {};
|
|
51
|
+
if (!Object.hasOwn(extensions, 'scope')) return;
|
|
52
|
+
const { scope } = extensions;
|
|
53
|
+
if (!scope || typeof scope !== 'object' || Array.isArray(scope)
|
|
54
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(scope))) {
|
|
55
|
+
throw invalidScope(gqltype.name, 'must be a plain object of find, get_by_id or aggregate functions');
|
|
56
|
+
}
|
|
57
|
+
// Every own string key, enumerable or not, because getScope reads any of them.
|
|
58
|
+
for (const operation of Object.getOwnPropertyNames(scope)) {
|
|
59
|
+
if (!SCOPE_OPERATIONS.includes(operation)) {
|
|
60
|
+
throw invalidScope(gqltype.name, `has unknown operation ${operation}; use find, get_by_id or aggregate`);
|
|
61
|
+
}
|
|
62
|
+
if (typeof scope[operation] !== 'function') {
|
|
63
|
+
throw invalidScope(gqltype.name, `${operation} must be a function`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Returns the type's scope function for an operation, or null when it declares none. Revalidates
|
|
69
|
+
// on each call because extensions stay mutable after createSchema.
|
|
70
|
+
const getScope = (gqltype, operation) => {
|
|
71
|
+
if (!gqltype?.extensions || !Object.hasOwn(gqltype.extensions, 'scope')) return null;
|
|
72
|
+
validateScope(gqltype);
|
|
73
|
+
const { scope } = gqltype.extensions;
|
|
74
|
+
return Object.hasOwn(scope, operation) ? scope[operation] : null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const requireRegisteredType = (dict, type, fieldName, ownerName) => {
|
|
78
|
+
const registration = dict.types[type.name];
|
|
79
|
+
if (!registration) {
|
|
80
|
+
throw new SimfinityError(`Field ${ownerName ? `${ownerName}.` : ''}${fieldName} references ${type.name}, which is not `
|
|
81
|
+
+ 'registered; register it with connect() or addNoEndpointType()', 'UNREGISTERED_RELATION_TARGET', 500);
|
|
82
|
+
}
|
|
83
|
+
return registration;
|
|
84
|
+
};
|
|
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
|
+
};
|
|
33
105
|
|
|
34
106
|
// Moves a joined type's scope group below the relation path that entered it.
|
|
35
107
|
const prefixScopeGroup = (group, prefix) => {
|
|
@@ -53,7 +125,11 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
|
53
125
|
const conditions = [];
|
|
54
126
|
const groups = [];
|
|
55
127
|
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
56
|
-
if (value == null
|
|
128
|
+
if (value == null) continue;
|
|
129
|
+
if (SCOPE_IGNORED_ARGS.has(name)) {
|
|
130
|
+
assertScopeControlArg(name, value);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
57
133
|
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
|
|
58
134
|
else if (name === 'OR') {
|
|
59
135
|
const branches = [].concat(value);
|
|
@@ -105,22 +181,85 @@ const GraphQLJSON = new GraphQLScalarType({
|
|
|
105
181
|
},
|
|
106
182
|
});
|
|
107
183
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
184
|
+
// graphql-js wraps a value thrown while resolving a field (locatedError): the wrapper has the field
|
|
185
|
+
// path and repeats the thrown error's message. A thrown non-Error becomes a NonErrorThrown whose
|
|
186
|
+
// message prints the value.
|
|
187
|
+
const isFieldErrorWrapper = (error) => Array.isArray(error.path) && error.originalError != null
|
|
188
|
+
&& error.message === error.originalError.message
|
|
189
|
+
// locatedError adds no code of its own, so a thrown GraphQLError with its own code is not a wrapper.
|
|
190
|
+
&& (error.originalError instanceof SimfinityError || typeof error.extensions?.code !== 'string'
|
|
191
|
+
|| error.extensions === error.originalError.extensions);
|
|
192
|
+
const thrownValueOf = (error) => (error.name === 'NonErrorThrown' && Object.hasOwn(error, 'thrownValue')
|
|
193
|
+
? error.thrownValue : error);
|
|
194
|
+
|
|
195
|
+
// The SimfinityError behind a GraphQLError, such as one a custom scalar threw, followed only through
|
|
196
|
+
// GraphQLErrors; any other hidden cause is ignored.
|
|
197
|
+
const simfinityCauseOf = (error) => {
|
|
198
|
+
let cause = error.originalError;
|
|
199
|
+
while (cause instanceof GraphQLError) cause = cause.originalError;
|
|
200
|
+
return cause instanceof SimfinityError ? cause : null;
|
|
201
|
+
};
|
|
116
202
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
};
|
|
123
|
-
|
|
203
|
+
// Request errors (syntax, validation, variable coercion) and application GraphQLErrors keep their own
|
|
204
|
+
// message and extensions, such as the http status a server adds. A SimfinityError cause supplies only
|
|
205
|
+
// the code and status, so a scalar's or application's hidden cause never replaces them.
|
|
206
|
+
const classifyGraphQLError = (error) => {
|
|
207
|
+
const simfinity = simfinityCauseOf(error)?.extensions ?? {};
|
|
208
|
+
const extensions = error.extensions || {};
|
|
209
|
+
const code = [simfinity.code, extensions.code].find((value) => typeof value === 'string') ?? 'BAD_REQUEST';
|
|
210
|
+
const status = [simfinity.status, extensions.status].find(Number.isInteger)
|
|
211
|
+
?? (code === 'INTERNAL_SERVER_ERROR' ? 500 : 400);
|
|
212
|
+
const classified = new SimfinityError(error.message, code, status);
|
|
213
|
+
classified.extensions = { ...extensions, ...classified.extensions };
|
|
214
|
+
return classified;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const classifyValue = (value) => {
|
|
218
|
+
if (value instanceof SimfinityError) return value;
|
|
219
|
+
if (value instanceof GraphQLError) return classifyGraphQLError(value);
|
|
220
|
+
// Never keep a non-Error value, such as a server's hook payload with request variables, as the cause.
|
|
221
|
+
return value instanceof Error
|
|
222
|
+
? new InternalServerError(value.message, value)
|
|
223
|
+
: new InternalServerError('Unexpected error value');
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Errors raised while executing a field have a path; request errors (syntax, validation, variable
|
|
227
|
+
// coercion) never do. GraphQL servers pass the GraphQLError that graphql-js created around the value
|
|
228
|
+
// a resolver threw, and that value is classified, without unwrapping a GraphQLError the resolver
|
|
229
|
+
// threw any further. A thrown GraphQLError that already has a path is passed as is.
|
|
230
|
+
// graphql-js raises its own code-less GraphQLErrors there too when it cannot complete a value (scalar
|
|
231
|
+
// or enum serialization, isTypeOf, abstract type resolution, a non-iterable list), and some print the
|
|
232
|
+
// value. So a GraphQLError raised there is an application error only with its own code or a
|
|
233
|
+
// SimfinityError cause; any other is unexpected.
|
|
234
|
+
const classifyError = (error) => {
|
|
235
|
+
if (!(error instanceof GraphQLError)) return classifyValue(error);
|
|
236
|
+
if (!Array.isArray(error.path)) {
|
|
237
|
+
// Scalar validation errors can wrap a plain Error unchanged, with or without nodes. A server
|
|
238
|
+
// may report a source-stream failure in the same shape, so only an explicit internal cause
|
|
239
|
+
// identifies it as unexpected; applications must mark those failures at their source.
|
|
240
|
+
const cause = simfinityCauseOf(error);
|
|
241
|
+
return cause instanceof InternalServerError ? cause : classifyValue(error);
|
|
242
|
+
}
|
|
243
|
+
const thrown = isFieldErrorWrapper(error) ? thrownValueOf(error.originalError) : error;
|
|
244
|
+
if (thrown instanceof GraphQLError && typeof thrown.extensions?.code !== 'string' && !simfinityCauseOf(thrown)) {
|
|
245
|
+
return new InternalServerError(thrown.message, thrown);
|
|
246
|
+
}
|
|
247
|
+
return classifyValue(thrown);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export const buildErrorFormatter = (callback) => (error) => {
|
|
251
|
+
const classified = classifyError(error);
|
|
252
|
+
const chosen = (callback && callback(classified)) || classified;
|
|
253
|
+
if (chosen instanceof GraphQLError) return chosen;
|
|
254
|
+
const located = error instanceof GraphQLError ? error : null;
|
|
255
|
+
return new GraphQLError(chosen.message, {
|
|
256
|
+
nodes: located?.nodes,
|
|
257
|
+
source: located?.source,
|
|
258
|
+
positions: located?.positions,
|
|
259
|
+
path: located?.path,
|
|
260
|
+
originalError: chosen,
|
|
261
|
+
extensions: chosen.extensions && typeof chosen.extensions === 'object' ? { ...chosen.extensions } : {},
|
|
262
|
+
});
|
|
124
263
|
};
|
|
125
264
|
|
|
126
265
|
export { SimfinityError, InternalServerError };
|
|
@@ -152,6 +291,7 @@ export const createRuntime = (adapter) => {
|
|
|
152
291
|
}
|
|
153
292
|
|
|
154
293
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
294
|
+
const runtimeIdentity = {};
|
|
155
295
|
const typesDict = { types: {} };
|
|
156
296
|
const waitingInputType = {};
|
|
157
297
|
const typesDictForUpdate = { types: {} };
|
|
@@ -167,6 +307,9 @@ export const createRuntime = (adapter) => {
|
|
|
167
307
|
};
|
|
168
308
|
|
|
169
309
|
const use = (middleware) => {
|
|
310
|
+
if (typeof middleware !== 'function') {
|
|
311
|
+
throw new SimfinityError('Middleware must be a function', 'INVALID_MIDDLEWARE', 500);
|
|
312
|
+
}
|
|
170
313
|
middlewares.push(middleware);
|
|
171
314
|
};
|
|
172
315
|
|
|
@@ -176,6 +319,69 @@ export const createRuntime = (adapter) => {
|
|
|
176
319
|
preventCollectionCreation = !!prevent;
|
|
177
320
|
};
|
|
178
321
|
|
|
322
|
+
const assertNotBoundElsewhere = (gqltype) => {
|
|
323
|
+
const owner = runtimeBoundTypes.get(gqltype);
|
|
324
|
+
if (owner !== undefined && owner !== runtimeIdentity) {
|
|
325
|
+
throw new SimfinityError(
|
|
326
|
+
`Type ${gqltype.name} is bound to another Simfinity runtime. Its relation fields resolve through the runtime `
|
|
327
|
+
+ 'that first created a schema reaching the type; define separate GraphQL types for each runtime, for example with a factory function',
|
|
328
|
+
'TYPE_BOUND_TO_OTHER_RUNTIME',
|
|
329
|
+
409,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const isForeign = (owner) => owner !== undefined && owner !== runtimeIdentity;
|
|
335
|
+
// A field copied, with toConfig() or a field spread, from a type after another runtime generated
|
|
336
|
+
// its relation resolver keeps that resolver or its extensions object. The extensions object counts
|
|
337
|
+
// only for a field whose resolver is untagged, such as one other code wrapped in place. A field
|
|
338
|
+
// without a resolver gets this runtime's own. graphql-js never runs interface field resolvers, so
|
|
339
|
+
// interface fields are not checked; the types they reference are.
|
|
340
|
+
const assertFieldsNotBoundElsewhere = (gqltype) => {
|
|
341
|
+
if (gqltype instanceof GraphQLInterfaceType) return;
|
|
342
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
343
|
+
if (!field.resolve) continue;
|
|
344
|
+
const resolverOwner = relationFieldOwners.get(field.resolve);
|
|
345
|
+
if (isForeign(resolverOwner)
|
|
346
|
+
|| (resolverOwner === undefined && isForeign(relationFieldOwners.get(field.extensions)))) {
|
|
347
|
+
throw new SimfinityError(
|
|
348
|
+
`Field ${gqltype.name}.${fieldName} was copied from a relation field that another Simfinity runtime generated, `
|
|
349
|
+
+ 'with its resolver or its extensions; define the field for each runtime instead of copying it from a type '
|
|
350
|
+
+ 'that runtime already used',
|
|
351
|
+
'TYPE_BOUND_TO_OTHER_RUNTIME',
|
|
352
|
+
409,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Object and interface types a schema of this runtime can reach from its registered types and
|
|
359
|
+
// custom mutation results, following the same references as GraphQLSchema.
|
|
360
|
+
const collectSchemaOutputTypes = () => {
|
|
361
|
+
const visited = new Set();
|
|
362
|
+
const outputTypes = [];
|
|
363
|
+
const visit = (type) => {
|
|
364
|
+
const named = type && getNamedType(type);
|
|
365
|
+
if (!named || visited.has(named)) return;
|
|
366
|
+
visited.add(named);
|
|
367
|
+
if (named instanceof GraphQLUnionType) {
|
|
368
|
+
named.getTypes().forEach(visit);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!(named instanceof GraphQLObjectType || named instanceof GraphQLInterfaceType)) return;
|
|
372
|
+
outputTypes.push(named);
|
|
373
|
+
named.getInterfaces().forEach(visit);
|
|
374
|
+
for (const field of Object.values(named.getFields())) visit(field.type);
|
|
375
|
+
};
|
|
376
|
+
for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
|
|
377
|
+
for (const { outputModel } of Object.values(registeredMutations)) visit(outputModel);
|
|
378
|
+
return outputTypes;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const hasUnresolvedRelation = (gqltype) => Object.values(gqltype.getFields()).some(
|
|
382
|
+
(field) => field.extensions?.relation && !field.extensions.relation.embedded && !field.resolve,
|
|
383
|
+
);
|
|
384
|
+
|
|
179
385
|
const QLFilter = new GraphQLInputObjectType({
|
|
180
386
|
name: 'QLFilter',
|
|
181
387
|
fields: () => ({
|
|
@@ -296,46 +502,6 @@ const unwrapListAndNonNull = (type) => {
|
|
|
296
502
|
return unwrapped;
|
|
297
503
|
};
|
|
298
504
|
|
|
299
|
-
const getListShape = (type) => {
|
|
300
|
-
const outerNonNull = type instanceof GraphQLNonNull;
|
|
301
|
-
const listType = unwrapNonNull(type);
|
|
302
|
-
if (!(listType instanceof GraphQLList)) return null;
|
|
303
|
-
const itemNonNull = listType.ofType instanceof GraphQLNonNull;
|
|
304
|
-
return {
|
|
305
|
-
outerNonNull,
|
|
306
|
-
itemNonNull,
|
|
307
|
-
itemType: unwrapNonNull(listType.ofType),
|
|
308
|
-
};
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
const getFieldStorageName = (fieldName, field) => {
|
|
312
|
-
const relation = field.extensions?.relation;
|
|
313
|
-
return relation && !relation.embedded && !getListShape(field.type)
|
|
314
|
-
? relation.connectionField || fieldName
|
|
315
|
-
: fieldName;
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
const normalizeConnectionField = (childType, declaredFieldName) => {
|
|
319
|
-
if (!declaredFieldName) {
|
|
320
|
-
return { declaredFieldName, graphqlFieldName: null, storageFieldName: null };
|
|
321
|
-
}
|
|
322
|
-
const childFields = childType.getFields();
|
|
323
|
-
let graphqlFieldName = childFields[declaredFieldName] ? declaredFieldName : null;
|
|
324
|
-
if (!graphqlFieldName) {
|
|
325
|
-
const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
|
|
326
|
-
getFieldStorageName(fieldName, field) === declaredFieldName
|
|
327
|
-
));
|
|
328
|
-
graphqlFieldName = aliasedField?.[0] || null;
|
|
329
|
-
}
|
|
330
|
-
return {
|
|
331
|
-
declaredFieldName,
|
|
332
|
-
graphqlFieldName,
|
|
333
|
-
storageFieldName: graphqlFieldName
|
|
334
|
-
? getFieldStorageName(graphqlFieldName, childFields[graphqlFieldName])
|
|
335
|
-
: declaredFieldName,
|
|
336
|
-
};
|
|
337
|
-
};
|
|
338
|
-
|
|
339
505
|
const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
|
|
340
506
|
const wrappedItem = listShape.itemNonNull ? new GraphQLNonNull(itemType) : itemType;
|
|
341
507
|
const listType = new GraphQLList(wrappedItem);
|
|
@@ -402,12 +568,13 @@ const createOneToManyInputType = (inputNamePrefix, fieldEntryName,
|
|
|
402
568
|
};
|
|
403
569
|
|
|
404
570
|
const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
|
|
405
|
-
inputNamePrefix, connectionField, preserveOuterNonNull) => {
|
|
571
|
+
inputNamePrefix, connectionField, preserveOuterNonNull, ownerName) => {
|
|
406
572
|
const listShape = getListShape(fieldEntry.type);
|
|
407
573
|
if (!listShape) return null;
|
|
408
574
|
const { itemType } = listShape;
|
|
409
575
|
|
|
410
|
-
if (itemType instanceof GraphQLObjectType
|
|
576
|
+
if (itemType instanceof GraphQLObjectType
|
|
577
|
+
&& requireRegisteredType(dict, itemType, fieldEntryName, ownerName).inputType) {
|
|
411
578
|
if (!fieldEntry.extensions || !fieldEntry.extensions.relation
|
|
412
579
|
|| !fieldEntry.extensions.relation.embedded) {
|
|
413
580
|
const oneToMany = createOneToManyInputType(inputNamePrefix, fieldEntryName,
|
|
@@ -471,7 +638,8 @@ const buildInputType = (gqltype) => {
|
|
|
471
638
|
fieldArg.type = fieldEntry.type instanceof GraphQLNonNull
|
|
472
639
|
? new GraphQLNonNull(IdInputType) : IdInputType;
|
|
473
640
|
fieldArgForUpdate.type = IdInputType;
|
|
474
|
-
} else if (typesDict.
|
|
641
|
+
} else if (requireRegisteredType(typesDict, unwrapNonNull(fieldEntry.type), fieldEntryName,
|
|
642
|
+
gqltype.name).inputType
|
|
475
643
|
&& typesDictForUpdate.types[fieldEntryNameValue].inputType) {
|
|
476
644
|
fieldArg.type = typesDict.types[fieldEntryNameValue].inputType;
|
|
477
645
|
fieldArgForUpdate.type = typesDictForUpdate.types[fieldEntryNameValue].inputType;
|
|
@@ -488,10 +656,10 @@ const buildInputType = (gqltype) => {
|
|
|
488
656
|
} else {
|
|
489
657
|
const listInputTypeForAdd = graphQLListInputType(typesDict, fieldEntry,
|
|
490
658
|
fieldEntryName, `${gqltype.name}A`,
|
|
491
|
-
fieldEntry.extensions?.relation?.connectionField, true);
|
|
659
|
+
fieldEntry.extensions?.relation?.connectionField, true, gqltype.name);
|
|
492
660
|
const listInputTypeForUpdate = graphQLListInputType(typesDictForUpdate, fieldEntry,
|
|
493
661
|
fieldEntryName, `${gqltype.name}U`,
|
|
494
|
-
fieldEntry.extensions?.relation?.connectionField, false);
|
|
662
|
+
fieldEntry.extensions?.relation?.connectionField, false, gqltype.name);
|
|
495
663
|
if (listInputTypeForAdd && listInputTypeForUpdate) {
|
|
496
664
|
fieldArg.type = listInputTypeForAdd;
|
|
497
665
|
fieldArgForUpdate.type = listInputTypeForUpdate;
|
|
@@ -640,8 +808,7 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
640
808
|
|| isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
|
|
641
809
|
if (fieldEntry.extensions && fieldEntry.extensions.relation) {
|
|
642
810
|
if (!fieldEntry.extensions.relation.embedded) {
|
|
643
|
-
|
|
644
|
-
modelArgs[connectionField] = adapter.castId(args[fieldEntryName].id);
|
|
811
|
+
modelArgs[getFieldStorageName(fieldEntryName, fieldEntry)] = adapter.castId(args[fieldEntryName].id);
|
|
645
812
|
} else {
|
|
646
813
|
const fieldType = fieldEntry.type instanceof GraphQLNonNull
|
|
647
814
|
? fieldEntry.type.ofType : fieldEntry.type;
|
|
@@ -778,6 +945,25 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
|
|
|
778
945
|
}
|
|
779
946
|
};
|
|
780
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
|
+
|
|
781
967
|
const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
|
|
782
968
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
|
|
783
969
|
const objectId = args.id;
|
|
@@ -801,6 +987,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
801
987
|
completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
|
|
802
988
|
} else {
|
|
803
989
|
materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
|
|
990
|
+
clearNullEmbeddedMembers(materializedModel.modelArgs[fieldEntryName],
|
|
991
|
+
argTypes[fieldEntryName], args[fieldEntryName]);
|
|
804
992
|
completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
|
|
805
993
|
materializedModel.modelArgs[fieldEntryName], newObjectData);
|
|
806
994
|
}
|
|
@@ -811,13 +999,9 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
811
999
|
|
|
812
1000
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
813
1001
|
if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull)) {
|
|
814
|
-
const relation = fieldEntry.extensions?.relation;
|
|
815
|
-
const storageFieldName = relation && !relation.embedded && !(unwrapNonNull(fieldEntry.type) instanceof GraphQLList)
|
|
816
|
-
? relation.connectionField || fieldEntryName
|
|
817
|
-
: fieldEntryName;
|
|
818
1002
|
materializedModel.modelArgs.$unset = {
|
|
819
1003
|
...materializedModel.modelArgs.$unset,
|
|
820
|
-
[
|
|
1004
|
+
[getFieldStorageName(fieldEntryName, fieldEntry)]: '',
|
|
821
1005
|
};
|
|
822
1006
|
}
|
|
823
1007
|
}
|
|
@@ -898,14 +1082,81 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
|
|
|
898
1082
|
|
|
899
1083
|
const saveObject = async (typeName, args, session, context) => {
|
|
900
1084
|
const type = typesDict.types[typeName];
|
|
1085
|
+
assertCollectionWrites(type.gqltype, args);
|
|
901
1086
|
const snapshot = cloneInput(args, type.inputType);
|
|
902
1087
|
return adapter.withTransaction(session, (transaction) => onSaveObject(
|
|
903
1088
|
type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
|
|
904
1089
|
), type.model);
|
|
905
1090
|
};
|
|
906
1091
|
|
|
1092
|
+
// Counts the added, updated and deleted entries of referenced collections, at every nesting level that
|
|
1093
|
+
// iterateOnCollectionFields executes, and stops once the configured maximum is exceeded.
|
|
1094
|
+
const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) => {
|
|
1095
|
+
if (!input || typeof input !== 'object') return;
|
|
1096
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
1097
|
+
const value = input[fieldName];
|
|
1098
|
+
const relation = field.extensions?.relation;
|
|
1099
|
+
const listShape = getListShape(field.type);
|
|
1100
|
+
if (value == null || !relation || relation.embedded || !listShape
|
|
1101
|
+
|| !(listShape.itemType instanceof GraphQLObjectType)) continue;
|
|
1102
|
+
for (const key of ['added', 'updated', 'deleted']) {
|
|
1103
|
+
if (!Array.isArray(value[key])) continue;
|
|
1104
|
+
state.count += value[key].length;
|
|
1105
|
+
if (state.count > limit) {
|
|
1106
|
+
throw new SimfinityError(`A mutation may perform at most ${limit} nested collection operations`,
|
|
1107
|
+
'NESTED_OPERATIONS_EXCEEDED', 400);
|
|
1108
|
+
}
|
|
1109
|
+
if (key !== 'deleted') {
|
|
1110
|
+
for (const item of value[key]) assertNestedOperations(listShape.itemType, item, limit, state);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
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
|
+
|
|
907
1155
|
const executeOperation = (Model, gqltype, controller, args, operation, actionField,
|
|
908
1156
|
session, context, inputType) => {
|
|
1157
|
+
const limit = getMaxNestedOperations();
|
|
1158
|
+
if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
|
|
1159
|
+
if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
|
|
909
1160
|
const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
|
|
910
1161
|
return adapter.withTransaction(
|
|
911
1162
|
session,
|
|
@@ -935,10 +1186,12 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
|
|
|
935
1186
|
collectionFieldsList, operationType, context) => {
|
|
936
1187
|
const argTypes = gqltype.getFields();
|
|
937
1188
|
const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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);
|
|
942
1195
|
|
|
943
1196
|
const type = operationType === operations.UPDATE
|
|
944
1197
|
? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
|
|
@@ -982,39 +1235,41 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
|
|
|
982
1235
|
const shouldNotBeIncludedInSchema = (includedTypes,
|
|
983
1236
|
type) => includedTypes && !includedTypes.includes(type);
|
|
984
1237
|
|
|
1238
|
+
// Runs the chain in registration order. The rest of the chain runs at most once, and is awaited
|
|
1239
|
+
// even when a middleware calls next() without awaiting it, so its errors still cancel the operation
|
|
1240
|
+
// and never become unhandled rejections; a middleware that fails after starting it rejects only once
|
|
1241
|
+
// it settles. A next() called after the middleware finished does nothing, so later middleware never
|
|
1242
|
+
// runs after the operation.
|
|
985
1243
|
const executeMiddleware = async (context) => {
|
|
986
|
-
const
|
|
987
|
-
if (
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1244
|
+
const run = async (index) => {
|
|
1245
|
+
if (index >= middlewares.length) return;
|
|
1246
|
+
let downstream;
|
|
1247
|
+
let finished = false;
|
|
1248
|
+
const next = () => {
|
|
1249
|
+
if (!downstream && !finished) {
|
|
1250
|
+
downstream = run(index + 1);
|
|
1251
|
+
// Handled here because the middleware may await other work first; awaited below.
|
|
1252
|
+
downstream.catch(() => {});
|
|
994
1253
|
}
|
|
1254
|
+
return downstream ?? Promise.resolve();
|
|
995
1255
|
};
|
|
1256
|
+
try {
|
|
1257
|
+
await middlewares[index](context, next);
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
finished = true;
|
|
1260
|
+
if (downstream) await downstream.catch(() => {});
|
|
1261
|
+
throw error;
|
|
1262
|
+
}
|
|
1263
|
+
finished = true;
|
|
1264
|
+
if (downstream) await downstream;
|
|
996
1265
|
};
|
|
997
|
-
|
|
998
|
-
await buildNext(middlewares)();
|
|
1266
|
+
await run(0);
|
|
999
1267
|
};
|
|
1000
1268
|
|
|
1001
1269
|
const executeScope = async (params) => {
|
|
1002
1270
|
const { type, args, operation, context } = params;
|
|
1003
|
-
|
|
1004
|
-
if (!
|
|
1005
|
-
return null;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
const extensions = type.gqltype.extensions;
|
|
1009
|
-
if (!extensions.scope || !extensions.scope[operation]) {
|
|
1010
|
-
return null;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
const scopeFunction = extensions.scope[operation];
|
|
1014
|
-
if (typeof scopeFunction !== 'function') {
|
|
1015
|
-
return null;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1271
|
+
const scopeFunction = getScope(type?.gqltype, operation);
|
|
1272
|
+
if (!scopeFunction) return null;
|
|
1018
1273
|
return scopeFunction({ type, args, operation, context });
|
|
1019
1274
|
};
|
|
1020
1275
|
|
|
@@ -1049,7 +1304,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1049
1304
|
const relation = field.extensions?.relation;
|
|
1050
1305
|
if (!relation || relation.embedded) return;
|
|
1051
1306
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
1052
|
-
if (
|
|
1307
|
+
if (!getScope(target?.gqltype, 'find')) return;
|
|
1053
1308
|
if (!joinedScopes.has(path)) {
|
|
1054
1309
|
joinedScopes.set(path, {
|
|
1055
1310
|
target,
|
|
@@ -1210,7 +1465,7 @@ const loadReference = (type, id, requiredId, context) => {
|
|
|
1210
1465
|
const resolveById = async (type, args, context, requiredId) => {
|
|
1211
1466
|
const requestedId = args.id;
|
|
1212
1467
|
await executeMiddleware({ type, args, operation: 'get_by_id', context });
|
|
1213
|
-
if (!type.gqltype
|
|
1468
|
+
if (!getScope(type.gqltype, 'get_by_id')) {
|
|
1214
1469
|
if (requiredId != null && args.id === requestedId && typeof context === 'object' && context !== null
|
|
1215
1470
|
&& typeof adapter.getByIds === 'function' && type.gqltype.getFields().id) {
|
|
1216
1471
|
return loadReference(type, args.id, requiredId, context);
|
|
@@ -1359,10 +1614,6 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1359
1614
|
if (!shouldNotBeIncludedInSchema(includedTypes, type.gqltype)) {
|
|
1360
1615
|
const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
|
|
1361
1616
|
if (!wasAddedAsNoEnpointType) {
|
|
1362
|
-
if (type.gqltype.getFields().id && !type.gqltype.getFields().id.resolve) {
|
|
1363
|
-
type.gqltype.getFields().id.resolve = markGenerated((parent) => parent._id);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
1617
|
rootQueryArgs.fields[type.simpleEntityEndpointName] = {
|
|
1367
1618
|
type: type.gqltype,
|
|
1368
1619
|
args: { id: { type: GraphQLID } },
|
|
@@ -1371,9 +1622,7 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1371
1622
|
},
|
|
1372
1623
|
};
|
|
1373
1624
|
|
|
1374
|
-
const
|
|
1375
|
-
|
|
1376
|
-
const argsObject = createArgsForQuery(argTypes);
|
|
1625
|
+
const argsObject = createArgsForQuery(type.gqltype);
|
|
1377
1626
|
|
|
1378
1627
|
rootQueryArgs.fields[type.listEntitiesEndpointName] = {
|
|
1379
1628
|
type: new GraphQLList(type.gqltype),
|
|
@@ -1407,6 +1656,11 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1407
1656
|
};
|
|
1408
1657
|
|
|
1409
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
|
+
}
|
|
1410
1664
|
aggregateArgsObject.aggregation = {
|
|
1411
1665
|
type: new GraphQLNonNull(QLTypeAggregationExpression),
|
|
1412
1666
|
};
|
|
@@ -1436,7 +1690,9 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1436
1690
|
return new GraphQLObjectType(rootQueryArgs);
|
|
1437
1691
|
};
|
|
1438
1692
|
|
|
1693
|
+
// Returns the registrations that a non-embedded relation of a registered type targets.
|
|
1439
1694
|
const markReferencedTypesForModelGeneration = () => {
|
|
1695
|
+
const referenced = new Set();
|
|
1440
1696
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
1441
1697
|
Object.values(typeInfo.gqltype.getFields()).forEach((fieldEntry) => {
|
|
1442
1698
|
const relation = fieldEntry.extensions?.relation;
|
|
@@ -1444,17 +1700,95 @@ const markReferencedTypesForModelGeneration = () => {
|
|
|
1444
1700
|
|
|
1445
1701
|
const relatedType = unwrapListAndNonNull(fieldEntry.type);
|
|
1446
1702
|
const relatedTypeInfo = typesDict.types[relatedType.name];
|
|
1703
|
+
if (relatedTypeInfo) referenced.add(relatedTypeInfo);
|
|
1447
1704
|
if (relatedTypeInfo && !relatedTypeInfo.endpoint) {
|
|
1448
1705
|
relatedTypeInfo.needsModel = true;
|
|
1449
1706
|
}
|
|
1450
1707
|
});
|
|
1451
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
|
+
}
|
|
1452
1776
|
};
|
|
1453
1777
|
|
|
1454
1778
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
1455
1779
|
|
|
1456
1780
|
const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
|
|
1457
|
-
|
|
1781
|
+
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
1782
|
+
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
1783
|
+
const schemaTypes = collectSchemaOutputTypes();
|
|
1784
|
+
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
1785
|
+
schemaTypes.forEach(assertFieldsNotBoundElsewhere);
|
|
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
|
+
});
|
|
1458
1792
|
if (adapter.prepare) {
|
|
1459
1793
|
adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
|
|
1460
1794
|
}
|
|
@@ -1474,12 +1808,23 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
1474
1808
|
Object.keys(typesDict.types).forEach((typeName) => {
|
|
1475
1809
|
if (typesDictForUpdate.types[typeName]) {
|
|
1476
1810
|
typesDictForUpdate.types[typeName].model = typesDict.types[typeName].model;
|
|
1811
|
+
typesDictForUpdate.types[typeName].storedIdentity = typesDict.types[typeName].storedIdentity;
|
|
1477
1812
|
}
|
|
1478
1813
|
});
|
|
1479
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();
|
|
1480
1818
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
1481
1819
|
if (typeInfo.gqltype) {
|
|
1820
|
+
if (typeInfo.storedIdentity) {
|
|
1821
|
+
markStoredIdentity(typeInfo.gqltype);
|
|
1822
|
+
installIdResolver(typeInfo.gqltype);
|
|
1823
|
+
}
|
|
1482
1824
|
autoGenerateResolvers(typeInfo.gqltype);
|
|
1825
|
+
if (typeof adapter.readEmbeddedValue === 'function') {
|
|
1826
|
+
installEmbeddedValueResolvers(typeInfo.gqltype, embeddedValueTypes);
|
|
1827
|
+
}
|
|
1483
1828
|
}
|
|
1484
1829
|
});
|
|
1485
1830
|
|
|
@@ -1490,10 +1835,18 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
1490
1835
|
}
|
|
1491
1836
|
});
|
|
1492
1837
|
|
|
1493
|
-
|
|
1838
|
+
const schema = new GraphQLSchema({
|
|
1494
1839
|
query,
|
|
1495
1840
|
mutation: buildMutation('Mutation', includedMutationTypes, includedCustomMutations),
|
|
1496
1841
|
});
|
|
1842
|
+
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
1843
|
+
// custom mutation result, so another runtime cannot later generate resolvers this schema executes.
|
|
1844
|
+
schemaTypes.forEach((type) => {
|
|
1845
|
+
if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
|
|
1846
|
+
runtimeBoundTypes.set(type, runtimeIdentity);
|
|
1847
|
+
}
|
|
1848
|
+
});
|
|
1849
|
+
return schema;
|
|
1497
1850
|
};
|
|
1498
1851
|
|
|
1499
1852
|
const getModel = (gqltype) => typesDict.types[gqltype.name]?.model;
|
|
@@ -1517,7 +1870,28 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
|
|
|
1517
1870
|
};
|
|
1518
1871
|
};
|
|
1519
1872
|
|
|
1873
|
+
// Gives the field a new extensions object as well as the resolver, so a copy made before keeps
|
|
1874
|
+
// untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
|
|
1875
|
+
// copying it, when a type is rebuilt from toConfig().
|
|
1876
|
+
const claimRelationField = (fieldEntry, resolve, extensions = {}) => {
|
|
1877
|
+
fieldEntry.extensions = Object.assign(Object.create(null), fieldEntry.extensions, extensions);
|
|
1878
|
+
fieldEntry.resolve = markGenerated(resolve);
|
|
1879
|
+
relationFieldOwners.set(fieldEntry.extensions, runtimeIdentity);
|
|
1880
|
+
relationFieldOwners.set(fieldEntry.resolve, runtimeIdentity);
|
|
1881
|
+
};
|
|
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
|
+
|
|
1520
1893
|
const autoGenerateResolvers = (gqltype) => {
|
|
1894
|
+
assertFieldsNotBoundElsewhere(gqltype);
|
|
1521
1895
|
const fields = gqltype.getFields();
|
|
1522
1896
|
|
|
1523
1897
|
for (const [fieldName, fieldEntry] of Object.entries(fields)) {
|
|
@@ -1532,16 +1906,11 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1532
1906
|
relatedType,
|
|
1533
1907
|
relation.connectionField || fieldName,
|
|
1534
1908
|
);
|
|
1535
|
-
const relatedTypeInfo = typesDict
|
|
1536
|
-
const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.
|
|
1537
|
-
if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
|
|
1909
|
+
const relatedTypeInfo = requireRegisteredType(typesDict, relatedType, fieldName, gqltype.name);
|
|
1910
|
+
const argsObject = createArgsForQuery(relatedTypeInfo.gqltype, connection.graphqlFieldName);
|
|
1538
1911
|
|
|
1539
1912
|
fieldEntry.args = formatArgs(Object.entries(argsObject));
|
|
1540
|
-
fieldEntry
|
|
1541
|
-
...fieldEntry.extensions,
|
|
1542
|
-
simfinityQuery: { typeName: relatedType.name, operation: 'find' },
|
|
1543
|
-
};
|
|
1544
|
-
fieldEntry.resolve = markGenerated(async (parent, args, context) => {
|
|
1913
|
+
claimRelationField(fieldEntry, async (parent, args, context) => {
|
|
1545
1914
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
1546
1915
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
1547
1916
|
}
|
|
@@ -1554,25 +1923,33 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1554
1923
|
relatedTypeInfo.model,
|
|
1555
1924
|
relatedTypeInfo.gqltype,
|
|
1556
1925
|
connection.storageFieldName,
|
|
1557
|
-
parent.
|
|
1926
|
+
parent._id ?? parent.id,
|
|
1558
1927
|
params.args,
|
|
1559
1928
|
null,
|
|
1560
1929
|
);
|
|
1561
|
-
});
|
|
1930
|
+
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
1931
|
+
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
1562
1932
|
} else if (fieldEntry.type instanceof GraphQLObjectType
|
|
1563
1933
|
|| (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
|
|
1564
1934
|
const relatedType = unwrapNonNull(fieldEntry.type);
|
|
1565
|
-
const connectionField =
|
|
1935
|
+
const connectionField = getFieldStorageName(fieldName, fieldEntry);
|
|
1566
1936
|
|
|
1567
|
-
fieldEntry
|
|
1937
|
+
claimRelationField(fieldEntry, async (parent, args, context) => {
|
|
1568
1938
|
const relatedTypeInfo = typesDict.types[relatedType.name];
|
|
1569
1939
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
1570
1940
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
1571
1941
|
}
|
|
1572
|
-
|
|
1573
|
-
const
|
|
1574
|
-
|
|
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
|
+
}
|
|
1575
1951
|
});
|
|
1952
|
+
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
1576
1953
|
}
|
|
1577
1954
|
}
|
|
1578
1955
|
};
|
|
@@ -1589,6 +1966,8 @@ const connect = (model, gqltype, simpleEntityEndpointName,
|
|
|
1589
1966
|
stateMachine,
|
|
1590
1967
|
onModelCreated,
|
|
1591
1968
|
};
|
|
1969
|
+
assertNotBoundElsewhere(gqltype);
|
|
1970
|
+
validateScope(gqltype);
|
|
1592
1971
|
if (adapter.validateRegistration) adapter.validateRegistration(registration);
|
|
1593
1972
|
|
|
1594
1973
|
waitingInputType[gqltype.name] = {
|
|
@@ -1601,6 +1980,7 @@ const connect = (model, gqltype, simpleEntityEndpointName,
|
|
|
1601
1980
|
};
|
|
1602
1981
|
|
|
1603
1982
|
const addNoEndpointType = (gqltype) => {
|
|
1983
|
+
assertNotBoundElsewhere(gqltype);
|
|
1604
1984
|
const fields = gqltype.getFields();
|
|
1605
1985
|
let needsModel = false;
|
|
1606
1986
|
for (const fieldEntry of Object.values(fields)) {
|
|
@@ -1616,6 +1996,7 @@ const addNoEndpointType = (gqltype) => {
|
|
|
1616
1996
|
const registration = {
|
|
1617
1997
|
gqltype, endpoint: false, model: null, needsModel,
|
|
1618
1998
|
};
|
|
1999
|
+
validateScope(gqltype);
|
|
1619
2000
|
if (adapter.validateRegistration) adapter.validateRegistration(registration);
|
|
1620
2001
|
|
|
1621
2002
|
waitingInputType[gqltype.name] = { gqltype };
|
|
@@ -1623,10 +2004,24 @@ const addNoEndpointType = (gqltype) => {
|
|
|
1623
2004
|
typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
|
|
1624
2005
|
};
|
|
1625
2006
|
|
|
1626
|
-
|
|
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) => {
|
|
1627
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
|
+
}
|
|
1628
2022
|
|
|
1629
2023
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
2024
|
+
if (fieldEntryName === omit) continue;
|
|
1630
2025
|
argsObject[fieldEntryName] = {};
|
|
1631
2026
|
|
|
1632
2027
|
if (fieldEntry.type instanceof GraphQLScalarType
|
|
@@ -1683,6 +2078,7 @@ function formatArgs(argsArray) {
|
|
|
1683
2078
|
|
|
1684
2079
|
return {
|
|
1685
2080
|
configureQueryLimits,
|
|
2081
|
+
configureMutationLimits,
|
|
1686
2082
|
connect,
|
|
1687
2083
|
addNoEndpointType,
|
|
1688
2084
|
createSchema,
|