@simtlix/simfinity-core 3.14.0 → 3.16.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.
@@ -1,10 +1,11 @@
1
1
  import QLOperator from './const/QLOperator.js';
2
2
  import SimfinityError from './errors/simfinity.error.js';
3
3
 
4
- // Internal: the filter operators and the value rules every backend applies to them. They are shared
5
- // by the core runtime, the query plan and the MongoDB compiler through the
6
- // `./internal/query-operators` subpath, which is not public API. The emptiness operators live in
7
- // query-emptiness.js and are re-exported here, so the subpath stays the only one.
4
+ // Internal: the filter operators and the value rules every backend applies to them, and the path
5
+ // and leaf rules of the list operator CONTAINS_ALL (#108, part 4). They are shared by the core
6
+ // runtime, the query plan and the backend compilers through the `./internal/query-operators`
7
+ // subpath, which is not public API. The emptiness operators live in query-emptiness.js and are
8
+ // re-exported here, so the subpath stays the only one.
8
9
 
9
10
  export {
10
11
  EMPTINESS_QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopePaths,
@@ -28,7 +29,9 @@ export const LEGACY_QUERY_OPERATORS = Object.freeze(['EQ', 'NE', 'LT', 'LTE', 'G
28
29
  * The literal text operators (#108, part 1) and what each one matches: `contains`, `prefix`,
29
30
  * `suffix` or `exact` (the whole value), respecting case or ignoring it (`ignoreCase`). Values are
30
31
  * literal: `%`, `_`, `.`, `*`, `\`, `^`, `$` and `(` match themselves. `LIKE`, the case-sensitive
31
- * substring of 3.7.0, keeps its own validation and compilation and is not listed here.
32
+ * substring of 3.7.0, keeps its own validation and compilation and is not listed here. Neither is
33
+ * `IIN` (#108, part 4), `IN` ignoring case: it takes a list of such values, which
34
+ * {@link validateTextListFilterValue} checks.
32
35
  */
33
36
  export const TEXT_QUERY_OPERATORS = Object.freeze({
34
37
  ILIKE: Object.freeze({ match: 'contains', ignoreCase: true }),
@@ -66,6 +69,85 @@ export const validateTextFilterValue = (operator, value, scalar, path) => {
66
69
  return value;
67
70
  };
68
71
 
72
+ /** The operators whose value is an array: IN, NIN and BTW (3.7.0), CONTAINS_ALL and IIN (#108 part 4). */
73
+ export const LIST_VALUE_OPERATORS = Object.freeze(['IN', 'NIN', 'BTW', 'CONTAINS_ALL', 'IIN']);
74
+
75
+ /** Whether `operator` takes an array value. */
76
+ export const takesListValue = (operator) => LIST_VALUE_OPERATORS.includes(operator);
77
+
78
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
79
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
80
+
81
+ /**
82
+ * Validates the value of CONTAINS_ALL (#108 section 3.3) before any backend compiles it: a non-empty array of
83
+ * non-null scalars, none of them an array or a plain object. Anything else fails with INVALID_FILTER_VALUE (400).
84
+ * Returns the value unchanged: the compilers cast each element as they cast IN elements, and keep duplicates.
85
+ */
86
+ export const validateContainsAllValue = (value) => {
87
+ if (value === undefined || value === null) invalidValue('Expected a scalar filter value');
88
+ // Array.from reads a hole of a sparse array as undefined; `some` would skip it.
89
+ if (Array.isArray(value) && Array.from(value).some((item) => item == null || Array.isArray(item) || isPlainObject(item))) {
90
+ invalidValue('Filter lists require non-null scalar elements');
91
+ }
92
+ if (!Array.isArray(value)) invalidValue('CONTAINS_ALL requires an array value');
93
+ if (!value.length) invalidValue('CONTAINS_ALL requires a non-empty list');
94
+ return value;
95
+ };
96
+
97
+ /**
98
+ * Validates the value of IIN, IN ignoring case, before any backend compiles it: the field must be a `String` field
99
+ * or a `String` list by its effective scalar, as for IEQ, and the value an array of strings without U+0000; the
100
+ * empty array is valid and matches nothing. Anything else fails with INVALID_FILTER_VALUE (400). Returns the value
101
+ * unchanged: it is neither folded nor deduplicated.
102
+ */
103
+ export const validateTextListFilterValue = (operator, value, scalar, path) => {
104
+ const where = path == null ? '' : ` on ${Array.isArray(path) ? path.join('.') : path}`;
105
+ if (scalar !== 'String') invalidValue(`${operator} requires a String field${where}`);
106
+ if (!Array.isArray(value)) invalidValue(`${operator} requires an array value${where}`);
107
+ // Array.from reads a hole of a sparse array as undefined; `some` would skip it.
108
+ const items = Array.from(value);
109
+ if (items.some((item) => typeof item !== 'string')) invalidValue(`${operator} requires non-null string elements${where}`);
110
+ if (items.some((item) => item.includes('\u0000'))) invalidValue(`${operator} values cannot contain U+0000${where}`);
111
+ return value;
112
+ };
113
+
114
+ const failPath = (message, code) => { throw new SimfinityError(message, code, 400); };
115
+ // The validator's steps (graphqlStep, modelStep) and resolveModelPath's field descriptions, read alike.
116
+ const stepKind = (step) => {
117
+ if (step.kind === 'embedded') return step.list ? 'embeddedList' : 'embedded';
118
+ if (step.kind === 'scalar') return step.list ? 'scalarList' : 'scalar';
119
+ if (step.kind === 'reference' && step.inferred) return 'scalar';
120
+ return step.kind;
121
+ };
122
+
123
+ /**
124
+ * The permanent path and leaf rules of CONTAINS_ALL (#108 section 3.3), on the steps of a path relative to its
125
+ * frame: the path ends at a value, as for every operator (MISSING_FILTER_PATH for one segment, INVALID_FILTER_PATH
126
+ * otherwise; the validator has applied this rule already, and PostgreSQL's hand-built plans get core's codes from
127
+ * it), no collection is crossed (INVALID_FILTER_PATH, which the shorthand's exclusion states), no reference follows
128
+ * an embedded list (INVALID_FILTER_PATH: that join yields one referenced record per row), and the leaf is a list: a
129
+ * scalar list, or a scalar reached through an embedded list (INVALID_FILTER_OPERATOR otherwise).
130
+ */
131
+ export const assertContainsAllPath = (steps) => {
132
+ const kinds = steps.map(stepKind);
133
+ const names = steps.map(({ name }) => name);
134
+ // Core's object-end rule first (query-frames.js:420-422), so that every caller gets core's codes in core's order.
135
+ if (['embedded', 'embeddedList', 'reference', 'collection'].includes(kinds.at(-1))) {
136
+ failPath('Filter on object field requires a scalar path', steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
137
+ }
138
+ const crossed = kinds.slice(0, -1);
139
+ if (crossed.includes('collection')) failPath('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
140
+ const list = crossed.indexOf('embeddedList');
141
+ const reference = list === -1 ? -1 : crossed.findIndex((kind, index) => index > list && kind === 'reference');
142
+ if (reference !== -1) {
143
+ failPath(`CONTAINS_ALL cannot cross the reference ${names.slice(0, reference + 1).join('.')} inside an embedded list, which joins one referenced record per row; combine EQ conditions on the path instead`, 'INVALID_FILTER_PATH');
144
+ }
145
+ const leaf = kinds.at(-1);
146
+ if (leaf !== 'scalarList' && !(leaf === 'scalar' && list !== -1)) {
147
+ failPath(`CONTAINS_ALL requires a list field, or a field reached through an embedded list; ${names.join('.')} is not one`, 'INVALID_FILTER_OPERATOR');
148
+ }
149
+ };
150
+
69
151
  const invalidFeatures = (message) => {
70
152
  throw new SimfinityError(`DatabaseAdapter queryFeatures ${message}`, 'INVALID_QUERY_FEATURES', 500);
71
153
  };
@@ -76,7 +158,9 @@ const invalidFeatures = (message) => {
76
158
  * Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
77
159
  * and neither feature. A declaration is an object whose `operators`
78
160
  * is an array of operator names, whose names this version does not know are ignored, and whose
79
- * optional `referenceEmptiness` and `quantifiers` are booleans, undefined or null (false).
161
+ * optional `referenceEmptiness` and `quantifiers` are booleans, undefined or null (false). A name
162
+ * counts once this version knows it: a declaration that listed `CONTAINS_ALL` or `IIN` before
163
+ * #108 part 4 added them receives them on upgrade.
80
164
  * Quantifiers require reference emptiness for scoped joins. Other members are left for later
81
165
  * features. Anything else throws `INVALID_QUERY_FEATURES` (500).
82
166
  */
package/src/query-plan.js CHANGED
@@ -4,7 +4,7 @@ import { paginationStages } from './query-limits.js';
4
4
  import SimfinityError from './errors/simfinity.error.js';
5
5
  import {
6
6
  describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator, isQueryOperator,
7
- textQueryOperator, validateTextFilterValue,
7
+ takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
8
8
  } from './query-operators.js';
9
9
 
10
10
  const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
@@ -256,10 +256,19 @@ export const resolveModelPath = (models, entityName, path) => {
256
256
 
257
257
  /**
258
258
  * Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. Operators
259
- * and values are validated here, text operators through validateTextFilterValue. With `operators`,
259
+ * and values are validated here, text operators through validateTextFilterValue, CONTAINS_ALL
260
+ * through validateContainsAllValue and IIN through validateTextListFilterValue. With `operators`,
260
261
  * the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
261
262
  * without it every QLOperator value is planned.
262
263
  *
264
+ * The list operators (#108 part 4) plan `{ kind: 'predicate', path, operator, value }` with the
265
+ * array as validated, neither cast nor deduplicated: CONTAINS_ALL's is a non-empty array of
266
+ * non-null scalars, on a scalar list or a scalar reached through embedded lists; IIN's is an array
267
+ * of strings, possibly empty, on a String field or list. CONTAINS_ALL's path and leaf rules are the
268
+ * shared validator's, which runs first: through a collection or a reference inside an embedded
269
+ * list it fails with INVALID_FILTER_PATH, on any other leaf with INVALID_FILTER_OPERATOR. IIN
270
+ * through a collection becomes a correlated ANY frame like IN, and CONTAINS_ALL is never cut.
271
+ *
263
272
  * The emptiness operators plan `{ kind: 'predicate', path, operator, value: null }`, whose path may
264
273
  * end at a scalar, a scalar list, an embedded list or an embedded object, after the checks of
265
274
  * describeEmptiness in their order (on the entity's GraphQL type, or on the field descriptions when
@@ -339,12 +348,14 @@ export const createQueryPlan = (models, entityName, source = {}, {
339
348
  }
340
349
  if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
341
350
  if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
342
- const collection = ['IN', 'NIN', 'BTW'].includes(op);
351
+ const collection = takesListValue(op);
343
352
  if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
344
353
  if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
345
- if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
354
+ if (collection && !Array.isArray(value)) fail(`${op} requires an array value`);
346
355
  if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
347
356
  if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
357
+ if (op === 'CONTAINS_ALL') validateContainsAllValue(value);
358
+ if (op === 'IIN') validateTextListFilterValue(op, value, field.scalar, path);
348
359
  const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
349
360
  if (hasMarker(construct)) {
350
361
  if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);