@simtlix/simfinity-core 3.15.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/package.json +1 -1
- package/src/auth/index.js +97 -36
- package/src/auth/operation-entries.js +111 -0
- package/src/const/QLOperator.js +2 -0
- package/src/execution-state.js +71 -0
- package/src/metadata.js +2 -2
- package/src/mutation-limits.js +37 -7
- package/src/operation-input.js +345 -0
- package/src/query-comparisons.js +54 -0
- package/src/query-frames.js +160 -26
- package/src/query-operators.js +98 -10
- package/src/query-plan.js +94 -29
- package/src/runtime.js +723 -77
- package/src/schema-registrations.js +6 -1
- package/src/transaction-references.js +259 -0
- package/src/transaction-schema.js +163 -0
- package/types/index.d.ts +177 -19
package/src/query-frames.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getNamedType, isObjectType } from 'graphql';
|
|
1
|
+
import { getNamedType, isEnumType, isObjectType } from 'graphql';
|
|
2
2
|
import { GROUP_KEYS, QUANTIFIED_LISTS, QUANTIFIERS } from './const/QLQuantifier.js';
|
|
3
3
|
import SimfinityError from './errors/simfinity.error.js';
|
|
4
4
|
import { effectiveScalarName } from './metadata.js';
|
|
@@ -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 {
|
|
9
|
+
import {
|
|
10
|
+
assertContainsAllPath, isQueryOperator, takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue,
|
|
11
|
+
validateTextListFilterValue, hasValuePath, validateComparisonOperand, validateComparisonTypes,
|
|
12
|
+
} from './query-operators.js';
|
|
10
13
|
import { collectQueryOperators, validateQueryGroupKeys } from './query-plan.js';
|
|
11
14
|
import { getQueryMaxFrameNesting } from './query-limits.js';
|
|
12
15
|
|
|
@@ -53,13 +56,17 @@ const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) =>
|
|
|
53
56
|
fail(`Query path ${label} uses the virtual field ${current.gqltype.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
54
57
|
}
|
|
55
58
|
step = graphqlStep(name, field);
|
|
56
|
-
|
|
59
|
+
const type = getNamedType(field.type);
|
|
60
|
+
step.scalar = isEnumType(type) ? 'Enum' : effectiveScalarName(type);
|
|
61
|
+
step.enumName = isEnumType(type) ? type.name : undefined;
|
|
62
|
+
step.temporalName = ['Date', 'Time', 'DateTime'].includes(step.scalar) ? step.scalar : undefined;
|
|
63
|
+
step.version = step.scalar === 'RecordVersion' || step.scalar === 'Version';
|
|
57
64
|
raw.push(field);
|
|
58
65
|
} else {
|
|
59
66
|
const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
|
|
60
67
|
const field = fields?.find((item) => item.name === name && !item.private);
|
|
61
68
|
if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
62
|
-
step = { ...modelStep(field)
|
|
69
|
+
step = { ...field, ...modelStep(field) };
|
|
63
70
|
if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
|
|
64
71
|
raw.push(field);
|
|
65
72
|
}
|
|
@@ -71,6 +78,24 @@ const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) =>
|
|
|
71
78
|
return { steps, raw, end: current };
|
|
72
79
|
};
|
|
73
80
|
|
|
81
|
+
const comparisonDescriptor = (resolved) => {
|
|
82
|
+
const last = resolved.steps.at(-1);
|
|
83
|
+
const previous = resolved.steps.at(-2);
|
|
84
|
+
return { ...last, list: last.kind === 'scalarList', identity: ['id', '_id'].includes(last.name)
|
|
85
|
+
&& (!previous || relation(previous)), version: last.version || ['Version', 'RecordVersion'].includes(last.scalar) };
|
|
86
|
+
};
|
|
87
|
+
const operandContext = (entry, ctx, up) => inFrames({ frames: entry.frames.slice(0, entry.frames.length - up) }, ctx);
|
|
88
|
+
const validateInternalOperand = (entry) => {
|
|
89
|
+
const operand = entry.operand;
|
|
90
|
+
if (operand === undefined) return;
|
|
91
|
+
if (!plain(operand) || !Number.isSafeInteger(operand.up) || operand.up <= 0 || operand.up > entry.frames.length
|
|
92
|
+
|| entry.frames.slice(-operand.up).some((frame) => frame.construct.__implicit !== true)
|
|
93
|
+
|| !Array.isArray(operand.path) || !operand.path.length
|
|
94
|
+
|| operand.path.some((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))
|
|
95
|
+
|| Object.keys(operand).some((key) => !['up', 'path', 'scopes'].includes(key)) || entry.hasValuePath || entry.value !== undefined) {
|
|
96
|
+
fail('Invalid compiler-generated comparison operand', 'INVALID_FILTER_PATH');
|
|
97
|
+
}
|
|
98
|
+
};
|
|
74
99
|
const inFrames = (entry, ctx) => {
|
|
75
100
|
let current = ctx;
|
|
76
101
|
for (const frame of entry.frames) {
|
|
@@ -121,6 +146,13 @@ const cutAt = (resolved, J) => {
|
|
|
121
146
|
}
|
|
122
147
|
return null;
|
|
123
148
|
};
|
|
149
|
+
const comparisonInGroup = (group, seen = new Set()) => {
|
|
150
|
+
if (seen.has(group)) return false;
|
|
151
|
+
seen.add(group);
|
|
152
|
+
return list(group.conditions).some((condition) => hasValuePath(condition) || condition.__operand !== undefined)
|
|
153
|
+
|| ['AND', 'OR', ...QUANTIFIED_LISTS].some((key) => list(group[key]).some((child) => comparisonInGroup(child, seen)));
|
|
154
|
+
};
|
|
155
|
+
const comparisonKeys = (resolved) => resolved.steps.flatMap((step, index, steps) => relation(step) ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []);
|
|
124
156
|
const markerKeys = (resolved, J = new Set()) => {
|
|
125
157
|
const cut = cutAt(resolved, J);
|
|
126
158
|
const anchored = resolved.steps.some((step, index, steps) => step.kind === 'collection'
|
|
@@ -133,14 +165,25 @@ const markerKeys = (resolved, J = new Set()) => {
|
|
|
133
165
|
const prefixScopes = (scopes, prefix, keys) => Object.fromEntries(Object.entries(scopes ?? {}).filter(([key]) => (
|
|
134
166
|
keys.includes(key) && (key === prefix || prefix.startsWith(`${key}.`))
|
|
135
167
|
)));
|
|
136
|
-
const cutCondition = (item, rest, prefix, keys) => {
|
|
168
|
+
const cutCondition = (item, rest, prefix, keys, rightKeys = []) => {
|
|
137
169
|
const { __scope, ...operands } = item;
|
|
138
170
|
delete operands.field;
|
|
139
171
|
delete operands.path;
|
|
140
172
|
const condition = { field: rest, ...operands };
|
|
173
|
+
if (item.__operand) condition.__operand = { ...item.__operand, up: item.__operand.up + 1 };
|
|
174
|
+
else if (hasValuePath(item)) {
|
|
175
|
+
if (item.valuePath.startsWith(`${prefix}.`)) condition.valuePath = item.valuePath.slice(prefix.length + 1);
|
|
176
|
+
else {
|
|
177
|
+
delete condition.valuePath;
|
|
178
|
+
condition.__operand = { up: 1, path: item.valuePath.split('.') };
|
|
179
|
+
if (hasMarker(item) && rightKeys.length) {
|
|
180
|
+
condition.__operand.scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {}).filter(([key]) => rightKeys.includes(key)));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
141
184
|
if (hasMarker(item)) {
|
|
142
185
|
const scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {})
|
|
143
|
-
.filter(([key]) => keys.includes(key) && key.startsWith(`${prefix}.`)).map(([key, value]) => [key.slice(prefix.length + 1), value]));
|
|
186
|
+
.filter(([key]) => [...keys, ...rightKeys].includes(key) && key.startsWith(`${prefix}.`)).map(([key, value]) => [key.slice(prefix.length + 1), value]));
|
|
144
187
|
if (Object.keys(scopes).length) condition.__scope = { id: __scope.id, scopes };
|
|
145
188
|
}
|
|
146
189
|
return condition;
|
|
@@ -182,11 +225,13 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
182
225
|
if (at.anchor.length) bucket.__anchor = at.anchor;
|
|
183
226
|
buckets.set(prefix, bucket);
|
|
184
227
|
}
|
|
185
|
-
const
|
|
228
|
+
const rightResolved = hasValuePath(condition) ? resolve(condition.valuePath, frame) : null;
|
|
229
|
+
const rightKeys = rightResolved ? comparisonKeys(rightResolved) : [];
|
|
230
|
+
const keys = hasValuePath(condition) || condition.__operand ? comparisonKeys(resolved) : markerKeys(resolved, J);
|
|
186
231
|
if (hasMarker(condition) && !hasMarker(bucket)) {
|
|
187
|
-
bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, keys) };
|
|
232
|
+
bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, [...keys, ...rightKeys]) };
|
|
188
233
|
}
|
|
189
|
-
bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys));
|
|
234
|
+
bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys, rightKeys));
|
|
190
235
|
return bucket;
|
|
191
236
|
};
|
|
192
237
|
if (Array.isArray(input.conditions)) {
|
|
@@ -226,7 +271,8 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
226
271
|
if (['scalar', 'scalarList'].includes(leaf.kind)) {
|
|
227
272
|
// Native planner inputs may name a scalar through collections. Each entry is its own
|
|
228
273
|
// correlation group; ignored terms and filter metadata never become predicates or markers.
|
|
229
|
-
const
|
|
274
|
+
const carrier = hasValuePath(value) ? { ...value } : { operator: value.operator ?? 'EQ', value: value.value };
|
|
275
|
+
const group = { conditions: [{ field: name, ...carrier }] };
|
|
230
276
|
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
231
277
|
if (list(rewritten.ANY).length) {
|
|
232
278
|
syntheticGroups.push({ __implicit: true, ANY: rewritten.ANY });
|
|
@@ -235,7 +281,7 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
235
281
|
continue;
|
|
236
282
|
}
|
|
237
283
|
if (!Array.isArray(value.terms) || valueLeaf(leaf, ctx)) continue;
|
|
238
|
-
const group = { conditions: value.terms.map((term) => ({ ...termCondition(term), field: pathless(term.path) ? name : `${name}.${term.path}` })) };
|
|
284
|
+
const group = { conditions: value.terms.map((term) => ({ ...termCondition(term), field: pathless(term.path) ? name : `${name}.${term.path}`, ...(hasValuePath(term) ? { valuePath: `${name}.${term.valuePath}` } : {}) })) };
|
|
239
285
|
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
240
286
|
implicitGroups.push(...list(rewritten.ANY));
|
|
241
287
|
if (rewritten.conditions.length === value.terms.length) continue;
|
|
@@ -281,18 +327,25 @@ const validateShapes = (args) => {
|
|
|
281
327
|
};
|
|
282
328
|
const validateValue = (entry, resolved, ctx) => {
|
|
283
329
|
const { operator, value } = entry;
|
|
330
|
+
const scalar = resolved.steps.at(-1).scalar;
|
|
284
331
|
// GraphQL compilers historically report the text operator's own type/value diagnostic.
|
|
285
332
|
// Model planning retains its generic scalar-shape checks before text validation.
|
|
286
333
|
if (ctx.gqltype && textQueryOperator(operator)) {
|
|
287
|
-
validateTextFilterValue(operator, value,
|
|
334
|
+
validateTextFilterValue(operator, value, scalar, entry.path);
|
|
288
335
|
return;
|
|
289
336
|
}
|
|
290
|
-
|
|
337
|
+
if (ctx.gqltype && operator === 'IIN') {
|
|
338
|
+
validateTextListFilterValue(operator, value, scalar, entry.path);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const array = takesListValue(operator);
|
|
291
342
|
if (value === undefined || (!array && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(operator))) fail('Expected a scalar filter value');
|
|
292
343
|
if (array && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
|
|
293
344
|
if (array && !Array.isArray(value)) fail(`${operator} requires an array value`);
|
|
294
345
|
if (operator === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
295
|
-
if (
|
|
346
|
+
if (operator === 'CONTAINS_ALL') validateContainsAllValue(value);
|
|
347
|
+
if (textQueryOperator(operator)) validateTextFilterValue(operator, value, scalar, entry.path);
|
|
348
|
+
if (operator === 'IIN') validateTextListFilterValue(operator, value, scalar, entry.path);
|
|
296
349
|
};
|
|
297
350
|
/** Internal: check sort shape and path syntax without resolving or authorizing model fields. */
|
|
298
351
|
export const assertSortSyntax = (sort) => {
|
|
@@ -363,7 +416,8 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
363
416
|
if (ctx.gqltype && leaf.kind === 'abstract') abstractFields.add(name);
|
|
364
417
|
if (value.quantifier == null && valueLeaf(leaf, ctx) && Array.isArray(value.terms) && value.operator === undefined) {
|
|
365
418
|
scalarCarriers.push({ form: 'filter', field: name, path: name, relative: partsOf(name), segments: partsOf(name), frames: [],
|
|
366
|
-
operator: 'EQ', value: value.value, construct: null
|
|
419
|
+
operator: 'EQ', value: value.value, construct: hasValuePath(value) ? value : null,
|
|
420
|
+
...(hasValuePath(value) ? { hasValuePath: true, valuePath: value.valuePath } : {}) });
|
|
367
421
|
}
|
|
368
422
|
if (!valueLeaf(leaf, ctx) && value.quantifier == null) {
|
|
369
423
|
if (isEmptinessOperator(value.operator)) fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term `
|
|
@@ -386,7 +440,12 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
386
440
|
entries.push(...abstractCarriers);
|
|
387
441
|
for (const entry of entries) {
|
|
388
442
|
if (ignoredValueTerm(entry, ctx)) continue;
|
|
443
|
+
if (entry.hasValuePath) validateComparisonOperand(entry.construct);
|
|
444
|
+
validateInternalOperand(entry);
|
|
445
|
+
const comparison = entry.hasValuePath || entry.operand !== undefined;
|
|
389
446
|
const current = inFrames(entry, ctx);
|
|
447
|
+
let rightResolved;
|
|
448
|
+
let rightCurrent;
|
|
390
449
|
const implicit = entry.form === 'quantified' && entry.construct.__implicit === true;
|
|
391
450
|
if (entry.form === 'condition' && typeof entry.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
392
451
|
if (entry.ambiguous) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
@@ -407,12 +466,70 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
407
466
|
if (entry.form === 'quantified') {
|
|
408
467
|
if (!implicit && (steps.slice(0, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind))
|
|
409
468
|
|| !['reference', 'collection', 'embeddedList'].includes(last.kind))) fail('A quantified path must end at a relation without crossing a list', 'INVALID_FILTER_PATH');
|
|
469
|
+
// A hand-built anchor (#108 part 4) is first a non-empty array of single path segments, before the path rule,
|
|
470
|
+
// which counts the anchor's steps by its elements; list() reads any other present value as [].
|
|
471
|
+
const anchored = implicit && entry.construct.__anchor != null;
|
|
472
|
+
if (anchored && (!anchor.length || anchor.some((name) => typeof name !== 'string' || name.includes('.')))) {
|
|
473
|
+
fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
|
|
474
|
+
}
|
|
475
|
+
// A hand-built implicit group, whatever its operators (#108 part 4): after any anchor, its path ends at a collection,
|
|
476
|
+
// whatever it crosses first, or at a reference without crossing a list, the explicit groups' rule for references.
|
|
477
|
+
if (implicit && last.kind !== 'collection' && (last.kind !== 'reference'
|
|
478
|
+
|| steps.slice(anchor.length, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind)))) {
|
|
479
|
+
fail('An implicit quantified path must end at a collection, or at a reference without crossing a list', 'INVALID_FILTER_PATH');
|
|
480
|
+
}
|
|
481
|
+
// Then the shape the rewrite writes (cutAt): in an aggregate, on a group at the root, an anchor that crosses at
|
|
482
|
+
// least one collection, every one a J prefix, with any embedded lists, embedded objects and references between,
|
|
483
|
+
// and after it one step, a collection.
|
|
484
|
+
if (anchored) {
|
|
485
|
+
const prefixes = ctx.J ?? new Set();
|
|
486
|
+
const crossed = steps.slice(0, anchor.length);
|
|
487
|
+
const prefix = (index) => crossed.slice(0, index + 1).map(({ name }) => name).join('.');
|
|
488
|
+
if (ctx.operation !== 'aggregate' || entry.frames.length
|
|
489
|
+
|| !crossed.some((step) => step.kind === 'collection')
|
|
490
|
+
|| crossed.some((step, index) => step.kind === 'collection' && !prefixes.has(prefix(index)))
|
|
491
|
+
|| partsOf(entry.construct.path).length !== 1 || last.kind !== 'collection') {
|
|
492
|
+
fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
410
495
|
if (!implicit && !QUANTIFIERS.has(entry.quantifier)) fail('Unknown quantifier', 'INVALID_FILTER_VALUE');
|
|
411
496
|
if (!implicit) gates.push(() => {
|
|
412
497
|
if (ctx.quantifiers !== true || last.kind === 'embeddedList') fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
413
498
|
});
|
|
414
499
|
} else {
|
|
415
|
-
if (
|
|
500
|
+
if (comparison) {
|
|
501
|
+
rightCurrent = entry.operand ? operandContext(entry, ctx, entry.operand.up) : current;
|
|
502
|
+
const rightPath = entry.operand?.path ?? partsOf(entry.valuePath);
|
|
503
|
+
rightResolved = resolve(rightPath, rightCurrent);
|
|
504
|
+
if (!['scalar', 'scalarList'].includes(last.kind) || !['scalar', 'scalarList'].includes(rightResolved.steps.at(-1).kind)) {
|
|
505
|
+
fail('Field comparison operands must end in scalar fields', 'INVALID_FILTER_PATH');
|
|
506
|
+
}
|
|
507
|
+
if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
|
|
508
|
+
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
509
|
+
const collections = new Set(steps.flatMap((step, index) => step.kind === 'collection' ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []));
|
|
510
|
+
if (entry.operand) {
|
|
511
|
+
const chain = entry.frames.slice(entry.frames.length - entry.operand.up)
|
|
512
|
+
.flatMap((frame) => [...list(frame.construct.__anchor), ...partsOf(frame.path)]);
|
|
513
|
+
const bound = resolve([...chain, ...path], rightCurrent).steps;
|
|
514
|
+
for (const [index, step] of bound.entries()) if (step.kind === 'collection') {
|
|
515
|
+
const key = bound.slice(0, index + 1).map(({ name }) => name).join('.');
|
|
516
|
+
if (ctx.J?.has(key) && entry.operand.up === entry.frames.length) collections.add(key);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (rightResolved.steps.some((step, index, right) => step.kind === 'collection'
|
|
520
|
+
&& !collections.has(right.slice(0, index + 1).map(({ name }) => name).join('.')))) {
|
|
521
|
+
fail('The right operand introduces an unbound collection; use an explicit group', 'INVALID_FILTER_PATH');
|
|
522
|
+
}
|
|
523
|
+
validateComparisonTypes(comparisonDescriptor(resolved), comparisonDescriptor(rightResolved), entry.operator, path.join('.'), rightPath.join('.'));
|
|
524
|
+
gates.push(() => {
|
|
525
|
+
if ([...steps, ...rightResolved.steps].some((step) => step.kind === 'embeddedList')) fail('Field comparisons through embedded lists require element frames', 'UNSUPPORTED_QUERY');
|
|
526
|
+
if (ctx.valuePath !== true || ctx.referenceEmptiness !== true) fail('Field comparisons require valuePath and referenceEmptiness support from this query compiler', 'UNSUPPORTED_QUERY');
|
|
527
|
+
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
528
|
+
if (ctx.quantifiers !== true && steps.some((step, index) => step.kind === 'collection' && !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.')))) {
|
|
529
|
+
fail('Collection field comparisons require quantifiers', 'UNSUPPORTED_QUERY');
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
} else if (isEmptinessOperator(entry.operator)) {
|
|
416
533
|
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
417
534
|
if (ctx.quantifiers === true && steps.slice(0, -1).some((step) => step.kind === 'collection')) fail('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
|
|
418
535
|
shape = describe(resolved, path, current, entry.operator, entry.value);
|
|
@@ -420,15 +537,22 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
420
537
|
if (!valueLeaf(last, current)) fail(ordinaryCondition && steps.length === 1
|
|
421
538
|
? `Filter on object field "${last.name}" requires a path` : 'Filter on object field requires a scalar path',
|
|
422
539
|
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
540
|
if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
|
|
427
541
|
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
542
|
+
// CONTAINS_ALL's permanent path rules, then its leaf rule (#108 section 3.3), on the frame-relative path.
|
|
543
|
+
if (entry.operator === 'CONTAINS_ALL') assertContainsAllPath(steps);
|
|
428
544
|
if (current.gqltype && last.kind === 'abstract' && entry.value === undefined) fail(`Expected a scalar value for ${entry.path}`);
|
|
429
545
|
validateValue(entry, resolved, current);
|
|
430
546
|
}
|
|
431
547
|
}
|
|
548
|
+
if (entry.operand && ctx.scopeMarkers !== 'pending' && (entry.operand.scopes !== undefined
|
|
549
|
+
|| hasMarker(entry.construct))) markers.push(() => {
|
|
550
|
+
// An outer binding can still read a collection row owned by aggregate J.
|
|
551
|
+
const keys = comparisonKeys(rightResolved);
|
|
552
|
+
const scopes = entry.operand.scopes;
|
|
553
|
+
if (keys.length && (!plain(scopes) || keys.some((key) => !Object.hasOwn(scopes, key) || scopes[key] === undefined))) detached('Outer operand scope marker is not filled for every crossing');
|
|
554
|
+
if (!keys.length && scopes !== undefined) detached('Outer operand scope marker reads no reference or collection');
|
|
555
|
+
});
|
|
432
556
|
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
433
557
|
const extra = entry.form === 'quantified' ? 1 : shape ? (emptinessScopePaths(shape, path).frames.length ? 1 : 0) : ctx.quantifiers === true ? steps.filter((step, index) => step.kind === 'collection'
|
|
434
558
|
&& !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.'))).length : 0;
|
|
@@ -437,7 +561,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
437
561
|
});
|
|
438
562
|
if (hasMarker(entry.construct) && ctx.scopeMarkers !== 'pending') markers.push(() => {
|
|
439
563
|
const suffix = '; only the runtime writes and fills client scope markers (docs/guide/middleware.md)';
|
|
440
|
-
if (!shape && entry.form !== 'quantified' && !entry.frames.length
|
|
564
|
+
if (!comparison && !shape && entry.form !== 'quantified' && !entry.frames.length
|
|
441
565
|
&& (ctx.quantifiers !== true || !cutAt(resolved, ctx.J ?? new Set()))) {
|
|
442
566
|
// Mongo rejects these at its native predicate checkpoint, after parsing/casting once.
|
|
443
567
|
// Every other caller remains fail-closed here, after the common 400 gates above.
|
|
@@ -449,6 +573,9 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
449
573
|
const paths = emptinessScopePaths(shape, path);
|
|
450
574
|
keys = [...paths.joins, ...paths.frames];
|
|
451
575
|
} else keys = markerKeys(resolved, !entry.frames.length && (implicit || entry.form !== 'quantified') ? ctx.J ?? new Set() : new Set());
|
|
576
|
+
if (comparison) keys = [...new Set([...comparisonKeys(resolved), ...(!entry.operand ? comparisonKeys(rightResolved) : [])])];
|
|
577
|
+
const comparisonAnchor = implicit && anchor.length && comparisonInGroup(entry.construct);
|
|
578
|
+
if (comparisonAnchor) keys = comparisonKeys(resolved);
|
|
452
579
|
const scopes = entry.construct.__scope?.scopes;
|
|
453
580
|
if (shape) {
|
|
454
581
|
// Preserve 2b's direct-compiler diagnostics when the shared validator now runs first.
|
|
@@ -461,7 +588,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
461
588
|
for (const [index, step] of steps.slice(0, anchor.length).entries()) {
|
|
462
589
|
const key = steps.slice(0, index + 1).map(({ name }) => name).join('.');
|
|
463
590
|
if ((step.kind === 'collection' && scopes[key] !== null)
|
|
464
|
-
|| (step.kind === 'reference' && Object.hasOwn(scopes, key))) detached('Aggregate anchors require null J scopes and legacy reference joins');
|
|
591
|
+
|| (step.kind === 'reference' && !comparisonAnchor && Object.hasOwn(scopes, key))) detached('Aggregate anchors require null J scopes and legacy reference joins');
|
|
465
592
|
}
|
|
466
593
|
}
|
|
467
594
|
});
|
|
@@ -482,6 +609,7 @@ export const frameUnits = (args, ctx) => {
|
|
|
482
609
|
return identities.get(construct);
|
|
483
610
|
};
|
|
484
611
|
const frameKey = (entry) => entry.frames.reduce((key, frame) => `${key}/${id(frame.construct)}:${frame.type}:${frame.path}`, 'root');
|
|
612
|
+
const joinUnit = (frame, path, scoped) => `join:${frame}:${path}${scoped ? ':scoped' : ''}`;
|
|
485
613
|
for (const entry of collectQueryOperators(rewritten, ctx.operation)) {
|
|
486
614
|
if (ignoredValueTerm(entry, ctx)) continue;
|
|
487
615
|
const current = inFrames(entry, ctx);
|
|
@@ -492,18 +620,24 @@ export const frameUnits = (args, ctx) => {
|
|
|
492
620
|
const path = entry.relative;
|
|
493
621
|
const { steps } = resolve(path, current);
|
|
494
622
|
for (const [index, step] of steps.entries()) {
|
|
495
|
-
if (index >= anchor.length && index < steps.length - 1 && step.kind === 'reference') {
|
|
496
|
-
units.add(
|
|
623
|
+
if ((index >= anchor.length || comparisonInGroup(entry.construct)) && index < steps.length - 1 && step.kind === 'reference') {
|
|
624
|
+
units.add(joinUnit(frame, steps.slice(0, index + 1).map(({ name }) => name).join('.'), hasMarker(entry.construct)));
|
|
497
625
|
}
|
|
498
626
|
}
|
|
499
627
|
} else if (isEmptinessOperator(entry.operator)) {
|
|
500
628
|
const resolved = resolve(entry.relative, current);
|
|
501
629
|
const shape = describe(resolved, entry.relative, current, entry.operator, entry.value);
|
|
502
630
|
const paths = emptinessScopePaths(shape, entry.relative);
|
|
503
|
-
for (const path of paths.joins) units.add(
|
|
631
|
+
for (const path of paths.joins) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
504
632
|
for (const path of paths.frames) units.add(`emptiness-frame:${frame}:${path}`);
|
|
505
|
-
} else if (entry.frames.length) {
|
|
506
|
-
for (const path of crossingKeys(entry.relative, current)) units.add(
|
|
633
|
+
} else if (entry.frames.length || entry.hasValuePath || entry.operand) {
|
|
634
|
+
for (const path of crossingKeys(entry.relative, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
635
|
+
if (entry.hasValuePath) for (const path of crossingKeys(entry.valuePath, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
636
|
+
if (entry.operand) {
|
|
637
|
+
const outer = operandContext(entry, ctx, entry.operand.up);
|
|
638
|
+
const owner = frameKey({ frames: entry.frames.slice(0, entry.frames.length - entry.operand.up) });
|
|
639
|
+
for (const path of crossingKeys(entry.operand.path, outer)) units.add(joinUnit(owner, path, Object.hasOwn(entry.operand, 'scopes')));
|
|
640
|
+
}
|
|
507
641
|
}
|
|
508
642
|
}
|
|
509
643
|
return { count: units.size, units: [...units] };
|
package/src/query-operators.js
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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,
|
|
@@ -13,6 +14,7 @@ export {
|
|
|
13
14
|
export {
|
|
14
15
|
ANCHOR_KEY, GROUP_KEYS, IMPLICIT_KEY, aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, frameUnits, validateQuantifiedGroups,
|
|
15
16
|
} from './query-frames.js';
|
|
17
|
+
export { hasValuePath, validateComparisonOperand, validateComparisonTypes } from './query-comparisons.js';
|
|
16
18
|
export { effectiveScalarName } from './metadata.js';
|
|
17
19
|
|
|
18
20
|
/** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
|
|
@@ -28,7 +30,9 @@ export const LEGACY_QUERY_OPERATORS = Object.freeze(['EQ', 'NE', 'LT', 'LTE', 'G
|
|
|
28
30
|
* The literal text operators (#108, part 1) and what each one matches: `contains`, `prefix`,
|
|
29
31
|
* `suffix` or `exact` (the whole value), respecting case or ignoring it (`ignoreCase`). Values are
|
|
30
32
|
* 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.
|
|
33
|
+
* substring of 3.7.0, keeps its own validation and compilation and is not listed here. Neither is
|
|
34
|
+
* `IIN` (#108, part 4), `IN` ignoring case: it takes a list of such values, which
|
|
35
|
+
* {@link validateTextListFilterValue} checks.
|
|
32
36
|
*/
|
|
33
37
|
export const TEXT_QUERY_OPERATORS = Object.freeze({
|
|
34
38
|
ILIKE: Object.freeze({ match: 'contains', ignoreCase: true }),
|
|
@@ -66,26 +70,107 @@ export const validateTextFilterValue = (operator, value, scalar, path) => {
|
|
|
66
70
|
return value;
|
|
67
71
|
};
|
|
68
72
|
|
|
73
|
+
/** The operators whose value is an array: IN, NIN and BTW (3.7.0), CONTAINS_ALL and IIN (#108 part 4). */
|
|
74
|
+
export const LIST_VALUE_OPERATORS = Object.freeze(['IN', 'NIN', 'BTW', 'CONTAINS_ALL', 'IIN']);
|
|
75
|
+
|
|
76
|
+
/** Whether `operator` takes an array value. */
|
|
77
|
+
export const takesListValue = (operator) => LIST_VALUE_OPERATORS.includes(operator);
|
|
78
|
+
|
|
79
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
80
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validates the value of CONTAINS_ALL (#108 section 3.3) before any backend compiles it: a non-empty array of
|
|
84
|
+
* non-null scalars, none of them an array or a plain object. Anything else fails with INVALID_FILTER_VALUE (400).
|
|
85
|
+
* Returns the value unchanged: the compilers cast each element as they cast IN elements, and keep duplicates.
|
|
86
|
+
*/
|
|
87
|
+
export const validateContainsAllValue = (value) => {
|
|
88
|
+
if (value === undefined || value === null) invalidValue('Expected a scalar filter value');
|
|
89
|
+
// Array.from reads a hole of a sparse array as undefined; `some` would skip it.
|
|
90
|
+
if (Array.isArray(value) && Array.from(value).some((item) => item == null || Array.isArray(item) || isPlainObject(item))) {
|
|
91
|
+
invalidValue('Filter lists require non-null scalar elements');
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(value)) invalidValue('CONTAINS_ALL requires an array value');
|
|
94
|
+
if (!value.length) invalidValue('CONTAINS_ALL requires a non-empty list');
|
|
95
|
+
return value;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validates the value of IIN, IN ignoring case, before any backend compiles it: the field must be a `String` field
|
|
100
|
+
* or a `String` list by its effective scalar, as for IEQ, and the value an array of strings without U+0000; the
|
|
101
|
+
* empty array is valid and matches nothing. Anything else fails with INVALID_FILTER_VALUE (400). Returns the value
|
|
102
|
+
* unchanged: it is neither folded nor deduplicated.
|
|
103
|
+
*/
|
|
104
|
+
export const validateTextListFilterValue = (operator, value, scalar, path) => {
|
|
105
|
+
const where = path == null ? '' : ` on ${Array.isArray(path) ? path.join('.') : path}`;
|
|
106
|
+
if (scalar !== 'String') invalidValue(`${operator} requires a String field${where}`);
|
|
107
|
+
if (!Array.isArray(value)) invalidValue(`${operator} requires an array value${where}`);
|
|
108
|
+
// Array.from reads a hole of a sparse array as undefined; `some` would skip it.
|
|
109
|
+
const items = Array.from(value);
|
|
110
|
+
if (items.some((item) => typeof item !== 'string')) invalidValue(`${operator} requires non-null string elements${where}`);
|
|
111
|
+
if (items.some((item) => item.includes('\u0000'))) invalidValue(`${operator} values cannot contain U+0000${where}`);
|
|
112
|
+
return value;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const failPath = (message, code) => { throw new SimfinityError(message, code, 400); };
|
|
116
|
+
// The validator's steps (graphqlStep, modelStep) and resolveModelPath's field descriptions, read alike.
|
|
117
|
+
const stepKind = (step) => {
|
|
118
|
+
if (step.kind === 'embedded') return step.list ? 'embeddedList' : 'embedded';
|
|
119
|
+
if (step.kind === 'scalar') return step.list ? 'scalarList' : 'scalar';
|
|
120
|
+
if (step.kind === 'reference' && step.inferred) return 'scalar';
|
|
121
|
+
return step.kind;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The permanent path and leaf rules of CONTAINS_ALL (#108 section 3.3), on the steps of a path relative to its
|
|
126
|
+
* frame: the path ends at a value, as for every operator (MISSING_FILTER_PATH for one segment, INVALID_FILTER_PATH
|
|
127
|
+
* otherwise; the validator has applied this rule already, and PostgreSQL's hand-built plans get core's codes from
|
|
128
|
+
* it), no collection is crossed (INVALID_FILTER_PATH, which the shorthand's exclusion states), no reference follows
|
|
129
|
+
* an embedded list (INVALID_FILTER_PATH: that join yields one referenced record per row), and the leaf is a list: a
|
|
130
|
+
* scalar list, or a scalar reached through an embedded list (INVALID_FILTER_OPERATOR otherwise).
|
|
131
|
+
*/
|
|
132
|
+
export const assertContainsAllPath = (steps) => {
|
|
133
|
+
const kinds = steps.map(stepKind);
|
|
134
|
+
const names = steps.map(({ name }) => name);
|
|
135
|
+
// Core's object-end rule first (query-frames.js:420-422), so that every caller gets core's codes in core's order.
|
|
136
|
+
if (['embedded', 'embeddedList', 'reference', 'collection'].includes(kinds.at(-1))) {
|
|
137
|
+
failPath('Filter on object field requires a scalar path', steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
138
|
+
}
|
|
139
|
+
const crossed = kinds.slice(0, -1);
|
|
140
|
+
if (crossed.includes('collection')) failPath('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
|
|
141
|
+
const list = crossed.indexOf('embeddedList');
|
|
142
|
+
const reference = list === -1 ? -1 : crossed.findIndex((kind, index) => index > list && kind === 'reference');
|
|
143
|
+
if (reference !== -1) {
|
|
144
|
+
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');
|
|
145
|
+
}
|
|
146
|
+
const leaf = kinds.at(-1);
|
|
147
|
+
if (leaf !== 'scalarList' && !(leaf === 'scalar' && list !== -1)) {
|
|
148
|
+
failPath(`CONTAINS_ALL requires a list field, or a field reached through an embedded list; ${names.join('.')} is not one`, 'INVALID_FILTER_OPERATOR');
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
69
152
|
const invalidFeatures = (message) => {
|
|
70
153
|
throw new SimfinityError(`DatabaseAdapter queryFeatures ${message}`, 'INVALID_QUERY_FEATURES', 500);
|
|
71
154
|
};
|
|
72
155
|
|
|
73
156
|
/**
|
|
74
|
-
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers }`:
|
|
157
|
+
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers, valuePath }`:
|
|
75
158
|
* the operators it compiles and whether it compiles reference emptiness and collection frames.
|
|
76
159
|
* Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
|
|
77
160
|
* and neither feature. A declaration is an object whose `operators`
|
|
78
161
|
* is an array of operator names, whose names this version does not know are ignored, and whose
|
|
79
|
-
* optional `referenceEmptiness` and `
|
|
80
|
-
*
|
|
162
|
+
* optional `referenceEmptiness`, `quantifiers` and `valuePath` are booleans, undefined or null (false). A name
|
|
163
|
+
* counts once this version knows it: a declaration that listed `CONTAINS_ALL` or `IIN` before
|
|
164
|
+
* #108 part 4 added them receives them on upgrade.
|
|
165
|
+
* Quantifiers and valuePath require reference emptiness for scoped joins. Other members are left for later
|
|
81
166
|
* features. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
82
167
|
*/
|
|
83
168
|
export const resolveQueryFeatures = (queryFeatures) => {
|
|
84
|
-
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false };
|
|
169
|
+
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false, valuePath: false };
|
|
85
170
|
if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
|
|
86
171
|
invalidFeatures('must be an object with an operators array');
|
|
87
172
|
}
|
|
88
|
-
const { operators, referenceEmptiness, quantifiers } = queryFeatures;
|
|
173
|
+
const { operators, referenceEmptiness, quantifiers, valuePath } = queryFeatures;
|
|
89
174
|
if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
|
|
90
175
|
invalidFeatures('operators must be an array of operator names');
|
|
91
176
|
}
|
|
@@ -98,9 +183,12 @@ export const resolveQueryFeatures = (queryFeatures) => {
|
|
|
98
183
|
if (quantifiers === true && referenceEmptiness !== true) {
|
|
99
184
|
invalidFeatures('quantifiers require referenceEmptiness: true');
|
|
100
185
|
}
|
|
186
|
+
if (valuePath != null && typeof valuePath !== 'boolean') invalidFeatures('valuePath must be a boolean');
|
|
187
|
+
if (valuePath === true && referenceEmptiness !== true) invalidFeatures('valuePath requires referenceEmptiness: true');
|
|
101
188
|
return {
|
|
102
189
|
operators: new Set(operators.filter(isQueryOperator)),
|
|
103
190
|
referenceEmptiness: referenceEmptiness === true,
|
|
104
191
|
quantifiers: quantifiers === true,
|
|
192
|
+
valuePath: valuePath === true,
|
|
105
193
|
};
|
|
106
194
|
};
|