@simtlix/simfinity-core 3.10.0 → 3.12.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/query-emptiness.js +109 -34
- package/src/query-limits.js +14 -2
- package/src/query-operators.js +15 -10
- package/src/query-plan.js +103 -39
- package/src/runtime.js +553 -88
- package/types/index.d.ts +136 -14
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, isObjectType, isOutputType,
|
|
9
|
+
Kind, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType, isScalarType,
|
|
8
10
|
} from 'graphql';
|
|
9
11
|
|
|
10
12
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -17,7 +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';
|
|
20
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
assertEmptinessPath, describeEmptiness, emptinessScopePaths, emptinessScopeTargets, isEmptinessOperator,
|
|
24
|
+
} from './query-emptiness.js';
|
|
21
25
|
import { relationFieldOwners } from './relation-owners.js';
|
|
22
26
|
import { isThenable } from './auth/thenable.js';
|
|
23
27
|
import {
|
|
@@ -468,8 +472,12 @@ export const createRuntime = (adapter) => {
|
|
|
468
472
|
throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
|
|
469
473
|
}
|
|
470
474
|
// The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
|
|
471
|
-
// operators when it declares none.
|
|
472
|
-
|
|
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);
|
|
473
481
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
474
482
|
|
|
475
483
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
@@ -487,6 +495,7 @@ export const createRuntime = (adapter) => {
|
|
|
487
495
|
const qualifiedSelfCollections = new Map();
|
|
488
496
|
const registeredMutations = {};
|
|
489
497
|
const registeredQueries = {};
|
|
498
|
+
const registeredViews = {};
|
|
490
499
|
const middlewares = [];
|
|
491
500
|
|
|
492
501
|
const operations = {
|
|
@@ -1750,41 +1759,81 @@ const executeScope = async (params) => {
|
|
|
1750
1759
|
return scopeFunction({ type, args, operation, context });
|
|
1751
1760
|
};
|
|
1752
1761
|
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1762
|
+
// The arguments of a list, aggregate or collection field call that are not filters. On aggregate
|
|
1763
|
+
// endpoints `aggregation` is one; elsewhere a field of that name is filtered like any other.
|
|
1764
|
+
const LIST_CONTROL_ARGS = new Set(['sort', 'pagination']);
|
|
1765
|
+
const AGGREGATE_CONTROL_ARGS = new Set(['sort', 'pagination', 'aggregation']);
|
|
1766
|
+
const isFilterObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1767
|
+
// A new object with the same prototype and own enumerable properties: graphql-js builds inline literals
|
|
1768
|
+
// with a null prototype and variables with Object's, and childArgsKey tells them apart.
|
|
1769
|
+
const copyFilterObject = (value) => Object.assign(Object.create(Object.getPrototypeOf(value)), value);
|
|
1770
|
+
// A term, a group condition or a field filter: a value array becomes a new Array with the same items.
|
|
1771
|
+
const copyFilterCondition = (condition) => {
|
|
1772
|
+
if (!isFilterObject(condition)) return condition;
|
|
1773
|
+
const copy = copyFilterObject(condition);
|
|
1774
|
+
if (Array.isArray(condition.value)) copy.value = [...condition.value];
|
|
1775
|
+
return copy;
|
|
1776
|
+
};
|
|
1777
|
+
const copyFilterGroup = (group) => {
|
|
1778
|
+
if (!isFilterObject(group)) return group;
|
|
1779
|
+
const copy = copyFilterObject(group);
|
|
1780
|
+
if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
|
|
1781
|
+
if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
|
|
1782
|
+
if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
|
|
1761
1783
|
return copy;
|
|
1762
|
-
}
|
|
1784
|
+
};
|
|
1785
|
+
const copyFieldFilter = (filter) => {
|
|
1786
|
+
const copy = copyFilterCondition(filter);
|
|
1787
|
+
if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
|
|
1788
|
+
return copy;
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
// A tree copy of the query-language structure of one call's filter arguments, onto that call's args:
|
|
1792
|
+
// field filters and their terms, AND/OR groups and their conditions. Every place gets its own object,
|
|
1793
|
+
// also where graphql-js hands one variable object to several arguments or list items, so that a
|
|
1794
|
+
// middleware edit or a client scope marker stays within the call. Value arrays are copied shallowly,
|
|
1795
|
+
// and every other value, sort, pagination and aggregation included, is kept as it is. GraphQL input
|
|
1796
|
+
// has no cycles, so nothing is memoized.
|
|
1797
|
+
const copyClientFilters = (args, operation) => {
|
|
1798
|
+
const controls = operation === 'aggregate' ? AGGREGATE_CONTROL_ARGS : LIST_CONTROL_ARGS;
|
|
1799
|
+
for (const [name, value] of Object.entries(args)) {
|
|
1800
|
+
if (name === 'AND' || name === 'OR') {
|
|
1801
|
+
if (Array.isArray(value)) args[name] = value.map(copyFilterGroup);
|
|
1802
|
+
} else if (!controls.has(name)) args[name] = copyFieldFilter(value);
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1763
1805
|
|
|
1764
1806
|
// Checks the filter, sort and aggregation paths a client supplied, before middleware and scopes
|
|
1765
1807
|
// add trusted paths. Returns the scoped relations those paths enter, keyed by path, with the
|
|
1766
1808
|
// client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
|
|
1767
1809
|
// graphql-js passes the same variable values to every resolver call of a request, so the client's
|
|
1768
|
-
//
|
|
1810
|
+
// filters are first copied onto this call's args (copyClientFilters), and joined scopes are only
|
|
1811
|
+
// placed in those copies.
|
|
1769
1812
|
// An emptiness condition on an embedded object reads its stored members, so their paths are checked
|
|
1770
1813
|
// as client paths too (#108 part 2a); a member path only gets the 403 checks, and enters no joined
|
|
1771
|
-
// scope.
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1814
|
+
// scope. When the adapter compiles reference emptiness (#108 part 2b), a client's emptiness term or
|
|
1815
|
+
// condition reads its references and collections through scoped joins and frames instead, so it
|
|
1816
|
+
// enters no joined scope either; every other entry, beside it or on the same path, records its own.
|
|
1817
|
+
// Without `checkQueryable`, for a view's `where`, whose paths are the author's as a scope's are, a
|
|
1818
|
+
// non-queryable field is not rejected; a virtual one still is.
|
|
1819
|
+
const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
|
|
1820
|
+
copyClientFilters(args, operation);
|
|
1776
1821
|
const joinedScopes = new Map();
|
|
1777
|
-
|
|
1822
|
+
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
1823
|
+
for (const {
|
|
1824
|
+
segments, group, member, operator,
|
|
1825
|
+
} of entries) {
|
|
1826
|
+
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator));
|
|
1778
1827
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1779
1828
|
const path = segments.slice(0, index + 1).join('.');
|
|
1780
1829
|
if (isVirtualField(field)) {
|
|
1781
1830
|
throw forbiddenPath(`Query path ${path} uses the virtual field ${type.name}.${fieldName}, which is not stored`);
|
|
1782
1831
|
}
|
|
1783
1832
|
const queryable = field.extensions?.queryable;
|
|
1784
|
-
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
1833
|
+
if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
|
|
1785
1834
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1786
1835
|
}
|
|
1787
|
-
if (
|
|
1836
|
+
if (!joins) return;
|
|
1788
1837
|
const relation = field.extensions?.relation;
|
|
1789
1838
|
if (!relation || relation.embedded) return;
|
|
1790
1839
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
@@ -1870,18 +1919,28 @@ const assertEmptinessForm = (gqltype, {
|
|
|
1870
1919
|
// Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
|
|
1871
1920
|
// UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile,
|
|
1872
1921
|
// and applies to every IS_EMPTY or IS_NOT_EMPTY condition the order of checks of #108 part 2a,
|
|
1873
|
-
// whatever the adapter declares
|
|
1874
|
-
// arguments before
|
|
1875
|
-
//
|
|
1876
|
-
//
|
|
1877
|
-
// adapter, which reports them as before.
|
|
1922
|
+
// whatever the adapter declares, rule 6 only when it does not declare reference emptiness. Runs on
|
|
1923
|
+
// the client's arguments before middleware and on the final arguments before the adapter call,
|
|
1924
|
+
// which covers what middleware, scopes and joined scopes added, so that an emptiness condition gets
|
|
1925
|
+
// the code that a direct createQueryPlan call gives, whoever wrote it and whatever the caller may
|
|
1926
|
+
// read. Operators that QLOperator does not list are left to the adapter, which reports them as before.
|
|
1927
|
+
// Returns every condition as `{ entry, shape }`: its collectQueryOperators entry, and the shape of an
|
|
1928
|
+
// emptiness condition that the backends read as one, or null.
|
|
1878
1929
|
const assertQueryConditions = (gqltype, args, operation) => {
|
|
1930
|
+
const checked = [];
|
|
1879
1931
|
for (const entry of collectQueryOperators(args, operation)) {
|
|
1880
1932
|
const { operator, path } = entry;
|
|
1933
|
+
let shape = null;
|
|
1881
1934
|
if (isEmptinessOperator(operator)) {
|
|
1882
|
-
if (assertEmptinessForm(gqltype, entry))
|
|
1935
|
+
if (assertEmptinessForm(gqltype, entry)) {
|
|
1936
|
+
shape = describeEmptiness(gqltype, entry.segments, {
|
|
1937
|
+
operator, value: entry.value, path, referenceEmptiness: supportsReferenceEmptiness,
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1883
1940
|
} else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
|
|
1941
|
+
checked.push({ entry, shape });
|
|
1884
1942
|
}
|
|
1943
|
+
return checked;
|
|
1885
1944
|
};
|
|
1886
1945
|
|
|
1887
1946
|
// Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
|
|
@@ -1894,6 +1953,8 @@ const assertQueryConditions = (gqltype, args, operation) => {
|
|
|
1894
1953
|
// adds more than a reference crossing. After its form and operator, each prefixed condition is checked
|
|
1895
1954
|
// on `rootType`, the queried type, where rule 3, which comes before rules 4 to 6, rejects it with the
|
|
1896
1955
|
// permanent INVALID_FILTER_PATH that the final gate and createQueryPlan give it wherever it is written.
|
|
1956
|
+
// An adapter that compiles reference emptiness lifts decision 3 (#108 part 2b): after the same checks,
|
|
1957
|
+
// the prefixed conditions are applied as trusted ones, which read stored data.
|
|
1897
1958
|
const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
|
|
1898
1959
|
const { gqltype } = target;
|
|
1899
1960
|
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
|
|
@@ -1909,17 +1970,21 @@ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target
|
|
|
1909
1970
|
if (!(error instanceof SimfinityError) || error.getCode() !== 'UNSUPPORTED_QUERY') throw error;
|
|
1910
1971
|
}
|
|
1911
1972
|
};
|
|
1973
|
+
const referenceEmptiness = supportsReferenceEmptiness;
|
|
1912
1974
|
for (const entry of entries) {
|
|
1913
1975
|
if (!assertEmptinessForm(gqltype, entry)) continue;
|
|
1914
1976
|
const { operator, value } = entry;
|
|
1915
|
-
if (throughList) exceptRule6(() => describeEmptiness(rootType, `${prefix}.${entry.path}`, { operator, value }));
|
|
1916
|
-
exceptRule6(() => describeEmptiness(gqltype, entry.segments, {
|
|
1977
|
+
if (throughList) exceptRule6(() => describeEmptiness(rootType, `${prefix}.${entry.path}`, { operator, value, referenceEmptiness }));
|
|
1978
|
+
exceptRule6(() => describeEmptiness(gqltype, entry.segments, {
|
|
1979
|
+
operator, value, path: entry.path, referenceEmptiness,
|
|
1980
|
+
}));
|
|
1917
1981
|
}
|
|
1982
|
+
if (referenceEmptiness) return;
|
|
1918
1983
|
const prefixed = collectQueryOperators({ AND: groups }).find(({ operator }) => isEmptinessOperator(operator))
|
|
1919
1984
|
?? { operator: entries[0].operator, path: `${prefix}.${entries[0].path}` };
|
|
1920
1985
|
throw new SimfinityError(`Query path ${prefix} enters ${gqltype.name}, whose find scope tests ${prefixed.path} with `
|
|
1921
|
-
+ `${prefixed.operator}: emptiness through references and collections
|
|
1922
|
-
+ `
|
|
1986
|
+
+ `${prefixed.operator}: the database adapter does not support emptiness through references and collections; `
|
|
1987
|
+
+ `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
|
|
1923
1988
|
};
|
|
1924
1989
|
|
|
1925
1990
|
// A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
|
|
@@ -1989,6 +2054,184 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
1989
2054
|
}
|
|
1990
2055
|
};
|
|
1991
2056
|
|
|
2057
|
+
// Client scope markers (#108 part 2b). When the adapter compiles reference emptiness, each IS_EMPTY or
|
|
2058
|
+
// IS_NOT_EMPTY term or group condition that a client sends and that crosses or ends at a reference or a
|
|
2059
|
+
// collection, or reads one as a member, gets `__scope: { id }` on this call's copy, before middleware.
|
|
2060
|
+
// After middleware, the query's own scope and joined scopes, the runtime checks that every marker it
|
|
2061
|
+
// issued is still there and that no other one is, gates the final arguments, then fills each marker
|
|
2062
|
+
// with the raw find scope arguments of every type the construct reads through, `__scope: { id, scopes:
|
|
2063
|
+
// { [path]: args | null } }`, which the compilers apply as scoped joins and frames, so that a record
|
|
2064
|
+
// those scopes hide reads as missing. Constructs without a marker are trusted and read stored data.
|
|
2065
|
+
// Markers live only on the terms and group conditions collectQueryOperators returns; a `__scope`
|
|
2066
|
+
// anywhere else, inside a value for one, is never read.
|
|
2067
|
+
const SCOPE_MARKER = '__scope';
|
|
2068
|
+
const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
|
|
2069
|
+
+ 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
|
|
2070
|
+
const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
|
|
2071
|
+
const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
|
|
2072
|
+
|
|
2073
|
+
// Marks the client's constructs that read references or collections, from the gate's conditions on the
|
|
2074
|
+
// client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
|
|
2075
|
+
// scoped joins and frames in these conditions, so every 2a code comes first. Returns the issued
|
|
2076
|
+
// markers, as id -> the client's operator and path, or null when the adapter does not compile
|
|
2077
|
+
// reference emptiness. Ids are ordinals in the order of the conditions, so identical arguments get
|
|
2078
|
+
// identical markers and sibling collection reads keep one batch key. With `issued`, the markers of
|
|
2079
|
+
// the call so far, the ids continue on that map, so that a view's `where`, marked after the client's
|
|
2080
|
+
// arguments, shares them; the limit still applies to each set of conditions on its own.
|
|
2081
|
+
const markClientConstructs = (checked, issued) => {
|
|
2082
|
+
if (!supportsReferenceEmptiness) return null;
|
|
2083
|
+
const markers = issued ?? new Map();
|
|
2084
|
+
const pairs = new Set();
|
|
2085
|
+
for (const { entry, shape } of checked) {
|
|
2086
|
+
const { construct } = entry;
|
|
2087
|
+
if (!shape || !construct || hasScopeMarker(construct)) continue;
|
|
2088
|
+
const { joins, frames } = emptinessScopePaths(shape, entry.segments);
|
|
2089
|
+
if (!joins.length && !frames.length) continue;
|
|
2090
|
+
const id = markers.size;
|
|
2091
|
+
construct[SCOPE_MARKER] = { id };
|
|
2092
|
+
markers.set(id, { operator: entry.operator, path: entry.path });
|
|
2093
|
+
for (const path of joins) pairs.add(`join ${path}`);
|
|
2094
|
+
for (const path of frames) pairs.add(`frame ${path}`);
|
|
2095
|
+
}
|
|
2096
|
+
const limit = getQueryMaxReferenceFrames();
|
|
2097
|
+
if (pairs.size > limit) {
|
|
2098
|
+
throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
|
|
2099
|
+
+ 'frames in one set of conditions (the client\'s arguments of a query field, or a view\'s where), more than the limit '
|
|
2100
|
+
+ `of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2101
|
+
}
|
|
2102
|
+
return markers;
|
|
2103
|
+
};
|
|
2104
|
+
|
|
2105
|
+
// The detach check: every marker issued in this call sits, well formed (an object whose only own key
|
|
2106
|
+
// is its id), on a term or group condition of the final arguments, possibly on several when middleware
|
|
2107
|
+
// copied or duplicated the construct; no other marker sits on one.
|
|
2108
|
+
const assertClientMarkers = (args, operation, issued) => {
|
|
2109
|
+
const found = new Set();
|
|
2110
|
+
for (const { construct, operator, path } of collectQueryOperators(args, operation)) {
|
|
2111
|
+
if (!hasScopeMarker(construct)) continue;
|
|
2112
|
+
const marker = construct[SCOPE_MARKER];
|
|
2113
|
+
const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
|
|
2114
|
+
if (!isFilterObject(marker) || Reflect.ownKeys(marker).length !== 1 || !Object.hasOwn(marker, 'id')) {
|
|
2115
|
+
const named = origin ? `, the client's ${origin.operator} condition on ${origin.path},` : '';
|
|
2116
|
+
throw detachedClientFilter(`The ${operator} condition on ${path}${named} carries a malformed client scope marker`);
|
|
2117
|
+
}
|
|
2118
|
+
if (!origin) {
|
|
2119
|
+
throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
|
|
2120
|
+
}
|
|
2121
|
+
found.add(marker.id);
|
|
2122
|
+
}
|
|
2123
|
+
for (const [id, origin] of issued) {
|
|
2124
|
+
if (!found.has(id)) {
|
|
2125
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is no longer in the arguments `
|
|
2126
|
+
+ 'after middleware and scopes');
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
};
|
|
2130
|
+
|
|
2131
|
+
// The first reference or collection a path crosses or ends at, as its dotted path, or null.
|
|
2132
|
+
const relationOnPath = (gqltype, segments) => {
|
|
2133
|
+
let found = null;
|
|
2134
|
+
walkQueryPath(gqltype, segments, ({ field, index }) => {
|
|
2135
|
+
const relation = field.extensions?.relation;
|
|
2136
|
+
if (!found && relation && !relation.embedded) found = segments.slice(0, index + 1).join('.');
|
|
2137
|
+
});
|
|
2138
|
+
return found;
|
|
2139
|
+
};
|
|
2140
|
+
|
|
2141
|
+
// The find scope arguments of a type that a client's constructs read through, once per call and type
|
|
2142
|
+
// (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
|
|
2143
|
+
// joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
|
|
2144
|
+
// gate on the type, where everything is trusted. Null when the type has no find scope or the scope
|
|
2145
|
+
// added nothing.
|
|
2146
|
+
const clientScopeArgs = async (typeName, context, cache) => {
|
|
2147
|
+
if (cache.has(typeName)) return cache.get(typeName);
|
|
2148
|
+
const target = typesDict.types[typeName];
|
|
2149
|
+
let result = null;
|
|
2150
|
+
if (getScope(target?.gqltype, 'find')) {
|
|
2151
|
+
const scopeArgs = {};
|
|
2152
|
+
await executeScope({ type: target, args: scopeArgs, operation: 'find', context });
|
|
2153
|
+
const kept = {};
|
|
2154
|
+
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2155
|
+
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2156
|
+
else if (value != null) assertScopeControlArg(name, value);
|
|
2157
|
+
}
|
|
2158
|
+
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2159
|
+
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2160
|
+
if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
|
|
2161
|
+
}
|
|
2162
|
+
cache.set(typeName, result);
|
|
2163
|
+
return result;
|
|
2164
|
+
};
|
|
2165
|
+
|
|
2166
|
+
// The fill, on the final arguments the gate passed (`checked`). Each marked object is read at each of
|
|
2167
|
+
// its final positions, with its final operator and path:
|
|
2168
|
+
// - an emptiness condition that crosses, ends at or has as members references or collections gets a
|
|
2169
|
+
// fresh `__scope: { id, scopes }`, one key per scoped join and frame path, with the union of the
|
|
2170
|
+
// keys of every position when middleware placed the object at several;
|
|
2171
|
+
// - an emptiness condition narrowed to the record's own fields loses its marker, and is trusted;
|
|
2172
|
+
// - another operator loses it too, unless its path still crosses or ends at a reference or a
|
|
2173
|
+
// collection: no joined scope was recorded for it, so it fails with DETACHED_CLIENT_FILTER.
|
|
2174
|
+
// An object that reads references at one position and none at another fails the same way, since one
|
|
2175
|
+
// marker cannot serve both. Every check comes before any target scope runs.
|
|
2176
|
+
const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
2177
|
+
const marked = new Map();
|
|
2178
|
+
for (const item of checked) {
|
|
2179
|
+
const { construct } = item.entry;
|
|
2180
|
+
if (!hasScopeMarker(construct)) continue;
|
|
2181
|
+
if (!marked.has(construct)) marked.set(construct, []);
|
|
2182
|
+
marked.get(construct).push(item);
|
|
2183
|
+
}
|
|
2184
|
+
const fills = [];
|
|
2185
|
+
for (const [construct, positions] of marked) {
|
|
2186
|
+
const { id } = construct[SCOPE_MARKER];
|
|
2187
|
+
const origin = issued.get(id);
|
|
2188
|
+
const targets = {};
|
|
2189
|
+
const unread = [];
|
|
2190
|
+
for (const { entry, shape } of positions) {
|
|
2191
|
+
if (!entry.emptiness) {
|
|
2192
|
+
const relation = relationOnPath(gqltype, entry.segments);
|
|
2193
|
+
if (relation) {
|
|
2194
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2195
|
+
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
2196
|
+
}
|
|
2197
|
+
unread.push(entry.path);
|
|
2198
|
+
} else {
|
|
2199
|
+
const keys = shape ? emptinessScopeTargets(shape, entry.segments) : {};
|
|
2200
|
+
if (Object.keys(keys).length) Object.assign(targets, keys);
|
|
2201
|
+
else unread.push(entry.path);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
const paths = Object.keys(targets);
|
|
2205
|
+
if (paths.length && unread.length) {
|
|
2206
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
|
|
2207
|
+
+ `references or collections and where it reads none (${unread.join(', ')})`);
|
|
2208
|
+
}
|
|
2209
|
+
fills.push({ construct, id, targets: paths.length ? targets : null });
|
|
2210
|
+
}
|
|
2211
|
+
const cache = new Map();
|
|
2212
|
+
for (const { construct, id, targets } of fills) {
|
|
2213
|
+
if (!targets) {
|
|
2214
|
+
delete construct[SCOPE_MARKER];
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
const scopes = {};
|
|
2218
|
+
for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
|
|
2219
|
+
construct[SCOPE_MARKER] = { id, scopes };
|
|
2220
|
+
}
|
|
2221
|
+
};
|
|
2222
|
+
|
|
2223
|
+
// After middleware, the query's own scope and joined scopes, and before the adapter call: the detach
|
|
2224
|
+
// check, the final gate (stored paths, then every condition), then the fill. Markers exist only when
|
|
2225
|
+
// the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
|
|
2226
|
+
// Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
|
|
2227
|
+
// the adapter after as many promise jobs as before.
|
|
2228
|
+
const settleClientArgs = (gqltype, params, operation, issued) => {
|
|
2229
|
+
if (issued) assertClientMarkers(params.args, operation, issued);
|
|
2230
|
+
assertStoredQueryPaths(gqltype, params.args, operation);
|
|
2231
|
+
const checked = assertQueryConditions(gqltype, params.args, operation);
|
|
2232
|
+
return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
|
|
2233
|
+
};
|
|
2234
|
+
|
|
1992
2235
|
// Unscoped relation reads of one request, grouped by related type until the promise jobs queued so
|
|
1993
2236
|
// far have run, and keyed by cast ID. Nothing is cached: each batch is read once and dropped.
|
|
1994
2237
|
const referenceBatches = new WeakMap();
|
|
@@ -2568,6 +2811,170 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2568
2811
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2569
2812
|
};
|
|
2570
2813
|
|
|
2814
|
+
// The prefix every generated read shares, for the list and aggregate roots, the collection fields and
|
|
2815
|
+
// the views: the client's paths and conditions are inspected and marked, middleware runs, with `entry`
|
|
2816
|
+
// when one is given, `extend` adds the caller's own constructs and returns their joined scopes, the
|
|
2817
|
+
// type's scope runs, the client's joined scopes and then the extension's are applied, and the final
|
|
2818
|
+
// arguments are settled. Returns the params, whose args the adapter reads. An empty map of the
|
|
2819
|
+
// client's joined scopes is not awaited, so that, with the caller's await of this function, a read
|
|
2820
|
+
// without one still reaches the adapter after as many promise jobs as before it was shared.
|
|
2821
|
+
const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
|
|
2822
|
+
const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
|
|
2823
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
|
|
2824
|
+
const params = entry === undefined
|
|
2825
|
+
? { type, args, operation, context }
|
|
2826
|
+
: { type, args, operation, entry, context };
|
|
2827
|
+
await executeMiddleware(params);
|
|
2828
|
+
const extended = extend ? await extend(params, issued) : null;
|
|
2829
|
+
await executeScope(params);
|
|
2830
|
+
if (joinedScopes.size) await applyJoinedScopes(joinedScopes, params);
|
|
2831
|
+
if (extended) await applyJoinedScopes(extended, params);
|
|
2832
|
+
const filling = settleClientArgs(type.gqltype, params, operation, issued);
|
|
2833
|
+
if (filling) await filling;
|
|
2834
|
+
return params;
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
// The tail of a list read, shared by the generated list root and the views: the rows and, when the
|
|
2838
|
+
// client asks for pagination.count and something can receive it, the count, reported through the sink.
|
|
2839
|
+
const readList = async (type, params, parent, info, context) => {
|
|
2840
|
+
const queryArgs = params.args;
|
|
2841
|
+
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2842
|
+
&& canReportCount(parent, info, context);
|
|
2843
|
+
|
|
2844
|
+
const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
|
|
2845
|
+
const countPromise = wantsCount
|
|
2846
|
+
? adapter.count(type.model, type.gqltype, queryArgs, null)
|
|
2847
|
+
: null;
|
|
2848
|
+
|
|
2849
|
+
const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
|
|
2850
|
+
if (wantsCount && !reportCount(parent, info, context, resultCount)) {
|
|
2851
|
+
warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
|
|
2852
|
+
+ 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
|
|
2853
|
+
+ 'reported; pass a fresh mutable context object for each request.');
|
|
2854
|
+
}
|
|
2855
|
+
return result;
|
|
2856
|
+
};
|
|
2857
|
+
|
|
2858
|
+
// Separates a view's params, named by `paramNames`, from the rest of its arguments, which are exactly
|
|
2859
|
+
// a generated list's.
|
|
2860
|
+
const splitViewArgs = (paramNames, args) => {
|
|
2861
|
+
const params = {};
|
|
2862
|
+
const rest = {};
|
|
2863
|
+
for (const [name, value] of Object.entries(args)) {
|
|
2864
|
+
if (paramNames.has(name)) params[name] = value;
|
|
2865
|
+
else rest[name] = value;
|
|
2866
|
+
}
|
|
2867
|
+
return { params, args: rest };
|
|
2868
|
+
};
|
|
2869
|
+
|
|
2870
|
+
const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
|
|
2871
|
+
|
|
2872
|
+
// The groups a view's `where` returned are plain objects whose `conditions`, `AND` and `OR`, when set,
|
|
2873
|
+
// are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
|
|
2874
|
+
// skips anything else silently, so this comes before their paths are read. Only the GraphQL input
|
|
2875
|
+
// fields are structural keys; condition values stay opaque. A condition never carries a client scope
|
|
2876
|
+
// marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
|
|
2877
|
+
const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
2878
|
+
const groupFields = QLFilterGroup.getFields();
|
|
2879
|
+
const conditionFields = QLFilterCondition.getFields();
|
|
2880
|
+
for (const group of groups) {
|
|
2881
|
+
if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
|
|
2882
|
+
if (ancestors.has(group)) throw invalidWhere(view, 'returned a cyclic group');
|
|
2883
|
+
for (const key of Object.keys(group)) {
|
|
2884
|
+
if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
|
|
2885
|
+
}
|
|
2886
|
+
for (const key of Object.keys(groupFields)) {
|
|
2887
|
+
if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
|
|
2888
|
+
}
|
|
2889
|
+
for (const condition of group.conditions ?? []) {
|
|
2890
|
+
if (!isNonNullPlainObject(condition) || typeof condition.field !== 'string' || condition.field === '') {
|
|
2891
|
+
throw invalidWhere(view, 'returned a condition that is not a plain object with a field name');
|
|
2892
|
+
}
|
|
2893
|
+
if (hasScopeMarker(condition)) {
|
|
2894
|
+
throw invalidWhere(view, `condition on ${condition.field} carries a ${SCOPE_MARKER} key, which Simfinity reserves`);
|
|
2895
|
+
}
|
|
2896
|
+
for (const key of Object.keys(condition)) {
|
|
2897
|
+
if (!Object.hasOwn(conditionFields, key)) throw invalidWhere(view, `condition on ${condition.field} has unknown key ${key}`);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
ancestors.add(group);
|
|
2901
|
+
assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
|
|
2902
|
+
ancestors.delete(group);
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
|
|
2906
|
+
// A `where` that does not return an array of groups, or names a field its type does not have, a path
|
|
2907
|
+
// past a field without fields, or a virtual field, is the author's bug: INVALID_VIEW (500), never the
|
|
2908
|
+
// FORBIDDEN_FILTER_PATH (403) that inspectClientPaths gives a client for a virtual field. Each path is
|
|
2909
|
+
// walked here, since walkQueryPath stops silently at an unknown segment.
|
|
2910
|
+
const assertViewGroups = (view, groups) => {
|
|
2911
|
+
if (!Array.isArray(groups)) throw invalidWhere(view, 'must return an array of AND groups');
|
|
2912
|
+
assertViewGroupShapes(view, groups);
|
|
2913
|
+
for (const { segments } of collectQueryPathEntries({ AND: groups }, 'find', { gqltype: view.type })) {
|
|
2914
|
+
let type = view.type;
|
|
2915
|
+
let parent = null;
|
|
2916
|
+
for (const [index, segment] of segments.entries()) {
|
|
2917
|
+
const names = (detail) => invalidWhere(view, `names ${segments.slice(0, index + 1).join('.')}, which is ${detail}`);
|
|
2918
|
+
if (typeof type.getFields !== 'function') throw names(`past ${parent}, whose type ${type.name} has no fields`);
|
|
2919
|
+
const fields = type.getFields();
|
|
2920
|
+
if (!Object.hasOwn(fields, segment)) throw names(`not a field of ${type.name}`);
|
|
2921
|
+
if (isVirtualField(fields[segment])) throw names(`the virtual field ${type.name}.${segment}`);
|
|
2922
|
+
parent = `${type.name}.${segment}`;
|
|
2923
|
+
type = unwrapListAndNonNull(fields[segment].type);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
};
|
|
2927
|
+
|
|
2928
|
+
// The `extend` of a view's read (see prepareRead). After middleware, the groups of `where` are read as
|
|
2929
|
+
// the client's constructs on a probe, whose AND holds copies of them (inspectClientPaths), so the
|
|
2930
|
+
// author's objects are never touched: their paths are inspected, their conditions checked and marked,
|
|
2931
|
+
// with ids that continue the client's, then the copies are appended to the top-level AND, where no
|
|
2932
|
+
// middleware can drop them. Returns their joined scopes, which attach to the appended copies. Their
|
|
2933
|
+
// paths are the author's, as a scope's are, so a non-queryable field is not rejected.
|
|
2934
|
+
const viewExtension = (view, viewParams) => async (params, issued) => {
|
|
2935
|
+
const groups = await view.where({ params: viewParams, context: params.context });
|
|
2936
|
+
assertViewGroups(view, groups);
|
|
2937
|
+
const probe = { AND: groups };
|
|
2938
|
+
const scopes = inspectClientPaths(view.type, probe, 'find', { checkQueryable: false });
|
|
2939
|
+
const checked = assertQueryConditions(view.type, probe, 'find');
|
|
2940
|
+
markClientConstructs(checked, issued);
|
|
2941
|
+
appendAndGroups(params.args, probe.AND);
|
|
2942
|
+
return scopes;
|
|
2943
|
+
};
|
|
2944
|
+
|
|
2945
|
+
// A view's root field: the generated list of its type, with the view's params before the generated
|
|
2946
|
+
// arguments. Each call reads the type's registration, whose model createSchema prepares, strips the
|
|
2947
|
+
// params, by the names the field was built with, before the list pipeline runs, and has the pipeline
|
|
2948
|
+
// append the view's `where` after middleware (viewExtension).
|
|
2949
|
+
const buildViewField = (view) => {
|
|
2950
|
+
const paramArgs = {};
|
|
2951
|
+
for (const [paramName, param] of Object.entries(view.params)) {
|
|
2952
|
+
const arg = { type: param.type };
|
|
2953
|
+
if (Object.hasOwn(param, 'description')) arg.description = param.description;
|
|
2954
|
+
if (Object.hasOwn(param, 'defaultValue')) arg.defaultValue = param.defaultValue;
|
|
2955
|
+
paramArgs[paramName] = arg;
|
|
2956
|
+
}
|
|
2957
|
+
const paramNames = new Set(Object.keys(paramArgs));
|
|
2958
|
+
return {
|
|
2959
|
+
type: new GraphQLList(view.type),
|
|
2960
|
+
description: view.description,
|
|
2961
|
+
args: { ...paramArgs, ...createArgsForQuery(view.type) },
|
|
2962
|
+
extensions: {
|
|
2963
|
+
simfinityQuery: {
|
|
2964
|
+
typeName: view.type.name, operation: 'find', countSink: true, view: { name: view.name },
|
|
2965
|
+
},
|
|
2966
|
+
},
|
|
2967
|
+
async resolve(parent, args, context, info) {
|
|
2968
|
+
const registration = typesDict.types[view.type.name];
|
|
2969
|
+
const split = splitViewArgs(paramNames, args);
|
|
2970
|
+
const params = await prepareRead(registration, split.args, context, 'find', {
|
|
2971
|
+
entry: view.name, extend: viewExtension(view, split.params),
|
|
2972
|
+
});
|
|
2973
|
+
return readList(registration, params, parent, info, context);
|
|
2974
|
+
},
|
|
2975
|
+
};
|
|
2976
|
+
};
|
|
2977
|
+
|
|
2571
2978
|
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
|
|
2572
2979
|
const rootQueryArgs = {};
|
|
2573
2980
|
rootQueryArgs.name = name;
|
|
@@ -2594,35 +3001,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2594
3001
|
// that supplies a root-value sink can pass its context unchanged.
|
|
2595
3002
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
|
|
2596
3003
|
async resolve(parent, args, context, info) {
|
|
2597
|
-
const
|
|
2598
|
-
|
|
2599
|
-
const params = {
|
|
2600
|
-
type,
|
|
2601
|
-
args,
|
|
2602
|
-
operation: 'find',
|
|
2603
|
-
context,
|
|
2604
|
-
};
|
|
2605
|
-
await executeMiddleware(params);
|
|
2606
|
-
await executeScope(params);
|
|
2607
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
2608
|
-
assertStoredQueryPaths(type.gqltype, params.args, 'find');
|
|
2609
|
-
assertQueryConditions(type.gqltype, params.args, 'find');
|
|
2610
|
-
const queryArgs = params.args;
|
|
2611
|
-
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2612
|
-
&& canReportCount(parent, info, context);
|
|
2613
|
-
|
|
2614
|
-
const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
|
|
2615
|
-
const countPromise = wantsCount
|
|
2616
|
-
? adapter.count(type.model, type.gqltype, queryArgs, null)
|
|
2617
|
-
: null;
|
|
2618
|
-
|
|
2619
|
-
const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
|
|
2620
|
-
if (wantsCount && !reportCount(parent, info, context, resultCount)) {
|
|
2621
|
-
warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
|
|
2622
|
-
+ 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
|
|
2623
|
-
+ 'reported; pass a fresh mutable context object for each request.');
|
|
2624
|
-
}
|
|
2625
|
-
return result;
|
|
3004
|
+
const params = await prepareRead(type, args, context, 'find');
|
|
3005
|
+
return readList(type, params, parent, info, context);
|
|
2626
3006
|
},
|
|
2627
3007
|
};
|
|
2628
3008
|
|
|
@@ -2642,19 +3022,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2642
3022
|
args: aggregateArgsObject,
|
|
2643
3023
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
2644
3024
|
async resolve(parent, args, context) {
|
|
2645
|
-
const
|
|
2646
|
-
assertQueryConditions(type.gqltype, args, 'aggregate');
|
|
2647
|
-
const params = {
|
|
2648
|
-
type,
|
|
2649
|
-
args,
|
|
2650
|
-
operation: 'aggregate',
|
|
2651
|
-
context,
|
|
2652
|
-
};
|
|
2653
|
-
await executeMiddleware(params);
|
|
2654
|
-
await executeScope(params);
|
|
2655
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
2656
|
-
assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
|
|
2657
|
-
assertQueryConditions(type.gqltype, params.args, 'aggregate');
|
|
3025
|
+
const params = await prepareRead(type, args, context, 'aggregate');
|
|
2658
3026
|
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
2659
3027
|
},
|
|
2660
3028
|
};
|
|
@@ -2669,6 +3037,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2669
3037
|
}
|
|
2670
3038
|
}
|
|
2671
3039
|
|
|
3040
|
+
// A view is in the schema exactly when its type is; assertViewsAvailable has checked its name.
|
|
3041
|
+
for (const view of Object.values(registeredViews)) {
|
|
3042
|
+
if (!shouldNotBeIncludedInSchema(includedTypes, view.type)) {
|
|
3043
|
+
rootQueryArgs.fields[view.name] = buildViewField(view);
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
|
|
2672
3047
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2673
3048
|
};
|
|
2674
3049
|
|
|
@@ -2965,13 +3340,50 @@ const queryNameConflict = (name) => new SimfinityError(
|
|
|
2965
3340
|
'QUERY_NAME_CONFLICT', 500,
|
|
2966
3341
|
);
|
|
2967
3342
|
|
|
3343
|
+
// The root names of the generated queries of the endpoint types a schema includes.
|
|
3344
|
+
const generatedQueryNames = (includedQueryTypes) => Object.values(typesDict.types)
|
|
3345
|
+
.filter((type) => type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
|
|
3346
|
+
.flatMap((type) => [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]);
|
|
3347
|
+
|
|
3348
|
+
const isIncludedCustomQuery = (name, includedCustomQueries) => Object.hasOwn(registeredQueries, name)
|
|
3349
|
+
&& !shouldNotBeIncludedInSchema(includedCustomQueries, name);
|
|
3350
|
+
|
|
2968
3351
|
const assertQueryNamesAvailable = (includedQueryTypes, includedCustomQueries) => {
|
|
2969
|
-
for (const
|
|
2970
|
-
if (
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
3352
|
+
for (const name of generatedQueryNames(includedQueryTypes)) {
|
|
3353
|
+
if (isIncludedCustomQuery(name, includedCustomQueries)) throw queryNameConflict(name);
|
|
3354
|
+
}
|
|
3355
|
+
};
|
|
3356
|
+
|
|
3357
|
+
// Every registered view, whether or not the schema includes it: its type is an endpoint type of this
|
|
3358
|
+
// runtime, and its params are valid argument names that are neither fields of the type, which covers
|
|
3359
|
+
// stored, reference, collection and virtual fields, nor the generated list arguments. A view that the
|
|
3360
|
+
// schema includes, because it includes the view's type, also needs a name that no included generated
|
|
3361
|
+
// query or custom query takes.
|
|
3362
|
+
const assertViewsAvailable = (includedQueryTypes, includedCustomQueries) => {
|
|
3363
|
+
const views = Object.values(registeredViews);
|
|
3364
|
+
if (!views.length) return;
|
|
3365
|
+
const generatedNames = new Set(generatedQueryNames(includedQueryTypes));
|
|
3366
|
+
for (const { name, type, params } of views) {
|
|
3367
|
+
const registration = typesDict.types[type.name];
|
|
3368
|
+
if (!registration || registration.gqltype !== type || registration.endpoint !== true
|
|
3369
|
+
|| !registration.simpleEntityEndpointName) {
|
|
3370
|
+
throw invalidView(`${name} needs a type registered with connect() in this runtime`);
|
|
3371
|
+
}
|
|
3372
|
+
const fields = type.getFields();
|
|
3373
|
+
for (const paramName of Object.keys(params)) {
|
|
3374
|
+
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(paramName) || paramName.startsWith('__')) {
|
|
3375
|
+
throw invalidView(`${name} param ${paramName} requires a valid GraphQL name that does not start with __`);
|
|
3376
|
+
}
|
|
3377
|
+
if (Object.hasOwn(fields, paramName)) {
|
|
3378
|
+
throw invalidView(`${name} param ${paramName} has the name of a field of ${type.name}`);
|
|
2974
3379
|
}
|
|
3380
|
+
if (LIST_QUERY_ARGS.includes(paramName)) {
|
|
3381
|
+
throw invalidView(`${name} param ${paramName} has the name of a generated list query argument`);
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
if (!shouldNotBeIncludedInSchema(includedQueryTypes, type)
|
|
3385
|
+
&& (generatedNames.has(name) || isIncludedCustomQuery(name, includedCustomQueries))) {
|
|
3386
|
+
throw queryNameConflict(name);
|
|
2975
3387
|
}
|
|
2976
3388
|
}
|
|
2977
3389
|
};
|
|
@@ -3004,6 +3416,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3004
3416
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3005
3417
|
const { includedCustomQueries } = readSchemaOptions(options);
|
|
3006
3418
|
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
3419
|
+
assertViewsAvailable(includedQueryTypes, includedCustomQueries);
|
|
3007
3420
|
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
3008
3421
|
const schemaTypes = collectSchemaOutputTypes();
|
|
3009
3422
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
@@ -3148,6 +3561,64 @@ const registerCustomQuery = (name, description, inputModel, outputModel, callbac
|
|
|
3148
3561
|
registeredQueries[name] = registration;
|
|
3149
3562
|
};
|
|
3150
3563
|
|
|
3564
|
+
const invalidView = (detail) => new SimfinityError(`registerView ${detail}`, 'INVALID_VIEW', 500);
|
|
3565
|
+
|
|
3566
|
+
// A view parameter is a value the client sends as a variable or a literal: a scalar, an enum or a list
|
|
3567
|
+
// of them, each possibly NonNull. An input object is never one, wherever it is nested.
|
|
3568
|
+
const isViewParamType = (type) => {
|
|
3569
|
+
if (isNonNullType(type) || isListType(type)) return isViewParamType(type.ofType);
|
|
3570
|
+
return isScalarType(type) || isEnumType(type);
|
|
3571
|
+
};
|
|
3572
|
+
|
|
3573
|
+
const isNonNullPlainObject = (value) => value !== null && typeof value === 'object' && isPlainObject(value);
|
|
3574
|
+
|
|
3575
|
+
const VIEW_OPTION_KEYS = new Set(['type', 'description', 'params', 'where']);
|
|
3576
|
+
const VIEW_PARAM_KEYS = new Set(['type', 'description', 'defaultValue']);
|
|
3577
|
+
|
|
3578
|
+
const validateViewParams = (params) => {
|
|
3579
|
+
if (params === undefined) return;
|
|
3580
|
+
if (!isNonNullPlainObject(params)) throw invalidView('params must be an object');
|
|
3581
|
+
for (const [paramName, param] of Object.entries(params)) {
|
|
3582
|
+
if (!isNonNullPlainObject(param)) throw invalidView(`param ${paramName} must be an object`);
|
|
3583
|
+
for (const key of Object.keys(param)) {
|
|
3584
|
+
if (!VIEW_PARAM_KEYS.has(key)) throw invalidView(`param ${paramName} has an unknown key ${key}`);
|
|
3585
|
+
}
|
|
3586
|
+
if (!isViewParamType(param.type)) {
|
|
3587
|
+
throw invalidView(`param ${paramName} type must be a scalar, an enum or a list of them, not an input object`);
|
|
3588
|
+
}
|
|
3589
|
+
if (param.description !== undefined && typeof param.description !== 'string') {
|
|
3590
|
+
throw invalidView(`param ${paramName} description must be a string`);
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
};
|
|
3594
|
+
|
|
3595
|
+
const validateViewRegistration = (name, options) => {
|
|
3596
|
+
if (typeof name !== 'string' || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) || name.startsWith('__')) {
|
|
3597
|
+
throw invalidView('requires a valid GraphQL field name that does not start with __');
|
|
3598
|
+
}
|
|
3599
|
+
if (name.endsWith('_aggregate')) {
|
|
3600
|
+
throw invalidView(`name ${name} must not end with _aggregate, which the generated aggregate queries use`);
|
|
3601
|
+
}
|
|
3602
|
+
if (!isNonNullPlainObject(options)) throw invalidView('requires an options object');
|
|
3603
|
+
for (const key of Object.keys(options)) {
|
|
3604
|
+
if (!VIEW_OPTION_KEYS.has(key)) throw invalidView(`has an unknown option ${key}`);
|
|
3605
|
+
}
|
|
3606
|
+
if (!(options.type instanceof GraphQLObjectType)) throw invalidView('type must be a GraphQLObjectType');
|
|
3607
|
+
if (typeof options.description !== 'string' || options.description === '') {
|
|
3608
|
+
throw invalidView('requires a non-empty description');
|
|
3609
|
+
}
|
|
3610
|
+
validateViewParams(options.params);
|
|
3611
|
+
if (typeof options.where !== 'function') throw invalidView('where must be a function');
|
|
3612
|
+
};
|
|
3613
|
+
|
|
3614
|
+
const registerView = (name, options) => {
|
|
3615
|
+
validateViewRegistration(name, options);
|
|
3616
|
+
if (Object.hasOwn(registeredViews, name)) throw queryNameConflict(name);
|
|
3617
|
+
registeredViews[name] = {
|
|
3618
|
+
name, type: options.type, description: options.description, params: options.params ?? {}, where: options.where,
|
|
3619
|
+
};
|
|
3620
|
+
};
|
|
3621
|
+
|
|
3151
3622
|
// Gives the field a new extensions object as well as the resolver, so a copy made before keeps
|
|
3152
3623
|
// untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
|
|
3153
3624
|
// copying it, when a type is rebuilt from toConfig().
|
|
@@ -3192,14 +3663,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
3192
3663
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
3193
3664
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
3194
3665
|
}
|
|
3195
|
-
const
|
|
3196
|
-
assertQueryConditions(relatedType, args, 'find');
|
|
3197
|
-
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
3198
|
-
await executeMiddleware(params);
|
|
3199
|
-
await executeScope(params);
|
|
3200
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
3201
|
-
assertStoredQueryPaths(relatedType, params.args, 'find');
|
|
3202
|
-
assertQueryConditions(relatedType, params.args, 'find');
|
|
3666
|
+
const params = await prepareRead(relatedTypeInfo, args, context, 'find');
|
|
3203
3667
|
return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
|
|
3204
3668
|
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
3205
3669
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
@@ -3385,6 +3849,7 @@ function formatArgs(argsArray) {
|
|
|
3385
3849
|
use,
|
|
3386
3850
|
registerMutation,
|
|
3387
3851
|
registerCustomQuery,
|
|
3852
|
+
registerView,
|
|
3388
3853
|
saveObject,
|
|
3389
3854
|
preventCreatingCollection,
|
|
3390
3855
|
};
|