@simtlix/simfinity-core 3.9.0 → 3.11.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 -4
- package/package.json +1 -1
- package/src/auth/index.js +5 -3
- package/src/const/QLOperator.js +2 -0
- package/src/metadata.js +24 -8
- package/src/query-emptiness.js +297 -0
- package/src/query-limits.js +14 -2
- package/src/query-operators.js +21 -9
- package/src/query-plan.js +206 -35
- package/src/runtime.js +528 -77
- package/types/index.d.ts +119 -12
package/src/query-plan.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { paginationStages } from './query-limits.js';
|
|
2
2
|
import SimfinityError from './errors/simfinity.error.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator,
|
|
5
|
+
textQueryOperator, validateTextFilterValue,
|
|
6
|
+
} from './query-operators.js';
|
|
4
7
|
|
|
5
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
|
+
};
|
|
6
14
|
const operators = new Set(QUERY_OPERATORS);
|
|
7
15
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
8
16
|
// `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
|
|
@@ -10,37 +18,80 @@ const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
|
10
18
|
const listReserved = new Set(['AND', 'OR', 'sort', 'pagination']);
|
|
11
19
|
const controlArgs = (operation) => (operation === 'aggregate' ? reserved : listReserved);
|
|
12
20
|
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
21
|
+
const nestedGroups = (group) => [...listOf(group.AND), ...listOf(group.OR)];
|
|
22
|
+
// A term or condition path that names nothing: for the emptiness operators only, the field itself.
|
|
23
|
+
const isPathless = (path) => path == null || path === '';
|
|
24
|
+
const hasTermPath = (term) => typeof term?.path === 'string' && term.path !== '';
|
|
13
25
|
|
|
14
26
|
/**
|
|
15
|
-
* Internal: the model paths named by generated query arguments, as `{ segments, group
|
|
16
|
-
* Covers filter keys and relation terms, AND/OR conditions, list sort terms,
|
|
17
|
-
* groupId and fact paths; aggregate sort terms name result keys. Outside aggregate
|
|
18
|
-
* named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
19
|
-
* object whose `conditions` hold the path, or null for the other forms.
|
|
20
|
-
*
|
|
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.
|
|
37
|
+
*
|
|
38
|
+
* With `expandMembers` and the queried `gqltype`, the path of each emptiness condition is followed by
|
|
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.
|
|
21
42
|
*/
|
|
22
|
-
export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
43
|
+
export const collectQueryPathEntries = (args, operation = 'find', { gqltype, expandMembers = false } = {}) => {
|
|
23
44
|
const entries = [];
|
|
24
|
-
const add = (path, group = null) => {
|
|
25
|
-
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 });
|
|
47
|
+
};
|
|
48
|
+
const addEmptiness = (path, group, operator, construct) => {
|
|
49
|
+
add(path, group, operator, construct);
|
|
50
|
+
if (!expandMembers || typeof path !== 'string' || !path) return;
|
|
51
|
+
for (const segments of emptinessMemberPaths(gqltype, path.split('.'))) {
|
|
52
|
+
entries.push({
|
|
53
|
+
segments, group, operator, construct, member: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
26
56
|
};
|
|
27
57
|
if (!args || typeof args !== 'object') return entries;
|
|
58
|
+
const operatorOf = (item) => item?.operator ?? 'EQ';
|
|
28
59
|
const visit = (group) => {
|
|
29
60
|
if (!group || typeof group !== 'object') return;
|
|
30
61
|
for (const condition of listOf(group.conditions)) {
|
|
31
62
|
if (typeof condition?.field !== 'string') continue;
|
|
32
|
-
|
|
63
|
+
const path = isPathless(condition.path) ? condition.field : `${condition.field}.${condition.path}`;
|
|
64
|
+
if (isEmptinessOperator(condition.operator)) addEmptiness(path, group, condition.operator, condition);
|
|
65
|
+
else add(path, group, operatorOf(condition), condition);
|
|
33
66
|
}
|
|
34
|
-
for (const item of
|
|
67
|
+
for (const item of nestedGroups(group)) visit(item);
|
|
35
68
|
};
|
|
36
69
|
const controls = controlArgs(operation);
|
|
37
70
|
for (const [name, value] of Object.entries(args)) {
|
|
38
71
|
if (controls.has(name) || value == null) continue;
|
|
39
|
-
const terms = listOf(value.terms)
|
|
40
|
-
|
|
41
|
-
|
|
72
|
+
const terms = listOf(value.terms);
|
|
73
|
+
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
74
|
+
if (!terms.some(hasTermPath)) {
|
|
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));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
let field = false;
|
|
82
|
+
for (const term of terms) {
|
|
83
|
+
if (hasTermPath(term)) {
|
|
84
|
+
if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}`, null, term.operator, term);
|
|
85
|
+
else add(`${name}.${term.path}`, null, operatorOf(term), term);
|
|
86
|
+
} else if (!field && pathless(term)) {
|
|
87
|
+
addEmptiness(name, null, term.operator, term);
|
|
88
|
+
field = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// An emptiness operator beside terms is rejected later; until then it reads the field too.
|
|
92
|
+
if (!field && isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
|
|
42
93
|
}
|
|
43
|
-
for (const item of
|
|
94
|
+
for (const item of nestedGroups(args)) visit(item);
|
|
44
95
|
if (operation === 'aggregate') {
|
|
45
96
|
add(args.aggregation?.groupId);
|
|
46
97
|
for (const fact of listOf(args.aggregation?.facts)) add(fact?.path);
|
|
@@ -49,36 +100,82 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
|
49
100
|
};
|
|
50
101
|
|
|
51
102
|
/**
|
|
52
|
-
* Internal:
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
103
|
+
* Internal: every condition the arguments hold, as `{ operator, path, segments, form, field, rawPath,
|
|
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`.
|
|
106
|
+
* - `form` is 'filter' (an argument's own operator, without a terms array), 'beside-terms' (an
|
|
107
|
+
* operator written next to a terms array, which the compilers read only to reject an emptiness
|
|
108
|
+
* operator), 'term' or 'condition'.
|
|
109
|
+
* - `field` is the argument or condition field as written (a condition's may be dotted) and `rawPath`
|
|
110
|
+
* the term or condition path as written. `path`, for messages, and `segments` join them; a path that
|
|
111
|
+
* is omitted, null or "" names the field itself.
|
|
112
|
+
* - `value` is the condition's value, and `hasValue` whether it has an own `value` key.
|
|
113
|
+
* - `ambiguous` marks a condition with both a dotted field and a non-empty path.
|
|
114
|
+
* - `depth` is 0 for argument filters and for the groups listed in args.AND and args.OR, and one more
|
|
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.
|
|
120
|
+
* Sort, pagination and aggregation hold no operators. Malformed entries are skipped and left to the
|
|
121
|
+
* backends; a group reached twice is read once.
|
|
56
122
|
*/
|
|
57
123
|
export const collectQueryOperators = (args, operation = 'find') => {
|
|
58
124
|
const entries = [];
|
|
59
125
|
if (!args || typeof args !== 'object') return entries;
|
|
60
126
|
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
61
|
-
const add = (item,
|
|
62
|
-
if (isObject(item))
|
|
127
|
+
const add = (item, form, field, rawPath, depth, ambiguous = false) => {
|
|
128
|
+
if (!isObject(item)) return;
|
|
129
|
+
const path = isPathless(rawPath) ? field : `${field}.${rawPath}`;
|
|
130
|
+
const operator = item.operator ?? 'EQ';
|
|
131
|
+
entries.push({
|
|
132
|
+
operator,
|
|
133
|
+
path,
|
|
134
|
+
segments: typeof path === 'string' && path ? path.split('.') : [],
|
|
135
|
+
form,
|
|
136
|
+
field,
|
|
137
|
+
rawPath,
|
|
138
|
+
value: item.value,
|
|
139
|
+
hasValue: Object.hasOwn(item, 'value'),
|
|
140
|
+
ambiguous,
|
|
141
|
+
depth,
|
|
142
|
+
construct: form === 'term' || form === 'condition' ? item : null,
|
|
143
|
+
emptiness: isEmptinessOperator(operator),
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
// The deepest level of each group, as the backends' depth limit counts it; a group inside itself is
|
|
147
|
+
// not entered again.
|
|
148
|
+
const depths = new Map();
|
|
149
|
+
const open = new Set();
|
|
150
|
+
const measure = (group, depth) => {
|
|
151
|
+
if (!isObject(group) || open.has(group) || depths.get(group) >= depth) return;
|
|
152
|
+
depths.set(group, depth);
|
|
153
|
+
open.add(group);
|
|
154
|
+
for (const item of nestedGroups(group)) measure(item, depth + 1);
|
|
155
|
+
open.delete(group);
|
|
63
156
|
};
|
|
64
157
|
const visited = new Set();
|
|
65
158
|
const visit = (group) => {
|
|
66
159
|
if (!isObject(group) || visited.has(group)) return;
|
|
67
160
|
visited.add(group);
|
|
68
161
|
for (const condition of listOf(group.conditions)) {
|
|
69
|
-
|
|
162
|
+
const field = condition?.field;
|
|
163
|
+
const rawPath = condition?.path;
|
|
164
|
+
add(condition, 'condition', field, rawPath, depths.get(group), typeof field === 'string' && field.includes('.') && !isPathless(rawPath));
|
|
70
165
|
}
|
|
71
|
-
for (const item of
|
|
166
|
+
for (const item of nestedGroups(group)) visit(item);
|
|
72
167
|
};
|
|
73
168
|
const controls = controlArgs(operation);
|
|
74
169
|
for (const [name, value] of Object.entries(args)) {
|
|
75
170
|
if (controls.has(name) || !isObject(value)) continue;
|
|
76
171
|
// A relation filter's operators are in its terms; an operator beside them is read too, since a
|
|
77
172
|
// backend that reads the field as a scalar would compile it.
|
|
78
|
-
|
|
79
|
-
if (
|
|
173
|
+
const terms = Array.isArray(value.terms);
|
|
174
|
+
if (!terms || value.operator !== undefined) add(value, terms ? 'beside-terms' : 'filter', name, undefined, 0);
|
|
175
|
+
if (terms) for (const term of value.terms) add(term, 'term', name, term?.path, 0);
|
|
80
176
|
}
|
|
81
|
-
for (const item of
|
|
177
|
+
for (const item of nestedGroups(args)) measure(item, 0);
|
|
178
|
+
for (const item of nestedGroups(args)) visit(item);
|
|
82
179
|
return entries;
|
|
83
180
|
};
|
|
84
181
|
|
|
@@ -127,18 +224,80 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
127
224
|
* and values are validated here, text operators through validateTextFilterValue. With `operators`,
|
|
128
225
|
* the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
|
|
129
226
|
* without it every QLOperator value is planned.
|
|
227
|
+
*
|
|
228
|
+
* The emptiness operators plan `{ kind: 'predicate', path, operator, value: null }`, whose path may
|
|
229
|
+
* end at a scalar, a scalar list, an embedded list or an embedded object, after the checks of
|
|
230
|
+
* describeEmptiness in their order (on the entity's GraphQL type, or on the field descriptions when
|
|
231
|
+
* the description has none). A term or condition without a path tests the field itself; an emptiness
|
|
232
|
+
* operator on an object filter without terms, or beside terms, fails with MISSING_FILTER_PATH. A
|
|
233
|
+
* filter path of other operators that has more than one segment and ends at an object fails with
|
|
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.
|
|
130
246
|
*/
|
|
131
|
-
export const createQueryPlan = (models, entityName, input = {}, {
|
|
247
|
+
export const createQueryPlan = (models, entityName, input = {}, {
|
|
248
|
+
mode = 'find', operators: supported, referenceEmptiness = supported == null,
|
|
249
|
+
} = {}) => {
|
|
132
250
|
const allowed = supported == null ? null : new Set(supported);
|
|
251
|
+
const references = referenceEmptiness === true;
|
|
133
252
|
const combine = (kind, terms) => {
|
|
134
253
|
const active = terms.filter(Boolean);
|
|
135
254
|
return active.length > 1 ? { kind, terms: active } : active[0] || null;
|
|
136
255
|
};
|
|
137
|
-
|
|
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');
|
|
276
|
+
// Rule 1 has resolved the path; rule 2 is the compiler's operators, rules 3 to 6 describeEmptiness's.
|
|
277
|
+
const emptiness = (path, steps, operator, value, construct) => {
|
|
278
|
+
if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
279
|
+
const segments = Array.isArray(path) ? path : path.split('.');
|
|
280
|
+
const options = {
|
|
281
|
+
operator, value, path: segments.join('.'), referenceEmptiness: references,
|
|
282
|
+
};
|
|
283
|
+
const gqltype = models.entities.find((item) => item.name === entityName)?.gqltype;
|
|
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;
|
|
290
|
+
};
|
|
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) => {
|
|
138
294
|
const steps = resolveModelPath(models, entityName, path);
|
|
139
|
-
const field = steps.at(-1);
|
|
140
|
-
if (field.kind !== 'scalar' && !field.inferred) fail(`Filter on object field ${path} requires a scalar path`, 'MISSING_FILTER_PATH');
|
|
141
295
|
const op = operator ?? 'EQ';
|
|
296
|
+
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct);
|
|
297
|
+
const field = steps.at(-1);
|
|
298
|
+
if (field.kind !== 'scalar' && !field.inferred) {
|
|
299
|
+
fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
300
|
+
}
|
|
142
301
|
if (!operators.has(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
143
302
|
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
144
303
|
const collection = ['IN', 'NIN', 'BTW'].includes(op);
|
|
@@ -147,6 +306,7 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
147
306
|
if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
|
|
148
307
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
149
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`);
|
|
150
310
|
return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
151
311
|
};
|
|
152
312
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
@@ -160,7 +320,7 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
160
320
|
for (const condition of value.conditions || []) {
|
|
161
321
|
if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
162
322
|
if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
163
|
-
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));
|
|
164
324
|
}
|
|
165
325
|
for (const item of value.AND || []) terms.push(group(item, depth + 1));
|
|
166
326
|
terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
|
|
@@ -174,12 +334,23 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find',
|
|
|
174
334
|
if (controls.has(name) || value == null) continue;
|
|
175
335
|
const field = resolveModelPath(models, entityName, name).at(-1);
|
|
176
336
|
if (typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter object');
|
|
177
|
-
|
|
178
|
-
|
|
337
|
+
const object = field.kind !== 'scalar' && !field.inferred;
|
|
338
|
+
// Only trusted code writes these forms, since QLTypeFilterExpression has no operator. Other
|
|
339
|
+
// operators beside terms are left out, as before.
|
|
340
|
+
if (object && isEmptinessOperator(value.operator)) {
|
|
341
|
+
fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term without a path: `
|
|
342
|
+
+ `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
|
|
343
|
+
}
|
|
344
|
+
if (object && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
345
|
+
if (!object) terms.push(predicate(name, value.operator, value.value));
|
|
179
346
|
else for (const term of value.terms) {
|
|
180
347
|
if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
|
|
348
|
+
if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
|
|
349
|
+
terms.push(predicate(name, term.operator, term.value, term));
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
181
352
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
182
|
-
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value));
|
|
353
|
+
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term));
|
|
183
354
|
}
|
|
184
355
|
}
|
|
185
356
|
for (const item of input.AND || []) terms.push(group(item));
|