@simtlix/simfinity-core 3.9.0 → 3.11.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 -4
- 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 +297 -0
- package/src/query-limits.js +14 -2
- package/src/query-operators.js +21 -9
- package/src/query-plan.js +206 -35
- package/src/runtime.js +528 -77
- package/types/index.d.ts +119 -12
package/src/runtime.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
configureQueryLimits, getQueryMaxPageSize, getQueryMaxReferenceFrames, paginationStages,
|
|
3
|
+
} from './query-limits.js';
|
|
2
4
|
import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
|
|
3
5
|
import {
|
|
4
6
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
5
7
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
6
8
|
GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
|
|
7
|
-
Kind, getNamedType,
|
|
9
|
+
Kind, getNamedType, isObjectType, isOutputType,
|
|
8
10
|
} from 'graphql';
|
|
9
11
|
|
|
10
12
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -17,6 +19,9 @@ import {
|
|
|
17
19
|
collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
|
|
18
20
|
} from './query-plan.js';
|
|
19
21
|
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
22
|
+
import {
|
|
23
|
+
assertEmptinessPath, describeEmptiness, emptinessScopePaths, emptinessScopeTargets, isEmptinessOperator,
|
|
24
|
+
} from './query-emptiness.js';
|
|
20
25
|
import { relationFieldOwners } from './relation-owners.js';
|
|
21
26
|
import { isThenable } from './auth/thenable.js';
|
|
22
27
|
import {
|
|
@@ -178,7 +183,10 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
|
178
183
|
throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
|
|
179
184
|
}
|
|
180
185
|
for (const term of value.terms) {
|
|
181
|
-
|
|
186
|
+
// An emptiness term without a path (omitted, null or "") tests the field itself; other
|
|
187
|
+
// operators keep the path as written.
|
|
188
|
+
const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
|
|
189
|
+
conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
|
|
182
190
|
}
|
|
183
191
|
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
|
|
184
192
|
}
|
|
@@ -272,13 +280,22 @@ const assertFieldMetadataNamesFree = (roots) => {
|
|
|
272
280
|
if (clash) throw reservedTypeNameError(clash);
|
|
273
281
|
};
|
|
274
282
|
|
|
283
|
+
const buildSchemaConfig = (query, mutation) => {
|
|
284
|
+
const config = { query };
|
|
285
|
+
if (Object.keys(mutation.getFields()).length > 0) {
|
|
286
|
+
config.mutation = mutation;
|
|
287
|
+
}
|
|
288
|
+
return config;
|
|
289
|
+
};
|
|
290
|
+
|
|
275
291
|
// Constructs the schema of these roots. When one of its types has the name of a field metadata type,
|
|
276
292
|
// those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
|
|
277
293
|
// when the schema also has those names, in every schema the process constructs from then on. A
|
|
278
294
|
// construction that fails puts the previous metadata types back, so only a schema that exists fixes
|
|
279
295
|
// the names.
|
|
280
296
|
const constructSchema = (query, mutation) => {
|
|
281
|
-
const
|
|
297
|
+
const config = buildSchemaConfig(query, mutation);
|
|
298
|
+
const roots = Object.values(config);
|
|
282
299
|
const clash = fieldMetadataClash(roots);
|
|
283
300
|
if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
|
|
284
301
|
let names = null;
|
|
@@ -293,7 +310,7 @@ const constructSchema = (query, mutation) => {
|
|
|
293
310
|
const restore = names ? renameFieldMetadataTypes(...names) : null;
|
|
294
311
|
let schema;
|
|
295
312
|
try {
|
|
296
|
-
schema = new GraphQLSchema(
|
|
313
|
+
schema = new GraphQLSchema(config);
|
|
297
314
|
} catch (error) {
|
|
298
315
|
if (restore) restore();
|
|
299
316
|
throw error;
|
|
@@ -455,8 +472,12 @@ export const createRuntime = (adapter) => {
|
|
|
455
472
|
throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
|
|
456
473
|
}
|
|
457
474
|
// The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
|
|
458
|
-
// operators when it declares none.
|
|
459
|
-
|
|
475
|
+
// operators when it declares none. With queryFeatures.referenceEmptiness, it also compiles
|
|
476
|
+
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
477
|
+
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
478
|
+
const {
|
|
479
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
|
|
480
|
+
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
460
481
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
461
482
|
|
|
462
483
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
@@ -473,6 +494,7 @@ export const createRuntime = (adapter) => {
|
|
|
473
494
|
const selfCollectionNameHolders = new Map();
|
|
474
495
|
const qualifiedSelfCollections = new Map();
|
|
475
496
|
const registeredMutations = {};
|
|
497
|
+
const registeredQueries = {};
|
|
476
498
|
const middlewares = [];
|
|
477
499
|
|
|
478
500
|
const operations = {
|
|
@@ -481,6 +503,7 @@ export const createRuntime = (adapter) => {
|
|
|
481
503
|
DELETE: 'delete',
|
|
482
504
|
STATE_CHANGED: 'state_changed',
|
|
483
505
|
CUSTOM_MUTATION: 'custom_mutation',
|
|
506
|
+
CUSTOM_QUERY: 'custom_query',
|
|
484
507
|
};
|
|
485
508
|
|
|
486
509
|
const use = (middleware) => {
|
|
@@ -533,7 +556,7 @@ export const createRuntime = (adapter) => {
|
|
|
533
556
|
};
|
|
534
557
|
|
|
535
558
|
// Object and interface types a schema of this runtime can reach from its registered types and
|
|
536
|
-
// custom
|
|
559
|
+
// custom operation results, following the same references as GraphQLSchema.
|
|
537
560
|
const collectSchemaOutputTypes = () => {
|
|
538
561
|
const visited = new Set();
|
|
539
562
|
const outputTypes = [];
|
|
@@ -550,8 +573,13 @@ export const createRuntime = (adapter) => {
|
|
|
550
573
|
named.getInterfaces().forEach(visit);
|
|
551
574
|
for (const field of Object.values(named.getFields())) visit(field.type);
|
|
552
575
|
};
|
|
553
|
-
|
|
554
|
-
|
|
576
|
+
const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
|
|
577
|
+
const customOperations = [
|
|
578
|
+
...Object.values(registeredMutations),
|
|
579
|
+
...Object.values(registeredQueries),
|
|
580
|
+
];
|
|
581
|
+
const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
|
|
582
|
+
[...registeredTypes, ...customOutputTypes].forEach(visit);
|
|
555
583
|
return outputTypes;
|
|
556
584
|
};
|
|
557
585
|
|
|
@@ -1274,10 +1302,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
1274
1302
|
return { modelArgs, collectionFields };
|
|
1275
1303
|
};
|
|
1276
1304
|
|
|
1277
|
-
|
|
1305
|
+
// Custom queries run without a transaction or session; custom mutations run in one, which may retry.
|
|
1306
|
+
const executeRegisteredOperation = (args, registration, operation, context) => {
|
|
1307
|
+
const { inputModel, callback } = registration;
|
|
1308
|
+
if (operation === operations.CUSTOM_QUERY) {
|
|
1309
|
+
return callback(cloneInput(args, inputModel), context);
|
|
1310
|
+
}
|
|
1278
1311
|
const nextInput = attemptInputs(args, inputModel);
|
|
1279
1312
|
return adapter.withTransaction(
|
|
1280
|
-
|
|
1313
|
+
null,
|
|
1281
1314
|
(mySession) => callback(nextInput(), mySession, context),
|
|
1282
1315
|
);
|
|
1283
1316
|
};
|
|
@@ -1725,28 +1758,69 @@ const executeScope = async (params) => {
|
|
|
1725
1758
|
return scopeFunction({ type, args, operation, context });
|
|
1726
1759
|
};
|
|
1727
1760
|
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
const
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1761
|
+
// The arguments of a list, aggregate or collection field call that are not filters. On aggregate
|
|
1762
|
+
// endpoints `aggregation` is one; elsewhere a field of that name is filtered like any other.
|
|
1763
|
+
const LIST_CONTROL_ARGS = new Set(['sort', 'pagination']);
|
|
1764
|
+
const AGGREGATE_CONTROL_ARGS = new Set(['sort', 'pagination', 'aggregation']);
|
|
1765
|
+
const isFilterObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1766
|
+
// A new object with the same prototype and own enumerable properties: graphql-js builds inline literals
|
|
1767
|
+
// with a null prototype and variables with Object's, and childArgsKey tells them apart.
|
|
1768
|
+
const copyFilterObject = (value) => Object.assign(Object.create(Object.getPrototypeOf(value)), value);
|
|
1769
|
+
// A term, a group condition or a field filter: a value array becomes a new Array with the same items.
|
|
1770
|
+
const copyFilterCondition = (condition) => {
|
|
1771
|
+
if (!isFilterObject(condition)) return condition;
|
|
1772
|
+
const copy = copyFilterObject(condition);
|
|
1773
|
+
if (Array.isArray(condition.value)) copy.value = [...condition.value];
|
|
1736
1774
|
return copy;
|
|
1737
|
-
}
|
|
1775
|
+
};
|
|
1776
|
+
const copyFilterGroup = (group) => {
|
|
1777
|
+
if (!isFilterObject(group)) return group;
|
|
1778
|
+
const copy = copyFilterObject(group);
|
|
1779
|
+
if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
|
|
1780
|
+
if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
|
|
1781
|
+
if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
|
|
1782
|
+
return copy;
|
|
1783
|
+
};
|
|
1784
|
+
const copyFieldFilter = (filter) => {
|
|
1785
|
+
const copy = copyFilterCondition(filter);
|
|
1786
|
+
if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
|
|
1787
|
+
return copy;
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
// A tree copy of the query-language structure of one call's filter arguments, onto that call's args:
|
|
1791
|
+
// field filters and their terms, AND/OR groups and their conditions. Every place gets its own object,
|
|
1792
|
+
// also where graphql-js hands one variable object to several arguments or list items, so that a
|
|
1793
|
+
// middleware edit or a client scope marker stays within the call. Value arrays are copied shallowly,
|
|
1794
|
+
// and every other value, sort, pagination and aggregation included, is kept as it is. GraphQL input
|
|
1795
|
+
// has no cycles, so nothing is memoized.
|
|
1796
|
+
const copyClientFilters = (args, operation) => {
|
|
1797
|
+
const controls = operation === 'aggregate' ? AGGREGATE_CONTROL_ARGS : LIST_CONTROL_ARGS;
|
|
1798
|
+
for (const [name, value] of Object.entries(args)) {
|
|
1799
|
+
if (name === 'AND' || name === 'OR') {
|
|
1800
|
+
if (Array.isArray(value)) args[name] = value.map(copyFilterGroup);
|
|
1801
|
+
} else if (!controls.has(name)) args[name] = copyFieldFilter(value);
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1738
1804
|
|
|
1739
1805
|
// Checks the filter, sort and aggregation paths a client supplied, before middleware and scopes
|
|
1740
1806
|
// add trusted paths. Returns the scoped relations those paths enter, keyed by path, with the
|
|
1741
1807
|
// client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
|
|
1742
1808
|
// graphql-js passes the same variable values to every resolver call of a request, so the client's
|
|
1743
|
-
//
|
|
1809
|
+
// filters are first copied onto this call's args (copyClientFilters), and joined scopes are only
|
|
1810
|
+
// placed in those copies.
|
|
1811
|
+
// An emptiness condition on an embedded object reads its stored members, so their paths are checked
|
|
1812
|
+
// as client paths too (#108 part 2a); a member path only gets the 403 checks, and enters no joined
|
|
1813
|
+
// scope. When the adapter compiles reference emptiness (#108 part 2b), a client's emptiness term or
|
|
1814
|
+
// condition reads its references and collections through scoped joins and frames instead, so it
|
|
1815
|
+
// enters no joined scope either; every other entry, beside it or on the same path, records its own.
|
|
1744
1816
|
const inspectClientPaths = (gqltype, args, operation) => {
|
|
1745
|
-
|
|
1746
|
-
if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
|
|
1747
|
-
}
|
|
1817
|
+
copyClientFilters(args, operation);
|
|
1748
1818
|
const joinedScopes = new Map();
|
|
1749
|
-
|
|
1819
|
+
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
1820
|
+
for (const {
|
|
1821
|
+
segments, group, member, operator,
|
|
1822
|
+
} of entries) {
|
|
1823
|
+
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator));
|
|
1750
1824
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1751
1825
|
const path = segments.slice(0, index + 1).join('.');
|
|
1752
1826
|
if (isVirtualField(field)) {
|
|
@@ -1756,6 +1830,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1756
1830
|
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
1757
1831
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1758
1832
|
}
|
|
1833
|
+
if (!joins) return;
|
|
1759
1834
|
const relation = field.extensions?.relation;
|
|
1760
1835
|
if (!relation || relation.embedded) return;
|
|
1761
1836
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
@@ -1794,19 +1869,119 @@ const assertStoredQueryPaths = (gqltype, args, operation) => {
|
|
|
1794
1869
|
}
|
|
1795
1870
|
};
|
|
1796
1871
|
|
|
1872
|
+
const assertDeclaredOperator = (gqltype, operator, path) => {
|
|
1873
|
+
if (!supportedOperators.has(operator)) {
|
|
1874
|
+
throw new SimfinityError(`The database adapter does not support the ${operator} filter operator, used on `
|
|
1875
|
+
+ `${gqltype.name}.${path}`, 'UNSUPPORTED_QUERY', 400);
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
// The checks of an emptiness condition (#108 part 2a) that come before rules 3 to 6 of
|
|
1880
|
+
// describeEmptiness, with the codes and messages of createQueryPlan: the group depth, a dotted field
|
|
1881
|
+
// next to a path, an emptiness operator on an object filter without terms or beside terms, the
|
|
1882
|
+
// fields of the path (rule 1), then the adapter's operators (rule 2). Returns false when the backends
|
|
1883
|
+
// never read the condition as one, so that rules 3 to 6 do not apply: a term of a field that is not
|
|
1884
|
+
// an object (they read that filter's own operator) and a condition without a string field (they
|
|
1885
|
+
// reject it their own way; when the adapter does not declare the operator, no compiler sees it, so
|
|
1886
|
+
// the gate gives createQueryPlan's rule-1 code).
|
|
1887
|
+
const assertEmptinessForm = (gqltype, {
|
|
1888
|
+
operator, path, segments, form, field, depth, ambiguous,
|
|
1889
|
+
}) => {
|
|
1890
|
+
if (form === 'condition') {
|
|
1891
|
+
if (depth > MAX_FILTER_GROUP_DEPTH) throw new SimfinityError('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED', 400);
|
|
1892
|
+
if (typeof field !== 'string') {
|
|
1893
|
+
if (!supportedOperators.has(operator)) throw new SimfinityError('Invalid filter path', 'INVALID_FILTER_PATH', 400);
|
|
1894
|
+
return false;
|
|
1895
|
+
}
|
|
1896
|
+
if (ambiguous) {
|
|
1897
|
+
throw new SimfinityError('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD', 400);
|
|
1898
|
+
}
|
|
1899
|
+
} else {
|
|
1900
|
+
assertEmptinessPath(gqltype, [field]);
|
|
1901
|
+
const type = getNamedType(gqltype.getFields()[field].type);
|
|
1902
|
+
if (isObjectType(type) && form !== 'term') {
|
|
1903
|
+
throw new SimfinityError(`Filter on ${field} takes ${operator} only in a term; to test the field itself, use a term `
|
|
1904
|
+
+ `without a path: ${field}: { terms: [{ operator: ${operator} }] }`, 'MISSING_FILTER_PATH', 400);
|
|
1905
|
+
}
|
|
1906
|
+
if (form === 'term' && !isObjectType(type) && !(type instanceof GraphQLInterfaceType) && !(type instanceof GraphQLUnionType)) {
|
|
1907
|
+
assertDeclaredOperator(gqltype, operator, path);
|
|
1908
|
+
return false;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
assertEmptinessPath(gqltype, segments, { path });
|
|
1912
|
+
assertDeclaredOperator(gqltype, operator, path);
|
|
1913
|
+
return true;
|
|
1914
|
+
};
|
|
1915
|
+
|
|
1797
1916
|
// 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
|
-
|
|
1917
|
+
// UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile,
|
|
1918
|
+
// and applies to every IS_EMPTY or IS_NOT_EMPTY condition the order of checks of #108 part 2a,
|
|
1919
|
+
// whatever the adapter declares, rule 6 only when it does not declare reference emptiness. Runs on
|
|
1920
|
+
// the client's arguments before middleware and on the final arguments before the adapter call,
|
|
1921
|
+
// which covers what middleware, scopes and joined scopes added, so that an emptiness condition gets
|
|
1922
|
+
// the code that a direct createQueryPlan call gives, whoever wrote it and whatever the caller may
|
|
1923
|
+
// read. Operators that QLOperator does not list are left to the adapter, which reports them as before.
|
|
1924
|
+
// Returns every condition as `{ entry, shape }`: its collectQueryOperators entry, and the shape of an
|
|
1925
|
+
// emptiness condition that the backends read as one, or null.
|
|
1926
|
+
const assertQueryConditions = (gqltype, args, operation) => {
|
|
1927
|
+
const checked = [];
|
|
1928
|
+
for (const entry of collectQueryOperators(args, operation)) {
|
|
1929
|
+
const { operator, path } = entry;
|
|
1930
|
+
let shape = null;
|
|
1931
|
+
if (isEmptinessOperator(operator)) {
|
|
1932
|
+
if (assertEmptinessForm(gqltype, entry)) {
|
|
1933
|
+
shape = describeEmptiness(gqltype, entry.segments, {
|
|
1934
|
+
operator, value: entry.value, path, referenceEmptiness: supportsReferenceEmptiness,
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
} else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
|
|
1938
|
+
checked.push({ entry, shape });
|
|
1939
|
+
}
|
|
1940
|
+
return checked;
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
// Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
|
|
1944
|
+
// client's relation path, because the prefix makes the condition cross a reference, which 2a
|
|
1945
|
+
// rejects. Runs after the scoped-collection and assertJoinedScopePaths 403s. The raw scope arguments,
|
|
1946
|
+
// operators beside terms included, first get the checks they get on the target's own endpoint (the
|
|
1947
|
+
// stored paths, then each emptiness condition's), so that a rejection the scope has there keeps its
|
|
1948
|
+
// code; otherwise the request fails with UNSUPPORTED_QUERY naming the scope and the prefixed condition.
|
|
1949
|
+
// A prefix that continues inside an embedded list or a collection (`olines.shop`, `reviews.item`)
|
|
1950
|
+
// adds more than a reference crossing. After its form and operator, each prefixed condition is checked
|
|
1951
|
+
// on `rootType`, the queried type, where rule 3, which comes before rules 4 to 6, rejects it with the
|
|
1952
|
+
// permanent INVALID_FILTER_PATH that the final gate and createQueryPlan give it wherever it is written.
|
|
1953
|
+
// An adapter that compiles reference emptiness lifts decision 3 (#108 part 2b): after the same checks,
|
|
1954
|
+
// the prefixed conditions are applied as trusted ones, which read stored data.
|
|
1955
|
+
const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
|
|
1956
|
+
const { gqltype } = target;
|
|
1957
|
+
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
|
|
1958
|
+
if (!entries.length) return;
|
|
1959
|
+
assertStoredQueryPaths(gqltype, scopeArgs, 'find');
|
|
1960
|
+
let throughList = false;
|
|
1961
|
+
walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
|
|
1962
|
+
// Rule 6, here or on the target's own endpoint, is the rejection thrown below.
|
|
1963
|
+
const exceptRule6 = (check) => {
|
|
1964
|
+
try {
|
|
1965
|
+
check();
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
if (!(error instanceof SimfinityError) || error.getCode() !== 'UNSUPPORTED_QUERY') throw error;
|
|
1808
1968
|
}
|
|
1969
|
+
};
|
|
1970
|
+
const referenceEmptiness = supportsReferenceEmptiness;
|
|
1971
|
+
for (const entry of entries) {
|
|
1972
|
+
if (!assertEmptinessForm(gqltype, entry)) continue;
|
|
1973
|
+
const { operator, value } = entry;
|
|
1974
|
+
if (throughList) exceptRule6(() => describeEmptiness(rootType, `${prefix}.${entry.path}`, { operator, value, referenceEmptiness }));
|
|
1975
|
+
exceptRule6(() => describeEmptiness(gqltype, entry.segments, {
|
|
1976
|
+
operator, value, path: entry.path, referenceEmptiness,
|
|
1977
|
+
}));
|
|
1809
1978
|
}
|
|
1979
|
+
if (referenceEmptiness) return;
|
|
1980
|
+
const prefixed = collectQueryOperators({ AND: groups }).find(({ operator }) => isEmptinessOperator(operator))
|
|
1981
|
+
?? { operator: entries[0].operator, path: `${prefix}.${entries[0].path}` };
|
|
1982
|
+
throw new SimfinityError(`Query path ${prefix} enters ${gqltype.name}, whose find scope tests ${prefixed.path} with `
|
|
1983
|
+
+ `${prefixed.operator}: the database adapter does not support emptiness through references and collections; `
|
|
1984
|
+
+ `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
|
|
1810
1985
|
};
|
|
1811
1986
|
|
|
1812
1987
|
// A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
|
|
@@ -1867,6 +2042,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
1867
2042
|
if (!groups.length) continue;
|
|
1868
2043
|
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
1869
2044
|
assertJoinedScopePaths(groups, prefix, target);
|
|
2045
|
+
assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
|
|
1870
2046
|
queryGroups ??= collectFilterGroups(params.args);
|
|
1871
2047
|
const addedDepth = 1 + Math.max(...groups.map(groupDepth));
|
|
1872
2048
|
if (topLevel || [...owners].some((owner) => !queryGroups.has(owner)
|
|
@@ -1875,6 +2051,181 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
1875
2051
|
}
|
|
1876
2052
|
};
|
|
1877
2053
|
|
|
2054
|
+
// Client scope markers (#108 part 2b). When the adapter compiles reference emptiness, each IS_EMPTY or
|
|
2055
|
+
// IS_NOT_EMPTY term or group condition that a client sends and that crosses or ends at a reference or a
|
|
2056
|
+
// collection, or reads one as a member, gets `__scope: { id }` on this call's copy, before middleware.
|
|
2057
|
+
// After middleware, the query's own scope and joined scopes, the runtime checks that every marker it
|
|
2058
|
+
// issued is still there and that no other one is, gates the final arguments, then fills each marker
|
|
2059
|
+
// with the raw find scope arguments of every type the construct reads through, `__scope: { id, scopes:
|
|
2060
|
+
// { [path]: args | null } }`, which the compilers apply as scoped joins and frames, so that a record
|
|
2061
|
+
// those scopes hide reads as missing. Constructs without a marker are trusted and read stored data.
|
|
2062
|
+
// Markers live only on the terms and group conditions collectQueryOperators returns; a `__scope`
|
|
2063
|
+
// anywhere else, inside a value for one, is never read.
|
|
2064
|
+
const SCOPE_MARKER = '__scope';
|
|
2065
|
+
const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
|
|
2066
|
+
+ 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
|
|
2067
|
+
const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
|
|
2068
|
+
const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
|
|
2069
|
+
|
|
2070
|
+
// Marks the client's constructs that read references or collections, from the gate's conditions on the
|
|
2071
|
+
// client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
|
|
2072
|
+
// scoped joins and frames in this call, so every 2a code comes first. Returns the issued markers, as
|
|
2073
|
+
// id -> the client's operator and path, or null when the adapter does not compile reference
|
|
2074
|
+
// emptiness. Ids are ordinals in the order of the conditions, so identical arguments get identical
|
|
2075
|
+
// markers and sibling collection reads keep one batch key.
|
|
2076
|
+
const markClientConstructs = (checked) => {
|
|
2077
|
+
if (!supportsReferenceEmptiness) return null;
|
|
2078
|
+
const issued = new Map();
|
|
2079
|
+
const pairs = new Set();
|
|
2080
|
+
for (const { entry, shape } of checked) {
|
|
2081
|
+
const { construct } = entry;
|
|
2082
|
+
if (!shape || !construct || hasScopeMarker(construct)) continue;
|
|
2083
|
+
const { joins, frames } = emptinessScopePaths(shape, entry.segments);
|
|
2084
|
+
if (!joins.length && !frames.length) continue;
|
|
2085
|
+
const id = issued.size;
|
|
2086
|
+
construct[SCOPE_MARKER] = { id };
|
|
2087
|
+
issued.set(id, { operator: entry.operator, path: entry.path });
|
|
2088
|
+
for (const path of joins) pairs.add(`join ${path}`);
|
|
2089
|
+
for (const path of frames) pairs.add(`frame ${path}`);
|
|
2090
|
+
}
|
|
2091
|
+
const limit = getQueryMaxReferenceFrames();
|
|
2092
|
+
if (pairs.size > limit) {
|
|
2093
|
+
throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
|
|
2094
|
+
+ `frames in one query field, more than the limit of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2095
|
+
}
|
|
2096
|
+
return issued;
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
// The detach check: every marker issued in this call sits, well formed (an object whose only own key
|
|
2100
|
+
// is its id), on a term or group condition of the final arguments, possibly on several when middleware
|
|
2101
|
+
// copied or duplicated the construct; no other marker sits on one.
|
|
2102
|
+
const assertClientMarkers = (args, operation, issued) => {
|
|
2103
|
+
const found = new Set();
|
|
2104
|
+
for (const { construct, operator, path } of collectQueryOperators(args, operation)) {
|
|
2105
|
+
if (!hasScopeMarker(construct)) continue;
|
|
2106
|
+
const marker = construct[SCOPE_MARKER];
|
|
2107
|
+
const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
|
|
2108
|
+
if (!isFilterObject(marker) || Reflect.ownKeys(marker).length !== 1 || !Object.hasOwn(marker, 'id')) {
|
|
2109
|
+
const named = origin ? `, the client's ${origin.operator} condition on ${origin.path},` : '';
|
|
2110
|
+
throw detachedClientFilter(`The ${operator} condition on ${path}${named} carries a malformed client scope marker`);
|
|
2111
|
+
}
|
|
2112
|
+
if (!origin) {
|
|
2113
|
+
throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
|
|
2114
|
+
}
|
|
2115
|
+
found.add(marker.id);
|
|
2116
|
+
}
|
|
2117
|
+
for (const [id, origin] of issued) {
|
|
2118
|
+
if (!found.has(id)) {
|
|
2119
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is no longer in the arguments `
|
|
2120
|
+
+ 'after middleware and scopes');
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
};
|
|
2124
|
+
|
|
2125
|
+
// The first reference or collection a path crosses or ends at, as its dotted path, or null.
|
|
2126
|
+
const relationOnPath = (gqltype, segments) => {
|
|
2127
|
+
let found = null;
|
|
2128
|
+
walkQueryPath(gqltype, segments, ({ field, index }) => {
|
|
2129
|
+
const relation = field.extensions?.relation;
|
|
2130
|
+
if (!found && relation && !relation.embedded) found = segments.slice(0, index + 1).join('.');
|
|
2131
|
+
});
|
|
2132
|
+
return found;
|
|
2133
|
+
};
|
|
2134
|
+
|
|
2135
|
+
// The find scope arguments of a type that a client's constructs read through, once per call and type
|
|
2136
|
+
// (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
|
|
2137
|
+
// joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
|
|
2138
|
+
// gate on the type, where everything is trusted. Null when the type has no find scope or the scope
|
|
2139
|
+
// added nothing.
|
|
2140
|
+
const clientScopeArgs = async (typeName, context, cache) => {
|
|
2141
|
+
if (cache.has(typeName)) return cache.get(typeName);
|
|
2142
|
+
const target = typesDict.types[typeName];
|
|
2143
|
+
let result = null;
|
|
2144
|
+
if (getScope(target?.gqltype, 'find')) {
|
|
2145
|
+
const scopeArgs = {};
|
|
2146
|
+
await executeScope({ type: target, args: scopeArgs, operation: 'find', context });
|
|
2147
|
+
const kept = {};
|
|
2148
|
+
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2149
|
+
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2150
|
+
else if (value != null) assertScopeControlArg(name, value);
|
|
2151
|
+
}
|
|
2152
|
+
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2153
|
+
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2154
|
+
if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
|
|
2155
|
+
}
|
|
2156
|
+
cache.set(typeName, result);
|
|
2157
|
+
return result;
|
|
2158
|
+
};
|
|
2159
|
+
|
|
2160
|
+
// The fill, on the final arguments the gate passed (`checked`). Each marked object is read at each of
|
|
2161
|
+
// its final positions, with its final operator and path:
|
|
2162
|
+
// - an emptiness condition that crosses, ends at or has as members references or collections gets a
|
|
2163
|
+
// fresh `__scope: { id, scopes }`, one key per scoped join and frame path, with the union of the
|
|
2164
|
+
// keys of every position when middleware placed the object at several;
|
|
2165
|
+
// - an emptiness condition narrowed to the record's own fields loses its marker, and is trusted;
|
|
2166
|
+
// - another operator loses it too, unless its path still crosses or ends at a reference or a
|
|
2167
|
+
// collection: no joined scope was recorded for it, so it fails with DETACHED_CLIENT_FILTER.
|
|
2168
|
+
// An object that reads references at one position and none at another fails the same way, since one
|
|
2169
|
+
// marker cannot serve both. Every check comes before any target scope runs.
|
|
2170
|
+
const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
2171
|
+
const marked = new Map();
|
|
2172
|
+
for (const item of checked) {
|
|
2173
|
+
const { construct } = item.entry;
|
|
2174
|
+
if (!hasScopeMarker(construct)) continue;
|
|
2175
|
+
if (!marked.has(construct)) marked.set(construct, []);
|
|
2176
|
+
marked.get(construct).push(item);
|
|
2177
|
+
}
|
|
2178
|
+
const fills = [];
|
|
2179
|
+
for (const [construct, positions] of marked) {
|
|
2180
|
+
const { id } = construct[SCOPE_MARKER];
|
|
2181
|
+
const origin = issued.get(id);
|
|
2182
|
+
const targets = {};
|
|
2183
|
+
const unread = [];
|
|
2184
|
+
for (const { entry, shape } of positions) {
|
|
2185
|
+
if (!entry.emptiness) {
|
|
2186
|
+
const relation = relationOnPath(gqltype, entry.segments);
|
|
2187
|
+
if (relation) {
|
|
2188
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2189
|
+
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
2190
|
+
}
|
|
2191
|
+
unread.push(entry.path);
|
|
2192
|
+
} else {
|
|
2193
|
+
const keys = shape ? emptinessScopeTargets(shape, entry.segments) : {};
|
|
2194
|
+
if (Object.keys(keys).length) Object.assign(targets, keys);
|
|
2195
|
+
else unread.push(entry.path);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
const paths = Object.keys(targets);
|
|
2199
|
+
if (paths.length && unread.length) {
|
|
2200
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
|
|
2201
|
+
+ `references or collections and where it reads none (${unread.join(', ')})`);
|
|
2202
|
+
}
|
|
2203
|
+
fills.push({ construct, id, targets: paths.length ? targets : null });
|
|
2204
|
+
}
|
|
2205
|
+
const cache = new Map();
|
|
2206
|
+
for (const { construct, id, targets } of fills) {
|
|
2207
|
+
if (!targets) {
|
|
2208
|
+
delete construct[SCOPE_MARKER];
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2211
|
+
const scopes = {};
|
|
2212
|
+
for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
|
|
2213
|
+
construct[SCOPE_MARKER] = { id, scopes };
|
|
2214
|
+
}
|
|
2215
|
+
};
|
|
2216
|
+
|
|
2217
|
+
// After middleware, the query's own scope and joined scopes, and before the adapter call: the detach
|
|
2218
|
+
// check, the final gate (stored paths, then every condition), then the fill. Markers exist only when
|
|
2219
|
+
// the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
|
|
2220
|
+
// Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
|
|
2221
|
+
// the adapter after as many promise jobs as before.
|
|
2222
|
+
const settleClientArgs = (gqltype, params, operation, issued) => {
|
|
2223
|
+
if (issued) assertClientMarkers(params.args, operation, issued);
|
|
2224
|
+
assertStoredQueryPaths(gqltype, params.args, operation);
|
|
2225
|
+
const checked = assertQueryConditions(gqltype, params.args, operation);
|
|
2226
|
+
return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
|
|
2227
|
+
};
|
|
2228
|
+
|
|
1878
2229
|
// Unscoped relation reads of one request, grouped by related type until the promise jobs queued so
|
|
1879
2230
|
// far have run, and keyed by cast ID. Nothing is cached: each batch is read once and dropped.
|
|
1880
2231
|
const referenceBatches = new WeakMap();
|
|
@@ -1963,7 +2314,7 @@ const resolveById = async (type, args, context, requiredId) => {
|
|
|
1963
2314
|
const queryArgs = { id: { operator: 'EQ', value: args.id } };
|
|
1964
2315
|
await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
|
|
1965
2316
|
assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
|
|
1966
|
-
|
|
2317
|
+
assertQueryConditions(type.gqltype, queryArgs, 'find');
|
|
1967
2318
|
const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
|
|
1968
2319
|
return results[0] || null;
|
|
1969
2320
|
};
|
|
@@ -2284,6 +2635,33 @@ const withoutWritableFields = (type, { noWritableId = false } = {}) => {
|
|
|
2284
2635
|
return `${type.gqltype.name} has no writable fields${state}${id}`;
|
|
2285
2636
|
};
|
|
2286
2637
|
|
|
2638
|
+
const createArgsForRegisteredOperation = (inputModel) => {
|
|
2639
|
+
if (!inputModel) return null;
|
|
2640
|
+
return { input: { type: new GraphQLNonNull(inputModel) } };
|
|
2641
|
+
};
|
|
2642
|
+
|
|
2643
|
+
const buildRegisteredOperationField = (entry, registration, operation) => {
|
|
2644
|
+
const field = {
|
|
2645
|
+
type: registration.outputModel,
|
|
2646
|
+
description: registration.description,
|
|
2647
|
+
args: createArgsForRegisteredOperation(registration.inputModel),
|
|
2648
|
+
async resolve(parent, args, context) {
|
|
2649
|
+
const params = {
|
|
2650
|
+
args,
|
|
2651
|
+
operation,
|
|
2652
|
+
entry,
|
|
2653
|
+
context,
|
|
2654
|
+
};
|
|
2655
|
+
await executeMiddleware(params);
|
|
2656
|
+
return executeRegisteredOperation(args.input, registration, operation, context);
|
|
2657
|
+
},
|
|
2658
|
+
};
|
|
2659
|
+
if (operation === operations.CUSTOM_MUTATION) {
|
|
2660
|
+
field.extensions = { simfinityMutation: { operation } };
|
|
2661
|
+
}
|
|
2662
|
+
return field;
|
|
2663
|
+
};
|
|
2664
|
+
|
|
2287
2665
|
// Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
|
|
2288
2666
|
// a state-machine action, the action and its from/to states), so tools such as the MCP package can
|
|
2289
2667
|
// classify it without guessing from its name. Extensions do not appear in SDL or introspection.
|
|
@@ -2419,32 +2797,15 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2419
2797
|
|
|
2420
2798
|
for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
|
|
2421
2799
|
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
|
-
};
|
|
2800
|
+
rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
|
|
2801
|
+
operations.CUSTOM_MUTATION);
|
|
2441
2802
|
}
|
|
2442
2803
|
}
|
|
2443
2804
|
|
|
2444
2805
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2445
2806
|
};
|
|
2446
2807
|
|
|
2447
|
-
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
|
|
2808
|
+
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
|
|
2448
2809
|
const rootQueryArgs = {};
|
|
2449
2810
|
rootQueryArgs.name = name;
|
|
2450
2811
|
rootQueryArgs.fields = {};
|
|
@@ -2471,7 +2832,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2471
2832
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
|
|
2472
2833
|
async resolve(parent, args, context, info) {
|
|
2473
2834
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
|
|
2474
|
-
|
|
2835
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'find'));
|
|
2475
2836
|
const params = {
|
|
2476
2837
|
type,
|
|
2477
2838
|
args,
|
|
@@ -2481,8 +2842,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2481
2842
|
await executeMiddleware(params);
|
|
2482
2843
|
await executeScope(params);
|
|
2483
2844
|
await applyJoinedScopes(joinedScopes, params);
|
|
2484
|
-
|
|
2485
|
-
|
|
2845
|
+
const filling = settleClientArgs(type.gqltype, params, 'find', issued);
|
|
2846
|
+
if (filling) await filling;
|
|
2486
2847
|
const queryArgs = params.args;
|
|
2487
2848
|
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2488
2849
|
&& canReportCount(parent, info, context);
|
|
@@ -2519,7 +2880,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2519
2880
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
2520
2881
|
async resolve(parent, args, context) {
|
|
2521
2882
|
const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
|
|
2522
|
-
|
|
2883
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'aggregate'));
|
|
2523
2884
|
const params = {
|
|
2524
2885
|
type,
|
|
2525
2886
|
args,
|
|
@@ -2529,8 +2890,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2529
2890
|
await executeMiddleware(params);
|
|
2530
2891
|
await executeScope(params);
|
|
2531
2892
|
await applyJoinedScopes(joinedScopes, params);
|
|
2532
|
-
|
|
2533
|
-
|
|
2893
|
+
const filling = settleClientArgs(type.gqltype, params, 'aggregate', issued);
|
|
2894
|
+
if (filling) await filling;
|
|
2534
2895
|
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
2535
2896
|
},
|
|
2536
2897
|
};
|
|
@@ -2538,6 +2899,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2538
2899
|
}
|
|
2539
2900
|
}
|
|
2540
2901
|
|
|
2902
|
+
for (const [entry, registeredQuery] of Object.entries(registeredQueries)) {
|
|
2903
|
+
if (!shouldNotBeIncludedInSchema(includedCustomQueries, entry)) {
|
|
2904
|
+
rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredQuery,
|
|
2905
|
+
operations.CUSTOM_QUERY);
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2541
2909
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2542
2910
|
};
|
|
2543
2911
|
|
|
@@ -2813,22 +3181,66 @@ const warnUndeclaredVirtualFields = () => {
|
|
|
2813
3181
|
|
|
2814
3182
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
2815
3183
|
|
|
3184
|
+
const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
|
|
3185
|
+
.filter(([entry]) => !shouldNotBeIncludedInSchema(includedOperations, entry))
|
|
3186
|
+
.flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
|
|
3187
|
+
|
|
2816
3188
|
// 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) => [
|
|
3189
|
+
// endpoint types of its queries and mutations, and the input and output types of its custom operations.
|
|
3190
|
+
const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
|
|
2820
3191
|
...Object.values(typesDict.types)
|
|
2821
3192
|
.filter((type) => type.endpoint
|
|
2822
3193
|
&& ((type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
|
|
2823
3194
|
|| !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)))
|
|
2824
3195
|
.map((type) => type.gqltype),
|
|
2825
|
-
...
|
|
2826
|
-
|
|
2827
|
-
.flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean)),
|
|
3196
|
+
...includedOperationTypes(registeredMutations, includedCustomMutations),
|
|
3197
|
+
...includedOperationTypes(registeredQueries, includedCustomQueries),
|
|
2828
3198
|
];
|
|
2829
3199
|
|
|
2830
|
-
const
|
|
3200
|
+
const queryNameConflict = (name) => new SimfinityError(
|
|
3201
|
+
`Query ${name} is already registered or conflicts with a generated query in this schema`,
|
|
3202
|
+
'QUERY_NAME_CONFLICT', 500,
|
|
3203
|
+
);
|
|
3204
|
+
|
|
3205
|
+
const assertQueryNamesAvailable = (includedQueryTypes, includedCustomQueries) => {
|
|
3206
|
+
for (const type of Object.values(typesDict.types)) {
|
|
3207
|
+
if (!type.simpleEntityEndpointName || shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype)) continue;
|
|
3208
|
+
for (const name of [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]) {
|
|
3209
|
+
if (Object.hasOwn(registeredQueries, name) && !shouldNotBeIncludedInSchema(includedCustomQueries, name)) {
|
|
3210
|
+
throw queryNameConflict(name);
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
};
|
|
3215
|
+
|
|
3216
|
+
const invalidSchemaOptions = (detail) => new SimfinityError(
|
|
3217
|
+
`createSchema options ${detail}`, 'INVALID_SCHEMA_OPTIONS', 500,
|
|
3218
|
+
);
|
|
3219
|
+
|
|
3220
|
+
const SCHEMA_OPTION_KEYS = new Set(['includedCustomQueries']);
|
|
3221
|
+
|
|
3222
|
+
// The fourth createSchema() argument: omitted, or a plain object with known keys only.
|
|
3223
|
+
const readSchemaOptions = (options) => {
|
|
3224
|
+
if (options === undefined) return {};
|
|
3225
|
+
if (options === null || typeof options !== 'object' || !isPlainObject(options)) {
|
|
3226
|
+
throw invalidSchemaOptions('must be a plain object');
|
|
3227
|
+
}
|
|
3228
|
+
const unknown = Reflect.ownKeys(options).find((key) => !SCHEMA_OPTION_KEYS.has(key));
|
|
3229
|
+
if (unknown !== undefined) {
|
|
3230
|
+
throw invalidSchemaOptions(`has an unknown option ${String(unknown)}`);
|
|
3231
|
+
}
|
|
3232
|
+
const { includedCustomQueries } = options;
|
|
3233
|
+
if (includedCustomQueries != null && (!Array.isArray(includedCustomQueries)
|
|
3234
|
+
|| includedCustomQueries.some((name) => typeof name !== 'string'))) {
|
|
3235
|
+
throw invalidSchemaOptions('includedCustomQueries must be an array of strings, null or undefined');
|
|
3236
|
+
}
|
|
3237
|
+
return { includedCustomQueries };
|
|
3238
|
+
};
|
|
3239
|
+
|
|
3240
|
+
const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
|
|
2831
3241
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3242
|
+
const { includedCustomQueries } = readSchemaOptions(options);
|
|
3243
|
+
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
2832
3244
|
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
2833
3245
|
const schemaTypes = collectSchemaOutputTypes();
|
|
2834
3246
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
@@ -2841,7 +3253,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2841
3253
|
// only generated types or the fields of an input object reach is found when the schema is
|
|
2842
3254
|
// constructed.
|
|
2843
3255
|
assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
|
|
2844
|
-
includedCustomMutations));
|
|
3256
|
+
includedCustomMutations, includedCustomQueries));
|
|
2845
3257
|
const referencedTypes = markReferencedTypesForModelGeneration();
|
|
2846
3258
|
// Entities, which keep their own stored identity: connect() types and relation targets.
|
|
2847
3259
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
@@ -2889,7 +3301,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2889
3301
|
const objectMemberTypes = new Set();
|
|
2890
3302
|
Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
|
|
2891
3303
|
|
|
2892
|
-
let query = buildRootQuery('RootQueryType', includedQueryTypes);
|
|
3304
|
+
let query = buildRootQuery('RootQueryType', includedQueryTypes, undefined, includedCustomQueries);
|
|
2893
3305
|
Object.values(typesDict.types).forEach(({ gqltype }) => {
|
|
2894
3306
|
if (gqltype && !applicationResolvedFields.has(gqltype)) {
|
|
2895
3307
|
applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
|
|
@@ -2900,12 +3312,13 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
2900
3312
|
// other type named JSON still collides, and GraphQLSchema reports the duplicate name.
|
|
2901
3313
|
const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
|
|
2902
3314
|
if (applicationJSON instanceof GraphQLScalarType) {
|
|
2903
|
-
query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON)
|
|
3315
|
+
query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
|
|
3316
|
+
includedCustomQueries);
|
|
2904
3317
|
}
|
|
2905
3318
|
|
|
2906
3319
|
const schema = constructSchema(query, mutation);
|
|
2907
3320
|
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
2908
|
-
// custom
|
|
3321
|
+
// custom operation result, so another runtime cannot later generate resolvers this schema executes.
|
|
2909
3322
|
schemaTypes.forEach((type) => {
|
|
2910
3323
|
if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
|
|
2911
3324
|
runtimeBoundTypes.set(type, runtimeIdentity);
|
|
@@ -2935,6 +3348,43 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
|
|
|
2935
3348
|
};
|
|
2936
3349
|
};
|
|
2937
3350
|
|
|
3351
|
+
const invalidQueryRegistration = (detail) => new SimfinityError(
|
|
3352
|
+
`registerCustomQuery ${detail}`, 'INVALID_QUERY_REGISTRATION', 500,
|
|
3353
|
+
);
|
|
3354
|
+
|
|
3355
|
+
const validateQueryRegistration = (name, registration) => {
|
|
3356
|
+
const { description, inputModel, outputModel, callback } = registration;
|
|
3357
|
+
if (typeof name !== 'string' || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) || name.startsWith('__')) {
|
|
3358
|
+
throw invalidQueryRegistration('requires a valid GraphQL field name that does not start with __');
|
|
3359
|
+
}
|
|
3360
|
+
if (description != null && typeof description !== 'string') {
|
|
3361
|
+
throw invalidQueryRegistration('description must be a string');
|
|
3362
|
+
}
|
|
3363
|
+
if (inputModel != null && !(inputModel instanceof GraphQLInputObjectType)) {
|
|
3364
|
+
throw invalidQueryRegistration('input must be a GraphQLInputObjectType, null or undefined');
|
|
3365
|
+
}
|
|
3366
|
+
if (!isOutputType(outputModel)) {
|
|
3367
|
+
throw invalidQueryRegistration('output must be a GraphQL output type');
|
|
3368
|
+
}
|
|
3369
|
+
if (typeof callback !== 'function') {
|
|
3370
|
+
throw invalidQueryRegistration('callback must be a function');
|
|
3371
|
+
}
|
|
3372
|
+
};
|
|
3373
|
+
|
|
3374
|
+
const registerCustomQuery = (name, description, inputModel, outputModel, callback) => {
|
|
3375
|
+
const registration = {
|
|
3376
|
+
description,
|
|
3377
|
+
inputModel,
|
|
3378
|
+
outputModel,
|
|
3379
|
+
callback,
|
|
3380
|
+
};
|
|
3381
|
+
validateQueryRegistration(name, registration);
|
|
3382
|
+
if (Object.hasOwn(registeredQueries, name)) {
|
|
3383
|
+
throw queryNameConflict(name);
|
|
3384
|
+
}
|
|
3385
|
+
registeredQueries[name] = registration;
|
|
3386
|
+
};
|
|
3387
|
+
|
|
2938
3388
|
// Gives the field a new extensions object as well as the resolver, so a copy made before keeps
|
|
2939
3389
|
// untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
|
|
2940
3390
|
// copying it, when a type is rebuilt from toConfig().
|
|
@@ -2980,13 +3430,13 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
2980
3430
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
2981
3431
|
}
|
|
2982
3432
|
const joinedScopes = inspectClientPaths(relatedType, args, 'find');
|
|
2983
|
-
|
|
3433
|
+
const issued = markClientConstructs(assertQueryConditions(relatedType, args, 'find'));
|
|
2984
3434
|
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
2985
3435
|
await executeMiddleware(params);
|
|
2986
3436
|
await executeScope(params);
|
|
2987
3437
|
await applyJoinedScopes(joinedScopes, params);
|
|
2988
|
-
|
|
2989
|
-
|
|
3438
|
+
const filling = settleClientArgs(relatedType, params, 'find', issued);
|
|
3439
|
+
if (filling) await filling;
|
|
2990
3440
|
return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
|
|
2991
3441
|
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
2992
3442
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
@@ -3171,6 +3621,7 @@ function formatArgs(argsArray) {
|
|
|
3171
3621
|
getRegistrations,
|
|
3172
3622
|
use,
|
|
3173
3623
|
registerMutation,
|
|
3624
|
+
registerCustomQuery,
|
|
3174
3625
|
saveObject,
|
|
3175
3626
|
preventCreatingCollection,
|
|
3176
3627
|
};
|