@simtlix/simfinity-core 3.8.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +2 -1
- package/src/auth/index.js +5 -3
- package/src/const/QLOperator.js +8 -0
- package/src/metadata.js +24 -8
- package/src/query-emptiness.js +222 -0
- package/src/query-operators.js +87 -0
- package/src/query-plan.js +165 -15
- package/src/runtime.js +285 -42
- package/types/index.d.ts +104 -4
package/src/query-plan.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { paginationStages } from './query-limits.js';
|
|
2
2
|
import SimfinityError from './errors/simfinity.error.js';
|
|
3
|
+
import {
|
|
4
|
+
QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, isEmptinessOperator, textQueryOperator,
|
|
5
|
+
validateTextFilterValue,
|
|
6
|
+
} from './query-operators.js';
|
|
3
7
|
|
|
4
8
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
5
|
-
const operators = new Set(
|
|
9
|
+
const operators = new Set(QUERY_OPERATORS);
|
|
6
10
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
7
11
|
// `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
|
|
8
12
|
// that name is filtered like any other.
|
|
9
13
|
const listReserved = new Set(['AND', 'OR', 'sort', 'pagination']);
|
|
10
14
|
const controlArgs = (operation) => (operation === 'aggregate' ? reserved : listReserved);
|
|
11
15
|
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
16
|
+
const nestedGroups = (group) => [...listOf(group.AND), ...listOf(group.OR)];
|
|
17
|
+
// A term or condition path that names nothing: for the emptiness operators only, the field itself.
|
|
18
|
+
const isPathless = (path) => path == null || path === '';
|
|
19
|
+
const hasTermPath = (term) => typeof term?.path === 'string' && term.path !== '';
|
|
12
20
|
|
|
13
21
|
/**
|
|
14
22
|
* Internal: the model paths named by generated query arguments, as `{ segments, group }` entries.
|
|
@@ -16,30 +24,59 @@ const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
|
16
24
|
* groupId and fact paths; aggregate sort terms name result keys. Outside aggregate mode a key
|
|
17
25
|
* named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
18
26
|
* object whose `conditions` hold the path, or null for the other forms. Malformed entries are
|
|
19
|
-
* left to the backends.
|
|
27
|
+
* left to the backends. An emptiness term without a path (omitted, null or "") names its field,
|
|
28
|
+
* also next to sibling terms that have paths.
|
|
29
|
+
*
|
|
30
|
+
* 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 `{ segments, group,
|
|
32
|
+
* member: true }` entries, so that the checks of client paths apply to them. Only client arguments
|
|
33
|
+
* are expanded.
|
|
20
34
|
*/
|
|
21
|
-
export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
35
|
+
export const collectQueryPathEntries = (args, operation = 'find', { gqltype, expandMembers = false } = {}) => {
|
|
22
36
|
const entries = [];
|
|
23
37
|
const add = (path, group = null) => {
|
|
24
38
|
if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group });
|
|
25
39
|
};
|
|
40
|
+
const addEmptiness = (path, group = null) => {
|
|
41
|
+
add(path, group);
|
|
42
|
+
if (!expandMembers || typeof path !== 'string' || !path) return;
|
|
43
|
+
for (const segments of emptinessMemberPaths(gqltype, path.split('.'))) entries.push({ segments, group, member: true });
|
|
44
|
+
};
|
|
26
45
|
if (!args || typeof args !== 'object') return entries;
|
|
27
46
|
const visit = (group) => {
|
|
28
47
|
if (!group || typeof group !== 'object') return;
|
|
29
48
|
for (const condition of listOf(group.conditions)) {
|
|
30
49
|
if (typeof condition?.field !== 'string') continue;
|
|
31
|
-
|
|
50
|
+
const path = isPathless(condition.path) ? condition.field : `${condition.field}.${condition.path}`;
|
|
51
|
+
if (isEmptinessOperator(condition.operator)) addEmptiness(path, group);
|
|
52
|
+
else add(path, group);
|
|
32
53
|
}
|
|
33
|
-
for (const item of
|
|
54
|
+
for (const item of nestedGroups(group)) visit(item);
|
|
34
55
|
};
|
|
35
56
|
const controls = controlArgs(operation);
|
|
36
57
|
for (const [name, value] of Object.entries(args)) {
|
|
37
58
|
if (controls.has(name) || value == null) continue;
|
|
38
|
-
const terms = listOf(value.terms)
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
const terms = listOf(value.terms);
|
|
60
|
+
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
61
|
+
if (!terms.some(hasTermPath)) {
|
|
62
|
+
if (isEmptinessOperator(value.operator) || terms.some(pathless)) addEmptiness(name);
|
|
63
|
+
else add(name);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let field = false;
|
|
67
|
+
for (const term of terms) {
|
|
68
|
+
if (hasTermPath(term)) {
|
|
69
|
+
if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}`);
|
|
70
|
+
else add(`${name}.${term.path}`);
|
|
71
|
+
} else if (!field && pathless(term)) {
|
|
72
|
+
addEmptiness(name);
|
|
73
|
+
field = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// An emptiness operator beside terms is rejected later; until then it reads the field too.
|
|
77
|
+
if (!field && isEmptinessOperator(value.operator)) addEmptiness(name);
|
|
41
78
|
}
|
|
42
|
-
for (const item of
|
|
79
|
+
for (const item of nestedGroups(args)) visit(item);
|
|
43
80
|
if (operation === 'aggregate') {
|
|
44
81
|
add(args.aggregation?.groupId);
|
|
45
82
|
for (const fact of listOf(args.aggregation?.facts)) add(fact?.path);
|
|
@@ -47,6 +84,79 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
|
47
84
|
return entries;
|
|
48
85
|
};
|
|
49
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Internal: every condition the arguments hold, as `{ operator, path, segments, form, field, rawPath,
|
|
89
|
+
* value, hasValue, ambiguous, depth }` entries: field filters, relation terms and AND/OR group
|
|
90
|
+
* conditions at any depth, with an omitted operator read as `EQ`.
|
|
91
|
+
* - `form` is 'filter' (an argument's own operator, without a terms array), 'beside-terms' (an
|
|
92
|
+
* operator written next to a terms array, which the compilers read only to reject an emptiness
|
|
93
|
+
* operator), 'term' or 'condition'.
|
|
94
|
+
* - `field` is the argument or condition field as written (a condition's may be dotted) and `rawPath`
|
|
95
|
+
* the term or condition path as written. `path`, for messages, and `segments` join them; a path that
|
|
96
|
+
* is omitted, null or "" names the field itself.
|
|
97
|
+
* - `value` is the condition's value, and `hasValue` whether it has an own `value` key.
|
|
98
|
+
* - `ambiguous` marks a condition with both a dotted field and a non-empty path.
|
|
99
|
+
* - `depth` is 0 for argument filters and for the groups listed in args.AND and args.OR, and one more
|
|
100
|
+
* for each nested group; a group reached at several depths reports the deepest.
|
|
101
|
+
* Sort, pagination and aggregation hold no operators. Malformed entries are skipped and left to the
|
|
102
|
+
* backends; a group reached twice is read once.
|
|
103
|
+
*/
|
|
104
|
+
export const collectQueryOperators = (args, operation = 'find') => {
|
|
105
|
+
const entries = [];
|
|
106
|
+
if (!args || typeof args !== 'object') return entries;
|
|
107
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
108
|
+
const add = (item, form, field, rawPath, depth, ambiguous = false) => {
|
|
109
|
+
if (!isObject(item)) return;
|
|
110
|
+
const path = isPathless(rawPath) ? field : `${field}.${rawPath}`;
|
|
111
|
+
entries.push({
|
|
112
|
+
operator: item.operator ?? 'EQ',
|
|
113
|
+
path,
|
|
114
|
+
segments: typeof path === 'string' && path ? path.split('.') : [],
|
|
115
|
+
form,
|
|
116
|
+
field,
|
|
117
|
+
rawPath,
|
|
118
|
+
value: item.value,
|
|
119
|
+
hasValue: Object.hasOwn(item, 'value'),
|
|
120
|
+
ambiguous,
|
|
121
|
+
depth,
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
// The deepest level of each group, as the backends' depth limit counts it; a group inside itself is
|
|
125
|
+
// not entered again.
|
|
126
|
+
const depths = new Map();
|
|
127
|
+
const open = new Set();
|
|
128
|
+
const measure = (group, depth) => {
|
|
129
|
+
if (!isObject(group) || open.has(group) || depths.get(group) >= depth) return;
|
|
130
|
+
depths.set(group, depth);
|
|
131
|
+
open.add(group);
|
|
132
|
+
for (const item of nestedGroups(group)) measure(item, depth + 1);
|
|
133
|
+
open.delete(group);
|
|
134
|
+
};
|
|
135
|
+
const visited = new Set();
|
|
136
|
+
const visit = (group) => {
|
|
137
|
+
if (!isObject(group) || visited.has(group)) return;
|
|
138
|
+
visited.add(group);
|
|
139
|
+
for (const condition of listOf(group.conditions)) {
|
|
140
|
+
const field = condition?.field;
|
|
141
|
+
const rawPath = condition?.path;
|
|
142
|
+
add(condition, 'condition', field, rawPath, depths.get(group), typeof field === 'string' && field.includes('.') && !isPathless(rawPath));
|
|
143
|
+
}
|
|
144
|
+
for (const item of nestedGroups(group)) visit(item);
|
|
145
|
+
};
|
|
146
|
+
const controls = controlArgs(operation);
|
|
147
|
+
for (const [name, value] of Object.entries(args)) {
|
|
148
|
+
if (controls.has(name) || !isObject(value)) continue;
|
|
149
|
+
// A relation filter's operators are in its terms; an operator beside them is read too, since a
|
|
150
|
+
// backend that reads the field as a scalar would compile it.
|
|
151
|
+
const terms = Array.isArray(value.terms);
|
|
152
|
+
if (!terms || value.operator !== undefined) add(value, terms ? 'beside-terms' : 'filter', name, undefined, 0);
|
|
153
|
+
if (terms) for (const term of value.terms) add(term, 'term', name, term?.path, 0);
|
|
154
|
+
}
|
|
155
|
+
for (const item of nestedGroups(args)) measure(item, 0);
|
|
156
|
+
for (const item of nestedGroups(args)) visit(item);
|
|
157
|
+
return entries;
|
|
158
|
+
};
|
|
159
|
+
|
|
50
160
|
/** Internal: the segment arrays of {@link collectQueryPathEntries}. */
|
|
51
161
|
export const collectQueryPaths = (args, operation = 'find') => (
|
|
52
162
|
collectQueryPathEntries(args, operation).map((entry) => entry.segments)
|
|
@@ -87,23 +197,52 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
87
197
|
return steps;
|
|
88
198
|
};
|
|
89
199
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. Operators
|
|
202
|
+
* and values are validated here, text operators through validateTextFilterValue. With `operators`,
|
|
203
|
+
* the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
|
|
204
|
+
* without it every QLOperator value is planned.
|
|
205
|
+
*
|
|
206
|
+
* The emptiness operators plan `{ kind: 'predicate', path, operator, value: null }`, whose path may
|
|
207
|
+
* end at a scalar, a scalar list, an embedded list or an embedded object, after the checks of
|
|
208
|
+
* describeEmptiness in their order (on the entity's GraphQL type, or on the field descriptions when
|
|
209
|
+
* the description has none). A term or condition without a path tests the field itself; an emptiness
|
|
210
|
+
* operator on an object filter without terms, or beside terms, fails with MISSING_FILTER_PATH. A
|
|
211
|
+
* filter path of other operators that has more than one segment and ends at an object fails with
|
|
212
|
+
* INVALID_FILTER_PATH; a bare object field keeps MISSING_FILTER_PATH.
|
|
213
|
+
*/
|
|
214
|
+
export const createQueryPlan = (models, entityName, input = {}, { mode = 'find', operators: supported } = {}) => {
|
|
215
|
+
const allowed = supported == null ? null : new Set(supported);
|
|
92
216
|
const combine = (kind, terms) => {
|
|
93
217
|
const active = terms.filter(Boolean);
|
|
94
218
|
return active.length > 1 ? { kind, terms: active } : active[0] || null;
|
|
95
219
|
};
|
|
220
|
+
// 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) => {
|
|
222
|
+
if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
223
|
+
const segments = Array.isArray(path) ? path : path.split('.');
|
|
224
|
+
const options = { operator, value, path: segments.join('.') };
|
|
225
|
+
const gqltype = models.entities.find((item) => item.name === entityName)?.gqltype;
|
|
226
|
+
if (gqltype) describeEmptiness(gqltype, segments, options);
|
|
227
|
+
else describeModelEmptiness(steps, options);
|
|
228
|
+
return { kind: 'predicate', path: segments, operator, value: null };
|
|
229
|
+
};
|
|
96
230
|
const predicate = (path, operator, value) => {
|
|
97
231
|
const steps = resolveModelPath(models, entityName, path);
|
|
98
|
-
const field = steps.at(-1);
|
|
99
|
-
if (field.kind !== 'scalar' && !field.inferred) fail(`Filter on object field ${path} requires a scalar path`, 'MISSING_FILTER_PATH');
|
|
100
232
|
const op = operator ?? 'EQ';
|
|
233
|
+
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value);
|
|
234
|
+
const field = steps.at(-1);
|
|
235
|
+
if (field.kind !== 'scalar' && !field.inferred) {
|
|
236
|
+
fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
237
|
+
}
|
|
101
238
|
if (!operators.has(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
239
|
+
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
102
240
|
const collection = ['IN', 'NIN', 'BTW'].includes(op);
|
|
103
241
|
if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
|
|
104
242
|
if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
|
|
105
243
|
if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
|
|
106
244
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
245
|
+
if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
|
|
107
246
|
return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
108
247
|
};
|
|
109
248
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
@@ -131,10 +270,21 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
|
|
|
131
270
|
if (controls.has(name) || value == null) continue;
|
|
132
271
|
const field = resolveModelPath(models, entityName, name).at(-1);
|
|
133
272
|
if (typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter object');
|
|
134
|
-
|
|
135
|
-
|
|
273
|
+
const object = field.kind !== 'scalar' && !field.inferred;
|
|
274
|
+
// Only trusted code writes these forms, since QLTypeFilterExpression has no operator. Other
|
|
275
|
+
// operators beside terms are left out, as before.
|
|
276
|
+
if (object && isEmptinessOperator(value.operator)) {
|
|
277
|
+
fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term without a path: `
|
|
278
|
+
+ `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
|
|
279
|
+
}
|
|
280
|
+
if (object && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
281
|
+
if (!object) terms.push(predicate(name, value.operator, value.value));
|
|
136
282
|
else for (const term of value.terms) {
|
|
137
283
|
if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
|
|
284
|
+
if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
|
|
285
|
+
terms.push(predicate(name, term.operator, term.value));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
138
288
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
139
289
|
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value));
|
|
140
290
|
}
|