@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
package/src/query-plan.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { GROUP_KEYS, QUANTIFIED_LISTS } from './const/QLQuantifier.js';
|
|
2
|
+
import { aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, validateQuantifiedGroups } from './query-frames.js';
|
|
1
3
|
import { paginationStages } from './query-limits.js';
|
|
2
4
|
import SimfinityError from './errors/simfinity.error.js';
|
|
3
5
|
import {
|
|
4
|
-
|
|
6
|
+
describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator, isQueryOperator,
|
|
5
7
|
textQueryOperator, validateTextFilterValue,
|
|
6
8
|
} from './query-operators.js';
|
|
7
9
|
|
|
@@ -11,7 +13,6 @@ const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityEr
|
|
|
11
13
|
const detached = (message) => {
|
|
12
14
|
throw new SimfinityError(`${message}; only the runtime writes and fills client scope markers (docs/guide/middleware.md)`, 'DETACHED_CLIENT_FILTER', 500);
|
|
13
15
|
};
|
|
14
|
-
const operators = new Set(QUERY_OPERATORS);
|
|
15
16
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
16
17
|
// `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
|
|
17
18
|
// that name is filtered like any other.
|
|
@@ -23,162 +24,196 @@ const nestedGroups = (group) => [...listOf(group.AND), ...listOf(group.OR)];
|
|
|
23
24
|
const isPathless = (path) => path == null || path === '';
|
|
24
25
|
const hasTermPath = (term) => typeof term?.path === 'string' && term.path !== '';
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
* Only client arguments are expanded.
|
|
42
|
-
*/
|
|
43
|
-
export const collectQueryPathEntries = (args, operation = 'find', { gqltype, expandMembers = false } = {}) => {
|
|
27
|
+
/** Internal: rejects group keys the query walker cannot inspect. Shape checks belong to the validator. */
|
|
28
|
+
export const validateQueryGroupKeys = (group, { quantified = false } = {}) => {
|
|
29
|
+
for (const key of Object.keys(group)) {
|
|
30
|
+
if (!GROUP_KEYS.has(key) || (key === 'path' && !quantified)) fail(`Unknown filter group key: ${key}`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
35
|
+
const pathSegments = (path) => (typeof path === 'string' && path ? path.split('.') : []);
|
|
36
|
+
const conditionPath = (field, path) => (isPathless(path) ? field : `${field}.${path}`);
|
|
37
|
+
|
|
38
|
+
// One graph walk for both collectors. Records are emitted after visiting so a group shared at
|
|
39
|
+
// several depths reports its deepest occurrence in that frame. Each quantified record owns a
|
|
40
|
+
// distinct child frame; the parent frame, construct and quantifier distinguish aliased positions.
|
|
41
|
+
const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembers = false } = {}) => {
|
|
44
42
|
const entries = [];
|
|
45
|
-
const add = (path, group = null, operator = null, construct = null) => {
|
|
46
|
-
if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group, operator, construct });
|
|
47
|
-
};
|
|
48
|
-
const addEmptiness = (path, group, operator, construct) => {
|
|
49
|
-
add(path, group, operator, construct);
|
|
50
|
-
if (!expandMembers || typeof path !== 'string' || !path) return;
|
|
51
|
-
for (const segments of emptinessMemberPaths(gqltype, path.split('.'))) {
|
|
52
|
-
entries.push({
|
|
53
|
-
segments, group, operator, construct, member: true,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
43
|
if (!args || typeof args !== 'object') return entries;
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
44
|
+
const root = { base: [], frames: [], groups: new Map() };
|
|
45
|
+
const records = [];
|
|
46
|
+
const open = new Set();
|
|
47
|
+
const childFrame = (parent, path, quantifier, construct, anchor = []) => ({
|
|
48
|
+
base: [...parent.base, ...anchor, ...pathSegments(path)],
|
|
49
|
+
frames: [...parent.frames, { path, type: quantifier, construct }],
|
|
50
|
+
groups: new Map(),
|
|
51
|
+
});
|
|
52
|
+
const visit = (group, frame, parentDepth, quantifier = null) => {
|
|
53
|
+
// Object cycles are cut even when a quantified edge would otherwise create another frame.
|
|
54
|
+
if (!isObject(group) || open.has(group)) return;
|
|
55
|
+
validateQueryGroupKeys(group, { quantified: quantifier !== null });
|
|
56
|
+
const depth = parentDepth + (group.__implicit === true ? 0 : 1);
|
|
57
|
+
let positions = frame.groups.get(group);
|
|
58
|
+
if (!positions) {
|
|
59
|
+
positions = new Map();
|
|
60
|
+
frame.groups.set(group, positions);
|
|
61
|
+
}
|
|
62
|
+
let record = positions.get(quantifier);
|
|
63
|
+
if (record && record.depth >= depth) return;
|
|
64
|
+
if (!record) {
|
|
65
|
+
const anchor = group.__implicit === true && Array.isArray(group.__anchor) ? group.__anchor : [];
|
|
66
|
+
record = {
|
|
67
|
+
group, frame, quantifier, depth, anchor,
|
|
68
|
+
inner: quantifier === null ? frame : childFrame(frame, group.path, quantifier, group, anchor),
|
|
69
|
+
};
|
|
70
|
+
positions.set(quantifier, record);
|
|
71
|
+
records.push(record);
|
|
72
|
+
} else record.depth = depth;
|
|
73
|
+
open.add(group);
|
|
74
|
+
for (const item of nestedGroups(group)) visit(item, record.inner, depth);
|
|
75
|
+
for (const type of QUANTIFIED_LISTS) {
|
|
76
|
+
for (const item of listOf(group[type])) visit(item, record.inner, depth, type);
|
|
66
77
|
}
|
|
67
|
-
|
|
78
|
+
open.delete(group);
|
|
68
79
|
};
|
|
69
80
|
const controls = controlArgs(operation);
|
|
70
81
|
for (const [name, value] of Object.entries(args)) {
|
|
71
|
-
if (controls.has(name)
|
|
72
|
-
const terms = listOf(value.terms);
|
|
73
|
-
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
74
|
-
if (!terms.some(hasTermPath)) {
|
|
75
|
-
const term = terms.find(pathless);
|
|
76
|
-
if (term) addEmptiness(name, null, term.operator, term);
|
|
77
|
-
else if (isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
|
|
78
|
-
else add(name, null, operatorOf(value));
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
let field = false;
|
|
82
|
-
for (const term of terms) {
|
|
83
|
-
if (hasTermPath(term)) {
|
|
84
|
-
if (isEmptinessOperator(term.operator)) addEmptiness(`${name}.${term.path}`, null, term.operator, term);
|
|
85
|
-
else add(`${name}.${term.path}`, null, operatorOf(term), term);
|
|
86
|
-
} else if (!field && pathless(term)) {
|
|
87
|
-
addEmptiness(name, null, term.operator, term);
|
|
88
|
-
field = true;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
// An emptiness operator beside terms is rejected later; until then it reads the field too.
|
|
92
|
-
if (!field && isEmptinessOperator(value.operator)) addEmptiness(name, null, value.operator, null);
|
|
82
|
+
if (!controls.has(name) && value != null) records.push({ name, value });
|
|
93
83
|
}
|
|
94
|
-
for (const
|
|
95
|
-
if (operation === 'aggregate') {
|
|
96
|
-
add(args.aggregation?.groupId);
|
|
97
|
-
for (const fact of listOf(args.aggregation?.facts)) add(fact?.path);
|
|
98
|
-
} else for (const term of listOf(args.sort?.terms)) add(term?.field);
|
|
99
|
-
return entries;
|
|
100
|
-
};
|
|
84
|
+
for (const group of nestedGroups(args)) visit(group, root, -1);
|
|
101
85
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
* - `construct` is the term or group condition object itself, or null for a field filter's own
|
|
117
|
-
* operator ('filter' and 'beside-terms'). These objects are the only ones that carry a client scope
|
|
118
|
-
* marker, `__scope` (#108 part 2b).
|
|
119
|
-
* - `emptiness` tells whether the operator is IS_EMPTY or IS_NOT_EMPTY.
|
|
120
|
-
* Sort, pagination and aggregation hold no operators. Malformed entries are skipped and left to the
|
|
121
|
-
* backends; a group reached twice is read once.
|
|
122
|
-
*/
|
|
123
|
-
export const collectQueryOperators = (args, operation = 'find') => {
|
|
124
|
-
const entries = [];
|
|
125
|
-
if (!args || typeof args !== 'object') return entries;
|
|
126
|
-
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
127
|
-
const add = (item, form, field, rawPath, depth, ambiguous = false) => {
|
|
86
|
+
const metadata = (relative, frame, container, use = 'filter') => ({
|
|
87
|
+
segments: [...frame.base, ...relative], relative, frames: frame.frames, container, use,
|
|
88
|
+
});
|
|
89
|
+
const addPath = (path, frame, container, group = null, operator = null, construct = null, extra = {}) => {
|
|
90
|
+
if (typeof path !== 'string' || !path) return;
|
|
91
|
+
const relative = path.split('.');
|
|
92
|
+
const entry = { ...metadata(relative, frame, container), group, operator, construct, ...extra };
|
|
93
|
+
entries.push(entry);
|
|
94
|
+
if (!expandMembers || !isEmptinessOperator(operator)) return;
|
|
95
|
+
for (const segments of emptinessMemberPaths(gqltype, entry.segments)) {
|
|
96
|
+
entries.push({ ...entry, segments, relative: segments.slice(frame.base.length), member: true });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier) => {
|
|
128
100
|
if (!isObject(item)) return;
|
|
129
|
-
const
|
|
130
|
-
const
|
|
101
|
+
const quantified = form === 'quantified';
|
|
102
|
+
const relative = pathSegments(relativePath);
|
|
103
|
+
const meta = metadata(relative, frame, container);
|
|
104
|
+
const operator = quantified ? null : item.operator ?? 'EQ';
|
|
105
|
+
const path = typeof relativePath === 'string' ? meta.segments.join('.') : relativePath;
|
|
131
106
|
entries.push({
|
|
107
|
+
...meta,
|
|
132
108
|
operator,
|
|
133
109
|
path,
|
|
134
|
-
segments: typeof path === 'string' && path ? path.split('.') : [],
|
|
135
110
|
form,
|
|
136
111
|
field,
|
|
137
112
|
rawPath,
|
|
138
113
|
value: item.value,
|
|
139
114
|
hasValue: Object.hasOwn(item, 'value'),
|
|
140
|
-
ambiguous,
|
|
115
|
+
ambiguous: form === 'condition' && typeof field === 'string' && field.includes('.') && !isPathless(rawPath),
|
|
141
116
|
depth,
|
|
142
|
-
construct: form === 'term' || form === 'condition' ? item : null,
|
|
117
|
+
construct: quantified || form === 'term' || form === 'condition' ? item : null,
|
|
143
118
|
emptiness: isEmptinessOperator(operator),
|
|
119
|
+
...(quantified ? { quantifier } : {}),
|
|
144
120
|
});
|
|
145
121
|
};
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const open = new Set();
|
|
150
|
-
const measure = (group, depth) => {
|
|
151
|
-
if (!isObject(group) || open.has(group) || depths.get(group) >= depth) return;
|
|
152
|
-
depths.set(group, depth);
|
|
153
|
-
open.add(group);
|
|
154
|
-
for (const item of nestedGroups(group)) measure(item, depth + 1);
|
|
155
|
-
open.delete(group);
|
|
122
|
+
const addQuantified = (construct, path, quantifier, frame, container, depth) => {
|
|
123
|
+
if (paths) addPath(path, frame, container, null, null, construct, { quantifier });
|
|
124
|
+
else addOperator(construct, 'quantified', path, undefined, depth, frame, container, path, quantifier);
|
|
156
125
|
};
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
126
|
+
for (const record of records) {
|
|
127
|
+
if (Object.hasOwn(record, 'group')) {
|
|
128
|
+
const { group, frame, inner, quantifier, depth, anchor } = record;
|
|
129
|
+
if (quantifier !== null) {
|
|
130
|
+
const path = anchor.length && typeof group.path === 'string' ? [...anchor, group.path].join('.') : group.path;
|
|
131
|
+
addQuantified(group, path, quantifier, frame, group, depth);
|
|
132
|
+
}
|
|
133
|
+
for (const condition of listOf(group.conditions)) {
|
|
134
|
+
const field = condition?.field;
|
|
135
|
+
const path = conditionPath(field, condition?.path);
|
|
136
|
+
if (paths) {
|
|
137
|
+
if (typeof field === 'string') addPath(path, inner, group, group, condition.operator ?? 'EQ', condition);
|
|
138
|
+
} else addOperator(condition, 'condition', field, condition?.path, depth, inner, group, path);
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
165
141
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
142
|
+
const { name, value } = record;
|
|
143
|
+
const terms = listOf(value.terms);
|
|
144
|
+
if (value.quantifier != null) {
|
|
145
|
+
addQuantified(value, name, value.quantifier, root, value, 0);
|
|
146
|
+
const frame = childFrame(root, name, value.quantifier, value);
|
|
147
|
+
for (const term of terms) {
|
|
148
|
+
if (!isObject(term)) continue;
|
|
149
|
+
if (paths) {
|
|
150
|
+
// Invalid path-less terms still name the field, as before; validation rejects them
|
|
151
|
+
// before authorization marking. Their relative path names nothing in the child.
|
|
152
|
+
if (hasTermPath(term)) addPath(term.path, frame, value, null, term.operator ?? 'EQ', term);
|
|
153
|
+
else if (isPathless(term.path)) {
|
|
154
|
+
entries.push({ ...metadata([], frame, value), group: null, operator: term.operator ?? 'EQ', construct: term });
|
|
155
|
+
}
|
|
156
|
+
} else addOperator(term, 'term', name, term.path, 0, frame, value, isPathless(term.path) ? '' : term.path);
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!paths) {
|
|
161
|
+
const hasTerms = Array.isArray(value.terms);
|
|
162
|
+
if (!hasTerms || value.operator !== undefined) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
|
|
163
|
+
if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
167
|
+
if (!terms.some(hasTermPath)) {
|
|
168
|
+
const term = terms.find(pathless);
|
|
169
|
+
if (term) addPath(name, root, value, null, term.operator, term);
|
|
170
|
+
else addPath(name, root, value, null, value.operator ?? 'EQ');
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
let field = false;
|
|
174
|
+
for (const term of terms) {
|
|
175
|
+
if (hasTermPath(term)) addPath(`${name}.${term.path}`, root, value, null, term.operator ?? 'EQ', term);
|
|
176
|
+
else if (!field && pathless(term)) {
|
|
177
|
+
addPath(name, root, value, null, term.operator, term);
|
|
178
|
+
field = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// An emptiness operator beside terms is rejected later; until then it reads the field too.
|
|
182
|
+
if (!field && isEmptinessOperator(value.operator)) addPath(name, root, value, null, value.operator);
|
|
183
|
+
}
|
|
184
|
+
if (paths) {
|
|
185
|
+
const addUse = (path, use) => addPath(path, root, null, null, null, null, { use });
|
|
186
|
+
if (operation === 'aggregate') {
|
|
187
|
+
addUse(args.aggregation?.groupId, 'groupId');
|
|
188
|
+
for (const fact of listOf(args.aggregation?.facts)) addUse(fact?.path, 'fact');
|
|
189
|
+
} else for (const term of listOf(args.sort?.terms)) addUse(term?.field, 'sort');
|
|
176
190
|
}
|
|
177
|
-
for (const item of nestedGroups(args)) measure(item, 0);
|
|
178
|
-
for (const item of nestedGroups(args)) visit(item);
|
|
179
191
|
return entries;
|
|
180
192
|
};
|
|
181
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Internal: model paths named by filter keys, terms, logical/quantified groups, sort, groupId and
|
|
196
|
+
* facts. Each entry carries `use`, the correlation `container`, enclosing `frames` (relative path,
|
|
197
|
+
* quantifier `type`, marker `construct`), `relative` segments in its frame and absolute `segments`.
|
|
198
|
+
* A quantified carrier names its path in the containing frame, with `quantifier` and its own
|
|
199
|
+
* construct; children enter its frame. The legacy `group` remains the conditions owner, otherwise
|
|
200
|
+
* null; operator and construct retain their old meanings except that quantifier filters now carry
|
|
201
|
+
* their own construct. An omitted filter operator is EQ; non-filter reads and quantified carriers
|
|
202
|
+
* have no operator. `expandMembers` appends stored emptiness member paths, preserving metadata.
|
|
203
|
+
*/
|
|
204
|
+
export const collectQueryPathEntries = (args, operation = 'find', options = {}) => (
|
|
205
|
+
walkQueryEntries(args, operation, { ...options, paths: true })
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Internal: filter constructs only, with the same frame metadata as the path collector. Preserves
|
|
210
|
+
* form, field, rawPath, value, hasValue, ambiguous, deepest depth and emptiness. Quantified carriers
|
|
211
|
+
* have form `quantified`, operator null and their own construct; ordinary own filter operators keep
|
|
212
|
+
* construct null. Explicit groups add a depth level, implicit ones none. Invalid entries are left
|
|
213
|
+
* to the validator; unknown group keys and path on a plain group fail closed here.
|
|
214
|
+
*/
|
|
215
|
+
export const collectQueryOperators = (args, operation = 'find') => walkQueryEntries(args, operation);
|
|
216
|
+
|
|
182
217
|
/** Internal: the segment arrays of {@link collectQueryPathEntries}. */
|
|
183
218
|
export const collectQueryPaths = (args, operation = 'find') => (
|
|
184
219
|
collectQueryPathEntries(args, operation).map((entry) => entry.segments)
|
|
@@ -242,11 +277,15 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
242
277
|
* that path's target find scope arguments, planned on the target type in its own frame, or null when
|
|
243
278
|
* the scope added nothing. A `__scope` without filled `scopes`, without one of those keys, or on a
|
|
244
279
|
* construct that reads no reference or collection fails with DETACHED_CLIENT_FILTER (500) after the
|
|
245
|
-
* checks above
|
|
280
|
+
* checks above. With `quantifiers`, collection scalar paths become correlated ANY frames,
|
|
281
|
+
* explicit ANY/ALL/NONE and quantifier filters retain their quantifier, and scoped reference
|
|
282
|
+
* crossings inside a frame lift into `joins`. Aggregate collection joins remain legacy; later
|
|
283
|
+
* frames carry their legacy chain in `anchor`. Sorts through collections fail even in count mode.
|
|
246
284
|
*/
|
|
247
|
-
export const createQueryPlan = (models, entityName,
|
|
248
|
-
mode = 'find', operators: supported, referenceEmptiness = supported == null,
|
|
285
|
+
export const createQueryPlan = (models, entityName, source = {}, {
|
|
286
|
+
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null,
|
|
249
287
|
} = {}) => {
|
|
288
|
+
let input = source;
|
|
250
289
|
const allowed = supported == null ? null : new Set(supported);
|
|
251
290
|
const references = referenceEmptiness === true;
|
|
252
291
|
const combine = (kind, terms) => {
|
|
@@ -256,9 +295,8 @@ export const createQueryPlan = (models, entityName, input = {}, {
|
|
|
256
295
|
// The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
|
|
257
296
|
// arguments are planned on the target type, as trusted find arguments in their own frame, whatever
|
|
258
297
|
// the query's mode, with the operator names already read (`operators` may be a one-time iterable).
|
|
259
|
-
const liftScopes = (marker,
|
|
298
|
+
const liftScopes = (marker, targets, segments, operator) => {
|
|
260
299
|
const label = `${operator} on ${segments.join('.')}`;
|
|
261
|
-
const targets = emptinessScopeTargets(shape, segments);
|
|
262
300
|
const paths = Object.keys(targets);
|
|
263
301
|
if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
|
|
264
302
|
const scopes = marker?.scopes;
|
|
@@ -266,39 +304,40 @@ export const createQueryPlan = (models, entityName, input = {}, {
|
|
|
266
304
|
const lifted = {};
|
|
267
305
|
for (const path of paths) {
|
|
268
306
|
if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
|
|
307
|
+
if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
|
|
269
308
|
lifted[path] = scopes[path] === null
|
|
270
309
|
? null
|
|
271
|
-
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references }).where;
|
|
310
|
+
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers }).where;
|
|
272
311
|
}
|
|
273
312
|
return lifted;
|
|
274
313
|
};
|
|
275
314
|
const hasMarker = (construct) => construct != null && typeof construct === 'object' && Object.hasOwn(construct, '__scope');
|
|
276
315
|
// Rule 1 has resolved the path; rule 2 is the compiler's operators, rules 3 to 6 describeEmptiness's.
|
|
277
|
-
const emptiness = (path, steps, operator, value, construct) => {
|
|
316
|
+
const emptiness = (path, steps, operator, value, construct, frameEntity) => {
|
|
278
317
|
if (allowed && !allowed.has(operator)) fail(`The ${operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
279
318
|
const segments = Array.isArray(path) ? path : path.split('.');
|
|
280
319
|
const options = {
|
|
281
320
|
operator, value, path: segments.join('.'), referenceEmptiness: references,
|
|
282
321
|
};
|
|
283
|
-
const gqltype = models.entities.find((item) => item.name ===
|
|
322
|
+
const gqltype = models.entities.find((item) => item.name === frameEntity)?.gqltype;
|
|
284
323
|
const shape = gqltype ? describeEmptiness(gqltype, segments, options) : describeModelEmptiness(steps, options);
|
|
285
324
|
const node = {
|
|
286
325
|
kind: 'predicate', path: segments, operator, value: null,
|
|
287
326
|
};
|
|
288
|
-
if (hasMarker(construct)) node.scopes = liftScopes(construct.__scope, shape, segments, operator);
|
|
327
|
+
if (hasMarker(construct)) node.scopes = liftScopes(construct.__scope, emptinessScopeTargets(shape, segments), segments, operator);
|
|
289
328
|
return node;
|
|
290
329
|
};
|
|
291
330
|
// `construct` is the term or group condition that holds the predicate, the only objects whose
|
|
292
331
|
// `__scope` is read; null for a field filter's own operator.
|
|
293
|
-
const predicate = (path, operator, value, construct = null) => {
|
|
294
|
-
const steps = resolveModelPath(models,
|
|
332
|
+
const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false) => {
|
|
333
|
+
const steps = resolveModelPath(models, frameEntity, path);
|
|
295
334
|
const op = operator ?? 'EQ';
|
|
296
|
-
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct);
|
|
335
|
+
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameEntity);
|
|
297
336
|
const field = steps.at(-1);
|
|
298
337
|
if (field.kind !== 'scalar' && !field.inferred) {
|
|
299
338
|
fail(`Filter on object field ${path} requires a scalar path`, steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
300
339
|
}
|
|
301
|
-
if (!
|
|
340
|
+
if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
302
341
|
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
303
342
|
const collection = ['IN', 'NIN', 'BTW'].includes(op);
|
|
304
343
|
if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
|
|
@@ -306,28 +345,64 @@ export const createQueryPlan = (models, entityName, input = {}, {
|
|
|
306
345
|
if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
|
|
307
346
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
308
347
|
if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
|
|
309
|
-
|
|
310
|
-
|
|
348
|
+
const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
349
|
+
if (hasMarker(construct)) {
|
|
350
|
+
if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);
|
|
351
|
+
const targets = Object.fromEntries(crossingKeys(node.path, { models, entity: frameEntity }).map((key) => [key, resolveModelPath(models, frameEntity, key).at(-1).target]));
|
|
352
|
+
node.joins = liftScopes(construct.__scope, targets, node.path, op);
|
|
353
|
+
}
|
|
354
|
+
return node;
|
|
311
355
|
};
|
|
312
356
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
313
357
|
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
|
|
314
358
|
for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
|
|
315
359
|
};
|
|
316
|
-
const group = (value, depth =
|
|
360
|
+
const group = (value, depth = -1, frameEntity = entityName, inFrame = false) => {
|
|
317
361
|
validateGroup(value);
|
|
318
|
-
|
|
362
|
+
const level = depth + (value.__implicit === true ? 0 : 1);
|
|
363
|
+
if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
|
|
319
364
|
const terms = [];
|
|
320
365
|
for (const condition of value.conditions || []) {
|
|
321
366
|
if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
322
367
|
if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
323
|
-
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition));
|
|
368
|
+
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameEntity, inFrame));
|
|
324
369
|
}
|
|
325
|
-
|
|
326
|
-
|
|
370
|
+
if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
|
|
371
|
+
fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
372
|
+
}
|
|
373
|
+
for (const quantifier of QUANTIFIED_LISTS) for (const item of value[quantifier] || []) {
|
|
374
|
+
const anchor = item.__implicit === true ? item.__anchor : undefined;
|
|
375
|
+
const path = item.path.split('.');
|
|
376
|
+
const target = resolveModelPath(models, frameEntity, [...(anchor ?? []), ...path]).at(-1).target;
|
|
377
|
+
const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true), scope: null };
|
|
378
|
+
if (anchor?.length) node.anchor = anchor;
|
|
379
|
+
if (hasMarker(item)) {
|
|
380
|
+
const fullPath = [...(anchor ?? []), ...path];
|
|
381
|
+
const key = fullPath.join('.');
|
|
382
|
+
const crossings = anchor?.length ? [] : crossingKeys(path, { models, entity: frameEntity });
|
|
383
|
+
const targets = Object.fromEntries(crossings.map((prefix) => [prefix, resolveModelPath(models, frameEntity, prefix).at(-1).target]));
|
|
384
|
+
targets[key] = target;
|
|
385
|
+
const scopes = liftScopes(item.__scope, targets, fullPath, quantifier);
|
|
386
|
+
node.scope = scopes[key];
|
|
387
|
+
if (anchor?.length) {
|
|
388
|
+
for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
|
|
389
|
+
if (!Object.hasOwn(item.__scope.scopes, prefix) || item.__scope.scopes[prefix] !== null) detached(`${quantifier} on ${key} requires a null aggregate anchor scope for ${prefix}`);
|
|
390
|
+
}
|
|
391
|
+
} else if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
|
|
392
|
+
}
|
|
393
|
+
terms.push(node);
|
|
394
|
+
}
|
|
395
|
+
for (const item of value.AND || []) terms.push(group(item, level, frameEntity, inFrame));
|
|
396
|
+
terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame))));
|
|
327
397
|
return combine('and', terms);
|
|
328
398
|
};
|
|
329
|
-
//
|
|
399
|
+
// Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
|
|
330
400
|
validateGroup(input, ['AND', 'OR']);
|
|
401
|
+
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers };
|
|
402
|
+
const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
|
|
403
|
+
context.J = J;
|
|
404
|
+
validateQuantifiedGroups(input, context);
|
|
405
|
+
input = frameCollectionPaths(input, context);
|
|
331
406
|
const controls = controlArgs(mode);
|
|
332
407
|
const terms = [];
|
|
333
408
|
for (const [name, value] of Object.entries(input)) {
|
|
@@ -371,16 +446,22 @@ export const createQueryPlan = (models, entityName, input = {}, {
|
|
|
371
446
|
plan.aggregation = { groupId: aggregation.groupId.split('.'), facts: aggregation.facts.map((fact) => ({ ...fact, path: fact.path.split('.') })) };
|
|
372
447
|
}
|
|
373
448
|
if (input.sort && (!Array.isArray(input.sort.terms) || !input.sort.terms.length)) fail('Sort requires non-empty terms', 'INVALID_SORT');
|
|
374
|
-
|
|
449
|
+
for (const sort of input.sort?.terms || []) {
|
|
375
450
|
if (!sort || (sort.order !== 'ASC' && sort.order !== 'DESC')) fail('Invalid sort order', 'INVALID_SORT');
|
|
376
451
|
if (typeof sort.field !== 'string' || sort.field.split('.').some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) {
|
|
377
452
|
fail('Invalid sort path', 'INVALID_FILTER_PATH');
|
|
378
453
|
}
|
|
379
454
|
if (mode !== 'aggregate') {
|
|
380
|
-
const
|
|
455
|
+
const steps = resolveModelPath(models, entityName, sort.field);
|
|
456
|
+
let owner = entityName;
|
|
457
|
+
for (const step of steps) {
|
|
458
|
+
if (step.kind === 'collection') fail(`Sort path ${sort.field} crosses the collection ${owner}.${step.name}; a record has no single value there`, 'INVALID_FILTER_PATH');
|
|
459
|
+
if (step.target && !step.inferred) owner = step.target;
|
|
460
|
+
}
|
|
461
|
+
const field = steps.at(-1);
|
|
381
462
|
if (field.kind !== 'scalar' && !field.inferred) fail('Sort path must end in a scalar field', 'INVALID_FILTER_PATH');
|
|
382
463
|
}
|
|
383
|
-
plan.sort.push({ path: sort.field.split('.'), order: sort.order });
|
|
464
|
+
if (mode !== 'count') plan.sort.push({ path: sort.field.split('.'), order: sort.order });
|
|
384
465
|
}
|
|
385
466
|
return plan;
|
|
386
467
|
};
|