@simtlix/simfinity-core 3.13.0 → 3.15.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 +28 -3
- package/package.json +12 -2
- package/src/after-commit.js +83 -0
- package/src/const/QLQuantifier.js +21 -0
- package/src/index.js +1 -0
- package/src/introspection-config.js +17 -0
- package/src/introspection-state.js +10 -0
- package/src/introspection.js +5 -3
- package/src/query-emptiness.js +2 -2
- package/src/query-frames.js +510 -0
- package/src/query-limits.js +9 -2
- package/src/query-operators.js +23 -9
- package/src/query-plan.js +236 -155
- package/src/runtime.js +291 -137
- package/src/schema-registrations.js +18 -0
- package/src/transaction-callbacks.js +34 -0
- package/types/index.d.ts +132 -19
- package/types/introspection.d.ts +4 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { getNamedType, isObjectType } from 'graphql';
|
|
2
|
+
import { GROUP_KEYS, QUANTIFIED_LISTS, QUANTIFIERS } from './const/QLQuantifier.js';
|
|
3
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
4
|
+
import { effectiveScalarName } from './metadata.js';
|
|
5
|
+
import { isVirtualField } from './relation-storage.js';
|
|
6
|
+
import {
|
|
7
|
+
describeEmptiness, describeModelEmptiness, emptinessScopePaths, graphqlStep, isEmptinessOperator, modelStep,
|
|
8
|
+
} from './query-emptiness.js';
|
|
9
|
+
import { isQueryOperator, textQueryOperator, validateTextFilterValue } from './query-operators.js';
|
|
10
|
+
import { collectQueryOperators, validateQueryGroupKeys } from './query-plan.js';
|
|
11
|
+
import { getQueryMaxFrameNesting } from './query-limits.js';
|
|
12
|
+
|
|
13
|
+
export { GROUP_KEYS };
|
|
14
|
+
export const IMPLICIT_KEY = '__implicit';
|
|
15
|
+
export const ANCHOR_KEY = '__anchor';
|
|
16
|
+
|
|
17
|
+
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
18
|
+
const detached = (message) => { throw new SimfinityError(message, 'DETACHED_CLIENT_FILTER', 500); };
|
|
19
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
const plain = (value) => object(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
21
|
+
const list = (value) => (Array.isArray(value) ? value : []);
|
|
22
|
+
const pathless = (value) => value == null || value === '';
|
|
23
|
+
const relation = (step) => ['reference', 'collection'].includes(step.kind);
|
|
24
|
+
const controls = (operation) => new Set(['AND', 'OR', 'sort', 'pagination', ...(operation === 'aggregate' ? ['aggregation'] : [])]);
|
|
25
|
+
const hasMarker = (value) => object(value) && Object.hasOwn(value, '__scope');
|
|
26
|
+
const nonemptyPath = (path) => typeof path === 'string' && path.length > 0;
|
|
27
|
+
const partsOf = (path) => (Array.isArray(path) ? path : typeof path === 'string' ? path.split('.') : []);
|
|
28
|
+
const childContext = (ctx, step) => (ctx.gqltype
|
|
29
|
+
? { ...ctx, gqltype: step.node }
|
|
30
|
+
: { ...ctx, entity: step.target ?? ctx.entity, fields: step.node });
|
|
31
|
+
|
|
32
|
+
// One schema-path resolver for these operations, using the same step classifications as emptiness.
|
|
33
|
+
// Model contexts retain embedded fields locally; relation steps switch to the target entity.
|
|
34
|
+
const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) => {
|
|
35
|
+
const parts = partsOf(path);
|
|
36
|
+
const invalid = parts.findIndex((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part));
|
|
37
|
+
if (!parts.length || invalid !== -1) {
|
|
38
|
+
if (ordinaryCondition && invalid > 0) fail(`Invalid filter path segment: "${parts[invalid]}"`, 'INVALID_FILTER_PATH');
|
|
39
|
+
fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
40
|
+
}
|
|
41
|
+
let current = ctx;
|
|
42
|
+
const steps = [];
|
|
43
|
+
const raw = [];
|
|
44
|
+
for (const [index, name] of parts.entries()) {
|
|
45
|
+
let step;
|
|
46
|
+
if (ctx.gqltype) {
|
|
47
|
+
const fields = isObjectType(current.gqltype) ? current.gqltype.getFields() : null;
|
|
48
|
+
const field = fields && Object.hasOwn(fields, name) ? fields[name] : null;
|
|
49
|
+
if (!field) fail(ordinaryCondition && index === 0 ? `Unknown filter field: ${name}`
|
|
50
|
+
: `Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
51
|
+
if (isVirtualField(field)) {
|
|
52
|
+
const label = virtualLabel?.(field) ?? parts.join('.');
|
|
53
|
+
fail(`Query path ${label} uses the virtual field ${current.gqltype.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
54
|
+
}
|
|
55
|
+
step = graphqlStep(name, field);
|
|
56
|
+
step.scalar = effectiveScalarName(getNamedType(field.type));
|
|
57
|
+
raw.push(field);
|
|
58
|
+
} else {
|
|
59
|
+
const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
|
|
60
|
+
const field = fields?.find((item) => item.name === name && !item.private);
|
|
61
|
+
if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
62
|
+
step = { ...modelStep(field), scalar: field.scalar };
|
|
63
|
+
if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
|
|
64
|
+
raw.push(field);
|
|
65
|
+
}
|
|
66
|
+
onStep?.(step, current);
|
|
67
|
+
steps.push(step);
|
|
68
|
+
if (index < parts.length - 1 && !step.node) fail(`Field ${name} cannot be traversed`, 'INVALID_FILTER_PATH');
|
|
69
|
+
current = childContext(current, step);
|
|
70
|
+
}
|
|
71
|
+
return { steps, raw, end: current };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const inFrames = (entry, ctx) => {
|
|
75
|
+
let current = ctx;
|
|
76
|
+
for (const frame of entry.frames) {
|
|
77
|
+
const anchor = frame.construct.__implicit === true ? list(frame.construct.__anchor) : [];
|
|
78
|
+
current = resolve([...anchor, ...partsOf(frame.path)], current).end;
|
|
79
|
+
}
|
|
80
|
+
return current;
|
|
81
|
+
};
|
|
82
|
+
const describe = (resolved, path, ctx, operator, value) => {
|
|
83
|
+
const options = { path, operator, value, referenceEmptiness: ctx.referenceEmptiness === true };
|
|
84
|
+
return ctx.gqltype ? describeEmptiness(ctx.gqltype, path, options) : describeModelEmptiness(resolved.raw, options);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Mongo's GraphQL boundary reads abstract fields through their own value carrier, not object
|
|
88
|
+
// terms. Metadata/model contexts cannot describe abstract fields and do not gain this behavior.
|
|
89
|
+
const valueLeaf = (step, ctx) => ['scalar', 'scalarList'].includes(step.kind) || (ctx.gqltype && step.kind === 'abstract');
|
|
90
|
+
|
|
91
|
+
/** Internal: the single references crossed, excluding a reference end and inferred scalar IDs. */
|
|
92
|
+
export const crossingKeys = (parts, ctx) => resolve(parts, ctx).steps.flatMap((step, index, steps) => (
|
|
93
|
+
step.kind === 'reference' && !step.inferred && index < steps.length - 1 ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []
|
|
94
|
+
));
|
|
95
|
+
|
|
96
|
+
/** Internal: lenient aggregate join prefixes; an invalid complete path contributes no prefixes. */
|
|
97
|
+
export const aggregateJoinPrefixes = (aggregation, ctx) => {
|
|
98
|
+
const prefixes = new Set();
|
|
99
|
+
for (const path of [aggregation?.groupId, ...list(aggregation?.facts).map((fact) => fact?.path)]) {
|
|
100
|
+
try {
|
|
101
|
+
const { steps } = resolve(path, ctx);
|
|
102
|
+
steps.forEach((step, index) => {
|
|
103
|
+
if (step.kind === 'collection') prefixes.add(steps.slice(0, index + 1).map(({ name }) => name).join('.'));
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (!(error instanceof SimfinityError)) throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return prefixes;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// The first non-J collection and its optional legacy-join anchor. J is supplied only at the root.
|
|
113
|
+
const cutAt = (resolved, J) => {
|
|
114
|
+
let joined = false;
|
|
115
|
+
for (const [index, step] of resolved.steps.entries()) {
|
|
116
|
+
if (step.kind !== 'collection') continue;
|
|
117
|
+
const prefix = resolved.steps.slice(0, index + 1).map(({ name }) => name);
|
|
118
|
+
if (J.has(prefix.join('.'))) { joined = true; continue; }
|
|
119
|
+
const anchor = joined ? prefix.slice(0, -1) : [];
|
|
120
|
+
return { index, prefix, anchor, path: prefix.slice(anchor.length).join('.') };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
124
|
+
const markerKeys = (resolved, J = new Set()) => {
|
|
125
|
+
const cut = cutAt(resolved, J);
|
|
126
|
+
const anchored = resolved.steps.some((step, index, steps) => step.kind === 'collection'
|
|
127
|
+
&& J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.')));
|
|
128
|
+
return resolved.steps.flatMap((step, index, steps) => {
|
|
129
|
+
if (!relation(step) || (anchored && step.kind === 'reference' && (cut === null || index < cut.index))) return [];
|
|
130
|
+
return [steps.slice(0, index + 1).map(({ name }) => name).join('.')];
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const prefixScopes = (scopes, prefix, keys) => Object.fromEntries(Object.entries(scopes ?? {}).filter(([key]) => (
|
|
134
|
+
keys.includes(key) && (key === prefix || prefix.startsWith(`${key}.`))
|
|
135
|
+
)));
|
|
136
|
+
const cutCondition = (item, rest, prefix, keys) => {
|
|
137
|
+
const { __scope, ...operands } = item;
|
|
138
|
+
delete operands.field;
|
|
139
|
+
delete operands.path;
|
|
140
|
+
const condition = { field: rest, ...operands };
|
|
141
|
+
if (hasMarker(item)) {
|
|
142
|
+
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]));
|
|
144
|
+
if (Object.keys(scopes).length) condition.__scope = { id: __scope.id, scopes };
|
|
145
|
+
}
|
|
146
|
+
return condition;
|
|
147
|
+
};
|
|
148
|
+
const termCondition = (term) => {
|
|
149
|
+
const { path, ...operands } = term;
|
|
150
|
+
return { field: path, ...operands };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Internal: pure collection-frame rewrite. The same J must be threaded through repeated passes. */
|
|
154
|
+
export const frameCollectionPaths = (args, ctx) => {
|
|
155
|
+
if (ctx.quantifiers !== true) return args;
|
|
156
|
+
const memo = new Map();
|
|
157
|
+
const active = new Map();
|
|
158
|
+
const identities = new Map();
|
|
159
|
+
const positionOf = (parent, construct, type, path) => {
|
|
160
|
+
if (!identities.has(construct)) identities.set(construct, identities.size + 1);
|
|
161
|
+
return `${parent}/${identities.get(construct)}:${type}:${path}`;
|
|
162
|
+
};
|
|
163
|
+
const rewriteGroup = (input, frame, J, position = 'root') => {
|
|
164
|
+
if (active.has(input)) return active.get(input);
|
|
165
|
+
if (!memo.has(input)) memo.set(input, new Map());
|
|
166
|
+
const positions = memo.get(input);
|
|
167
|
+
if (positions.has(position)) return positions.get(position);
|
|
168
|
+
const output = { ...input };
|
|
169
|
+
positions.set(position, output);
|
|
170
|
+
active.set(input, output);
|
|
171
|
+
const buckets = new Map();
|
|
172
|
+
const cut = (condition, path) => {
|
|
173
|
+
const resolved = resolve(path, frame);
|
|
174
|
+
const operator = condition.operator ?? 'EQ';
|
|
175
|
+
if (isEmptinessOperator(operator) || operator === 'CONTAINS_ALL') return null;
|
|
176
|
+
const at = cutAt(resolved, J);
|
|
177
|
+
if (!at) return null;
|
|
178
|
+
const prefix = at.prefix.join('.');
|
|
179
|
+
let bucket = buckets.get(prefix);
|
|
180
|
+
if (!bucket) {
|
|
181
|
+
bucket = { path: at.path, conditions: [], __implicit: true };
|
|
182
|
+
if (at.anchor.length) bucket.__anchor = at.anchor;
|
|
183
|
+
buckets.set(prefix, bucket);
|
|
184
|
+
}
|
|
185
|
+
const keys = markerKeys(resolved, J);
|
|
186
|
+
if (hasMarker(condition) && !hasMarker(bucket)) {
|
|
187
|
+
bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, keys) };
|
|
188
|
+
}
|
|
189
|
+
bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys));
|
|
190
|
+
return bucket;
|
|
191
|
+
};
|
|
192
|
+
if (Array.isArray(input.conditions)) {
|
|
193
|
+
output.conditions = input.conditions.filter((item) => !cut(item, pathless(item.path) ? item.field : `${item.field}.${item.path}`));
|
|
194
|
+
}
|
|
195
|
+
for (const key of ['AND', 'OR']) if (Array.isArray(input[key])) output[key] = input[key].map((group) => rewriteGroup(group, frame, J, position));
|
|
196
|
+
for (const type of QUANTIFIED_LISTS) if (Array.isArray(input[type])) {
|
|
197
|
+
output[type] = input[type].map((group) => {
|
|
198
|
+
const anchor = group.__implicit === true ? list(group.__anchor) : [];
|
|
199
|
+
return rewriteGroup(group, resolve([...anchor, ...partsOf(group.path)], frame).end, new Set(), positionOf(position, group, type, group.path));
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
for (const bucket of buckets.values()) {
|
|
203
|
+
const path = [...list(bucket.__anchor), ...partsOf(bucket.path)];
|
|
204
|
+
const rewritten = rewriteGroup(bucket, resolve(path, frame).end, new Set(), positionOf(position, bucket, 'ANY', bucket.path));
|
|
205
|
+
output.ANY = [...list(output.ANY), rewritten];
|
|
206
|
+
}
|
|
207
|
+
active.delete(input);
|
|
208
|
+
return output;
|
|
209
|
+
};
|
|
210
|
+
const output = { ...args };
|
|
211
|
+
const syntheticGroups = [];
|
|
212
|
+
const consumed = new Set();
|
|
213
|
+
// Expand every quantified field before considering any shorthand terms.
|
|
214
|
+
for (const [name, value] of Object.entries(args)) {
|
|
215
|
+
if (controls(ctx.operation).has(name) || !object(value) || value.quantifier == null) continue;
|
|
216
|
+
const group = { path: name, conditions: list(value.terms).map(termCondition) };
|
|
217
|
+
if (hasMarker(value)) group.__scope = value.__scope;
|
|
218
|
+
syntheticGroups.push({ __implicit: true, [value.quantifier]: [group] });
|
|
219
|
+
delete output[name];
|
|
220
|
+
consumed.add(name);
|
|
221
|
+
}
|
|
222
|
+
const implicitGroups = [];
|
|
223
|
+
for (const [name, value] of Object.entries(args)) {
|
|
224
|
+
if (controls(ctx.operation).has(name) || consumed.has(name) || !object(value)) continue;
|
|
225
|
+
const leaf = resolve(name, ctx).steps.at(-1);
|
|
226
|
+
if (['scalar', 'scalarList'].includes(leaf.kind)) {
|
|
227
|
+
// Native planner inputs may name a scalar through collections. Each entry is its own
|
|
228
|
+
// correlation group; ignored terms and filter metadata never become predicates or markers.
|
|
229
|
+
const group = { conditions: [{ field: name, operator: value.operator ?? 'EQ', value: value.value }] };
|
|
230
|
+
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
231
|
+
if (list(rewritten.ANY).length) {
|
|
232
|
+
syntheticGroups.push({ __implicit: true, ANY: rewritten.ANY });
|
|
233
|
+
delete output[name];
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
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}` })) };
|
|
239
|
+
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
240
|
+
implicitGroups.push(...list(rewritten.ANY));
|
|
241
|
+
if (rewritten.conditions.length === value.terms.length) continue;
|
|
242
|
+
if (!rewritten.conditions.length) delete output[name];
|
|
243
|
+
else output[name] = { ...value, terms: value.terms.filter((term, index) => rewritten.conditions.includes(group.conditions[index])) };
|
|
244
|
+
}
|
|
245
|
+
if (implicitGroups.length) syntheticGroups.push({ __implicit: true, ANY: implicitGroups });
|
|
246
|
+
for (const key of ['AND', 'OR']) if (Array.isArray(args[key])) output[key] = args[key].map((group) => rewriteGroup(group, ctx, ctx.J ?? new Set()));
|
|
247
|
+
for (const group of syntheticGroups) {
|
|
248
|
+
output.AND = [...list(output.AND), rewriteGroup(group, ctx, ctx.J ?? new Set())];
|
|
249
|
+
}
|
|
250
|
+
return output;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const validateShapes = (args) => {
|
|
254
|
+
let hasQuantifiedLists = false;
|
|
255
|
+
if (!plain(args)) fail('Expected query arguments object');
|
|
256
|
+
const open = new Set();
|
|
257
|
+
const visit = (group, depth, quantified = false) => {
|
|
258
|
+
if (!plain(group)) fail('Expected a filter group object');
|
|
259
|
+
if (open.has(group)) fail('Cyclic filter groups', 'FILTER_DEPTH_EXCEEDED');
|
|
260
|
+
validateQueryGroupKeys(group, { quantified });
|
|
261
|
+
for (const key of ['conditions', 'AND', 'OR', ...QUANTIFIED_LISTS]) {
|
|
262
|
+
if (group[key] != null && !Array.isArray(group[key])) fail(`${key} requires an array`);
|
|
263
|
+
if (Array.isArray(group[key]) && group[key].some((item) => !plain(item))) fail(`${key} requires an array of filter objects`);
|
|
264
|
+
}
|
|
265
|
+
const level = depth + (group.__implicit === true ? 0 : 1);
|
|
266
|
+
if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
|
|
267
|
+
if (quantified && group.__implicit !== true && !nonemptyPath(group.path)) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
268
|
+
open.add(group);
|
|
269
|
+
for (const key of ['AND', 'OR']) for (const item of list(group[key])) visit(item, level);
|
|
270
|
+
for (const key of QUANTIFIED_LISTS) {
|
|
271
|
+
if (group[key] != null && group.__implicit !== true) hasQuantifiedLists = true;
|
|
272
|
+
for (const item of list(group[key])) visit(item, level, true);
|
|
273
|
+
}
|
|
274
|
+
open.delete(group);
|
|
275
|
+
};
|
|
276
|
+
for (const key of ['AND', 'OR']) {
|
|
277
|
+
if (args[key] != null && !Array.isArray(args[key])) fail(`${key} requires an array`);
|
|
278
|
+
for (const group of list(args[key])) visit(group, -1);
|
|
279
|
+
}
|
|
280
|
+
return hasQuantifiedLists;
|
|
281
|
+
};
|
|
282
|
+
const validateValue = (entry, resolved, ctx) => {
|
|
283
|
+
const { operator, value } = entry;
|
|
284
|
+
// GraphQL compilers historically report the text operator's own type/value diagnostic.
|
|
285
|
+
// Model planning retains its generic scalar-shape checks before text validation.
|
|
286
|
+
if (ctx.gqltype && textQueryOperator(operator)) {
|
|
287
|
+
validateTextFilterValue(operator, value, resolved.steps.at(-1).scalar, entry.path);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const array = ['IN', 'NIN', 'BTW'].includes(operator);
|
|
291
|
+
if (value === undefined || (!array && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(operator))) fail('Expected a scalar filter value');
|
|
292
|
+
if (array && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
|
|
293
|
+
if (array && !Array.isArray(value)) fail(`${operator} requires an array value`);
|
|
294
|
+
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);
|
|
296
|
+
};
|
|
297
|
+
/** Internal: check sort shape and path syntax without resolving or authorizing model fields. */
|
|
298
|
+
export const assertSortSyntax = (sort) => {
|
|
299
|
+
if (sort == null) return;
|
|
300
|
+
if (!plain(sort) || !Array.isArray(sort.terms) || !sort.terms.length) fail('Sort requires non-empty terms', 'INVALID_SORT');
|
|
301
|
+
if (sort.terms.some((term) => !plain(term) || !['ASC', 'DESC'].includes(term.order))) fail('Invalid sort order', 'INVALID_SORT');
|
|
302
|
+
for (const term of sort.terms) {
|
|
303
|
+
const parts = partsOf(term.field);
|
|
304
|
+
if (typeof term.field !== 'string' || !parts.length || parts.some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) fail('Invalid sort path', 'INVALID_FILTER_PATH');
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const validateSort = (args, ctx) => {
|
|
308
|
+
assertSortSyntax(args.sort);
|
|
309
|
+
for (const term of args.sort?.terms ?? []) {
|
|
310
|
+
const parts = partsOf(term.field);
|
|
311
|
+
if (ctx.operation === 'aggregate') continue;
|
|
312
|
+
const { steps } = resolve(parts, ctx, (step, owner) => {
|
|
313
|
+
if (step.kind === 'collection') {
|
|
314
|
+
const type = owner.gqltype?.name ?? owner.entity;
|
|
315
|
+
fail(`Sort path ${term.field} crosses the collection ${type}.${step.name}; a record has no single value there`, 'INVALID_FILTER_PATH');
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
if (!valueLeaf(steps.at(-1), ctx)) fail('Sort path must end in a scalar field', 'INVALID_FILTER_PATH');
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Scalar terms are inspection-only. Abstract terms also are, except that their emptiness forms
|
|
323
|
+
// still require describeEmptiness's path/type rejection before validating the actual carrier.
|
|
324
|
+
const ignoredValueTerm = (entry, ctx) => {
|
|
325
|
+
if (entry.form !== 'term' || entry.frames.length) return false;
|
|
326
|
+
const leaf = resolve(entry.field, ctx).steps.at(-1);
|
|
327
|
+
return ['scalar', 'scalarList'].includes(leaf.kind)
|
|
328
|
+
|| (ctx.gqltype && leaf.kind === 'abstract' && !isEmptinessOperator(entry.operator));
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Internal: validate received arguments before rewriting, with all 400 checks before markers.
|
|
333
|
+
* Runtime gates before fill pass scopeMarkers: 'pending'; issued IDs and detach validity then
|
|
334
|
+
* belong to the runtime. Every other mode requires filled scopes, as direct compilers do.
|
|
335
|
+
*/
|
|
336
|
+
export const validateQuantifiedGroups = (args, ctx) => {
|
|
337
|
+
const hasQuantifiedLists = validateShapes(args);
|
|
338
|
+
const markers = [];
|
|
339
|
+
const gates = [];
|
|
340
|
+
const scalarCarriers = [];
|
|
341
|
+
const abstractFields = new Set();
|
|
342
|
+
for (const [name, value] of Object.entries(args)) {
|
|
343
|
+
if (controls(ctx.operation).has(name) || value == null) continue;
|
|
344
|
+
if (!plain(value)) {
|
|
345
|
+
// Unknown top-level arguments are fields, retaining the planner's existing field error.
|
|
346
|
+
resolve(name, ctx);
|
|
347
|
+
fail('Expected a filter object');
|
|
348
|
+
}
|
|
349
|
+
if (value.quantifier != null) {
|
|
350
|
+
if (value.terms != null && !Array.isArray(value.terms)) fail('Relationship terms require an array');
|
|
351
|
+
for (const term of list(value.terms)) {
|
|
352
|
+
if (!plain(term)) fail('Expected a relationship filter term');
|
|
353
|
+
if (!nonemptyPath(term.path)) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const resolved = resolve(name, ctx, undefined, (field) => {
|
|
357
|
+
// Classification must not shorten the actual object term's virtual-path diagnostic.
|
|
358
|
+
const termPath = value.quantifier == null && !isEmptinessOperator(value.operator)
|
|
359
|
+
&& isObjectType(getNamedType(field.type)) && Array.isArray(value.terms) ? value.terms[0]?.path : null;
|
|
360
|
+
return typeof termPath === 'string' && termPath ? `${name}.${termPath}` : name;
|
|
361
|
+
});
|
|
362
|
+
const leaf = resolved.steps.at(-1);
|
|
363
|
+
if (ctx.gqltype && leaf.kind === 'abstract') abstractFields.add(name);
|
|
364
|
+
if (value.quantifier == null && valueLeaf(leaf, ctx) && Array.isArray(value.terms) && value.operator === undefined) {
|
|
365
|
+
scalarCarriers.push({ form: 'filter', field: name, path: name, relative: partsOf(name), segments: partsOf(name), frames: [],
|
|
366
|
+
operator: 'EQ', value: value.value, construct: null });
|
|
367
|
+
}
|
|
368
|
+
if (!valueLeaf(leaf, ctx) && value.quantifier == null) {
|
|
369
|
+
if (isEmptinessOperator(value.operator)) fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term `
|
|
370
|
+
+ `without a path: ${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
|
|
371
|
+
if (!Array.isArray(value.terms) || !value.terms.length) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
372
|
+
for (const term of value.terms) {
|
|
373
|
+
if (!plain(term)) fail('Expected a relationship filter term');
|
|
374
|
+
if (!nonemptyPath(term.path) && !(isEmptinessOperator(term.operator) && pathless(term.path))) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const abstractCarriers = [];
|
|
379
|
+
const entries = [...scalarCarriers, ...collectQueryOperators(args, ctx.operation)].filter((entry) => {
|
|
380
|
+
if (abstractFields.has(entry.field) && ['filter', 'beside-terms'].includes(entry.form) && !isEmptinessOperator(entry.operator)) {
|
|
381
|
+
abstractCarriers.push(entry);
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
});
|
|
386
|
+
entries.push(...abstractCarriers);
|
|
387
|
+
for (const entry of entries) {
|
|
388
|
+
if (ignoredValueTerm(entry, ctx)) continue;
|
|
389
|
+
const current = inFrames(entry, ctx);
|
|
390
|
+
const implicit = entry.form === 'quantified' && entry.construct.__implicit === true;
|
|
391
|
+
if (entry.form === 'condition' && typeof entry.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
392
|
+
if (entry.ambiguous) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
393
|
+
const anchor = implicit ? list(entry.construct.__anchor) : [];
|
|
394
|
+
const path = entry.relative;
|
|
395
|
+
// Direct GraphQL compilers retain native condition diagnostics; pending runtime gates keep
|
|
396
|
+
// their shared diagnostics, as do model contexts and quantified forms.
|
|
397
|
+
const ordinaryCondition = Boolean(current.gqltype) && ctx.scopeMarkers !== 'pending' && entry.form === 'condition'
|
|
398
|
+
&& !entry.frames.length && !isEmptinessOperator(entry.operator);
|
|
399
|
+
const resolved = resolve(path, current, undefined, undefined, ordinaryCondition);
|
|
400
|
+
const { steps } = resolved;
|
|
401
|
+
const last = steps.at(-1);
|
|
402
|
+
// Ordinary operators beside object terms are inspection carriers, not predicates. Runtime
|
|
403
|
+
// capability inspection still sees these rows; direct planners validate only their terms.
|
|
404
|
+
if (entry.form === 'beside-terms' && !isEmptinessOperator(entry.operator)
|
|
405
|
+
&& !valueLeaf(last, current)) continue;
|
|
406
|
+
let shape;
|
|
407
|
+
if (entry.form === 'quantified') {
|
|
408
|
+
if (!implicit && (steps.slice(0, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind))
|
|
409
|
+
|| !['reference', 'collection', 'embeddedList'].includes(last.kind))) fail('A quantified path must end at a relation without crossing a list', 'INVALID_FILTER_PATH');
|
|
410
|
+
if (!implicit && !QUANTIFIERS.has(entry.quantifier)) fail('Unknown quantifier', 'INVALID_FILTER_VALUE');
|
|
411
|
+
if (!implicit) gates.push(() => {
|
|
412
|
+
if (ctx.quantifiers !== true || last.kind === 'embeddedList') fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
413
|
+
});
|
|
414
|
+
} else {
|
|
415
|
+
if (isEmptinessOperator(entry.operator)) {
|
|
416
|
+
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
417
|
+
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
|
+
shape = describe(resolved, path, current, entry.operator, entry.value);
|
|
419
|
+
} else {
|
|
420
|
+
if (!valueLeaf(last, current)) fail(ordinaryCondition && steps.length === 1
|
|
421
|
+
? `Filter on object field "${last.name}" requires a path` : 'Filter on object field requires a scalar path',
|
|
422
|
+
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
|
+
if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
|
|
427
|
+
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
428
|
+
if (current.gqltype && last.kind === 'abstract' && entry.value === undefined) fail(`Expected a scalar value for ${entry.path}`);
|
|
429
|
+
validateValue(entry, resolved, current);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
433
|
+
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
|
+
&& !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.'))).length : 0;
|
|
435
|
+
gates.push(() => {
|
|
436
|
+
if (entry.frames.length + extra > getQueryMaxFrameNesting()) fail('Frame nesting too deep (maxFrameNesting)', 'FILTER_DEPTH_EXCEEDED');
|
|
437
|
+
});
|
|
438
|
+
if (hasMarker(entry.construct) && ctx.scopeMarkers !== 'pending') markers.push(() => {
|
|
439
|
+
const suffix = '; only the runtime writes and fills client scope markers (docs/guide/middleware.md)';
|
|
440
|
+
if (!shape && entry.form !== 'quantified' && !entry.frames.length
|
|
441
|
+
&& (ctx.quantifiers !== true || !cutAt(resolved, ctx.J ?? new Set()))) {
|
|
442
|
+
// Mongo rejects these at its native predicate checkpoint, after parsing/casting once.
|
|
443
|
+
// Every other caller remains fail-closed here, after the common 400 gates above.
|
|
444
|
+
if (ctx.gqltype && ctx.deferOrdinaryRootMarkers === true) return;
|
|
445
|
+
detached(`${entry.operator} on ${path.join('.')} carries a client scope marker, but is not an emptiness condition${suffix}`);
|
|
446
|
+
}
|
|
447
|
+
let keys;
|
|
448
|
+
if (shape) {
|
|
449
|
+
const paths = emptinessScopePaths(shape, path);
|
|
450
|
+
keys = [...paths.joins, ...paths.frames];
|
|
451
|
+
} else keys = markerKeys(resolved, !entry.frames.length && (implicit || entry.form !== 'quantified') ? ctx.J ?? new Set() : new Set());
|
|
452
|
+
const scopes = entry.construct.__scope?.scopes;
|
|
453
|
+
if (shape) {
|
|
454
|
+
// Preserve 2b's direct-compiler diagnostics when the shared validator now runs first.
|
|
455
|
+
const label = `${entry.operator} on ${path.join('.')}`;
|
|
456
|
+
if (!keys.length) detached(`${label} carries a client scope marker, but reads no reference or collection${suffix}`);
|
|
457
|
+
if (!plain(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled${suffix}`);
|
|
458
|
+
for (const key of keys) if (!Object.hasOwn(scopes, key) || scopes[key] === undefined) detached(`${label} carries a client scope marker without the scope of ${key}${suffix}`);
|
|
459
|
+
} else if (!keys.length || !plain(scopes) || keys.some((key) => !Object.hasOwn(scopes, key) || scopes[key] === undefined)) detached('Client scope marker is not filled for every crossing and frame');
|
|
460
|
+
if (implicit && anchor.length) {
|
|
461
|
+
for (const [index, step] of steps.slice(0, anchor.length).entries()) {
|
|
462
|
+
const key = steps.slice(0, index + 1).map(({ name }) => name).join('.');
|
|
463
|
+
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');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
validateSort(args, ctx);
|
|
470
|
+
if (hasQuantifiedLists && ctx.quantifiers !== true) fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
471
|
+
for (const gate of gates) gate();
|
|
472
|
+
for (const marker of markers) marker();
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/** Internal: count compiled client frames/joins independently of markers and target scope bodies. */
|
|
476
|
+
export const frameUnits = (args, ctx) => {
|
|
477
|
+
const rewritten = frameCollectionPaths(args, ctx);
|
|
478
|
+
const units = new Set();
|
|
479
|
+
const identities = new Map();
|
|
480
|
+
const id = (construct) => {
|
|
481
|
+
if (!identities.has(construct)) identities.set(construct, identities.size + 1);
|
|
482
|
+
return identities.get(construct);
|
|
483
|
+
};
|
|
484
|
+
const frameKey = (entry) => entry.frames.reduce((key, frame) => `${key}/${id(frame.construct)}:${frame.type}:${frame.path}`, 'root');
|
|
485
|
+
for (const entry of collectQueryOperators(rewritten, ctx.operation)) {
|
|
486
|
+
if (ignoredValueTerm(entry, ctx)) continue;
|
|
487
|
+
const current = inFrames(entry, ctx);
|
|
488
|
+
const frame = frameKey(entry);
|
|
489
|
+
if (entry.form === 'quantified') {
|
|
490
|
+
units.add(`frame:${frame}/${id(entry.construct)}:${entry.quantifier}:${entry.path}`);
|
|
491
|
+
const anchor = entry.construct.__implicit === true ? list(entry.construct.__anchor) : [];
|
|
492
|
+
const path = entry.relative;
|
|
493
|
+
const { steps } = resolve(path, current);
|
|
494
|
+
for (const [index, step] of steps.entries()) {
|
|
495
|
+
if (index >= anchor.length && index < steps.length - 1 && step.kind === 'reference') {
|
|
496
|
+
units.add(`join:${frame}:${steps.slice(0, index + 1).map(({ name }) => name).join('.')}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} else if (isEmptinessOperator(entry.operator)) {
|
|
500
|
+
const resolved = resolve(entry.relative, current);
|
|
501
|
+
const shape = describe(resolved, entry.relative, current, entry.operator, entry.value);
|
|
502
|
+
const paths = emptinessScopePaths(shape, entry.relative);
|
|
503
|
+
for (const path of paths.joins) units.add(`join:${frame}:${path}`);
|
|
504
|
+
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(`join:${frame}:${path}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return { count: units.size, units: [...units] };
|
|
510
|
+
};
|
package/src/query-limits.js
CHANGED
|
@@ -2,32 +2,39 @@ import SimfinityError from './errors/simfinity.error.js';
|
|
|
2
2
|
|
|
3
3
|
let queryMaxPageSize = 1000;
|
|
4
4
|
let queryMaxReferenceFrames = 10;
|
|
5
|
+
let queryMaxFrameNesting = 3;
|
|
5
6
|
|
|
6
7
|
const isPositiveSafeInteger = (value) => Number.isSafeInteger(value) && value >= 1;
|
|
7
8
|
|
|
8
9
|
// Process-wide. Each omitted option takes its default again, and every option is checked before any
|
|
9
10
|
// is assigned, so a call that fails keeps every current limit. `maxReferenceFrames` caps, per resolver
|
|
10
|
-
// call, the
|
|
11
|
+
// call, the client's compiled frames and scoped joins; `maxFrameNesting` caps nested frame chains.
|
|
11
12
|
export const configureQueryLimits = (options = {}) => {
|
|
12
13
|
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
13
14
|
|| ![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
|
|
14
15
|
throw new SimfinityError('Query limit options must be a plain object', 'INVALID_QUERY_LIMITS', 400);
|
|
15
16
|
}
|
|
16
|
-
const { maxPageSize = 1000, maxReferenceFrames = 10 } = options;
|
|
17
|
+
const { maxPageSize = 1000, maxReferenceFrames = 10, maxFrameNesting = 3 } = options;
|
|
17
18
|
if (!isPositiveSafeInteger(maxPageSize)) {
|
|
18
19
|
throw new SimfinityError('maxPageSize must be a positive safe integer', 'INVALID_QUERY_LIMITS', 400);
|
|
19
20
|
}
|
|
20
21
|
if (!isPositiveSafeInteger(maxReferenceFrames)) {
|
|
21
22
|
throw new SimfinityError('maxReferenceFrames must be a positive safe integer', 'INVALID_QUERY_LIMITS', 400);
|
|
22
23
|
}
|
|
24
|
+
if (!isPositiveSafeInteger(maxFrameNesting)) {
|
|
25
|
+
throw new SimfinityError('maxFrameNesting must be a positive safe integer', 'INVALID_QUERY_LIMITS', 400);
|
|
26
|
+
}
|
|
23
27
|
queryMaxPageSize = maxPageSize;
|
|
24
28
|
queryMaxReferenceFrames = maxReferenceFrames;
|
|
29
|
+
queryMaxFrameNesting = maxFrameNesting;
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
export const getQueryMaxPageSize = () => queryMaxPageSize;
|
|
28
33
|
|
|
29
34
|
export const getQueryMaxReferenceFrames = () => queryMaxReferenceFrames;
|
|
30
35
|
|
|
36
|
+
export const getQueryMaxFrameNesting = () => queryMaxFrameNesting;
|
|
37
|
+
|
|
31
38
|
export const paginationStages = (pagination, withDefault) => {
|
|
32
39
|
if (pagination == null) return withDefault ? [{ $skip: 0 }, { $limit: Math.min(100, queryMaxPageSize) }] : [];
|
|
33
40
|
const { page, size } = pagination;
|
package/src/query-operators.js
CHANGED
|
@@ -10,6 +10,9 @@ export {
|
|
|
10
10
|
EMPTINESS_QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopePaths,
|
|
11
11
|
emptinessScopeTargets, isEmptinessOperator, validateEmptinessValue,
|
|
12
12
|
} from './query-emptiness.js';
|
|
13
|
+
export {
|
|
14
|
+
ANCHOR_KEY, GROUP_KEYS, IMPLICIT_KEY, aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, frameUnits, validateQuantifiedGroups,
|
|
15
|
+
} from './query-frames.js';
|
|
13
16
|
export { effectiveScalarName } from './metadata.js';
|
|
14
17
|
|
|
15
18
|
/** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
|
|
@@ -68,25 +71,36 @@ const invalidFeatures = (message) => {
|
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
|
-
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness }`:
|
|
72
|
-
* operators it compiles
|
|
73
|
-
*
|
|
74
|
-
*
|
|
74
|
+
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers }`:
|
|
75
|
+
* the operators it compiles and whether it compiles reference emptiness and collection frames.
|
|
76
|
+
* Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
|
|
77
|
+
* and neither feature. A declaration is an object whose `operators`
|
|
75
78
|
* is an array of operator names, whose names this version does not know are ignored, and whose
|
|
76
|
-
* optional `referenceEmptiness`
|
|
77
|
-
*
|
|
79
|
+
* optional `referenceEmptiness` and `quantifiers` are booleans, undefined or null (false).
|
|
80
|
+
* Quantifiers require reference emptiness for scoped joins. Other members are left for later
|
|
81
|
+
* features. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
78
82
|
*/
|
|
79
83
|
export const resolveQueryFeatures = (queryFeatures) => {
|
|
80
|
-
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false };
|
|
84
|
+
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false };
|
|
81
85
|
if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
|
|
82
86
|
invalidFeatures('must be an object with an operators array');
|
|
83
87
|
}
|
|
84
|
-
const { operators, referenceEmptiness } = queryFeatures;
|
|
88
|
+
const { operators, referenceEmptiness, quantifiers } = queryFeatures;
|
|
85
89
|
if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
|
|
86
90
|
invalidFeatures('operators must be an array of operator names');
|
|
87
91
|
}
|
|
88
92
|
if (referenceEmptiness != null && typeof referenceEmptiness !== 'boolean') {
|
|
89
93
|
invalidFeatures('referenceEmptiness must be a boolean');
|
|
90
94
|
}
|
|
91
|
-
|
|
95
|
+
if (quantifiers != null && typeof quantifiers !== 'boolean') {
|
|
96
|
+
invalidFeatures('quantifiers must be a boolean');
|
|
97
|
+
}
|
|
98
|
+
if (quantifiers === true && referenceEmptiness !== true) {
|
|
99
|
+
invalidFeatures('quantifiers require referenceEmptiness: true');
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
operators: new Set(operators.filter(isQueryOperator)),
|
|
103
|
+
referenceEmptiness: referenceEmptiness === true,
|
|
104
|
+
quantifiers: quantifiers === true,
|
|
105
|
+
};
|
|
92
106
|
};
|