@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/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, isEmptinessOperator, textQueryOperator,
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 }` entries.
23
- * Covers filter keys and relation terms, AND/OR conditions, list sort terms, and aggregation
24
- * groupId and fact paths; aggregate sort terms name result keys. Outside aggregate mode a key
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. Malformed entries are
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.
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 `{ segments, group,
32
- * member: true }` entries, so that the checks of client paths apply to them. Only client arguments
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 = null) => {
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('.'))) entries.push({ segments, group, member: true });
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
- if (isEmptinessOperator(value.operator) || terms.some(pathless)) addEmptiness(name);
63
- else add(name);
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 and AND/OR group
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: item.operator ?? 'EQ',
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 = {}, { mode = 'find', operators: supported } = {}) => {
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 = { operator, value, path: segments.join('.') };
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
- if (gqltype) describeEmptiness(gqltype, segments, options);
227
- else describeModelEmptiness(steps, options);
228
- return { kind: 'predicate', path: segments, operator, value: null };
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
- const predicate = (path, operator, value) => {
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));