@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/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 { QUERY_OPERATORS, textQueryOperator, validateTextFilterValue } from './query-operators.js';
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 }` entries.
16
- * Covers filter keys and relation terms, AND/OR conditions, list sort terms, and aggregation
17
- * groupId and fact paths; aggregate sort terms name result keys. Outside aggregate mode a key
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. Malformed entries are
20
- * left to the backends.
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
- add(condition.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition.field, group);
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 [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
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).filter((term) => typeof term?.path === 'string' && term.path);
40
- if (terms.length) for (const term of terms) add(`${name}.${term.path}`);
41
- else add(name);
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 [...listOf(args.AND), ...listOf(args.OR)]) visit(item);
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: the operator of every condition the arguments hold, as `{ operator, path }` entries: field
53
- * filters, relation terms and AND/OR group conditions at any depth, with an omitted operator read as
54
- * `EQ`. `path` names the condition for messages. Sort, pagination and aggregation hold no operators.
55
- * Malformed entries are skipped and left to the backends; a group reached twice is read once.
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, path) => {
62
- if (isObject(item)) entries.push({ operator: item.operator ?? 'EQ', path });
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
- add(condition, condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field);
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 [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
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
- if (!Array.isArray(value.terms) || value.operator !== undefined) add(value, name);
79
- if (Array.isArray(value.terms)) for (const term of value.terms) add(term, `${name}.${term?.path}`);
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 [...listOf(args.AND), ...listOf(args.OR)]) visit(item);
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 = {}, { mode = 'find', operators: supported } = {}) => {
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
- const predicate = (path, operator, value) => {
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
- if (field.kind !== 'scalar' && !field.inferred && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
178
- if (field.kind === 'scalar' || field.inferred) terms.push(predicate(name, value.operator, value.value));
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));