@simtlix/simfinity-core 3.9.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 +4 -3
- package/package.json +1 -1
- package/src/auth/index.js +5 -3
- package/src/const/QLOperator.js +2 -0
- package/src/metadata.js +24 -8
- package/src/query-emptiness.js +222 -0
- package/src/query-operators.js +8 -1
- package/src/query-plan.js +131 -24
- package/src/runtime.js +272 -58
- package/types/index.d.ts +60 -6
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';
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
|
|
18
18
|
} from './query-plan.js';
|
|
19
19
|
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
20
|
+
import { assertEmptinessPath, describeEmptiness, isEmptinessOperator } from './query-emptiness.js';
|
|
20
21
|
import { relationFieldOwners } from './relation-owners.js';
|
|
21
22
|
import { isThenable } from './auth/thenable.js';
|
|
22
23
|
import {
|
|
@@ -178,7 +179,10 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
|
178
179
|
throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
|
|
179
180
|
}
|
|
180
181
|
for (const term of value.terms) {
|
|
181
|
-
|
|
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 });
|
|
182
186
|
}
|
|
183
187
|
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
|
|
184
188
|
}
|
|
@@ -272,13 +276,22 @@ const assertFieldMetadataNamesFree = (roots) => {
|
|
|
272
276
|
if (clash) throw reservedTypeNameError(clash);
|
|
273
277
|
};
|
|
274
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
|
+
|
|
275
287
|
// Constructs the schema of these roots. When one of its types has the name of a field metadata type,
|
|
276
288
|
// those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
|
|
277
289
|
// when the schema also has those names, in every schema the process constructs from then on. A
|
|
278
290
|
// construction that fails puts the previous metadata types back, so only a schema that exists fixes
|
|
279
291
|
// the names.
|
|
280
292
|
const constructSchema = (query, mutation) => {
|
|
281
|
-
const
|
|
293
|
+
const config = buildSchemaConfig(query, mutation);
|
|
294
|
+
const roots = Object.values(config);
|
|
282
295
|
const clash = fieldMetadataClash(roots);
|
|
283
296
|
if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
|
|
284
297
|
let names = null;
|
|
@@ -293,7 +306,7 @@ const constructSchema = (query, mutation) => {
|
|
|
293
306
|
const restore = names ? renameFieldMetadataTypes(...names) : null;
|
|
294
307
|
let schema;
|
|
295
308
|
try {
|
|
296
|
-
schema = new GraphQLSchema(
|
|
309
|
+
schema = new GraphQLSchema(config);
|
|
297
310
|
} catch (error) {
|
|
298
311
|
if (restore) restore();
|
|
299
312
|
throw error;
|
|
@@ -473,6 +486,7 @@ export const createRuntime = (adapter) => {
|
|
|
473
486
|
const selfCollectionNameHolders = new Map();
|
|
474
487
|
const qualifiedSelfCollections = new Map();
|
|
475
488
|
const registeredMutations = {};
|
|
489
|
+
const registeredQueries = {};
|
|
476
490
|
const middlewares = [];
|
|
477
491
|
|
|
478
492
|
const operations = {
|
|
@@ -481,6 +495,7 @@ export const createRuntime = (adapter) => {
|
|
|
481
495
|
DELETE: 'delete',
|
|
482
496
|
STATE_CHANGED: 'state_changed',
|
|
483
497
|
CUSTOM_MUTATION: 'custom_mutation',
|
|
498
|
+
CUSTOM_QUERY: 'custom_query',
|
|
484
499
|
};
|
|
485
500
|
|
|
486
501
|
const use = (middleware) => {
|
|
@@ -533,7 +548,7 @@ export const createRuntime = (adapter) => {
|
|
|
533
548
|
};
|
|
534
549
|
|
|
535
550
|
// Object and interface types a schema of this runtime can reach from its registered types and
|
|
536
|
-
// custom
|
|
551
|
+
// custom operation results, following the same references as GraphQLSchema.
|
|
537
552
|
const collectSchemaOutputTypes = () => {
|
|
538
553
|
const visited = new Set();
|
|
539
554
|
const outputTypes = [];
|
|
@@ -550,8 +565,13 @@ export const createRuntime = (adapter) => {
|
|
|
550
565
|
named.getInterfaces().forEach(visit);
|
|
551
566
|
for (const field of Object.values(named.getFields())) visit(field.type);
|
|
552
567
|
};
|
|
553
|
-
|
|
554
|
-
|
|
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);
|
|
555
575
|
return outputTypes;
|
|
556
576
|
};
|
|
557
577
|
|
|
@@ -1274,10 +1294,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
1274
1294
|
return { modelArgs, collectionFields };
|
|
1275
1295
|
};
|
|
1276
1296
|
|
|
1277
|
-
|
|
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
|
+
}
|
|
1278
1303
|
const nextInput = attemptInputs(args, inputModel);
|
|
1279
1304
|
return adapter.withTransaction(
|
|
1280
|
-
|
|
1305
|
+
null,
|
|
1281
1306
|
(mySession) => callback(nextInput(), mySession, context),
|
|
1282
1307
|
);
|
|
1283
1308
|
};
|
|
@@ -1741,12 +1766,15 @@ const copyFilterGroups = (groups) => (Array.isArray(groups) ? groups.map((group)
|
|
|
1741
1766
|
// client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
|
|
1742
1767
|
// graphql-js passes the same variable values to every resolver call of a request, so the client's
|
|
1743
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.
|
|
1744
1772
|
const inspectClientPaths = (gqltype, args, operation) => {
|
|
1745
1773
|
for (const key of ['AND', 'OR']) {
|
|
1746
1774
|
if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
|
|
1747
1775
|
}
|
|
1748
1776
|
const joinedScopes = new Map();
|
|
1749
|
-
for (const { segments, group } of collectQueryPathEntries(args, operation)) {
|
|
1777
|
+
for (const { segments, group, member } of collectQueryPathEntries(args, operation, { gqltype, expandMembers: true })) {
|
|
1750
1778
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1751
1779
|
const path = segments.slice(0, index + 1).join('.');
|
|
1752
1780
|
if (isVirtualField(field)) {
|
|
@@ -1756,6 +1784,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1756
1784
|
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
1757
1785
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1758
1786
|
}
|
|
1787
|
+
if (member) return;
|
|
1759
1788
|
const relation = field.extensions?.relation;
|
|
1760
1789
|
if (!relation || relation.embedded) return;
|
|
1761
1790
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
@@ -1794,19 +1823,103 @@ const assertStoredQueryPaths = (gqltype, args, operation) => {
|
|
|
1794
1823
|
}
|
|
1795
1824
|
};
|
|
1796
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
|
+
|
|
1797
1870
|
// Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
|
|
1798
|
-
// UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
1801
|
-
//
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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;
|
|
1808
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 }));
|
|
1809
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);
|
|
1810
1923
|
};
|
|
1811
1924
|
|
|
1812
1925
|
// A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
|
|
@@ -1867,6 +1980,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
1867
1980
|
if (!groups.length) continue;
|
|
1868
1981
|
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
1869
1982
|
assertJoinedScopePaths(groups, prefix, target);
|
|
1983
|
+
assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
|
|
1870
1984
|
queryGroups ??= collectFilterGroups(params.args);
|
|
1871
1985
|
const addedDepth = 1 + Math.max(...groups.map(groupDepth));
|
|
1872
1986
|
if (topLevel || [...owners].some((owner) => !queryGroups.has(owner)
|
|
@@ -1963,7 +2077,7 @@ const resolveById = async (type, args, context, requiredId) => {
|
|
|
1963
2077
|
const queryArgs = { id: { operator: 'EQ', value: args.id } };
|
|
1964
2078
|
await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
|
|
1965
2079
|
assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
|
|
1966
|
-
|
|
2080
|
+
assertQueryConditions(type.gqltype, queryArgs, 'find');
|
|
1967
2081
|
const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
|
|
1968
2082
|
return results[0] || null;
|
|
1969
2083
|
};
|
|
@@ -2284,6 +2398,33 @@ const withoutWritableFields = (type, { noWritableId = false } = {}) => {
|
|
|
2284
2398
|
return `${type.gqltype.name} has no writable fields${state}${id}`;
|
|
2285
2399
|
};
|
|
2286
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
|
+
|
|
2287
2428
|
// Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
|
|
2288
2429
|
// a state-machine action, the action and its from/to states), so tools such as the MCP package can
|
|
2289
2430
|
// classify it without guessing from its name. Extensions do not appear in SDL or introspection.
|
|
@@ -2419,32 +2560,15 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2419
2560
|
|
|
2420
2561
|
for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
|
|
2421
2562
|
if (!shouldNotBeIncludedInSchema(includedCustomMutations, entry)) {
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
rootQueryArgs.fields[entry] = {
|
|
2425
|
-
type: registeredMutation.outputModel,
|
|
2426
|
-
description: registeredMutation.description,
|
|
2427
|
-
args: argsObject,
|
|
2428
|
-
extensions: { simfinityMutation: { operation: operations.CUSTOM_MUTATION } },
|
|
2429
|
-
async resolve(parent, args, context) {
|
|
2430
|
-
const params = {
|
|
2431
|
-
args,
|
|
2432
|
-
operation: operations.CUSTOM_MUTATION,
|
|
2433
|
-
entry,
|
|
2434
|
-
context,
|
|
2435
|
-
};
|
|
2436
|
-
await executeMiddleware(params);
|
|
2437
|
-
return executeRegisteredMutation(args.input, registeredMutation.callback,
|
|
2438
|
-
context, null, registeredMutation.inputModel);
|
|
2439
|
-
},
|
|
2440
|
-
};
|
|
2563
|
+
rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
|
|
2564
|
+
operations.CUSTOM_MUTATION);
|
|
2441
2565
|
}
|
|
2442
2566
|
}
|
|
2443
2567
|
|
|
2444
2568
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2445
2569
|
};
|
|
2446
2570
|
|
|
2447
|
-
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
|
|
2571
|
+
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
|
|
2448
2572
|
const rootQueryArgs = {};
|
|
2449
2573
|
rootQueryArgs.name = name;
|
|
2450
2574
|
rootQueryArgs.fields = {};
|
|
@@ -2471,7 +2595,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2471
2595
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
|
|
2472
2596
|
async resolve(parent, args, context, info) {
|
|
2473
2597
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
|
|
2474
|
-
|
|
2598
|
+
assertQueryConditions(type.gqltype, args, 'find');
|
|
2475
2599
|
const params = {
|
|
2476
2600
|
type,
|
|
2477
2601
|
args,
|
|
@@ -2482,7 +2606,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2482
2606
|
await executeScope(params);
|
|
2483
2607
|
await applyJoinedScopes(joinedScopes, params);
|
|
2484
2608
|
assertStoredQueryPaths(type.gqltype, params.args, 'find');
|
|
2485
|
-
|
|
2609
|
+
assertQueryConditions(type.gqltype, params.args, 'find');
|
|
2486
2610
|
const queryArgs = params.args;
|
|
2487
2611
|
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2488
2612
|
&& canReportCount(parent, info, context);
|
|
@@ -2519,7 +2643,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2519
2643
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
2520
2644
|
async resolve(parent, args, context) {
|
|
2521
2645
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
|
|
2522
|
-
|
|
2646
|
+
assertQueryConditions(type.gqltype, args, 'aggregate');
|
|
2523
2647
|
const params = {
|
|
2524
2648
|
type,
|
|
2525
2649
|
args,
|
|
@@ -2530,7 +2654,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2530
2654
|
await executeScope(params);
|
|
2531
2655
|
await applyJoinedScopes(joinedScopes, params);
|
|
2532
2656
|
assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
|
|
2533
|
-
|
|
2657
|
+
assertQueryConditions(type.gqltype, params.args, 'aggregate');
|
|
2534
2658
|
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
2535
2659
|
},
|
|
2536
2660
|
};
|
|
@@ -2538,6 +2662,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2538
2662
|
}
|
|
2539
2663
|
}
|
|
2540
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
|
+
|
|
2541
2672
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2542
2673
|
};
|
|
2543
2674
|
|
|
@@ -2813,22 +2944,66 @@ const warnUndeclaredVirtualFields = () => {
|
|
|
2813
2944
|
|
|
2814
2945
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
2815
2946
|
|
|
2947
|
+
const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
|
|
2948
|
+
.filter(([entry]) => !shouldNotBeIncludedInSchema(includedOperations, entry))
|
|
2949
|
+
.flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
|
|
2950
|
+
|
|
2816
2951
|
// Application types that a schema with these allowlists contains, whatever else it reaches: the
|
|
2817
|
-
// endpoint types of its queries and mutations, and the input and output types of its
|
|
2818
|
-
|
|
2819
|
-
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) => [
|
|
2820
2954
|
...Object.values(typesDict.types)
|
|
2821
2955
|
.filter((type) => type.endpoint
|
|
2822
2956
|
&& ((type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
|
|
2823
2957
|
|| !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)))
|
|
2824
2958
|
.map((type) => type.gqltype),
|
|
2825
|
-
...
|
|
2826
|
-
|
|
2827
|
-
.flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean)),
|
|
2959
|
+
...includedOperationTypes(registeredMutations, includedCustomMutations),
|
|
2960
|
+
...includedOperationTypes(registeredQueries, includedCustomQueries),
|
|
2828
2961
|
];
|
|
2829
2962
|
|
|
2830
|
-
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) => {
|
|
2831
3004
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3005
|
+
const { includedCustomQueries } = readSchemaOptions(options);
|
|
3006
|
+
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
2832
3007
|
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
2833
3008
|
const schemaTypes = collectSchemaOutputTypes();
|
|
2834
3009
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
@@ -2841,7 +3016,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2841
3016
|
// only generated types or the fields of an input object reach is found when the schema is
|
|
2842
3017
|
// constructed.
|
|
2843
3018
|
assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
|
|
2844
|
-
includedCustomMutations));
|
|
3019
|
+
includedCustomMutations, includedCustomQueries));
|
|
2845
3020
|
const referencedTypes = markReferencedTypesForModelGeneration();
|
|
2846
3021
|
// Entities, which keep their own stored identity: connect() types and relation targets.
|
|
2847
3022
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
@@ -2889,7 +3064,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2889
3064
|
const objectMemberTypes = new Set();
|
|
2890
3065
|
Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
|
|
2891
3066
|
|
|
2892
|
-
let query = buildRootQuery('RootQueryType', includedQueryTypes);
|
|
3067
|
+
let query = buildRootQuery('RootQueryType', includedQueryTypes, undefined, includedCustomQueries);
|
|
2893
3068
|
Object.values(typesDict.types).forEach(({ gqltype }) => {
|
|
2894
3069
|
if (gqltype && !applicationResolvedFields.has(gqltype)) {
|
|
2895
3070
|
applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
|
|
@@ -2900,12 +3075,13 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2900
3075
|
// other type named JSON still collides, and GraphQLSchema reports the duplicate name.
|
|
2901
3076
|
const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
|
|
2902
3077
|
if (applicationJSON instanceof GraphQLScalarType) {
|
|
2903
|
-
query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON)
|
|
3078
|
+
query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
|
|
3079
|
+
includedCustomQueries);
|
|
2904
3080
|
}
|
|
2905
3081
|
|
|
2906
3082
|
const schema = constructSchema(query, mutation);
|
|
2907
3083
|
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
2908
|
-
// custom
|
|
3084
|
+
// custom operation result, so another runtime cannot later generate resolvers this schema executes.
|
|
2909
3085
|
schemaTypes.forEach((type) => {
|
|
2910
3086
|
if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
|
|
2911
3087
|
runtimeBoundTypes.set(type, runtimeIdentity);
|
|
@@ -2935,6 +3111,43 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
|
|
|
2935
3111
|
};
|
|
2936
3112
|
};
|
|
2937
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
|
+
|
|
2938
3151
|
// Gives the field a new extensions object as well as the resolver, so a copy made before keeps
|
|
2939
3152
|
// untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
|
|
2940
3153
|
// copying it, when a type is rebuilt from toConfig().
|
|
@@ -2980,13 +3193,13 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
2980
3193
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
2981
3194
|
}
|
|
2982
3195
|
const joinedScopes = inspectClientPaths(relatedType, args, 'find');
|
|
2983
|
-
|
|
3196
|
+
assertQueryConditions(relatedType, args, 'find');
|
|
2984
3197
|
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
2985
3198
|
await executeMiddleware(params);
|
|
2986
3199
|
await executeScope(params);
|
|
2987
3200
|
await applyJoinedScopes(joinedScopes, params);
|
|
2988
3201
|
assertStoredQueryPaths(relatedType, params.args, 'find');
|
|
2989
|
-
|
|
3202
|
+
assertQueryConditions(relatedType, params.args, 'find');
|
|
2990
3203
|
return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
|
|
2991
3204
|
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
2992
3205
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
@@ -3171,6 +3384,7 @@ function formatArgs(argsArray) {
|
|
|
3171
3384
|
getRegistrations,
|
|
3172
3385
|
use,
|
|
3173
3386
|
registerMutation,
|
|
3387
|
+
registerCustomQuery,
|
|
3174
3388
|
saveObject,
|
|
3175
3389
|
preventCreatingCollection,
|
|
3176
3390
|
};
|