@simtlix/simfinity-core 3.11.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 +2 -1
- package/package.json +1 -1
- package/src/runtime.js +296 -68
- package/types/index.d.ts +69 -0
package/README.md
CHANGED
|
@@ -8,12 +8,13 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
|
|
|
8
8
|
|
|
9
9
|
## Runtime
|
|
10
10
|
|
|
11
|
-
`createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom queries and mutations, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
|
|
11
|
+
`createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom queries and mutations, views, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
|
|
12
12
|
|
|
13
13
|
- `connect`, `addNoEndpointType`, and `createSchema` for registration and schema creation;
|
|
14
14
|
- `getModel`, `getType`, `getInputType`, and `getRegistrations` for runtime inspection;
|
|
15
15
|
- `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
|
|
16
16
|
- `registerCustomQuery` for reads whose results are not stored records, such as previews, other sources or permission evaluations;
|
|
17
|
+
- `registerView` for a named list read of one registered type, with params and a fixed predicate, run through the type's scope;
|
|
17
18
|
- `preventCreatingCollection` to pass validation-only storage setup to an adapter;
|
|
18
19
|
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size, reference-frame and nested-operation limits.
|
|
19
20
|
|
package/package.json
CHANGED
package/src/runtime.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
7
7
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
8
8
|
GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
|
|
9
|
-
Kind, getNamedType, isObjectType, isOutputType,
|
|
9
|
+
Kind, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType, isScalarType,
|
|
10
10
|
} from 'graphql';
|
|
11
11
|
|
|
12
12
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -495,6 +495,7 @@ export const createRuntime = (adapter) => {
|
|
|
495
495
|
const qualifiedSelfCollections = new Map();
|
|
496
496
|
const registeredMutations = {};
|
|
497
497
|
const registeredQueries = {};
|
|
498
|
+
const registeredViews = {};
|
|
498
499
|
const middlewares = [];
|
|
499
500
|
|
|
500
501
|
const operations = {
|
|
@@ -1813,7 +1814,9 @@ const copyClientFilters = (args, operation) => {
|
|
|
1813
1814
|
// scope. When the adapter compiles reference emptiness (#108 part 2b), a client's emptiness term or
|
|
1814
1815
|
// condition reads its references and collections through scoped joins and frames instead, so it
|
|
1815
1816
|
// enters no joined scope either; every other entry, beside it or on the same path, records its own.
|
|
1816
|
-
|
|
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 } = {}) => {
|
|
1817
1820
|
copyClientFilters(args, operation);
|
|
1818
1821
|
const joinedScopes = new Map();
|
|
1819
1822
|
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
@@ -1827,7 +1830,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1827
1830
|
throw forbiddenPath(`Query path ${path} uses the virtual field ${type.name}.${fieldName}, which is not stored`);
|
|
1828
1831
|
}
|
|
1829
1832
|
const queryable = field.extensions?.queryable;
|
|
1830
|
-
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
1833
|
+
if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
|
|
1831
1834
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1832
1835
|
}
|
|
1833
1836
|
if (!joins) return;
|
|
@@ -2069,31 +2072,34 @@ const hasScopeMarker = (construct) => construct != null && Object.hasOwn(constru
|
|
|
2069
2072
|
|
|
2070
2073
|
// Marks the client's constructs that read references or collections, from the gate's conditions on the
|
|
2071
2074
|
// client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
|
|
2072
|
-
// scoped joins and frames in
|
|
2073
|
-
// id -> the client's operator and path, or null when the adapter does not compile
|
|
2074
|
-
// emptiness. Ids are ordinals in the order of the conditions, so identical arguments get
|
|
2075
|
-
// markers and sibling collection reads keep one batch key.
|
|
2076
|
-
|
|
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) => {
|
|
2077
2082
|
if (!supportsReferenceEmptiness) return null;
|
|
2078
|
-
const
|
|
2083
|
+
const markers = issued ?? new Map();
|
|
2079
2084
|
const pairs = new Set();
|
|
2080
2085
|
for (const { entry, shape } of checked) {
|
|
2081
2086
|
const { construct } = entry;
|
|
2082
2087
|
if (!shape || !construct || hasScopeMarker(construct)) continue;
|
|
2083
2088
|
const { joins, frames } = emptinessScopePaths(shape, entry.segments);
|
|
2084
2089
|
if (!joins.length && !frames.length) continue;
|
|
2085
|
-
const id =
|
|
2090
|
+
const id = markers.size;
|
|
2086
2091
|
construct[SCOPE_MARKER] = { id };
|
|
2087
|
-
|
|
2092
|
+
markers.set(id, { operator: entry.operator, path: entry.path });
|
|
2088
2093
|
for (const path of joins) pairs.add(`join ${path}`);
|
|
2089
2094
|
for (const path of frames) pairs.add(`frame ${path}`);
|
|
2090
2095
|
}
|
|
2091
2096
|
const limit = getQueryMaxReferenceFrames();
|
|
2092
2097
|
if (pairs.size > limit) {
|
|
2093
2098
|
throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
|
|
2094
|
-
+
|
|
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);
|
|
2095
2101
|
}
|
|
2096
|
-
return
|
|
2102
|
+
return markers;
|
|
2097
2103
|
};
|
|
2098
2104
|
|
|
2099
2105
|
// The detach check: every marker issued in this call sits, well formed (an object whose only own key
|
|
@@ -2805,6 +2811,170 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2805
2811
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2806
2812
|
};
|
|
2807
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
|
+
|
|
2808
2978
|
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
|
|
2809
2979
|
const rootQueryArgs = {};
|
|
2810
2980
|
rootQueryArgs.name = name;
|
|
@@ -2831,35 +3001,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2831
3001
|
// that supplies a root-value sink can pass its context unchanged.
|
|
2832
3002
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
|
|
2833
3003
|
async resolve(parent, args, context, info) {
|
|
2834
|
-
const
|
|
2835
|
-
|
|
2836
|
-
const params = {
|
|
2837
|
-
type,
|
|
2838
|
-
args,
|
|
2839
|
-
operation: 'find',
|
|
2840
|
-
context,
|
|
2841
|
-
};
|
|
2842
|
-
await executeMiddleware(params);
|
|
2843
|
-
await executeScope(params);
|
|
2844
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
2845
|
-
const filling = settleClientArgs(type.gqltype, params, 'find', issued);
|
|
2846
|
-
if (filling) await filling;
|
|
2847
|
-
const queryArgs = params.args;
|
|
2848
|
-
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2849
|
-
&& canReportCount(parent, info, context);
|
|
2850
|
-
|
|
2851
|
-
const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
|
|
2852
|
-
const countPromise = wantsCount
|
|
2853
|
-
? adapter.count(type.model, type.gqltype, queryArgs, null)
|
|
2854
|
-
: null;
|
|
2855
|
-
|
|
2856
|
-
const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
|
|
2857
|
-
if (wantsCount && !reportCount(parent, info, context, resultCount)) {
|
|
2858
|
-
warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
|
|
2859
|
-
+ 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
|
|
2860
|
-
+ 'reported; pass a fresh mutable context object for each request.');
|
|
2861
|
-
}
|
|
2862
|
-
return result;
|
|
3004
|
+
const params = await prepareRead(type, args, context, 'find');
|
|
3005
|
+
return readList(type, params, parent, info, context);
|
|
2863
3006
|
},
|
|
2864
3007
|
};
|
|
2865
3008
|
|
|
@@ -2879,19 +3022,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2879
3022
|
args: aggregateArgsObject,
|
|
2880
3023
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
2881
3024
|
async resolve(parent, args, context) {
|
|
2882
|
-
const
|
|
2883
|
-
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'aggregate'));
|
|
2884
|
-
const params = {
|
|
2885
|
-
type,
|
|
2886
|
-
args,
|
|
2887
|
-
operation: 'aggregate',
|
|
2888
|
-
context,
|
|
2889
|
-
};
|
|
2890
|
-
await executeMiddleware(params);
|
|
2891
|
-
await executeScope(params);
|
|
2892
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
2893
|
-
const filling = settleClientArgs(type.gqltype, params, 'aggregate', issued);
|
|
2894
|
-
if (filling) await filling;
|
|
3025
|
+
const params = await prepareRead(type, args, context, 'aggregate');
|
|
2895
3026
|
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
2896
3027
|
},
|
|
2897
3028
|
};
|
|
@@ -2906,6 +3037,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2906
3037
|
}
|
|
2907
3038
|
}
|
|
2908
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
|
+
|
|
2909
3047
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2910
3048
|
};
|
|
2911
3049
|
|
|
@@ -3202,13 +3340,50 @@ const queryNameConflict = (name) => new SimfinityError(
|
|
|
3202
3340
|
'QUERY_NAME_CONFLICT', 500,
|
|
3203
3341
|
);
|
|
3204
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
|
+
|
|
3205
3351
|
const assertQueryNamesAvailable = (includedQueryTypes, includedCustomQueries) => {
|
|
3206
|
-
for (const
|
|
3207
|
-
if (
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
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}`);
|
|
3211
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);
|
|
3212
3387
|
}
|
|
3213
3388
|
}
|
|
3214
3389
|
};
|
|
@@ -3241,6 +3416,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3241
3416
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3242
3417
|
const { includedCustomQueries } = readSchemaOptions(options);
|
|
3243
3418
|
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
3419
|
+
assertViewsAvailable(includedQueryTypes, includedCustomQueries);
|
|
3244
3420
|
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
3245
3421
|
const schemaTypes = collectSchemaOutputTypes();
|
|
3246
3422
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
@@ -3385,6 +3561,64 @@ const registerCustomQuery = (name, description, inputModel, outputModel, callbac
|
|
|
3385
3561
|
registeredQueries[name] = registration;
|
|
3386
3562
|
};
|
|
3387
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
|
+
|
|
3388
3622
|
// Gives the field a new extensions object as well as the resolver, so a copy made before keeps
|
|
3389
3623
|
// untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
|
|
3390
3624
|
// copying it, when a type is rebuilt from toConfig().
|
|
@@ -3429,14 +3663,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
3429
3663
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
3430
3664
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
3431
3665
|
}
|
|
3432
|
-
const
|
|
3433
|
-
const issued = markClientConstructs(assertQueryConditions(relatedType, args, 'find'));
|
|
3434
|
-
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
3435
|
-
await executeMiddleware(params);
|
|
3436
|
-
await executeScope(params);
|
|
3437
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
3438
|
-
const filling = settleClientArgs(relatedType, params, 'find', issued);
|
|
3439
|
-
if (filling) await filling;
|
|
3666
|
+
const params = await prepareRead(relatedTypeInfo, args, context, 'find');
|
|
3440
3667
|
return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
|
|
3441
3668
|
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
3442
3669
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
@@ -3622,6 +3849,7 @@ function formatArgs(argsArray) {
|
|
|
3622
3849
|
use,
|
|
3623
3850
|
registerMutation,
|
|
3624
3851
|
registerCustomQuery,
|
|
3852
|
+
registerView,
|
|
3625
3853
|
saveObject,
|
|
3626
3854
|
preventCreatingCollection,
|
|
3627
3855
|
};
|
package/types/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
GraphQLEnumType,
|
|
3
3
|
GraphQLError,
|
|
4
4
|
GraphQLInputObjectType,
|
|
5
|
+
GraphQLList,
|
|
6
|
+
GraphQLNonNull,
|
|
5
7
|
GraphQLObjectType,
|
|
6
8
|
GraphQLOutputType,
|
|
7
9
|
GraphQLScalarType,
|
|
@@ -42,6 +44,26 @@ export type QueryOperator =
|
|
|
42
44
|
| 'ILIKE' | 'STARTS_WITH' | 'ISTARTS_WITH' | 'ENDS_WITH' | 'IENDS_WITH' | 'IEQ'
|
|
43
45
|
| 'IS_EMPTY' | 'IS_NOT_EMPTY';
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A condition of a {@link FilterGroup}, as the `QLFilterCondition` input takes it: the field it
|
|
49
|
+
* tests, its {@link QueryOperator} (`EQ` when omitted) and its value. On an object field, `path`
|
|
50
|
+
* names the member it tests; `IS_EMPTY` and `IS_NOT_EMPTY` take no value and may omit `path` to test
|
|
51
|
+
* the field itself.
|
|
52
|
+
*/
|
|
53
|
+
export interface FilterCondition {
|
|
54
|
+
field: string;
|
|
55
|
+
operator?: QueryOperator | (string & {});
|
|
56
|
+
value?: any;
|
|
57
|
+
path?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A `QLFilterGroup` value: flat conditions plus nested `AND` and `OR` groups. */
|
|
61
|
+
export interface FilterGroup {
|
|
62
|
+
AND?: FilterGroup[];
|
|
63
|
+
OR?: FilterGroup[];
|
|
64
|
+
conditions?: FilterCondition[];
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
/**
|
|
46
68
|
* The query language features a {@link DatabaseAdapter} compiles. Declare it so that the runtime
|
|
47
69
|
* sends the adapter only conditions it compiles; later releases add members for later features.
|
|
@@ -327,6 +349,42 @@ export interface QueryLimitsOptions {
|
|
|
327
349
|
maxReferenceFrames?: number;
|
|
328
350
|
}
|
|
329
351
|
|
|
352
|
+
/**
|
|
353
|
+
* The type of a view parameter: a value the client sends as a variable or a literal, so a scalar,
|
|
354
|
+
* an enum or a list of them, each possibly non-null, never an input object.
|
|
355
|
+
*/
|
|
356
|
+
export type ViewParamType =
|
|
357
|
+
| GraphQLScalarType
|
|
358
|
+
| GraphQLEnumType
|
|
359
|
+
| GraphQLList<ViewParamType>
|
|
360
|
+
| GraphQLNonNull<GraphQLScalarType | GraphQLEnumType | GraphQLList<ViewParamType>>;
|
|
361
|
+
|
|
362
|
+
/** A view parameter: an argument of the view's root field that only its `where` reads. */
|
|
363
|
+
export interface ViewParam {
|
|
364
|
+
type: ViewParamType;
|
|
365
|
+
description?: string;
|
|
366
|
+
defaultValue?: unknown;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The declaration of a named view (see {@link Runtime.registerView}). */
|
|
370
|
+
export interface ViewOptions {
|
|
371
|
+
/** The connected endpoint type whose list the view returns. */
|
|
372
|
+
type: GraphQLObjectType;
|
|
373
|
+
/** Non-empty; the root field's description. */
|
|
374
|
+
description: string;
|
|
375
|
+
/**
|
|
376
|
+
* Arguments of the root field besides the generated list arguments, named unlike them and unlike
|
|
377
|
+
* the fields of `type`. They never reach the adapter: only `where` receives them.
|
|
378
|
+
*/
|
|
379
|
+
params?: Record<string, ViewParam>;
|
|
380
|
+
/**
|
|
381
|
+
* Called on every read with the param values and the request context. The groups it returns are
|
|
382
|
+
* appended to the client's filters after middleware, so neither the client nor middleware can drop
|
|
383
|
+
* them; their paths may name fields that are not queryable.
|
|
384
|
+
*/
|
|
385
|
+
where: (input: { params: Record<string, any>; context: any }) => FilterGroup[] | Promise<FilterGroup[]>;
|
|
386
|
+
}
|
|
387
|
+
|
|
330
388
|
export interface Runtime<Model = any, Session = any> {
|
|
331
389
|
/** The same process-wide setting as the root `configureQueryLimits`. */
|
|
332
390
|
configureQueryLimits(options?: QueryLimitsOptions): void;
|
|
@@ -410,6 +468,17 @@ export interface Runtime<Model = any, Session = any> {
|
|
|
410
468
|
* (500) for a name already registered.
|
|
411
469
|
*/
|
|
412
470
|
registerCustomQuery(name: string, description: string, input: GraphQLInputObjectType | null | undefined, output: GraphQLOutputType, callback: (input: any, context: any) => any): void;
|
|
471
|
+
/**
|
|
472
|
+
* Register a named view: a root query field that returns the list of `options.type` with the
|
|
473
|
+
* generated list arguments plus `options.params`, and appends the groups of `options.where` after
|
|
474
|
+
* middleware. A schema includes the view exactly when it includes the type. Throws `INVALID_VIEW`
|
|
475
|
+
* (500) for an invalid declaration and `QUERY_NAME_CONFLICT` (500) for a name already registered as
|
|
476
|
+
* a view, keeping the first registration. `createSchema` throws `INVALID_VIEW` when the type is not
|
|
477
|
+
* connected in this runtime or a param is named like one of its fields or a generated list
|
|
478
|
+
* argument, and `QUERY_NAME_CONFLICT` when an included view is named like an included generated or
|
|
479
|
+
* custom query.
|
|
480
|
+
*/
|
|
481
|
+
registerView(name: string, options: ViewOptions): void;
|
|
413
482
|
/** Owns the full workflow transaction unless an active caller session is supplied. */
|
|
414
483
|
saveObject(typeName: string, args: Record<string, any>, session?: Session, context?: any): Promise<any>;
|
|
415
484
|
preventCreatingCollection(prevent: boolean): void;
|