@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/query-plan.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { paginationStages } from './query-limits.js';
|
|
2
2
|
import SimfinityError from './errors/simfinity.error.js';
|
|
3
3
|
import {
|
|
4
|
-
QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths,
|
|
5
|
-
validateTextFilterValue,
|
|
4
|
+
QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator,
|
|
5
|
+
textQueryOperator, validateTextFilterValue,
|
|
6
6
|
} from './query-operators.js';
|
|
7
7
|
|
|
8
8
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
9
|
+
// A client scope marker that cannot be honored: only the runtime writes `__scope` on a term or group
|
|
10
|
+
// condition, and fills it after middleware (#108 part 2b).
|
|
11
|
+
const detached = (message) => {
|
|
12
|
+
throw new SimfinityError(`${message}; only the runtime writes and fills client scope markers (docs/guide/middleware.md)`, 'DETACHED_CLIENT_FILTER', 500);
|
|
13
|
+
};
|
|
9
14
|
const operators = new Set(QUERY_OPERATORS);
|
|
10
15
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
11
16
|
// `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
|
|
@@ -19,37 +24,45 @@ const isPathless = (path) => path == null || path === '';
|
|
|
19
24
|
const hasTermPath = (term) => typeof term?.path === 'string' && term.path !== '';
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
|
-
* Internal: the model paths named by generated query arguments, as `{ segments, group
|
|
23
|
-
* Covers filter keys and relation terms, AND/OR conditions, list sort terms,
|
|
24
|
-
* groupId and fact paths; aggregate sort terms name result keys. Outside aggregate
|
|
25
|
-
* named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
26
|
-
* object whose `conditions` hold the path, or null for the other forms.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Internal: the model paths named by generated query arguments, as `{ segments, group, operator,
|
|
28
|
+
* construct }` entries. Covers filter keys and relation terms, AND/OR conditions, list sort terms,
|
|
29
|
+
* and aggregation groupId and fact paths; aggregate sort terms name result keys. Outside aggregate
|
|
30
|
+
* mode a key named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
31
|
+
* object whose `conditions` hold the path, or null for the other forms. `operator` is the operator
|
|
32
|
+
* that reads the path, an omitted one read as `EQ`, or null for sort, groupId and fact paths.
|
|
33
|
+
* `construct` is the term or group condition object that names the path, or null for a field
|
|
34
|
+
* filter's own operator and for sort, groupId and fact paths. Malformed entries are left to the
|
|
35
|
+
* backends. An emptiness term without a path (omitted, null or "") names its field, also next to
|
|
36
|
+
* sibling terms that have paths.
|
|
29
37
|
*
|
|
30
38
|
* With `expandMembers` and the queried `gqltype`, the path of each emptiness condition is followed by
|
|
31
|
-
* the stored member paths that condition reads (emptinessMemberPaths), as
|
|
32
|
-
* member: true
|
|
33
|
-
* are expanded.
|
|
39
|
+
* the stored member paths that condition reads (emptinessMemberPaths), as entries with the same
|
|
40
|
+
* group, operator and construct and `member: true`, so that the checks of client paths apply to them.
|
|
41
|
+
* Only client arguments are expanded.
|
|
34
42
|
*/
|
|
35
43
|
export const collectQueryPathEntries = (args, operation = 'find', { gqltype, expandMembers = false } = {}) => {
|
|
36
44
|
const entries = [];
|
|
37
|
-
const add = (path, group = null) => {
|
|
38
|
-
if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group });
|
|
45
|
+
const add = (path, group = null, operator = null, construct = null) => {
|
|
46
|
+
if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group, operator, construct });
|
|
39
47
|
};
|
|
40
|
-
const addEmptiness = (path, group
|
|
41
|
-
add(path, group);
|
|
48
|
+
const addEmptiness = (path, group, operator, construct) => {
|
|
49
|
+
add(path, group, operator, construct);
|
|
42
50
|
if (!expandMembers || typeof path !== 'string' || !path) return;
|
|
43
|
-
for (const segments of emptinessMemberPaths(gqltype, path.split('.')))
|
|
51
|
+
for (const segments of emptinessMemberPaths(gqltype, path.split('.'))) {
|
|
52
|
+
entries.push({
|
|
53
|
+
segments, group, operator, construct, member: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
44
56
|
};
|
|
45
57
|
if (!args || typeof args !== 'object') return entries;
|
|
58
|
+
const operatorOf = (item) => item?.operator ?? 'EQ';
|
|
46
59
|
const visit = (group) => {
|
|
47
60
|
if (!group || typeof group !== 'object') return;
|
|
48
61
|
for (const condition of listOf(group.conditions)) {
|
|
49
62
|
if (typeof condition?.field !== 'string') continue;
|
|
50
63
|
const path = isPathless(condition.path) ? condition.field : `${condition.field}.${condition.path}`;
|
|
51
|
-
if (isEmptinessOperator(condition.operator)) addEmptiness(path, group);
|
|
52
|
-
else add(path, group);
|
|
64
|
+
if (isEmptinessOperator(condition.operator)) addEmptiness(path, group, condition.operator, condition);
|
|
65
|
+
else add(path, group, operatorOf(condition), condition);
|
|
53
66
|
}
|
|
54
67
|
for (const item of nestedGroups(group)) visit(item);
|
|
55
68
|
};
|
|
@@ -59,22 +72,24 @@ export const collectQueryPathEntries = (args, operation = 'find', { gqltype, exp
|
|
|
59
72
|
const terms = listOf(value.terms);
|
|
60
73
|
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
61
74
|
if (!terms.some(hasTermPath)) {
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
const term = terms.find(pathless);
|
|
76
|
+
if (term) addEmptiness(name, null, term.operator, term);
|
|
77
|
+
else if (isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
|
|
78
|
+
else add(name, null, operatorOf(value));
|
|
64
79
|
continue;
|
|
65
80
|
}
|
|
66
81
|
let field = false;
|
|
67
82
|
for (const term of terms) {
|
|
68
83
|
if (hasTermPath(term)) {
|
|
69
|
-
if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}
|
|
70
|
-
else add(`${name}.${term.path}
|
|
84
|
+
if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}`, null, term.operator, term);
|
|
85
|
+
else add(`${name}.${term.path}`, null, operatorOf(term), term);
|
|
71
86
|
} else if (!field && pathless(term)) {
|
|
72
|
-
addEmptiness(name);
|
|
87
|
+
addEmptiness(name, null, term.operator, term);
|
|
73
88
|
field = true;
|
|
74
89
|
}
|
|
75
90
|
}
|
|
76
91
|
// An emptiness operator beside terms is rejected later; until then it reads the field too.
|
|
77
|
-
if (!field && isEmptinessOperator(value.operator)) addEmptiness(name);
|
|
92
|
+
if (!field && isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
|
|
78
93
|
}
|
|
79
94
|
for (const item of nestedGroups(args)) visit(item);
|
|
80
95
|
if (operation === 'aggregate') {
|
|
@@ -86,8 +101,8 @@ export const collectQueryPathEntries = (args, operation = 'find', { gqltype, exp
|
|
|
86
101
|
|
|
87
102
|
/**
|
|
88
103
|
* Internal: every condition the arguments hold, as `{ operator, path, segments, form, field, rawPath,
|
|
89
|
-
* value, hasValue, ambiguous, depth }` entries: field filters, relation terms
|
|
90
|
-
* conditions at any depth, with an omitted operator read as `EQ`.
|
|
104
|
+
* value, hasValue, ambiguous, depth, construct, emptiness }` entries: field filters, relation terms
|
|
105
|
+
* and AND/OR group conditions at any depth, with an omitted operator read as `EQ`.
|
|
91
106
|
* - `form` is 'filter' (an argument's own operator, without a terms array), 'beside-terms' (an
|
|
92
107
|
* operator written next to a terms array, which the compilers read only to reject an emptiness
|
|
93
108
|
* operator), 'term' or 'condition'.
|
|
@@ -98,6 +113,10 @@ export const collectQueryPathEntries = (args, operation = 'find', { gqltype, exp
|
|
|
98
113
|
* - `ambiguous` marks a condition with both a dotted field and a non-empty path.
|
|
99
114
|
* - `depth` is 0 for argument filters and for the groups listed in args.AND and args.OR, and one more
|
|
100
115
|
* for each nested group; a group reached at several depths reports the deepest.
|
|
116
|
+
* - `construct` is the term or group condition object itself, or null for a field filter's own
|
|
117
|
+
* operator ('filter' and 'beside-terms'). These objects are the only ones that carry a client scope
|
|
118
|
+
* marker, `__scope` (#108 part 2b).
|
|
119
|
+
* - `emptiness` tells whether the operator is IS_EMPTY or IS_NOT_EMPTY.
|
|
101
120
|
* Sort, pagination and aggregation hold no operators. Malformed entries are skipped and left to the
|
|
102
121
|
* backends; a group reached twice is read once.
|
|
103
122
|
*/
|
|
@@ -108,8 +127,9 @@ export const collectQueryOperators = (args, operation = 'find') => {
|
|
|
108
127
|
const add = (item, form, field, rawPath, depth, ambiguous = false) => {
|
|
109
128
|
if (!isObject(item)) return;
|
|
110
129
|
const path = isPathless(rawPath) ? field : `${field}.${rawPath}`;
|
|
130
|
+
const operator = item.operator ?? 'EQ';
|
|
111
131
|
entries.push({
|
|
112
|
-
operator
|
|
132
|
+
operator,
|
|
113
133
|
path,
|
|
114
134
|
segments: typeof path === 'string' && path ? path.split('.') : [],
|
|
115
135
|
form,
|
|
@@ -119,6 +139,8 @@ export const collectQueryOperators = (args, operation = 'find') => {
|
|
|
119
139
|
hasValue: Object.hasOwn(item, 'value'),
|
|
120
140
|
ambiguous,
|
|
121
141
|
depth,
|
|
142
|
+
construct: form === 'term' || form === 'condition' ? item : null,
|
|
143
|
+
emptiness: isEmptinessOperator(operator),
|
|
122
144
|
});
|
|
123
145
|
};
|
|
124
146
|
// The deepest level of each group, as the backends' depth limit counts it; a group inside itself is
|
|
@@ -210,27 +232,68 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
210
232
|
* operator on an object filter without terms, or beside terms, fails with MISSING_FILTER_PATH. A
|
|
211
233
|
* filter path of other operators that has more than one segment and ends at an object fails with
|
|
212
234
|
* INVALID_FILTER_PATH; a bare object field keeps MISSING_FILTER_PATH.
|
|
235
|
+
*
|
|
236
|
+
* With `referenceEmptiness` (#108 part 2b; by default true when `operators` is omitted and false
|
|
237
|
+
* when it is given), an emptiness path may also cross single references, end at a reference or a
|
|
238
|
+
* collection, or read reference and collection members; without it they fail with UNSUPPORTED_QUERY.
|
|
239
|
+
* Such a predicate is trusted, with stored semantics, unless its term or group condition carries the
|
|
240
|
+
* client scope marker the runtime fills, `__scope: { id, scopes }`. Then the predicate gets `scopes:
|
|
241
|
+
* { [path]: where | null }`, with one key for each path emptinessScopePaths lists for it: the plan of
|
|
242
|
+
* that path's target find scope arguments, planned on the target type in its own frame, or null when
|
|
243
|
+
* the scope added nothing. A `__scope` without filled `scopes`, without one of those keys, or on a
|
|
244
|
+
* construct that reads no reference or collection fails with DETACHED_CLIENT_FILTER (500) after the
|
|
245
|
+
* checks above; a `__scope` anywhere else is never read.
|
|
213
246
|
*/
|
|
214
|
-
export const createQueryPlan = (models, entityName, input = {}, {
|
|
247
|
+
export const createQueryPlan = (models, entityName, input = {}, {
|
|
248
|
+
mode = 'find', operators: supported, referenceEmptiness = supported == null,
|
|
249
|
+
} = {}) => {
|
|
215
250
|
const allowed = supported == null ? null : new Set(supported);
|
|
251
|
+
const references = referenceEmptiness === true;
|
|
216
252
|
const combine = (kind, terms) => {
|
|
217
253
|
const active = terms.filter(Boolean);
|
|
218
254
|
return active.length > 1 ? { kind, terms: active } : active[0] || null;
|
|
219
255
|
};
|
|
256
|
+
// The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
|
|
257
|
+
// arguments are planned on the target type, as trusted find arguments in their own frame, whatever
|
|
258
|
+
// the query's mode, with the operator names already read (`operators` may be a one-time iterable).
|
|
259
|
+
const liftScopes = (marker, shape, segments, operator) => {
|
|
260
|
+
const label = `${operator} on ${segments.join('.')}`;
|
|
261
|
+
const targets = emptinessScopeTargets(shape, segments);
|
|
262
|
+
const paths = Object.keys(targets);
|
|
263
|
+
if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
|
|
264
|
+
const scopes = marker?.scopes;
|
|
265
|
+
if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled`);
|
|
266
|
+
const lifted = {};
|
|
267
|
+
for (const path of paths) {
|
|
268
|
+
if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
|
|
269
|
+
lifted[path] = scopes[path] === null
|
|
270
|
+
? null
|
|
271
|
+
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references }).where;
|
|
272
|
+
}
|
|
273
|
+
return lifted;
|
|
274
|
+
};
|
|
275
|
+
const hasMarker = (construct) => construct != null && typeof construct === 'object' && Object.hasOwn(construct, '__scope');
|
|
220
276
|
// Rule 1 has resolved the path; rule 2 is the compiler's operators, rules 3 to 6 describeEmptiness's.
|
|
221
|
-
const emptiness = (path, steps, operator, value) => {
|
|
277
|
+
const emptiness = (path, steps, operator, value, construct) => {
|
|
222
278
|
if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
223
279
|
const segments = Array.isArray(path) ? path : path.split('.');
|
|
224
|
-
const options = {
|
|
280
|
+
const options = {
|
|
281
|
+
operator, value, path: segments.join('.'), referenceEmptiness: references,
|
|
282
|
+
};
|
|
225
283
|
const gqltype = models.entities.find((item) => item.name === entityName)?.gqltype;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
284
|
+
const shape = gqltype ? describeEmptiness(gqltype, segments, options) : describeModelEmptiness(steps, options);
|
|
285
|
+
const node = {
|
|
286
|
+
kind: 'predicate', path: segments, operator, value: null,
|
|
287
|
+
};
|
|
288
|
+
if (hasMarker(construct)) node.scopes = liftScopes(construct.__scope, shape, segments, operator);
|
|
289
|
+
return node;
|
|
229
290
|
};
|
|
230
|
-
|
|
291
|
+
// `construct` is the term or group condition that holds the predicate, the only objects whose
|
|
292
|
+
// `__scope` is read; null for a field filter's own operator.
|
|
293
|
+
const predicate = (path, operator, value, construct = null) => {
|
|
231
294
|
const steps = resolveModelPath(models, entityName, path);
|
|
232
295
|
const op = operator ?? 'EQ';
|
|
233
|
-
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value);
|
|
296
|
+
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct);
|
|
234
297
|
const field = steps.at(-1);
|
|
235
298
|
if (field.kind !== 'scalar' && !field.inferred) {
|
|
236
299
|
fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
@@ -243,6 +306,7 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
243
306
|
if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
|
|
244
307
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
245
308
|
if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
|
|
309
|
+
if (hasMarker(construct)) detached(`${op} on ${Array.isArray(path) ? path.join('.') : path} carries a client scope marker, but is not an emptiness condition`);
|
|
246
310
|
return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
247
311
|
};
|
|
248
312
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
@@ -256,7 +320,7 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
256
320
|
for (const condition of value.conditions || []) {
|
|
257
321
|
if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
258
322
|
if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
259
|
-
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value));
|
|
323
|
+
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition));
|
|
260
324
|
}
|
|
261
325
|
for (const item of value.AND || []) terms.push(group(item, depth + 1));
|
|
262
326
|
terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
|
|
@@ -282,11 +346,11 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
282
346
|
else for (const term of value.terms) {
|
|
283
347
|
if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
|
|
284
348
|
if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
|
|
285
|
-
terms.push(predicate(name, term.operator, term.value));
|
|
349
|
+
terms.push(predicate(name, term.operator, term.value, term));
|
|
286
350
|
continue;
|
|
287
351
|
}
|
|
288
352
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
289
|
-
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value));
|
|
353
|
+
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term));
|
|
290
354
|
}
|
|
291
355
|
}
|
|
292
356
|
for (const item of input.AND || []) terms.push(group(item));
|