@simtlix/simfinity-core 3.13.0 → 3.14.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 +7 -3
- package/package.json +1 -1
- package/src/const/QLQuantifier.js +21 -0
- package/src/index.js +1 -0
- package/src/query-emptiness.js +2 -2
- package/src/query-frames.js +510 -0
- package/src/query-limits.js +9 -2
- package/src/query-operators.js +23 -9
- package/src/query-plan.js +236 -155
- package/src/runtime.js +241 -122
- package/types/index.d.ts +97 -18
package/src/runtime.js
CHANGED
|
@@ -13,14 +13,16 @@ import SimfinityError from './errors/simfinity.error.js';
|
|
|
13
13
|
import InternalServerError from './errors/internal-server.error.js';
|
|
14
14
|
import { ForbiddenError } from './auth/errors.js';
|
|
15
15
|
import QLOperator from './const/QLOperator.js';
|
|
16
|
+
import QLQuantifier, { QUANTIFIED_LISTS } from './const/QLQuantifier.js';
|
|
17
|
+
import { aggregateJoinPrefixes, assertSortSyntax, frameUnits, validateQuantifiedGroups } from './query-frames.js';
|
|
16
18
|
import QLValue from './const/QLValue.js';
|
|
17
19
|
import QLSort from './const/QLSort.js';
|
|
18
20
|
import {
|
|
19
|
-
collectQueryOperators, collectQueryPathEntries,
|
|
21
|
+
collectQueryOperators, collectQueryPathEntries, walkQueryPath,
|
|
20
22
|
} from './query-plan.js';
|
|
21
23
|
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
22
24
|
import {
|
|
23
|
-
assertEmptinessPath, describeEmptiness,
|
|
25
|
+
assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
|
|
24
26
|
} from './query-emptiness.js';
|
|
25
27
|
import { relationFieldOwners } from './relation-owners.js';
|
|
26
28
|
import { isThenable } from './auth/thenable.js';
|
|
@@ -181,7 +183,7 @@ const assertScopeControlArg = (name, value) => {
|
|
|
181
183
|
};
|
|
182
184
|
|
|
183
185
|
// Moves a joined type's scope group below the relation path that entered it.
|
|
184
|
-
const prefixScopeGroup = (group, prefix) => {
|
|
186
|
+
const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
|
|
185
187
|
if (!group || typeof group !== 'object' || Array.isArray(group)) {
|
|
186
188
|
throw new SimfinityError('Expected a scope filter group object', 'INVALID_FILTER_VALUE', 400);
|
|
187
189
|
}
|
|
@@ -193,12 +195,15 @@ const prefixScopeGroup = (group, prefix) => {
|
|
|
193
195
|
value: condition?.value,
|
|
194
196
|
}));
|
|
195
197
|
}
|
|
196
|
-
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix));
|
|
197
|
-
if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix));
|
|
198
|
+
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
199
|
+
if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
200
|
+
for (const key of QUANTIFIED_LISTS) if (group[key] != null) {
|
|
201
|
+
prefixed[key] = group[key].map((item) => ({ ...copyQuantifiedGroup(item), path: `${prefix}.${item.path}` }));
|
|
202
|
+
}
|
|
198
203
|
return prefixed;
|
|
199
204
|
};
|
|
200
205
|
|
|
201
|
-
const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
206
|
+
const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
202
207
|
const conditions = [];
|
|
203
208
|
const groups = [];
|
|
204
209
|
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
@@ -207,10 +212,12 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
|
207
212
|
assertScopeControlArg(name, value);
|
|
208
213
|
continue;
|
|
209
214
|
}
|
|
210
|
-
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
|
|
215
|
+
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)));
|
|
211
216
|
else if (name === 'OR') {
|
|
212
217
|
const branches = [].concat(value);
|
|
213
|
-
if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix)) });
|
|
218
|
+
if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)) });
|
|
219
|
+
} else if (value.quantifier != null) {
|
|
220
|
+
groups.push({ [value.quantifier]: [{ path: `${prefix}.${name}`, conditions: (value.terms ?? []).map(({ path, ...term }) => ({ field: path, ...term })) }] });
|
|
214
221
|
} else if (Object.hasOwn(value, 'terms')) {
|
|
215
222
|
if (!Array.isArray(value.terms) || !value.terms.length) {
|
|
216
223
|
throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
|
|
@@ -545,7 +552,7 @@ export const createRuntime = (adapter) => {
|
|
|
545
552
|
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
546
553
|
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
547
554
|
const {
|
|
548
|
-
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
|
|
555
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
|
|
549
556
|
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
550
557
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
551
558
|
// The write features the adapter implements, read once: with optimisticConcurrency, schemas may
|
|
@@ -697,6 +704,7 @@ const QLTypeFilterExpression = new GraphQLInputObjectType({
|
|
|
697
704
|
name: 'QLTypeFilterExpression',
|
|
698
705
|
fields: () => ({
|
|
699
706
|
terms: { type: new GraphQLList(QLTypeFilter) },
|
|
707
|
+
quantifier: { type: QLQuantifier },
|
|
700
708
|
}),
|
|
701
709
|
});
|
|
702
710
|
|
|
@@ -710,9 +718,25 @@ const QLFilterCondition = new GraphQLInputObjectType({
|
|
|
710
718
|
}),
|
|
711
719
|
});
|
|
712
720
|
|
|
721
|
+
const QLQuantifiedGroup = new GraphQLInputObjectType({
|
|
722
|
+
name: 'QLQuantifiedGroup',
|
|
723
|
+
fields: () => ({
|
|
724
|
+
path: { type: new GraphQLNonNull(GraphQLString) },
|
|
725
|
+
AND: { type: new GraphQLList(QLFilterGroup) },
|
|
726
|
+
OR: { type: new GraphQLList(QLFilterGroup) },
|
|
727
|
+
ANY: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
728
|
+
ALL: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
729
|
+
NONE: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
730
|
+
conditions: { type: new GraphQLList(QLFilterCondition) },
|
|
731
|
+
}),
|
|
732
|
+
});
|
|
733
|
+
|
|
713
734
|
const QLFilterGroup = new GraphQLInputObjectType({
|
|
714
735
|
name: 'QLFilterGroup',
|
|
715
736
|
fields: () => ({
|
|
737
|
+
ANY: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
738
|
+
ALL: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
739
|
+
NONE: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
716
740
|
AND: { type: new GraphQLList(QLFilterGroup) },
|
|
717
741
|
OR: { type: new GraphQLList(QLFilterGroup) },
|
|
718
742
|
conditions: { type: new GraphQLList(QLFilterCondition) },
|
|
@@ -2010,8 +2034,10 @@ const copyFilterGroup = (group) => {
|
|
|
2010
2034
|
if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
|
|
2011
2035
|
if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
|
|
2012
2036
|
if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
|
|
2037
|
+
for (const key of QUANTIFIED_LISTS) if (Array.isArray(group[key])) copy[key] = group[key].map(copyQuantifiedGroup);
|
|
2013
2038
|
return copy;
|
|
2014
2039
|
};
|
|
2040
|
+
const copyQuantifiedGroup = (group) => copyFilterGroup(group);
|
|
2015
2041
|
const copyFieldFilter = (filter) => {
|
|
2016
2042
|
const copy = copyFilterCondition(filter);
|
|
2017
2043
|
if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
|
|
@@ -2048,12 +2074,19 @@ const copyClientFilters = (args, operation) => {
|
|
|
2048
2074
|
// non-queryable field is not rejected; a virtual one still is.
|
|
2049
2075
|
const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
|
|
2050
2076
|
copyClientFilters(args, operation);
|
|
2077
|
+
assertSortSyntax(args.sort);
|
|
2051
2078
|
const joinedScopes = new Map();
|
|
2079
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2052
2080
|
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
2053
2081
|
for (const {
|
|
2054
|
-
segments, group, member, operator,
|
|
2082
|
+
segments, group, member, operator, use, frames, quantifier,
|
|
2055
2083
|
} of entries) {
|
|
2056
|
-
const
|
|
2084
|
+
const filter = use === 'filter';
|
|
2085
|
+
const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
|
|
2086
|
+
const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
|
|
2087
|
+
const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
|
|
2088
|
+
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2089
|
+
&& !(filter && (frames.length || quantifier || (cut && !anchored)));
|
|
2057
2090
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2058
2091
|
const path = segments.slice(0, index + 1).join('.');
|
|
2059
2092
|
if (isVirtualField(field)) {
|
|
@@ -2063,8 +2096,11 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
|
|
|
2063
2096
|
if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
|
|
2064
2097
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
2065
2098
|
}
|
|
2066
|
-
if (!joins) return;
|
|
2067
2099
|
const relation = field.extensions?.relation;
|
|
2100
|
+
if (use === 'sort' && operation !== 'aggregate' && relation && !relation.embedded && getListShape(field.type)) {
|
|
2101
|
+
throw new SimfinityError(`Sort path ${segments.join('.')} crosses the collection ${type.name}.${fieldName}; a record has no single value there`, 'INVALID_FILTER_PATH', 400);
|
|
2102
|
+
}
|
|
2103
|
+
if (!joins || (cut && index >= cut.index)) return;
|
|
2068
2104
|
if (!relation || relation.embedded) return;
|
|
2069
2105
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
2070
2106
|
if (!getScope(target?.gqltype, 'find')) return;
|
|
@@ -2146,31 +2182,43 @@ const assertEmptinessForm = (gqltype, {
|
|
|
2146
2182
|
return true;
|
|
2147
2183
|
};
|
|
2148
2184
|
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2185
|
+
// Every runtime gate validates the received language, before rewriting or filling any markers.
|
|
2186
|
+
// Legacy adapters keep the detailed 2b diagnostics; quantified adapters use the shared validator's
|
|
2187
|
+
// frame-relative checks first. The separate operator inspection includes ignored beside-terms
|
|
2188
|
+
// carriers, which do not become predicates but must not use known undeclared capabilities.
|
|
2189
|
+
const queryFrameContext = (gqltype, args, operation) => ({
|
|
2190
|
+
gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
|
|
2191
|
+
operators: supportedOperators,
|
|
2192
|
+
J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
|
|
2193
|
+
scopeMarkers: 'pending',
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
const entryType = (gqltype, entry) => {
|
|
2197
|
+
let type = gqltype;
|
|
2198
|
+
const prefix = entry.segments.slice(0, entry.segments.length - entry.relative.length);
|
|
2199
|
+
if (prefix.length) walkQueryPath(gqltype, prefix, ({ field }) => { type = getNamedType(field.type); });
|
|
2200
|
+
return type;
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2159
2203
|
const assertQueryConditions = (gqltype, args, operation) => {
|
|
2160
|
-
|
|
2204
|
+
if (supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
|
|
2205
|
+
const conditions = [];
|
|
2161
2206
|
for (const entry of collectQueryOperators(args, operation)) {
|
|
2162
2207
|
const { operator, path } = entry;
|
|
2208
|
+
const type = entryType(gqltype, entry);
|
|
2163
2209
|
let shape = null;
|
|
2164
2210
|
if (isEmptinessOperator(operator)) {
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2211
|
+
// The validator owns quantified term forms; old top-frame forms retain their diagnostics.
|
|
2212
|
+
if (entry.frames.length || assertEmptinessForm(gqltype, entry)) {
|
|
2213
|
+
if (entry.relative.length) shape = describeEmptiness(type, entry.relative, {
|
|
2214
|
+
operator, value: entry.value, path: entry.relative.join('.'), referenceEmptiness: supportsReferenceEmptiness,
|
|
2168
2215
|
});
|
|
2169
2216
|
}
|
|
2170
2217
|
} else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
|
|
2171
|
-
|
|
2218
|
+
conditions.push({ entry, shape, type });
|
|
2172
2219
|
}
|
|
2173
|
-
|
|
2220
|
+
if (!supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
|
|
2221
|
+
return { conditions, gqltype, args, operation };
|
|
2174
2222
|
};
|
|
2175
2223
|
|
|
2176
2224
|
// Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
|
|
@@ -2187,9 +2235,8 @@ const assertQueryConditions = (gqltype, args, operation) => {
|
|
|
2187
2235
|
// the prefixed conditions are applied as trusted ones, which read stored data.
|
|
2188
2236
|
const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
|
|
2189
2237
|
const { gqltype } = target;
|
|
2190
|
-
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
|
|
2238
|
+
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator, frames }) => !frames.length && isEmptinessOperator(operator));
|
|
2191
2239
|
if (!entries.length) return;
|
|
2192
|
-
assertStoredQueryPaths(gqltype, scopeArgs, 'find');
|
|
2193
2240
|
let throughList = false;
|
|
2194
2241
|
walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
|
|
2195
2242
|
// Rule 6, here or on the target's own endpoint, is the rejection thrown below.
|
|
@@ -2217,20 +2264,28 @@ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target
|
|
|
2217
2264
|
+ `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
|
|
2218
2265
|
};
|
|
2219
2266
|
|
|
2220
|
-
//
|
|
2221
|
-
//
|
|
2222
|
-
const assertJoinedScopePaths = (groups, prefix, target) => {
|
|
2267
|
+
// Declared collection frames preserve cardinality. A legacy adapter, or a prefix through an
|
|
2268
|
+
// embedded list, still cannot lift collection scopes; vector reference scopes remain forbidden.
|
|
2269
|
+
const assertJoinedScopePaths = (rootType, groups, prefix, target) => {
|
|
2270
|
+
let embeddedPrefix = false;
|
|
2271
|
+
walkQueryPath(rootType, prefix.split('.'), ({ field }) => {
|
|
2272
|
+
embeddedPrefix ||= !!(field.extensions?.relation?.embedded && getListShape(field.type));
|
|
2273
|
+
});
|
|
2223
2274
|
const depth = prefix.split('.').length;
|
|
2224
|
-
for (const segments of
|
|
2275
|
+
for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups })) {
|
|
2225
2276
|
let inList = false;
|
|
2226
|
-
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field }) => {
|
|
2277
|
+
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field, index }) => {
|
|
2227
2278
|
const relation = field.extensions?.relation;
|
|
2228
2279
|
const list = !!getListShape(field.type);
|
|
2229
|
-
if (relation && !relation.embedded && (list ||
|
|
2280
|
+
if (relation && !relation.embedded && (inList || (list && (!supportsQuantifiers || embeddedPrefix)))) {
|
|
2230
2281
|
throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope filters through `
|
|
2231
2282
|
+ `${list ? 'the collection' : 'the relation'} ${type.name}.${fieldName}${list ? '' : ' inside a list'}`);
|
|
2232
2283
|
}
|
|
2233
|
-
|
|
2284
|
+
if (relation && !relation.embedded && quantifier && embeddedPrefix && index === segments.length - depth - 1) {
|
|
2285
|
+
throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope tests `
|
|
2286
|
+
+ `the quantified reference ${type.name}.${fieldName} under an embedded-list prefix`);
|
|
2287
|
+
}
|
|
2288
|
+
inList = relation && !relation.embedded ? false : inList || list;
|
|
2234
2289
|
});
|
|
2235
2290
|
}
|
|
2236
2291
|
};
|
|
@@ -2244,7 +2299,7 @@ const appendAndGroups = (owner, groups) => {
|
|
|
2244
2299
|
|
|
2245
2300
|
// The deepest AND/OR group nesting the backends accept; groups listed in args.AND/OR are at depth 0.
|
|
2246
2301
|
const MAX_FILTER_GROUP_DEPTH = 5;
|
|
2247
|
-
const nestedGroups = (group) => [...
|
|
2302
|
+
const nestedGroups = (group) => ['AND', 'OR', ...QUANTIFIED_LISTS].flatMap((key) => [].concat(group[key] ?? []));
|
|
2248
2303
|
const groupDepth = (group) => Math.max(0, ...nestedGroups(group)
|
|
2249
2304
|
.filter((item) => item && typeof item === 'object').map((item) => groupDepth(item) + 1));
|
|
2250
2305
|
|
|
@@ -2271,10 +2326,13 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
2271
2326
|
}] of joinedScopes) {
|
|
2272
2327
|
const scopeArgs = {};
|
|
2273
2328
|
await executeScope({ type: target, args: scopeArgs, operation: 'find', context: params.context });
|
|
2274
|
-
const
|
|
2329
|
+
const kept = scopeFilterArgs(scopeArgs);
|
|
2330
|
+
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2331
|
+
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2332
|
+
const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup);
|
|
2275
2333
|
if (!groups.length) continue;
|
|
2276
2334
|
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
2277
|
-
assertJoinedScopePaths(groups, prefix, target);
|
|
2335
|
+
assertJoinedScopePaths(params.type.gqltype, groups, prefix, target);
|
|
2278
2336
|
assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
|
|
2279
2337
|
queryGroups ??= collectFilterGroups(params.args);
|
|
2280
2338
|
const addedDepth = 1 + Math.max(...groups.map(groupDepth));
|
|
@@ -2284,60 +2342,93 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
2284
2342
|
}
|
|
2285
2343
|
};
|
|
2286
2344
|
|
|
2287
|
-
// Client scope
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
// issued is still there and that no other one is, gates the final arguments, then fills each marker
|
|
2292
|
-
// with the raw find scope arguments of every type the construct reads through, `__scope: { id, scopes:
|
|
2293
|
-
// { [path]: args | null } }`, which the compilers apply as scoped joins and frames, so that a record
|
|
2294
|
-
// those scopes hide reads as missing. Constructs without a marker are trusted and read stored data.
|
|
2295
|
-
// Markers live only on the terms and group conditions collectQueryOperators returns; a `__scope`
|
|
2296
|
-
// anywhere else, inside a value for one, is never read.
|
|
2345
|
+
// Client scope provenance is carried by terms, conditions and explicit frames, never by plain
|
|
2346
|
+
// logical groups or values. Only reads of registered scoped targets are marked. Middleware sees
|
|
2347
|
+
// ordinal IDs; after detach and final validation, fill adds relative target-scope arguments. The
|
|
2348
|
+
// planner/compiler rewrite, not the runtime, splits those keys across implicit collection frames.
|
|
2297
2349
|
const SCOPE_MARKER = '__scope';
|
|
2298
2350
|
const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
|
|
2299
2351
|
+ 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
|
|
2300
2352
|
const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
|
|
2301
2353
|
const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
|
|
2302
2354
|
|
|
2303
|
-
//
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2355
|
+
// Relation metadata is position-relative when used for fill and absolute for eligibility/recording.
|
|
2356
|
+
const pathRelations = (gqltype, segments) => {
|
|
2357
|
+
const found = [];
|
|
2358
|
+
let insideList = false;
|
|
2359
|
+
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2360
|
+
const relation = field.extensions?.relation;
|
|
2361
|
+
const list = !!getListShape(field.type);
|
|
2362
|
+
if (relation && !relation.embedded) found.push({
|
|
2363
|
+
path: segments.slice(0, index + 1).join('.'), index,
|
|
2364
|
+
typeName: getNamedType(field.type).name, collection: list, insideList, owner: type.name, fieldName,
|
|
2365
|
+
});
|
|
2366
|
+
// A collection enters an element frame; only embedded lists make vector reference joins.
|
|
2367
|
+
if (relation && !relation.embedded && list) insideList = false;
|
|
2368
|
+
else if (!relation || relation.embedded) insideList ||= list;
|
|
2369
|
+
});
|
|
2370
|
+
return found;
|
|
2371
|
+
};
|
|
2372
|
+
|
|
2373
|
+
// The walker retains scalar-filter terms for capability/auth inspection, but no compiler consumes
|
|
2374
|
+
// them. Such positions cannot establish client provenance, including when aliased with a predicate.
|
|
2375
|
+
const ignoredScalarTerm = (gqltype, entry) => {
|
|
2376
|
+
if (entry.form !== 'term' || entry.frames.length || typeof entry.field !== 'string') return false;
|
|
2377
|
+
const segments = entry.field.split('.');
|
|
2378
|
+
let scalar = false;
|
|
2379
|
+
walkQueryPath(gqltype, segments, ({ field, index }) => {
|
|
2380
|
+
if (index === segments.length - 1) scalar = !isObjectType(getNamedType(field.type));
|
|
2381
|
+
});
|
|
2382
|
+
return scalar;
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
const ordinaryFrameCarrier = (gqltype, entry, J) => supportsQuantifiers && entry.construct
|
|
2386
|
+
&& !ignoredScalarTerm(gqltype, entry)
|
|
2387
|
+
&& entry.form !== 'beside-terms' && (entry.frames.length || pathRelations(gqltype, entry.segments)
|
|
2388
|
+
.some(({ path, collection }) => collection && !J.has(path)));
|
|
2389
|
+
|
|
2390
|
+
// Eligibility is per carrier: ordinary legacy reference-only/all-J predicates keep joined scopes.
|
|
2391
|
+
// Enclosing explicit frames inherit provenance from marked descendants, independently of unit counts.
|
|
2392
|
+
// A view continues issuance on the same map, but checks its own compiled-unit budget (as in 3.12.0).
|
|
2393
|
+
const markClientConstructs = (checked, { aggregation, issued = new Map() } = {}) => {
|
|
2394
|
+
const { gqltype, args, operation, conditions } = checked;
|
|
2395
|
+
const clientJ = operation === 'aggregate' ? aggregateJoinPrefixes(aggregation, { gqltype }) : new Set();
|
|
2396
|
+
if (!supportsReferenceEmptiness) return { issued: null, clientJ };
|
|
2397
|
+
const mark = (construct, origin) => {
|
|
2398
|
+
if (!construct || hasScopeMarker(construct)) return;
|
|
2399
|
+
const id = issued.size;
|
|
2321
2400
|
construct[SCOPE_MARKER] = { id };
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2401
|
+
issued.set(id, origin);
|
|
2402
|
+
};
|
|
2403
|
+
for (const { entry, shape } of conditions) {
|
|
2404
|
+
const { construct } = entry;
|
|
2405
|
+
if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2406
|
+
const targets = pathRelations(gqltype, entry.segments).map(({ typeName }) => typeName);
|
|
2407
|
+
if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
|
|
2408
|
+
if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
|
|
2409
|
+
const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
|
|
2410
|
+
mark(construct, origin);
|
|
2411
|
+
const prefix = [];
|
|
2412
|
+
for (const frame of entry.frames) {
|
|
2413
|
+
prefix.push(...frame.path.split('.'));
|
|
2414
|
+
mark(frame.construct, { operator: frame.type, path: prefix.join('.') });
|
|
2415
|
+
}
|
|
2325
2416
|
}
|
|
2417
|
+
const { count } = frameUnits(args, queryFrameContext(gqltype, args, operation));
|
|
2326
2418
|
const limit = getQueryMaxReferenceFrames();
|
|
2327
|
-
if (
|
|
2328
|
-
throw new SimfinityError(`
|
|
2329
|
-
+ 'frames in one set of conditions (the client\'s arguments of a query field, or a view\'s where), more than the limit '
|
|
2330
|
-
+ `of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2419
|
+
if (count > limit) {
|
|
2420
|
+
throw new SimfinityError(`The client's conditions need ${count} frames and scoped joins in one query field, more than the limit of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2331
2421
|
}
|
|
2332
|
-
return
|
|
2422
|
+
return { issued, clientJ };
|
|
2333
2423
|
};
|
|
2334
2424
|
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
|
|
2338
|
-
const
|
|
2425
|
+
// Every issued ID must survive on a valid carrier, with no forged or prefilled marker. Marked
|
|
2426
|
+
// descendants may only live inside marked client frames; top-frame collection prefixes keep J.
|
|
2427
|
+
const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
|
|
2428
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2339
2429
|
const found = new Set();
|
|
2340
|
-
for (const
|
|
2430
|
+
for (const entry of collectQueryOperators(args, operation)) {
|
|
2431
|
+
const { construct, operator, path } = entry;
|
|
2341
2432
|
if (!hasScopeMarker(construct)) continue;
|
|
2342
2433
|
const marker = construct[SCOPE_MARKER];
|
|
2343
2434
|
const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
|
|
@@ -2348,6 +2439,17 @@ const assertClientMarkers = (args, operation, issued) => {
|
|
|
2348
2439
|
if (!origin) {
|
|
2349
2440
|
throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
|
|
2350
2441
|
}
|
|
2442
|
+
if (ignoredScalarTerm(gqltype, entry)) {
|
|
2443
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is in ignored scalar filter terms`);
|
|
2444
|
+
}
|
|
2445
|
+
if (entry.frames.some((frame) => !hasScopeMarker(frame.construct))) {
|
|
2446
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
|
|
2447
|
+
}
|
|
2448
|
+
if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
|
|
2449
|
+
for (const relation of pathRelations(gqltype, entry.segments)) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
|
|
2450
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2351
2453
|
found.add(marker.id);
|
|
2352
2454
|
}
|
|
2353
2455
|
for (const [id, origin] of issued) {
|
|
@@ -2368,6 +2470,15 @@ const relationOnPath = (gqltype, segments) => {
|
|
|
2368
2470
|
return found;
|
|
2369
2471
|
};
|
|
2370
2472
|
|
|
2473
|
+
const scopeFilterArgs = (scopeArgs) => {
|
|
2474
|
+
const kept = {};
|
|
2475
|
+
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2476
|
+
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2477
|
+
else if (value != null) assertScopeControlArg(name, value);
|
|
2478
|
+
}
|
|
2479
|
+
return kept;
|
|
2480
|
+
};
|
|
2481
|
+
|
|
2371
2482
|
// The find scope arguments of a type that a client's constructs read through, once per call and type
|
|
2372
2483
|
// (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
|
|
2373
2484
|
// joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
|
|
@@ -2380,11 +2491,7 @@ const clientScopeArgs = async (typeName, context, cache) => {
|
|
|
2380
2491
|
if (getScope(target?.gqltype, 'find')) {
|
|
2381
2492
|
const scopeArgs = {};
|
|
2382
2493
|
await executeScope({ type: target, args: scopeArgs, operation: 'find', context });
|
|
2383
|
-
const kept =
|
|
2384
|
-
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2385
|
-
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2386
|
-
else if (value != null) assertScopeControlArg(name, value);
|
|
2387
|
-
}
|
|
2494
|
+
const kept = scopeFilterArgs(scopeArgs);
|
|
2388
2495
|
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2389
2496
|
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2390
2497
|
if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
|
|
@@ -2393,19 +2500,15 @@ const clientScopeArgs = async (typeName, context, cache) => {
|
|
|
2393
2500
|
return result;
|
|
2394
2501
|
};
|
|
2395
2502
|
|
|
2396
|
-
//
|
|
2397
|
-
//
|
|
2398
|
-
//
|
|
2399
|
-
//
|
|
2400
|
-
// keys of every position when middleware placed the object at several;
|
|
2401
|
-
// - an emptiness condition narrowed to the record's own fields loses its marker, and is trusted;
|
|
2402
|
-
// - another operator loses it too, unless its path still crosses or ends at a reference or a
|
|
2403
|
-
// collection: no joined scope was recorded for it, so it fails with DETACHED_CLIENT_FILTER.
|
|
2404
|
-
// An object that reads references at one position and none at another fails the same way, since one
|
|
2405
|
-
// marker cannot serve both. Every check comes before any target scope runs.
|
|
2503
|
+
// Preflight every final position before running target scopes: union relative keys, reject target
|
|
2504
|
+
// collisions and mixed reads, and retain the compiler's rejection of marked legacy predicates.
|
|
2505
|
+
// J collection keys are null; anchor references remain legacy joins. Keyless carriers lose their
|
|
2506
|
+
// marker. One per-call type cache serves all remaining keys, separately from joined-scope execution.
|
|
2406
2507
|
const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
2508
|
+
const { conditions, args, operation } = checked;
|
|
2509
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2407
2510
|
const marked = new Map();
|
|
2408
|
-
for (const item of
|
|
2511
|
+
for (const item of conditions) {
|
|
2409
2512
|
const { construct } = item.entry;
|
|
2410
2513
|
if (!hasScopeMarker(construct)) continue;
|
|
2411
2514
|
if (!marked.has(construct)) marked.set(construct, []);
|
|
@@ -2417,18 +2520,29 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2417
2520
|
const origin = issued.get(id);
|
|
2418
2521
|
const targets = {};
|
|
2419
2522
|
const unread = [];
|
|
2420
|
-
|
|
2421
|
-
|
|
2523
|
+
const vectors = [];
|
|
2524
|
+
for (const { entry, shape, type } of positions) {
|
|
2525
|
+
const relations = pathRelations(type, entry.relative);
|
|
2526
|
+
if (!shape && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
|
|
2422
2527
|
const relation = relationOnPath(gqltype, entry.segments);
|
|
2423
|
-
if (relation) {
|
|
2424
|
-
|
|
2425
|
-
|
|
2528
|
+
if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2529
|
+
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
2530
|
+
}
|
|
2531
|
+
const rootShorthand = !entry.frames.length && entry.form !== 'quantified' && !shape;
|
|
2532
|
+
const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
|
|
2533
|
+
const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
|
|
2534
|
+
const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
|
|
2535
|
+
.filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2536
|
+
.map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
|
|
2537
|
+
for (const [key, target] of Object.entries(keys)) {
|
|
2538
|
+
if (Object.hasOwn(targets, key) && targets[key] !== target) {
|
|
2539
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} uses ${key} for different scope targets`);
|
|
2426
2540
|
}
|
|
2427
|
-
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2541
|
+
targets[key] = target;
|
|
2542
|
+
}
|
|
2543
|
+
if (!Object.keys(keys).length) unread.push(entry.path);
|
|
2544
|
+
for (const relation of relations) if (!relation.collection && relation.insideList && Object.hasOwn(keys, relation.path)) {
|
|
2545
|
+
vectors.push({ ...relation, absolute: [...entry.segments.slice(0, -entry.relative.length), ...relation.path.split('.')].join('.') });
|
|
2432
2546
|
}
|
|
2433
2547
|
}
|
|
2434
2548
|
const paths = Object.keys(targets);
|
|
@@ -2436,16 +2550,16 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2436
2550
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
|
|
2437
2551
|
+ `references or collections and where it reads none (${unread.join(', ')})`);
|
|
2438
2552
|
}
|
|
2439
|
-
fills.push({ construct, id, targets: paths.length ? targets : null });
|
|
2553
|
+
fills.push({ construct, id, targets: paths.length ? targets : null, vectors });
|
|
2440
2554
|
}
|
|
2441
2555
|
const cache = new Map();
|
|
2442
|
-
for (const { construct, id, targets } of fills) {
|
|
2443
|
-
if (!targets) {
|
|
2444
|
-
delete construct[SCOPE_MARKER];
|
|
2445
|
-
continue;
|
|
2446
|
-
}
|
|
2556
|
+
for (const { construct, id, targets, vectors } of fills) {
|
|
2557
|
+
if (!targets) { delete construct[SCOPE_MARKER]; continue; }
|
|
2447
2558
|
const scopes = {};
|
|
2448
|
-
for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
|
|
2559
|
+
for (const [path, typeName] of Object.entries(targets)) scopes[path] = typeName === null ? null : await clientScopeArgs(typeName, context, cache);
|
|
2560
|
+
for (const relation of vectors) if (scopes[relation.path] !== null) {
|
|
2561
|
+
throw forbiddenPath(`Query path ${relation.absolute} enters ${relation.typeName}, whose find scope restricts the relation ${relation.owner}.${relation.fieldName} inside a list`);
|
|
2562
|
+
}
|
|
2449
2563
|
construct[SCOPE_MARKER] = { id, scopes };
|
|
2450
2564
|
}
|
|
2451
2565
|
};
|
|
@@ -2455,8 +2569,8 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2455
2569
|
// the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
|
|
2456
2570
|
// Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
|
|
2457
2571
|
// the adapter after as many promise jobs as before.
|
|
2458
|
-
const settleClientArgs = (gqltype, params, operation, issued) => {
|
|
2459
|
-
if (issued) assertClientMarkers(params.args, operation, issued);
|
|
2572
|
+
const settleClientArgs = (gqltype, params, operation, { issued, clientJ }) => {
|
|
2573
|
+
if (issued) assertClientMarkers(gqltype, params.args, operation, issued, clientJ);
|
|
2460
2574
|
assertStoredQueryPaths(gqltype, params.args, operation);
|
|
2461
2575
|
const checked = assertQueryConditions(gqltype, params.args, operation);
|
|
2462
2576
|
return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
|
|
@@ -3073,7 +3187,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
3073
3187
|
// without one still reaches the adapter after as many promise jobs as before it was shared.
|
|
3074
3188
|
const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
|
|
3075
3189
|
const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
|
|
3076
|
-
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
|
|
3190
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation), { aggregation: args.aggregation });
|
|
3077
3191
|
const params = entry === undefined
|
|
3078
3192
|
? { type, args, operation, context }
|
|
3079
3193
|
: { type, args, operation, entry, context };
|
|
@@ -3122,13 +3236,13 @@ const splitViewArgs = (paramNames, args) => {
|
|
|
3122
3236
|
|
|
3123
3237
|
const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
|
|
3124
3238
|
|
|
3125
|
-
// The groups a view's `where` returned are plain objects whose
|
|
3239
|
+
// The groups a view's `where` returned are plain objects whose condition and Boolean/quantified lists, when set,
|
|
3126
3240
|
// are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
|
|
3127
3241
|
// skips anything else silently, so this comes before their paths are read. Only the GraphQL input
|
|
3128
|
-
// fields are structural keys; condition values stay opaque.
|
|
3129
|
-
// marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
|
|
3130
|
-
const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
3131
|
-
const groupFields = QLFilterGroup.getFields();
|
|
3242
|
+
// fields are structural keys; condition values stay opaque. Conditions and quantified groups never
|
|
3243
|
+
// carry a client scope marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
|
|
3244
|
+
const assertViewGroupShapes = (view, groups, ancestors = new Set(), quantified = false) => {
|
|
3245
|
+
const groupFields = (quantified ? QLQuantifiedGroup : QLFilterGroup).getFields();
|
|
3132
3246
|
const conditionFields = QLFilterCondition.getFields();
|
|
3133
3247
|
for (const group of groups) {
|
|
3134
3248
|
if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
|
|
@@ -3136,7 +3250,11 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
|
3136
3250
|
for (const key of Object.keys(group)) {
|
|
3137
3251
|
if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
|
|
3138
3252
|
}
|
|
3253
|
+
if (quantified && (typeof group.path !== 'string' || group.path.length === 0)) {
|
|
3254
|
+
throw invalidWhere(view, 'returned a quantified group without a non-empty path');
|
|
3255
|
+
}
|
|
3139
3256
|
for (const key of Object.keys(groupFields)) {
|
|
3257
|
+
if (key === 'path') continue;
|
|
3140
3258
|
if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
|
|
3141
3259
|
}
|
|
3142
3260
|
for (const condition of group.conditions ?? []) {
|
|
@@ -3152,6 +3270,7 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
|
3152
3270
|
}
|
|
3153
3271
|
ancestors.add(group);
|
|
3154
3272
|
assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
|
|
3273
|
+
for (const key of QUANTIFIED_LISTS) assertViewGroupShapes(view, group[key] ?? [], ancestors, true);
|
|
3155
3274
|
ancestors.delete(group);
|
|
3156
3275
|
}
|
|
3157
3276
|
};
|