@simtlix/simfinity-core 3.8.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +2 -1
- package/src/auth/index.js +5 -3
- package/src/const/QLOperator.js +8 -0
- package/src/metadata.js +24 -8
- package/src/query-emptiness.js +222 -0
- package/src/query-operators.js +87 -0
- package/src/query-plan.js +165 -15
- package/src/runtime.js +285 -42
- package/types/index.d.ts +104 -4
package/src/runtime.js
CHANGED
|
@@ -4,7 +4,7 @@ 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, getNamedType, isObjectType, isOutputType,
|
|
8
8
|
} from 'graphql';
|
|
9
9
|
|
|
10
10
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -13,7 +13,11 @@ import { ForbiddenError } from './auth/errors.js';
|
|
|
13
13
|
import QLOperator from './const/QLOperator.js';
|
|
14
14
|
import QLValue from './const/QLValue.js';
|
|
15
15
|
import QLSort from './const/QLSort.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
|
|
18
|
+
} from './query-plan.js';
|
|
19
|
+
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
20
|
+
import { assertEmptinessPath, describeEmptiness, isEmptinessOperator } from './query-emptiness.js';
|
|
17
21
|
import { relationFieldOwners } from './relation-owners.js';
|
|
18
22
|
import { isThenable } from './auth/thenable.js';
|
|
19
23
|
import {
|
|
@@ -175,7 +179,10 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
|
175
179
|
throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
|
|
176
180
|
}
|
|
177
181
|
for (const term of value.terms) {
|
|
178
|
-
|
|
182
|
+
// An emptiness term without a path (omitted, null or "") tests the field itself; other
|
|
183
|
+
// operators keep the path as written.
|
|
184
|
+
const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
|
|
185
|
+
conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
|
|
179
186
|
}
|
|
180
187
|
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
|
|
181
188
|
}
|
|
@@ -269,13 +276,22 @@ const assertFieldMetadataNamesFree = (roots) => {
|
|
|
269
276
|
if (clash) throw reservedTypeNameError(clash);
|
|
270
277
|
};
|
|
271
278
|
|
|
279
|
+
const buildSchemaConfig = (query, mutation) => {
|
|
280
|
+
const config = { query };
|
|
281
|
+
if (Object.keys(mutation.getFields()).length > 0) {
|
|
282
|
+
config.mutation = mutation;
|
|
283
|
+
}
|
|
284
|
+
return config;
|
|
285
|
+
};
|
|
286
|
+
|
|
272
287
|
// Constructs the schema of these roots. When one of its types has the name of a field metadata type,
|
|
273
288
|
// those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
|
|
274
289
|
// when the schema also has those names, in every schema the process constructs from then on. A
|
|
275
290
|
// construction that fails puts the previous metadata types back, so only a schema that exists fixes
|
|
276
291
|
// the names.
|
|
277
292
|
const constructSchema = (query, mutation) => {
|
|
278
|
-
const
|
|
293
|
+
const config = buildSchemaConfig(query, mutation);
|
|
294
|
+
const roots = Object.values(config);
|
|
279
295
|
const clash = fieldMetadataClash(roots);
|
|
280
296
|
if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
|
|
281
297
|
let names = null;
|
|
@@ -290,7 +306,7 @@ const constructSchema = (query, mutation) => {
|
|
|
290
306
|
const restore = names ? renameFieldMetadataTypes(...names) : null;
|
|
291
307
|
let schema;
|
|
292
308
|
try {
|
|
293
|
-
schema = new GraphQLSchema(
|
|
309
|
+
schema = new GraphQLSchema(config);
|
|
294
310
|
} catch (error) {
|
|
295
311
|
if (restore) restore();
|
|
296
312
|
throw error;
|
|
@@ -451,6 +467,10 @@ export const createRuntime = (adapter) => {
|
|
|
451
467
|
if (!adapter) {
|
|
452
468
|
throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
|
|
453
469
|
}
|
|
470
|
+
// The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
|
|
471
|
+
// operators when it declares none.
|
|
472
|
+
const supportedOperators = resolveQueryFeatures(adapter.queryFeatures).operators;
|
|
473
|
+
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
454
474
|
|
|
455
475
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
456
476
|
const runtimeIdentity = {};
|
|
@@ -466,6 +486,7 @@ export const createRuntime = (adapter) => {
|
|
|
466
486
|
const selfCollectionNameHolders = new Map();
|
|
467
487
|
const qualifiedSelfCollections = new Map();
|
|
468
488
|
const registeredMutations = {};
|
|
489
|
+
const registeredQueries = {};
|
|
469
490
|
const middlewares = [];
|
|
470
491
|
|
|
471
492
|
const operations = {
|
|
@@ -474,6 +495,7 @@ export const createRuntime = (adapter) => {
|
|
|
474
495
|
DELETE: 'delete',
|
|
475
496
|
STATE_CHANGED: 'state_changed',
|
|
476
497
|
CUSTOM_MUTATION: 'custom_mutation',
|
|
498
|
+
CUSTOM_QUERY: 'custom_query',
|
|
477
499
|
};
|
|
478
500
|
|
|
479
501
|
const use = (middleware) => {
|
|
@@ -526,7 +548,7 @@ export const createRuntime = (adapter) => {
|
|
|
526
548
|
};
|
|
527
549
|
|
|
528
550
|
// Object and interface types a schema of this runtime can reach from its registered types and
|
|
529
|
-
// custom
|
|
551
|
+
// custom operation results, following the same references as GraphQLSchema.
|
|
530
552
|
const collectSchemaOutputTypes = () => {
|
|
531
553
|
const visited = new Set();
|
|
532
554
|
const outputTypes = [];
|
|
@@ -543,8 +565,13 @@ export const createRuntime = (adapter) => {
|
|
|
543
565
|
named.getInterfaces().forEach(visit);
|
|
544
566
|
for (const field of Object.values(named.getFields())) visit(field.type);
|
|
545
567
|
};
|
|
546
|
-
|
|
547
|
-
|
|
568
|
+
const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
|
|
569
|
+
const customOperations = [
|
|
570
|
+
...Object.values(registeredMutations),
|
|
571
|
+
...Object.values(registeredQueries),
|
|
572
|
+
];
|
|
573
|
+
const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
|
|
574
|
+
[...registeredTypes, ...customOutputTypes].forEach(visit);
|
|
548
575
|
return outputTypes;
|
|
549
576
|
};
|
|
550
577
|
|
|
@@ -1267,10 +1294,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
1267
1294
|
return { modelArgs, collectionFields };
|
|
1268
1295
|
};
|
|
1269
1296
|
|
|
1270
|
-
|
|
1297
|
+
// Custom queries run without a transaction or session; custom mutations run in one, which may retry.
|
|
1298
|
+
const executeRegisteredOperation = (args, registration, operation, context) => {
|
|
1299
|
+
const { inputModel, callback } = registration;
|
|
1300
|
+
if (operation === operations.CUSTOM_QUERY) {
|
|
1301
|
+
return callback(cloneInput(args, inputModel), context);
|
|
1302
|
+
}
|
|
1271
1303
|
const nextInput = attemptInputs(args, inputModel);
|
|
1272
1304
|
return adapter.withTransaction(
|
|
1273
|
-
|
|
1305
|
+
null,
|
|
1274
1306
|
(mySession) => callback(nextInput(), mySession, context),
|
|
1275
1307
|
);
|
|
1276
1308
|
};
|
|
@@ -1734,12 +1766,15 @@ const copyFilterGroups = (groups) => (Array.isArray(groups) ? groups.map((group)
|
|
|
1734
1766
|
// client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
|
|
1735
1767
|
// graphql-js passes the same variable values to every resolver call of a request, so the client's
|
|
1736
1768
|
// AND/OR groups are first copied onto this call's args, and joined scopes are only placed in those copies.
|
|
1769
|
+
// An emptiness condition on an embedded object reads its stored members, so their paths are checked
|
|
1770
|
+
// as client paths too (#108 part 2a); a member path only gets the 403 checks, and enters no joined
|
|
1771
|
+
// scope.
|
|
1737
1772
|
const inspectClientPaths = (gqltype, args, operation) => {
|
|
1738
1773
|
for (const key of ['AND', 'OR']) {
|
|
1739
1774
|
if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
|
|
1740
1775
|
}
|
|
1741
1776
|
const joinedScopes = new Map();
|
|
1742
|
-
for (const { segments, group } of collectQueryPathEntries(args, operation)) {
|
|
1777
|
+
for (const { segments, group, member } of collectQueryPathEntries(args, operation, { gqltype, expandMembers: true })) {
|
|
1743
1778
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1744
1779
|
const path = segments.slice(0, index + 1).join('.');
|
|
1745
1780
|
if (isVirtualField(field)) {
|
|
@@ -1749,6 +1784,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1749
1784
|
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
1750
1785
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1751
1786
|
}
|
|
1787
|
+
if (member) return;
|
|
1752
1788
|
const relation = field.extensions?.relation;
|
|
1753
1789
|
if (!relation || relation.embedded) return;
|
|
1754
1790
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
@@ -1787,6 +1823,105 @@ const assertStoredQueryPaths = (gqltype, args, operation) => {
|
|
|
1787
1823
|
}
|
|
1788
1824
|
};
|
|
1789
1825
|
|
|
1826
|
+
const assertDeclaredOperator = (gqltype, operator, path) => {
|
|
1827
|
+
if (!supportedOperators.has(operator)) {
|
|
1828
|
+
throw new SimfinityError(`The database adapter does not support the ${operator} filter operator, used on `
|
|
1829
|
+
+ `${gqltype.name}.${path}`, 'UNSUPPORTED_QUERY', 400);
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
// The checks of an emptiness condition (#108 part 2a) that come before rules 3 to 6 of
|
|
1834
|
+
// describeEmptiness, with the codes and messages of createQueryPlan: the group depth, a dotted field
|
|
1835
|
+
// next to a path, an emptiness operator on an object filter without terms or beside terms, the
|
|
1836
|
+
// fields of the path (rule 1), then the adapter's operators (rule 2). Returns false when the backends
|
|
1837
|
+
// never read the condition as one, so that rules 3 to 6 do not apply: a term of a field that is not
|
|
1838
|
+
// an object (they read that filter's own operator) and a condition without a string field (they
|
|
1839
|
+
// reject it their own way; when the adapter does not declare the operator, no compiler sees it, so
|
|
1840
|
+
// the gate gives createQueryPlan's rule-1 code).
|
|
1841
|
+
const assertEmptinessForm = (gqltype, {
|
|
1842
|
+
operator, path, segments, form, field, depth, ambiguous,
|
|
1843
|
+
}) => {
|
|
1844
|
+
if (form === 'condition') {
|
|
1845
|
+
if (depth > MAX_FILTER_GROUP_DEPTH) throw new SimfinityError('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED', 400);
|
|
1846
|
+
if (typeof field !== 'string') {
|
|
1847
|
+
if (!supportedOperators.has(operator)) throw new SimfinityError('Invalid filter path', 'INVALID_FILTER_PATH', 400);
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
if (ambiguous) {
|
|
1851
|
+
throw new SimfinityError('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD', 400);
|
|
1852
|
+
}
|
|
1853
|
+
} else {
|
|
1854
|
+
assertEmptinessPath(gqltype, [field]);
|
|
1855
|
+
const type = getNamedType(gqltype.getFields()[field].type);
|
|
1856
|
+
if (isObjectType(type) && form !== 'term') {
|
|
1857
|
+
throw new SimfinityError(`Filter on ${field} takes ${operator} only in a term; to test the field itself, use a term `
|
|
1858
|
+
+ `without a path: ${field}: { terms: [{ operator: ${operator} }] }`, 'MISSING_FILTER_PATH', 400);
|
|
1859
|
+
}
|
|
1860
|
+
if (form === 'term' && !isObjectType(type) && !(type instanceof GraphQLInterfaceType) && !(type instanceof GraphQLUnionType)) {
|
|
1861
|
+
assertDeclaredOperator(gqltype, operator, path);
|
|
1862
|
+
return false;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
assertEmptinessPath(gqltype, segments, { path });
|
|
1866
|
+
assertDeclaredOperator(gqltype, operator, path);
|
|
1867
|
+
return true;
|
|
1868
|
+
};
|
|
1869
|
+
|
|
1870
|
+
// Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
|
|
1871
|
+
// UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile,
|
|
1872
|
+
// and applies to every IS_EMPTY or IS_NOT_EMPTY condition the order of checks of #108 part 2a,
|
|
1873
|
+
// whatever the adapter declares. Runs on the client's arguments before middleware and on the final
|
|
1874
|
+
// arguments before the adapter call, which covers what middleware, scopes and joined scopes added,
|
|
1875
|
+
// so that an emptiness condition gets the code that a direct createQueryPlan call gives, whoever
|
|
1876
|
+
// wrote it and whatever the caller may read. Operators that QLOperator does not list are left to the
|
|
1877
|
+
// adapter, which reports them as before.
|
|
1878
|
+
const assertQueryConditions = (gqltype, args, operation) => {
|
|
1879
|
+
for (const entry of collectQueryOperators(args, operation)) {
|
|
1880
|
+
const { operator, path } = entry;
|
|
1881
|
+
if (isEmptinessOperator(operator)) {
|
|
1882
|
+
if (assertEmptinessForm(gqltype, entry)) describeEmptiness(gqltype, entry.segments, { operator, value: entry.value, path });
|
|
1883
|
+
} else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
|
|
1884
|
+
}
|
|
1885
|
+
};
|
|
1886
|
+
|
|
1887
|
+
// Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
|
|
1888
|
+
// client's relation path, because the prefix makes the condition cross a reference, which 2a
|
|
1889
|
+
// rejects. Runs after the scoped-collection and assertJoinedScopePaths 403s. The raw scope arguments,
|
|
1890
|
+
// operators beside terms included, first get the checks they get on the target's own endpoint (the
|
|
1891
|
+
// stored paths, then each emptiness condition's), so that a rejection the scope has there keeps its
|
|
1892
|
+
// code; otherwise the request fails with UNSUPPORTED_QUERY naming the scope and the prefixed condition.
|
|
1893
|
+
// A prefix that continues inside an embedded list or a collection (`olines.shop`, `reviews.item`)
|
|
1894
|
+
// adds more than a reference crossing. After its form and operator, each prefixed condition is checked
|
|
1895
|
+
// on `rootType`, the queried type, where rule 3, which comes before rules 4 to 6, rejects it with the
|
|
1896
|
+
// permanent INVALID_FILTER_PATH that the final gate and createQueryPlan give it wherever it is written.
|
|
1897
|
+
const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
|
|
1898
|
+
const { gqltype } = target;
|
|
1899
|
+
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
|
|
1900
|
+
if (!entries.length) return;
|
|
1901
|
+
assertStoredQueryPaths(gqltype, scopeArgs, 'find');
|
|
1902
|
+
let throughList = false;
|
|
1903
|
+
walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
|
|
1904
|
+
// Rule 6, here or on the target's own endpoint, is the rejection thrown below.
|
|
1905
|
+
const exceptRule6 = (check) => {
|
|
1906
|
+
try {
|
|
1907
|
+
check();
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
if (!(error instanceof SimfinityError) || error.getCode() !== 'UNSUPPORTED_QUERY') throw error;
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
1912
|
+
for (const entry of entries) {
|
|
1913
|
+
if (!assertEmptinessForm(gqltype, entry)) continue;
|
|
1914
|
+
const { operator, value } = entry;
|
|
1915
|
+
if (throughList) exceptRule6(() => describeEmptiness(rootType, `${prefix}.${entry.path}`, { operator, value }));
|
|
1916
|
+
exceptRule6(() => describeEmptiness(gqltype, entry.segments, { operator, value, path: entry.path }));
|
|
1917
|
+
}
|
|
1918
|
+
const prefixed = collectQueryOperators({ AND: groups }).find(({ operator }) => isEmptinessOperator(operator))
|
|
1919
|
+
?? { operator: entries[0].operator, path: `${prefix}.${entries[0].path}` };
|
|
1920
|
+
throw new SimfinityError(`Query path ${prefix} enters ${gqltype.name}, whose find scope tests ${prefixed.path} with `
|
|
1921
|
+
+ `${prefixed.operator}: emptiness through references and collections arrives with reference emptiness (#108); `
|
|
1922
|
+
+ `until then, use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1790
1925
|
// A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
|
|
1791
1926
|
// reach a non-embedded relation inside an embedded list, which is joined once per list item.
|
|
1792
1927
|
const assertJoinedScopePaths = (groups, prefix, target) => {
|
|
@@ -1845,6 +1980,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
1845
1980
|
if (!groups.length) continue;
|
|
1846
1981
|
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
1847
1982
|
assertJoinedScopePaths(groups, prefix, target);
|
|
1983
|
+
assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
|
|
1848
1984
|
queryGroups ??= collectFilterGroups(params.args);
|
|
1849
1985
|
const addedDepth = 1 + Math.max(...groups.map(groupDepth));
|
|
1850
1986
|
if (topLevel || [...owners].some((owner) => !queryGroups.has(owner)
|
|
@@ -1941,6 +2077,7 @@ const resolveById = async (type, args, context, requiredId) => {
|
|
|
1941
2077
|
const queryArgs = { id: { operator: 'EQ', value: args.id } };
|
|
1942
2078
|
await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
|
|
1943
2079
|
assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
|
|
2080
|
+
assertQueryConditions(type.gqltype, queryArgs, 'find');
|
|
1944
2081
|
const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
|
|
1945
2082
|
return results[0] || null;
|
|
1946
2083
|
};
|
|
@@ -2261,6 +2398,33 @@ const withoutWritableFields = (type, { noWritableId = false } = {}) => {
|
|
|
2261
2398
|
return `${type.gqltype.name} has no writable fields${state}${id}`;
|
|
2262
2399
|
};
|
|
2263
2400
|
|
|
2401
|
+
const createArgsForRegisteredOperation = (inputModel) => {
|
|
2402
|
+
if (!inputModel) return null;
|
|
2403
|
+
return { input: { type: new GraphQLNonNull(inputModel) } };
|
|
2404
|
+
};
|
|
2405
|
+
|
|
2406
|
+
const buildRegisteredOperationField = (entry, registration, operation) => {
|
|
2407
|
+
const field = {
|
|
2408
|
+
type: registration.outputModel,
|
|
2409
|
+
description: registration.description,
|
|
2410
|
+
args: createArgsForRegisteredOperation(registration.inputModel),
|
|
2411
|
+
async resolve(parent, args, context) {
|
|
2412
|
+
const params = {
|
|
2413
|
+
args,
|
|
2414
|
+
operation,
|
|
2415
|
+
entry,
|
|
2416
|
+
context,
|
|
2417
|
+
};
|
|
2418
|
+
await executeMiddleware(params);
|
|
2419
|
+
return executeRegisteredOperation(args.input, registration, operation, context);
|
|
2420
|
+
},
|
|
2421
|
+
};
|
|
2422
|
+
if (operation === operations.CUSTOM_MUTATION) {
|
|
2423
|
+
field.extensions = { simfinityMutation: { operation } };
|
|
2424
|
+
}
|
|
2425
|
+
return field;
|
|
2426
|
+
};
|
|
2427
|
+
|
|
2264
2428
|
// Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
|
|
2265
2429
|
// a state-machine action, the action and its from/to states), so tools such as the MCP package can
|
|
2266
2430
|
// classify it without guessing from its name. Extensions do not appear in SDL or introspection.
|
|
@@ -2396,32 +2560,15 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2396
2560
|
|
|
2397
2561
|
for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
|
|
2398
2562
|
if (!shouldNotBeIncludedInSchema(includedCustomMutations, entry)) {
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
rootQueryArgs.fields[entry] = {
|
|
2402
|
-
type: registeredMutation.outputModel,
|
|
2403
|
-
description: registeredMutation.description,
|
|
2404
|
-
args: argsObject,
|
|
2405
|
-
extensions: { simfinityMutation: { operation: operations.CUSTOM_MUTATION } },
|
|
2406
|
-
async resolve(parent, args, context) {
|
|
2407
|
-
const params = {
|
|
2408
|
-
args,
|
|
2409
|
-
operation: operations.CUSTOM_MUTATION,
|
|
2410
|
-
entry,
|
|
2411
|
-
context,
|
|
2412
|
-
};
|
|
2413
|
-
await executeMiddleware(params);
|
|
2414
|
-
return executeRegisteredMutation(args.input, registeredMutation.callback,
|
|
2415
|
-
context, null, registeredMutation.inputModel);
|
|
2416
|
-
},
|
|
2417
|
-
};
|
|
2563
|
+
rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
|
|
2564
|
+
operations.CUSTOM_MUTATION);
|
|
2418
2565
|
}
|
|
2419
2566
|
}
|
|
2420
2567
|
|
|
2421
2568
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2422
2569
|
};
|
|
2423
2570
|
|
|
2424
|
-
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
|
|
2571
|
+
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
|
|
2425
2572
|
const rootQueryArgs = {};
|
|
2426
2573
|
rootQueryArgs.name = name;
|
|
2427
2574
|
rootQueryArgs.fields = {};
|
|
@@ -2448,6 +2595,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2448
2595
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
|
|
2449
2596
|
async resolve(parent, args, context, info) {
|
|
2450
2597
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
|
|
2598
|
+
assertQueryConditions(type.gqltype, args, 'find');
|
|
2451
2599
|
const params = {
|
|
2452
2600
|
type,
|
|
2453
2601
|
args,
|
|
@@ -2458,6 +2606,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2458
2606
|
await executeScope(params);
|
|
2459
2607
|
await applyJoinedScopes(joinedScopes, params);
|
|
2460
2608
|
assertStoredQueryPaths(type.gqltype, params.args, 'find');
|
|
2609
|
+
assertQueryConditions(type.gqltype, params.args, 'find');
|
|
2461
2610
|
const queryArgs = params.args;
|
|
2462
2611
|
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2463
2612
|
&& canReportCount(parent, info, context);
|
|
@@ -2494,6 +2643,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2494
2643
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
2495
2644
|
async resolve(parent, args, context) {
|
|
2496
2645
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
|
|
2646
|
+
assertQueryConditions(type.gqltype, args, 'aggregate');
|
|
2497
2647
|
const params = {
|
|
2498
2648
|
type,
|
|
2499
2649
|
args,
|
|
@@ -2504,6 +2654,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2504
2654
|
await executeScope(params);
|
|
2505
2655
|
await applyJoinedScopes(joinedScopes, params);
|
|
2506
2656
|
assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
|
|
2657
|
+
assertQueryConditions(type.gqltype, params.args, 'aggregate');
|
|
2507
2658
|
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
2508
2659
|
},
|
|
2509
2660
|
};
|
|
@@ -2511,6 +2662,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2511
2662
|
}
|
|
2512
2663
|
}
|
|
2513
2664
|
|
|
2665
|
+
for (const [entry, registeredQuery] of Object.entries(registeredQueries)) {
|
|
2666
|
+
if (!shouldNotBeIncludedInSchema(includedCustomQueries, entry)) {
|
|
2667
|
+
rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredQuery,
|
|
2668
|
+
operations.CUSTOM_QUERY);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2514
2672
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2515
2673
|
};
|
|
2516
2674
|
|
|
@@ -2786,22 +2944,66 @@ const warnUndeclaredVirtualFields = () => {
|
|
|
2786
2944
|
|
|
2787
2945
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
2788
2946
|
|
|
2947
|
+
const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
|
|
2948
|
+
.filter(([entry]) => !shouldNotBeIncludedInSchema(includedOperations, entry))
|
|
2949
|
+
.flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
|
|
2950
|
+
|
|
2789
2951
|
// Application types that a schema with these allowlists contains, whatever else it reaches: the
|
|
2790
|
-
// endpoint types of its queries and mutations, and the input and output types of its
|
|
2791
|
-
|
|
2792
|
-
const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => [
|
|
2952
|
+
// endpoint types of its queries and mutations, and the input and output types of its custom operations.
|
|
2953
|
+
const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
|
|
2793
2954
|
...Object.values(typesDict.types)
|
|
2794
2955
|
.filter((type) => type.endpoint
|
|
2795
2956
|
&& ((type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
|
|
2796
2957
|
|| !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)))
|
|
2797
2958
|
.map((type) => type.gqltype),
|
|
2798
|
-
...
|
|
2799
|
-
|
|
2800
|
-
.flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean)),
|
|
2959
|
+
...includedOperationTypes(registeredMutations, includedCustomMutations),
|
|
2960
|
+
...includedOperationTypes(registeredQueries, includedCustomQueries),
|
|
2801
2961
|
];
|
|
2802
2962
|
|
|
2803
|
-
const
|
|
2963
|
+
const queryNameConflict = (name) => new SimfinityError(
|
|
2964
|
+
`Query ${name} is already registered or conflicts with a generated query in this schema`,
|
|
2965
|
+
'QUERY_NAME_CONFLICT', 500,
|
|
2966
|
+
);
|
|
2967
|
+
|
|
2968
|
+
const assertQueryNamesAvailable = (includedQueryTypes, includedCustomQueries) => {
|
|
2969
|
+
for (const type of Object.values(typesDict.types)) {
|
|
2970
|
+
if (!type.simpleEntityEndpointName || shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype)) continue;
|
|
2971
|
+
for (const name of [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]) {
|
|
2972
|
+
if (Object.hasOwn(registeredQueries, name) && !shouldNotBeIncludedInSchema(includedCustomQueries, name)) {
|
|
2973
|
+
throw queryNameConflict(name);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
};
|
|
2978
|
+
|
|
2979
|
+
const invalidSchemaOptions = (detail) => new SimfinityError(
|
|
2980
|
+
`createSchema options ${detail}`, 'INVALID_SCHEMA_OPTIONS', 500,
|
|
2981
|
+
);
|
|
2982
|
+
|
|
2983
|
+
const SCHEMA_OPTION_KEYS = new Set(['includedCustomQueries']);
|
|
2984
|
+
|
|
2985
|
+
// The fourth createSchema() argument: omitted, or a plain object with known keys only.
|
|
2986
|
+
const readSchemaOptions = (options) => {
|
|
2987
|
+
if (options === undefined) return {};
|
|
2988
|
+
if (options === null || typeof options !== 'object' || !isPlainObject(options)) {
|
|
2989
|
+
throw invalidSchemaOptions('must be a plain object');
|
|
2990
|
+
}
|
|
2991
|
+
const unknown = Reflect.ownKeys(options).find((key) => !SCHEMA_OPTION_KEYS.has(key));
|
|
2992
|
+
if (unknown !== undefined) {
|
|
2993
|
+
throw invalidSchemaOptions(`has an unknown option ${String(unknown)}`);
|
|
2994
|
+
}
|
|
2995
|
+
const { includedCustomQueries } = options;
|
|
2996
|
+
if (includedCustomQueries != null && (!Array.isArray(includedCustomQueries)
|
|
2997
|
+
|| includedCustomQueries.some((name) => typeof name !== 'string'))) {
|
|
2998
|
+
throw invalidSchemaOptions('includedCustomQueries must be an array of strings, null or undefined');
|
|
2999
|
+
}
|
|
3000
|
+
return { includedCustomQueries };
|
|
3001
|
+
};
|
|
3002
|
+
|
|
3003
|
+
const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
|
|
2804
3004
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3005
|
+
const { includedCustomQueries } = readSchemaOptions(options);
|
|
3006
|
+
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
2805
3007
|
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
2806
3008
|
const schemaTypes = collectSchemaOutputTypes();
|
|
2807
3009
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
@@ -2814,7 +3016,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2814
3016
|
// only generated types or the fields of an input object reach is found when the schema is
|
|
2815
3017
|
// constructed.
|
|
2816
3018
|
assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
|
|
2817
|
-
includedCustomMutations));
|
|
3019
|
+
includedCustomMutations, includedCustomQueries));
|
|
2818
3020
|
const referencedTypes = markReferencedTypesForModelGeneration();
|
|
2819
3021
|
// Entities, which keep their own stored identity: connect() types and relation targets.
|
|
2820
3022
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
@@ -2862,7 +3064,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2862
3064
|
const objectMemberTypes = new Set();
|
|
2863
3065
|
Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
|
|
2864
3066
|
|
|
2865
|
-
let query = buildRootQuery('RootQueryType', includedQueryTypes);
|
|
3067
|
+
let query = buildRootQuery('RootQueryType', includedQueryTypes, undefined, includedCustomQueries);
|
|
2866
3068
|
Object.values(typesDict.types).forEach(({ gqltype }) => {
|
|
2867
3069
|
if (gqltype && !applicationResolvedFields.has(gqltype)) {
|
|
2868
3070
|
applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
|
|
@@ -2873,12 +3075,13 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2873
3075
|
// other type named JSON still collides, and GraphQLSchema reports the duplicate name.
|
|
2874
3076
|
const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
|
|
2875
3077
|
if (applicationJSON instanceof GraphQLScalarType) {
|
|
2876
|
-
query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON)
|
|
3078
|
+
query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
|
|
3079
|
+
includedCustomQueries);
|
|
2877
3080
|
}
|
|
2878
3081
|
|
|
2879
3082
|
const schema = constructSchema(query, mutation);
|
|
2880
3083
|
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
2881
|
-
// custom
|
|
3084
|
+
// custom operation result, so another runtime cannot later generate resolvers this schema executes.
|
|
2882
3085
|
schemaTypes.forEach((type) => {
|
|
2883
3086
|
if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
|
|
2884
3087
|
runtimeBoundTypes.set(type, runtimeIdentity);
|
|
@@ -2908,6 +3111,43 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
|
|
|
2908
3111
|
};
|
|
2909
3112
|
};
|
|
2910
3113
|
|
|
3114
|
+
const invalidQueryRegistration = (detail) => new SimfinityError(
|
|
3115
|
+
`registerCustomQuery ${detail}`, 'INVALID_QUERY_REGISTRATION', 500,
|
|
3116
|
+
);
|
|
3117
|
+
|
|
3118
|
+
const validateQueryRegistration = (name, registration) => {
|
|
3119
|
+
const { description, inputModel, outputModel, callback } = registration;
|
|
3120
|
+
if (typeof name !== 'string' || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) || name.startsWith('__')) {
|
|
3121
|
+
throw invalidQueryRegistration('requires a valid GraphQL field name that does not start with __');
|
|
3122
|
+
}
|
|
3123
|
+
if (description != null && typeof description !== 'string') {
|
|
3124
|
+
throw invalidQueryRegistration('description must be a string');
|
|
3125
|
+
}
|
|
3126
|
+
if (inputModel != null && !(inputModel instanceof GraphQLInputObjectType)) {
|
|
3127
|
+
throw invalidQueryRegistration('input must be a GraphQLInputObjectType, null or undefined');
|
|
3128
|
+
}
|
|
3129
|
+
if (!isOutputType(outputModel)) {
|
|
3130
|
+
throw invalidQueryRegistration('output must be a GraphQL output type');
|
|
3131
|
+
}
|
|
3132
|
+
if (typeof callback !== 'function') {
|
|
3133
|
+
throw invalidQueryRegistration('callback must be a function');
|
|
3134
|
+
}
|
|
3135
|
+
};
|
|
3136
|
+
|
|
3137
|
+
const registerCustomQuery = (name, description, inputModel, outputModel, callback) => {
|
|
3138
|
+
const registration = {
|
|
3139
|
+
description,
|
|
3140
|
+
inputModel,
|
|
3141
|
+
outputModel,
|
|
3142
|
+
callback,
|
|
3143
|
+
};
|
|
3144
|
+
validateQueryRegistration(name, registration);
|
|
3145
|
+
if (Object.hasOwn(registeredQueries, name)) {
|
|
3146
|
+
throw queryNameConflict(name);
|
|
3147
|
+
}
|
|
3148
|
+
registeredQueries[name] = registration;
|
|
3149
|
+
};
|
|
3150
|
+
|
|
2911
3151
|
// Gives the field a new extensions object as well as the resolver, so a copy made before keeps
|
|
2912
3152
|
// untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
|
|
2913
3153
|
// copying it, when a type is rebuilt from toConfig().
|
|
@@ -2953,11 +3193,13 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
2953
3193
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
2954
3194
|
}
|
|
2955
3195
|
const joinedScopes = inspectClientPaths(relatedType, args, 'find');
|
|
3196
|
+
assertQueryConditions(relatedType, args, 'find');
|
|
2956
3197
|
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
2957
3198
|
await executeMiddleware(params);
|
|
2958
3199
|
await executeScope(params);
|
|
2959
3200
|
await applyJoinedScopes(joinedScopes, params);
|
|
2960
3201
|
assertStoredQueryPaths(relatedType, params.args, 'find');
|
|
3202
|
+
assertQueryConditions(relatedType, params.args, 'find');
|
|
2961
3203
|
return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
|
|
2962
3204
|
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
2963
3205
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
@@ -3142,6 +3384,7 @@ function formatArgs(argsArray) {
|
|
|
3142
3384
|
getRegistrations,
|
|
3143
3385
|
use,
|
|
3144
3386
|
registerMutation,
|
|
3387
|
+
registerCustomQuery,
|
|
3145
3388
|
saveObject,
|
|
3146
3389
|
preventCreatingCollection,
|
|
3147
3390
|
};
|