@simtlix/simfinity-core 3.15.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.
@@ -0,0 +1,345 @@
1
+ import {
2
+ GraphQLID, getNamedType, isEnumType, isInputObjectType, isListType, isNonNullType,
3
+ } from 'graphql';
4
+ import SimfinityError from './errors/simfinity.error.js';
5
+ import { RecordVersionRef } from './record-version.js';
6
+
7
+ // Internal, and never exported from the package. The typed walk that validates the inputs of the
8
+ // lifecycle helpers and of the generated transaction (#151) and builds the snapshots they work on.
9
+ // It is pure: it reads the value and its GraphQL input type, calls no scalar function and no hook,
10
+ // and never changes what it is given. Every container, list and plain-data scalar of the result is
11
+ // new, so no two positions of a snapshot share an object and a hook that rewrites one cannot change
12
+ // another, a retry or the caller's input.
13
+
14
+ // Programmatic callers get the whole check; a transaction's operations arrive already coerced by
15
+ // GraphQL, so they only get the scalar and record-key rules; a copy of a snapshot gets no check.
16
+ const MODES = ['programmatic', 'coerced', 'copy'];
17
+
18
+ const UNKNOWN_FIELD = 'unknown field';
19
+ const STATE_FIELD = 'state changes only through transitionObject';
20
+ const EXPECTED_OBJECT = 'expected a plain object';
21
+ const EXPECTED_LIST = 'expected a list';
22
+ const EXPECTED_DENSE_LIST = 'expected a dense list';
23
+ const ACCESSOR = 'accessor property';
24
+ const HIDDEN_PROPERTY = 'non-enumerable or symbol-keyed property';
25
+ const CYCLE = 'cycle';
26
+ const UNSUPPORTED_SCALAR = 'unsupported scalar value';
27
+ const STRING_KEY = 'pass the ID as a string, for example String(record._id)';
28
+ const PRIMITIVE_KEY = 'pass the ID as a primitive value';
29
+ const NESTED_TOO_DEEPLY = 'input is nested too deeply';
30
+
31
+ const requiredValueError = (path) => new SimfinityError(`Required value ${path} is missing`, 'REQUIRED_VALUE', 400);
32
+
33
+ // The message names the path and the rule, never a value.
34
+ const invalidInputError = (path, reason) => new SimfinityError(`Invalid input at ${path}: ${reason}`,
35
+ 'INVALID_INPUT', 400);
36
+
37
+ // How many containers one scalar value may nest. Past it the copy would recurse without a bound: a JSON
38
+ // value of a few thousand nested arrays is a client's to send, and it must be refused as input, not
39
+ // crash with a stack overflow.
40
+ const MAX_SCALAR_DEPTH = 256;
41
+
42
+ // How many input objects and lists an input may nest, the top-level input object included. Past it the walk
43
+ // would recurse without a bound and end in a stack overflow, a 500 where GraphQL's own roots give a 400.
44
+ // Scalar values inside count on their own, against MAX_SCALAR_DEPTH.
45
+ const MAX_INPUT_DEPTH = 256;
46
+
47
+ const intrinsicGetTime = Date.prototype.getTime;
48
+
49
+ // The time of a real Date, read with the intrinsic so that nothing the caller put on the value runs, or
50
+ // undefined when the value is not one (an object that only has the Date prototype, a Proxy of a Date).
51
+ const dateTime = (value) => {
52
+ try {
53
+ return intrinsicGetTime.call(value);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ };
58
+
59
+ const isPrimitive = (value) => ['string', 'number', 'boolean', 'bigint'].includes(typeof value);
60
+
61
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
62
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
63
+
64
+ // Every copied property is an own data property, even `__proto__`, which assignment would turn into a
65
+ // change of prototype.
66
+ const defineData = (target, key, value) => Object.defineProperty(target, key,
67
+ { value, writable: true, enumerable: true, configurable: true });
68
+
69
+ // The keys the walk visits: with checks, every own key, so that a symbol key is seen and rejected;
70
+ // without them, the enumerable string keys.
71
+ const ownKeysOf = (source, checked) => (checked ? Reflect.ownKeys(source) : Object.keys(source));
72
+
73
+ // The value of an own property, read from its descriptor so that a getter is never run. Only an
74
+ // enumerable data property qualifies. The caller has already refused symbol keys.
75
+ const dataProperty = (source, key, path) => {
76
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
77
+ // A key that was reported and then has no descriptor (a Proxy, or a property that caller code removed
78
+ // while the walk was running) is as hidden as one that is not enumerable.
79
+ if (descriptor === undefined) throw invalidInputError(path, HIDDEN_PROPERTY);
80
+ if (!('value' in descriptor)) throw invalidInputError(path, ACCESSOR);
81
+ if (!descriptor.enumerable) throw invalidInputError(path, HIDDEN_PROPERTY);
82
+ return descriptor.value;
83
+ };
84
+
85
+ // The items of a list, in order. With checks the list must be dense and made of enumerable data
86
+ // properties; without them it is read as it is.
87
+ const listItems = (list, path, checked) => {
88
+ const items = [];
89
+ for (let index = 0; index < list.length; index += 1) {
90
+ if (!checked) {
91
+ items.push(list[index]);
92
+ } else if (Object.hasOwn(list, index)) {
93
+ items.push(dataProperty(list, index, `${path}[${index}]`));
94
+ } else {
95
+ throw invalidInputError(path, EXPECTED_DENSE_LIST);
96
+ }
97
+ }
98
+ return items;
99
+ };
100
+
101
+ // Cycles are looked for along the path from the root to the value being copied, so an object that two
102
+ // positions share without containing itself is not one. `state.depth` counts the input objects and lists
103
+ // entered, and the one past MAX_INPUT_DEPTH is refused at its own path.
104
+ const enter = (source, path, state) => {
105
+ if (state.ancestors.has(source)) throw invalidInputError(path, CYCLE);
106
+ if (state.depth >= MAX_INPUT_DEPTH) throw invalidInputError(path, NESTED_TOO_DEEPLY);
107
+ state.ancestors.add(source);
108
+ state.depth += 1;
109
+ };
110
+
111
+ const leave = (source, state) => {
112
+ state.ancestors.delete(source);
113
+ state.depth -= 1;
114
+ };
115
+
116
+ // A scalar value as plain data: primitives as they are, `Date` by time, and arrays and objects with
117
+ // the Object or a null prototype deeply. `shared` maps the objects already copied within this one
118
+ // value, so a reference it repeats stays shared in the copy; a position never shares with another.
119
+ // Each copied object keeps its source's prototype and is never frozen. Anything else (a function, a
120
+ // symbol, an instance of another class such as ObjectId, Buffer or Map) is unsupported, and so is a
121
+ // value nested in more than MAX_SCALAR_DEPTH containers. `depth` counts the containers above the value.
122
+ const copyData = (value, path, state, shared, depth = 0) => {
123
+ if (typeof value === 'function' || typeof value === 'symbol') {
124
+ if (state.checkValues) throw invalidInputError(path, UNSUPPORTED_SCALAR);
125
+ return value;
126
+ }
127
+ if (value === null || typeof value !== 'object') return value;
128
+ const isList = Array.isArray(value);
129
+ const time = !isList && Object.getPrototypeOf(value) === Date.prototype ? dateTime(value) : undefined;
130
+ const isDate = time !== undefined;
131
+ if (!isList && !isDate && !isPlainObject(value)) {
132
+ if (state.checkValues) throw invalidInputError(path, UNSUPPORTED_SCALAR);
133
+ return value;
134
+ }
135
+ if (state.ancestors.has(value)) throw invalidInputError(path, CYCLE);
136
+ if (shared.has(value)) return shared.get(value);
137
+ let copy;
138
+ if (isDate) {
139
+ copy = new Date(time);
140
+ } else {
141
+ if (depth >= MAX_SCALAR_DEPTH) throw invalidInputError(path, UNSUPPORTED_SCALAR);
142
+ state.ancestors.add(value);
143
+ if (isList) {
144
+ copy = listItems(value, path, state.checkValues)
145
+ .map((item, index) => copyData(item, `${path}[${index}]`, state, shared, depth + 1));
146
+ } else {
147
+ copy = Object.create(Object.getPrototypeOf(value));
148
+ for (const key of ownKeysOf(value, state.checkValues)) {
149
+ if (typeof key === 'symbol') throw invalidInputError(path, HIDDEN_PROPERTY);
150
+ const keyPath = `${path}.${key}`;
151
+ const item = state.checkValues ? dataProperty(value, key, keyPath) : value[key];
152
+ defineData(copy, key, copyData(item, keyPath, state, shared, depth + 1));
153
+ }
154
+ }
155
+ state.ancestors.delete(value);
156
+ }
157
+ shared.set(value, copy);
158
+ return copy;
159
+ };
160
+
161
+ // A record key: where the declared type is ID, and for IdInputType.id, only a string; where it is
162
+ // another scalar, any primitive. Plain objects, arrays, dates and class instances such as ObjectId are
163
+ // refused, because on a database that casts nothing an `id` of { $ne: null } would select a record (D1).
164
+ const assertRecordKey = (value, kind, path) => {
165
+ const accepted = kind === 'string' ? typeof value === 'string' : isPrimitive(value);
166
+ if (!accepted) throw invalidInputError(path, kind === 'string' ? STRING_KEY : PRIMITIVE_KEY);
167
+ };
168
+
169
+ const keyKind = (declaredType, isIdInput = false) => (
170
+ isIdInput || getNamedType(declaredType) === GraphQLID ? 'string' : 'primitive'
171
+ );
172
+
173
+ const walkScalar = (value, path, state, position) => {
174
+ if (state.checkValues && position.key !== undefined) assertRecordKey(value, position.key, path);
175
+ return copyData(value, path, state, new Map());
176
+ };
177
+
178
+ // The matched internal value is kept by identity. The test is membership under SameValueZero, which is
179
+ // what `includes` uses; it is not parseValue, and only programmatic input needs it, as GraphQL has
180
+ // already checked a transaction's.
181
+ const walkEnum = (value, type, path, state) => {
182
+ if (state.checkShape && !type.getValues().map((entry) => entry.value).includes(value)) {
183
+ throw invalidInputError(path, `not a value of enum ${type.name}`);
184
+ }
185
+ return value;
186
+ };
187
+
188
+ const walkList = (value, type, path, state, position) => {
189
+ if (!Array.isArray(value)) {
190
+ if (state.checkShape) throw invalidInputError(path, EXPECTED_LIST);
191
+ return walkValue(value, type.ofType, path, state, position);
192
+ }
193
+ enter(value, path, state);
194
+ // walkValue turns an `undefined` item into `null`, which executeItemFunction skips.
195
+ const items = listItems(value, path, state.checkShape)
196
+ .map((item, index) => walkValue(item, type.ofType, `${path}[${index}]`, state, position));
197
+ leave(value, state);
198
+ return items;
199
+ };
200
+
201
+ const walkObject = (value, type, path, state, position) => {
202
+ if (state.checkShape && !isPlainObject(value)) throw invalidInputError(path, EXPECTED_OBJECT);
203
+ // Only a coerced or copied value can get here without being an object, and GraphQL does not
204
+ // produce one: it is copied as plain data.
205
+ if (typeof value !== 'object' || Array.isArray(value)) return copyData(value, path, state, new Map());
206
+ enter(value, path, state);
207
+ const fields = type.getFields();
208
+ const isCollection = state.collectionInputTypes?.has(type) === true;
209
+ // The top-level id of an update, a transition or a delete is required whatever its declared type,
210
+ // and it is a record key.
211
+ const isTopLevelId = (key) => key === 'id' && position.topLevel === true && state.requireId;
212
+ const copy = {};
213
+ for (const key of ownKeysOf(value, state.checkShape)) {
214
+ if (typeof key === 'symbol') throw invalidInputError(path, HIDDEN_PROPERTY);
215
+ const fieldPath = `${path}.${key}`;
216
+ const item = state.checkShape ? dataProperty(value, key, fieldPath) : value[key];
217
+ // An own `undefined` is an omitted field, as GraphQL never delivers one.
218
+ if (item === undefined) continue;
219
+ const field = Object.hasOwn(fields, key) ? fields[key] : undefined;
220
+ if (field === undefined) {
221
+ if (state.checkShape) {
222
+ const managedState = position.topLevel && state.stateManaged && key === 'state';
223
+ throw invalidInputError(fieldPath, managedState ? STATE_FIELD : UNKNOWN_FIELD);
224
+ }
225
+ // GraphQL leaves no undeclared field in a coerced input, so there is no rule to apply: the
226
+ // value is kept as plain data.
227
+ defineData(copy, key, walkScalar(item, fieldPath, state, {}));
228
+ continue;
229
+ }
230
+ const fieldPosition = {};
231
+ if (key === 'id' && (isTopLevelId(key) || position.idIsKey || type === state.idInputType
232
+ || type === state.recordVersionRef)) {
233
+ fieldPosition.key = keyKind(field.type, type === state.idInputType);
234
+ }
235
+ if (isCollection && key === 'updated') fieldPosition.idIsKey = true;
236
+ if (isCollection && key === 'deleted') fieldPosition.key = keyKind(field.type);
237
+ if (item === null && isTopLevelId(key)) throw requiredValueError(fieldPath);
238
+ defineData(copy, key, walkValue(item, field.type, fieldPath, state, fieldPosition));
239
+ }
240
+ leave(value, state);
241
+ for (const [name, field] of Object.entries(fields)) {
242
+ const required = (state.checkShape && isNonNullType(field.type)) || isTopLevelId(name);
243
+ if (required && !Object.hasOwn(copy, name)) throw requiredValueError(`${path}.${name}`);
244
+ }
245
+ return copy;
246
+ };
247
+
248
+ const walkValue = (value, type, path, state, position) => {
249
+ if (isNonNullType(type)) {
250
+ if (state.checkShape && (value === null || value === undefined)) throw requiredValueError(path);
251
+ return walkValue(value, type.ofType, path, state, position);
252
+ }
253
+ if (value === null || value === undefined) return null;
254
+ if (isListType(type)) return walkList(value, type, path, state, position);
255
+ if (isInputObjectType(type)) return walkObject(value, type, path, state, position);
256
+ if (isEnumType(type)) return walkEnum(value, type, path, state);
257
+ return walkScalar(value, path, state, position);
258
+ };
259
+
260
+ const createWalkState = ({
261
+ mode = 'programmatic', topLevelId = false, stateManaged = false, collectionInputTypes, idInputType,
262
+ recordVersionRef = RecordVersionRef,
263
+ }) => {
264
+ if (!MODES.includes(mode)) throw new TypeError(`Unknown input walk mode: ${typeof mode === 'string' ? mode : typeof mode}`);
265
+ return {
266
+ // Containers, lists, fields, nullability and enums.
267
+ checkShape: mode === 'programmatic',
268
+ // Scalars and record keys.
269
+ checkValues: mode !== 'copy',
270
+ requireId: topLevelId === true && mode !== 'copy',
271
+ stateManaged: stateManaged === true,
272
+ collectionInputTypes,
273
+ idInputType,
274
+ recordVersionRef,
275
+ ancestors: new Set(),
276
+ depth: 0,
277
+ };
278
+ };
279
+
280
+ /**
281
+ * Validates `value` against an input object type and returns a snapshot of it.
282
+ *
283
+ * `options.mode` is 'programmatic' (the default: the structure rules and the scalar rules, for the
284
+ * lifecycle helpers), 'coerced' (the scalar rules and the record keys only, for the operations of a
285
+ * transaction, which GraphQL has already coerced) or 'copy' (no check, a type-directed copy of an
286
+ * existing snapshot for one attempt). `path` names the root in errors (default `input`). `topLevelId`
287
+ * makes the top-level `id` required and a record key. `collectionInputTypes` is the set of generated
288
+ * collection inputs (`added`, `updated`, `deleted`), `idInputType` the input of a single reference and
289
+ * `recordVersionRef` the input of a versioned delete (default: Simfinity's own), which the walk knows
290
+ * by identity to find their record keys. With `stateManaged`, an undeclared top-level `state` says that
291
+ * state changes only through transitionObject; elsewhere it is an unknown field.
292
+ *
293
+ * Input objects and lists may nest 256 levels, the top-level input included, in every mode; the next one
294
+ * is refused with `input is nested too deeply`.
295
+ *
296
+ * A missing or null value throws REQUIRED_VALUE (400) and any other violation INVALID_INPUT (400), both
297
+ * naming a path such as `input.lines.added[1].price` and never a value. The value is never changed.
298
+ * The result has new, extensible, plain containers; dates and plain data are copied and enum values
299
+ * are kept by identity.
300
+ */
301
+ export const snapshotOperationInput = (value, inputType, options = {}) => {
302
+ const state = createWalkState(options);
303
+ const path = options.path ?? 'input';
304
+ if (!isInputObjectType(isNonNullType(inputType) ? inputType.ofType : inputType)) {
305
+ throw new TypeError('snapshotOperationInput needs an input object type');
306
+ }
307
+ if (value === undefined || value === null) {
308
+ if (state.checkValues) throw requiredValueError(path);
309
+ return value;
310
+ }
311
+ return walkValue(value, inputType, path, state, { topLevel: true });
312
+ };
313
+
314
+ /**
315
+ * The target of `deleteObject`: a plain object with `id` and, on a protected type only, `version`. The
316
+ * id is a record key and must be a string, as the `ID` of the delete root is (D1). Any other key,
317
+ * including `version` on a type that is not protected, is an unknown field. A missing id, or a missing
318
+ * version on a protected type, throws REQUIRED_VALUE; a version that is present but invalid is left for
319
+ * the OCC check inside the attempt. Returns `{ id }` or `{ id, version }`, which are new objects.
320
+ */
321
+ export const readDeleteTarget = (value, { protectedType, path = 'input' } = {}) => {
322
+ if (value === undefined || value === null) throw requiredValueError(path);
323
+ if (!isPlainObject(value)) throw invalidInputError(path, EXPECTED_OBJECT);
324
+ const state = createWalkState({});
325
+ let id;
326
+ let version;
327
+ for (const key of Reflect.ownKeys(value)) {
328
+ if (typeof key === 'symbol') throw invalidInputError(path, HIDDEN_PROPERTY);
329
+ const keyPath = `${path}.${key}`;
330
+ const item = dataProperty(value, key, keyPath);
331
+ if (item === undefined) continue;
332
+ if (key !== 'id' && !(key === 'version' && protectedType)) throw invalidInputError(keyPath, UNKNOWN_FIELD);
333
+ if (item === null) throw requiredValueError(keyPath);
334
+ if (key === 'id') {
335
+ assertRecordKey(item, 'string', keyPath);
336
+ id = item;
337
+ } else {
338
+ version = copyData(item, keyPath, state, new Map());
339
+ }
340
+ }
341
+ if (id === undefined) throw requiredValueError(`${path}.id`);
342
+ if (!protectedType) return { id };
343
+ if (version === undefined) throw requiredValueError(`${path}.version`);
344
+ return { id, version };
345
+ };
@@ -6,7 +6,10 @@ import { isVirtualField } from './relation-storage.js';
6
6
  import {
7
7
  describeEmptiness, describeModelEmptiness, emptinessScopePaths, graphqlStep, isEmptinessOperator, modelStep,
8
8
  } from './query-emptiness.js';
9
- import { isQueryOperator, textQueryOperator, validateTextFilterValue } from './query-operators.js';
9
+ import {
10
+ assertContainsAllPath, isQueryOperator, takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue,
11
+ validateTextListFilterValue,
12
+ } from './query-operators.js';
10
13
  import { collectQueryOperators, validateQueryGroupKeys } from './query-plan.js';
11
14
  import { getQueryMaxFrameNesting } from './query-limits.js';
12
15
 
@@ -281,18 +284,25 @@ const validateShapes = (args) => {
281
284
  };
282
285
  const validateValue = (entry, resolved, ctx) => {
283
286
  const { operator, value } = entry;
287
+ const scalar = resolved.steps.at(-1).scalar;
284
288
  // GraphQL compilers historically report the text operator's own type/value diagnostic.
285
289
  // Model planning retains its generic scalar-shape checks before text validation.
286
290
  if (ctx.gqltype && textQueryOperator(operator)) {
287
- validateTextFilterValue(operator, value, resolved.steps.at(-1).scalar, entry.path);
291
+ validateTextFilterValue(operator, value, scalar, entry.path);
292
+ return;
293
+ }
294
+ if (ctx.gqltype && operator === 'IIN') {
295
+ validateTextListFilterValue(operator, value, scalar, entry.path);
288
296
  return;
289
297
  }
290
- const array = ['IN', 'NIN', 'BTW'].includes(operator);
298
+ const array = takesListValue(operator);
291
299
  if (value === undefined || (!array && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(operator))) fail('Expected a scalar filter value');
292
300
  if (array && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
293
301
  if (array && !Array.isArray(value)) fail(`${operator} requires an array value`);
294
302
  if (operator === 'BTW' && value.length !== 2) fail('BTW requires two values');
295
- if (textQueryOperator(operator)) validateTextFilterValue(operator, value, resolved.steps.at(-1).scalar, entry.path);
303
+ if (operator === 'CONTAINS_ALL') validateContainsAllValue(value);
304
+ if (textQueryOperator(operator)) validateTextFilterValue(operator, value, scalar, entry.path);
305
+ if (operator === 'IIN') validateTextListFilterValue(operator, value, scalar, entry.path);
296
306
  };
297
307
  /** Internal: check sort shape and path syntax without resolving or authorizing model fields. */
298
308
  export const assertSortSyntax = (sort) => {
@@ -407,6 +417,32 @@ export const validateQuantifiedGroups = (args, ctx) => {
407
417
  if (entry.form === 'quantified') {
408
418
  if (!implicit && (steps.slice(0, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind))
409
419
  || !['reference', 'collection', 'embeddedList'].includes(last.kind))) fail('A quantified path must end at a relation without crossing a list', 'INVALID_FILTER_PATH');
420
+ // A hand-built anchor (#108 part 4) is first a non-empty array of single path segments, before the path rule,
421
+ // which counts the anchor's steps by its elements; list() reads any other present value as [].
422
+ const anchored = implicit && entry.construct.__anchor != null;
423
+ if (anchored && (!anchor.length || anchor.some((name) => typeof name !== 'string' || name.includes('.')))) {
424
+ fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
425
+ }
426
+ // A hand-built implicit group, whatever its operators (#108 part 4): after any anchor, its path ends at a collection,
427
+ // whatever it crosses first, or at a reference without crossing a list, the explicit groups' rule for references.
428
+ if (implicit && last.kind !== 'collection' && (last.kind !== 'reference'
429
+ || steps.slice(anchor.length, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind)))) {
430
+ fail('An implicit quantified path must end at a collection, or at a reference without crossing a list', 'INVALID_FILTER_PATH');
431
+ }
432
+ // Then the shape the rewrite writes (cutAt): in an aggregate, on a group at the root, an anchor that crosses at
433
+ // least one collection, every one a J prefix, with any embedded lists, embedded objects and references between,
434
+ // and after it one step, a collection.
435
+ if (anchored) {
436
+ const prefixes = ctx.J ?? new Set();
437
+ const crossed = steps.slice(0, anchor.length);
438
+ const prefix = (index) => crossed.slice(0, index + 1).map(({ name }) => name).join('.');
439
+ if (ctx.operation !== 'aggregate' || entry.frames.length
440
+ || !crossed.some((step) => step.kind === 'collection')
441
+ || crossed.some((step, index) => step.kind === 'collection' && !prefixes.has(prefix(index)))
442
+ || partsOf(entry.construct.path).length !== 1 || last.kind !== 'collection') {
443
+ fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
444
+ }
445
+ }
410
446
  if (!implicit && !QUANTIFIERS.has(entry.quantifier)) fail('Unknown quantifier', 'INVALID_FILTER_VALUE');
411
447
  if (!implicit) gates.push(() => {
412
448
  if (ctx.quantifiers !== true || last.kind === 'embeddedList') fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
@@ -420,11 +456,10 @@ export const validateQuantifiedGroups = (args, ctx) => {
420
456
  if (!valueLeaf(last, current)) fail(ordinaryCondition && steps.length === 1
421
457
  ? `Filter on object field "${last.name}" requires a path` : 'Filter on object field requires a scalar path',
422
458
  steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
423
- // CONTAINS_ALL has no semantics in this release; its excluded shorthand form has
424
- // the permanent path error specified for it, while every other use stays unknown.
425
- if (entry.operator === 'CONTAINS_ALL' && steps.slice(0, -1).some((step) => step.kind === 'collection')) fail('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
426
459
  if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
427
460
  if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
461
+ // CONTAINS_ALL's permanent path rules, then its leaf rule (#108 section 3.3), on the frame-relative path.
462
+ if (entry.operator === 'CONTAINS_ALL') assertContainsAllPath(steps);
428
463
  if (current.gqltype && last.kind === 'abstract' && entry.value === undefined) fail(`Expected a scalar value for ${entry.path}`);
429
464
  validateValue(entry, resolved, current);
430
465
  }
@@ -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`);