@yunsoft/yuncms-core 0.1.3 → 0.1.6
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/package.json +1 -1
- package/src/auth/external-state.js +76 -0
- package/src/bootstrap.js +6 -0
- package/src/cache.js +85 -0
- package/src/config.js +115 -20
- package/src/context.js +5 -2
- package/src/hooks.js +117 -32
- package/src/index.js +32 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/migrations.js +113 -3
- package/src/query.js +156 -54
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +564 -142
- package/src/services/auth-service.js +40 -2
- package/src/services/core-services.js +2 -0
- package/src/services/external-auth-service.js +323 -0
- package/src/services/files-service.js +85 -7
- package/src/services/items-service.js +121 -81
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { assertIdentifier } from './identifier.js';
|
|
1
|
+
import { assertIdentifier, quoteIdentifier } from './identifier.js';
|
|
2
|
+
import {
|
|
3
|
+
assertQueryCost,
|
|
4
|
+
compileFilter,
|
|
5
|
+
compileSelectFields,
|
|
6
|
+
QUERY_LIMITS,
|
|
7
|
+
} from './query.js';
|
|
2
8
|
import { SchemaCache } from './schema.js';
|
|
3
9
|
import { ItemsService } from './services/items-service.js';
|
|
4
10
|
|
|
5
|
-
const MAX_EXPAND_FIELDS =
|
|
11
|
+
export const MAX_EXPAND_FIELDS = QUERY_LIMITS.maxRelationExpansions;
|
|
12
|
+
export const MAX_RELATION_DEPTH = QUERY_LIMITS.maxRelationDepth;
|
|
13
|
+
export const MAX_TO_MANY_ROWS = 2_000;
|
|
6
14
|
const defaultSchemaCache = new SchemaCache();
|
|
7
15
|
|
|
8
16
|
function expansionError(code, message, path = null) {
|
|
@@ -18,49 +26,25 @@ function normalizeDelimited(value) {
|
|
|
18
26
|
return values.map((entry) => String(entry).trim()).filter(Boolean);
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
function assertFieldToken(field, path) {
|
|
30
|
+
try { assertIdentifier(field, 'field'); } catch { throw expansionError('INVALID_QUERY', `Invalid field: ${field}`, path); }
|
|
31
|
+
return field;
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
export function parseExpandInput(value) {
|
|
22
35
|
const fields = [...new Set(normalizeDelimited(value))];
|
|
23
36
|
if (fields.length > MAX_EXPAND_FIELDS) {
|
|
24
|
-
throw expansionError(
|
|
25
|
-
'INVALID_QUERY',
|
|
26
|
-
`expand supports at most ${MAX_EXPAND_FIELDS} direct relation fields`,
|
|
27
|
-
'expand',
|
|
28
|
-
);
|
|
37
|
+
throw expansionError('INVALID_QUERY', `expand cannot contain more than ${MAX_EXPAND_FIELDS} entries`, 'expand');
|
|
29
38
|
}
|
|
30
|
-
|
|
31
39
|
for (const field of fields) {
|
|
32
|
-
try {
|
|
33
|
-
assertIdentifier(field, 'expand field');
|
|
34
|
-
} catch {
|
|
35
|
-
throw expansionError('INVALID_QUERY', `Invalid expand field: ${field}`, 'expand');
|
|
36
|
-
}
|
|
40
|
+
try { assertIdentifier(field, 'expand field'); } catch { throw expansionError('INVALID_QUERY', `Invalid expand field: ${field}`, 'expand'); }
|
|
37
41
|
}
|
|
38
42
|
return fields;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
function parseRelationMetadata(value) {
|
|
42
46
|
if (value == null || typeof value === 'object') return value ?? {};
|
|
43
|
-
try {
|
|
44
|
-
return JSON.parse(value);
|
|
45
|
-
} catch {
|
|
46
|
-
return {};
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function withExpansionFields(rawFields, expandFields) {
|
|
51
|
-
if (rawFields == null || rawFields === '') return rawFields;
|
|
52
|
-
const selected = normalizeDelimited(rawFields);
|
|
53
|
-
if (selected.includes('*')) return selected;
|
|
54
|
-
return [...new Set([...selected, ...expandFields])];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function withoutExpand(query = {}, expandFields = []) {
|
|
58
|
-
const base = { ...query };
|
|
59
|
-
delete base.expand;
|
|
60
|
-
if (expandFields.length > 0 && Object.hasOwn(base, 'fields')) {
|
|
61
|
-
base.fields = withExpansionFields(base.fields, expandFields);
|
|
62
|
-
}
|
|
63
|
-
return base;
|
|
47
|
+
try { return JSON.parse(value); } catch { return {}; }
|
|
64
48
|
}
|
|
65
49
|
|
|
66
50
|
async function schemaSnapshot(options) {
|
|
@@ -69,148 +53,586 @@ async function schemaSnapshot(options) {
|
|
|
69
53
|
return cache.get(options.database);
|
|
70
54
|
}
|
|
71
55
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
throw expansionError(
|
|
76
|
-
'INVALID_QUERY',
|
|
77
|
-
`Field is not a direct relation and cannot be expanded: ${collection}.${field}`,
|
|
78
|
-
`expand.${field}`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
56
|
+
function relationFromSnapshot(snapshot, collection, field) {
|
|
57
|
+
return snapshot.relationByManyField?.get(`${collection}.${field}`) ?? null;
|
|
58
|
+
}
|
|
81
59
|
|
|
60
|
+
function isDirectRelation(relation) {
|
|
61
|
+
if (!relation) return false;
|
|
82
62
|
const metadata = parseRelationMetadata(relation.metadata);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
63
|
+
return !relation.junction_collection && metadata.kind !== 'm2m';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function relationKind(relation) {
|
|
67
|
+
return parseRelationMetadata(relation?.metadata).kind ?? 'm2o';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readableSourceField(sourceSchema, permission, field) {
|
|
71
|
+
return Boolean(sourceSchema.fields?.[field] && (!permission.fields || permission.fields.includes(field)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeVirtualAlias(value) {
|
|
75
|
+
if (typeof value !== 'string' || value.length > 64) return null;
|
|
76
|
+
try { return assertIdentifier(value, 'relation alias'); } catch { return null; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function chooseVirtualAlias({ preferred, fallback, used, relationId }) {
|
|
80
|
+
const candidates = [preferred, fallback, `${fallback}_${relationId ?? 'relation'}`]
|
|
81
|
+
.map(safeVirtualAlias)
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
if (!used.has(candidate)) {
|
|
85
|
+
used.add(candidate);
|
|
86
|
+
return candidate;
|
|
87
|
+
}
|
|
89
88
|
}
|
|
90
|
-
return
|
|
89
|
+
return null;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
function companionM2MRelation(snapshot, relation) {
|
|
93
|
+
return snapshot.relations?.find((candidate) => (
|
|
94
|
+
candidate.junction_collection === relation.junction_collection
|
|
95
|
+
&& candidate.many_collection === relation.junction_collection
|
|
96
|
+
&& candidate.many_field === relation.junction_field
|
|
97
|
+
)) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function relationDescriptorsForCollection(snapshot, collection) {
|
|
95
101
|
const sourceSchema = snapshot.collections?.[collection];
|
|
96
|
-
if (!sourceSchema)
|
|
97
|
-
|
|
102
|
+
if (!sourceSchema) return new Map();
|
|
103
|
+
const descriptors = new Map();
|
|
104
|
+
const used = new Set(Object.keys(sourceSchema.fields ?? {}));
|
|
105
|
+
const directPrefix = `${collection}.`;
|
|
106
|
+
|
|
107
|
+
for (const [key, relation] of snapshot.relationByManyField?.entries?.() ?? []) {
|
|
108
|
+
if (!key.startsWith(directPrefix) || !isDirectRelation(relation)) continue;
|
|
109
|
+
const field = key.slice(directPrefix.length);
|
|
110
|
+
descriptors.set(field, Object.freeze({
|
|
111
|
+
alias: field,
|
|
112
|
+
kind: 'to_one',
|
|
113
|
+
sourceCollection: collection,
|
|
114
|
+
sourceField: field,
|
|
115
|
+
sourceKey: field,
|
|
116
|
+
targetCollection: relation.one_collection,
|
|
117
|
+
targetKey: relation.one_field || snapshot.collections?.[relation.one_collection]?.primary_key || 'id',
|
|
118
|
+
relation,
|
|
119
|
+
edge: `to_one:${collection}.${field}`,
|
|
120
|
+
}));
|
|
98
121
|
}
|
|
99
|
-
if (expandFields.length === 0) return snapshot;
|
|
100
122
|
|
|
101
|
-
const
|
|
102
|
-
for (const
|
|
103
|
-
if (
|
|
104
|
-
|
|
123
|
+
const virtualCandidates = [];
|
|
124
|
+
for (const relation of snapshot.relations ?? []) {
|
|
125
|
+
if (relation.one_collection !== collection) continue;
|
|
126
|
+
const metadata = parseRelationMetadata(relation.metadata);
|
|
127
|
+
|
|
128
|
+
if (relation.junction_collection && metadata.kind === 'm2m') {
|
|
129
|
+
const companion = companionM2MRelation(snapshot, relation);
|
|
130
|
+
if (!companion || companion.one_collection === collection) continue;
|
|
131
|
+
virtualCandidates.push({
|
|
132
|
+
preferred: metadata.reverseField ?? metadata.alias ?? companion.one_collection,
|
|
133
|
+
fallback: `${companion.one_collection}_${relation.junction_collection}`,
|
|
134
|
+
relationId: relation.id,
|
|
135
|
+
descriptor: {
|
|
136
|
+
kind: 'm2m',
|
|
137
|
+
sourceCollection: collection,
|
|
138
|
+
sourceKey: relation.one_field || sourceSchema.primary_key || 'id',
|
|
139
|
+
targetCollection: companion.one_collection,
|
|
140
|
+
targetKey: companion.one_field || snapshot.collections?.[companion.one_collection]?.primary_key || 'id',
|
|
141
|
+
junctionCollection: relation.junction_collection,
|
|
142
|
+
sourceJunctionField: relation.many_field,
|
|
143
|
+
targetJunctionField: relation.junction_field,
|
|
144
|
+
relation,
|
|
145
|
+
edge: `m2m:${relation.junction_collection}:${relation.many_field}`,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
105
149
|
}
|
|
106
|
-
|
|
150
|
+
|
|
151
|
+
if (!isDirectRelation(relation) || relation.many_collection === collection) continue;
|
|
152
|
+
const kind = relationKind(relation) === 'o2o' ? 'reverse_to_one' : 'o2m';
|
|
153
|
+
virtualCandidates.push({
|
|
154
|
+
preferred: metadata.reverseField ?? metadata.alias ?? relation.many_collection,
|
|
155
|
+
fallback: `${relation.many_collection}_${relation.many_field}`,
|
|
156
|
+
relationId: relation.id,
|
|
157
|
+
descriptor: {
|
|
158
|
+
kind,
|
|
159
|
+
sourceCollection: collection,
|
|
160
|
+
sourceKey: relation.one_field || sourceSchema.primary_key || 'id',
|
|
161
|
+
targetCollection: relation.many_collection,
|
|
162
|
+
targetKey: snapshot.collections?.[relation.many_collection]?.primary_key || 'id',
|
|
163
|
+
targetForeignKey: relation.many_field,
|
|
164
|
+
relation,
|
|
165
|
+
edge: `${kind}:${relation.many_collection}.${relation.many_field}`,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
virtualCandidates.sort((left, right) => left.descriptor.edge.localeCompare(right.descriptor.edge));
|
|
171
|
+
for (const candidate of virtualCandidates) {
|
|
172
|
+
const alias = chooseVirtualAlias({ ...candidate, used });
|
|
173
|
+
if (!alias) continue;
|
|
174
|
+
descriptors.set(alias, Object.freeze({ ...candidate.descriptor, alias }));
|
|
107
175
|
}
|
|
108
|
-
|
|
176
|
+
|
|
177
|
+
return descriptors;
|
|
109
178
|
}
|
|
110
179
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
180
|
+
function relationDescriptor(snapshot, collection, field, path = `fields.${field}`) {
|
|
181
|
+
const descriptor = relationDescriptorsForCollection(snapshot, collection).get(field);
|
|
182
|
+
if (!descriptor) {
|
|
183
|
+
throw expansionError('INVALID_QUERY', `Field is not a relation and cannot be expanded: ${collection}.${field}`, path);
|
|
184
|
+
}
|
|
185
|
+
return descriptor;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function createPlanNode() {
|
|
189
|
+
return {
|
|
190
|
+
all: false,
|
|
191
|
+
explicit: false,
|
|
192
|
+
fields: new Set(),
|
|
193
|
+
internalFields: new Set(),
|
|
194
|
+
children: new Map(),
|
|
195
|
+
relations: new Map(),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function countChildren(node) {
|
|
200
|
+
let count = node.children.size;
|
|
201
|
+
for (const child of node.children.values()) count += countChildren(child);
|
|
202
|
+
return count;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function countToMany(node) {
|
|
206
|
+
let count = 0;
|
|
207
|
+
for (const [alias, child] of node.children) {
|
|
208
|
+
const kind = node.relations.get(alias)?.kind;
|
|
209
|
+
if (kind === 'o2m' || kind === 'm2m') count += 1;
|
|
210
|
+
count += countToMany(child);
|
|
211
|
+
}
|
|
212
|
+
return count;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function addPath({ node, snapshot, collection, parts, path, depth = 0, visited = new Set() }) {
|
|
216
|
+
if (parts.length === 0) return;
|
|
217
|
+
const [head, ...rest] = parts;
|
|
218
|
+
if (rest.length === 0) {
|
|
219
|
+
if (head === '*') node.all = true;
|
|
220
|
+
else node.fields.add(assertFieldToken(head, path));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (depth >= MAX_RELATION_DEPTH) {
|
|
224
|
+
throw expansionError('QUERY_RELATION_DEPTH_LIMIT', `Relation depth cannot exceed ${MAX_RELATION_DEPTH}`, path);
|
|
225
|
+
}
|
|
226
|
+
const field = assertFieldToken(head, path);
|
|
227
|
+
const descriptor = relationDescriptor(snapshot, collection, field, path);
|
|
228
|
+
if (visited.has(descriptor.edge)) throw expansionError('INVALID_QUERY', `Cyclic relation path is not allowed: ${path}`, path);
|
|
229
|
+
node.relations.set(field, descriptor);
|
|
230
|
+
if (descriptor.kind !== 'to_one') node.internalFields.add(descriptor.sourceKey);
|
|
231
|
+
const child = node.children.get(field) ?? createPlanNode();
|
|
232
|
+
child.explicit = true;
|
|
233
|
+
node.children.set(field, child);
|
|
234
|
+
addPath({
|
|
235
|
+
node: child,
|
|
236
|
+
snapshot,
|
|
237
|
+
collection: descriptor.targetCollection,
|
|
238
|
+
parts: rest,
|
|
239
|
+
path,
|
|
240
|
+
depth: depth + 1,
|
|
241
|
+
visited: new Set([...visited, descriptor.edge]),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function relationAllowedAtRoot(descriptor, sourceSchema, permission) {
|
|
246
|
+
const requiredField = descriptor.kind === 'to_one' ? descriptor.sourceField : descriptor.sourceKey;
|
|
247
|
+
return readableSourceField(sourceSchema, permission, requiredField);
|
|
248
|
+
}
|
|
115
249
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
250
|
+
function parseFieldsPlan({ value, snapshot, collection, sourceSchema, permission }) {
|
|
251
|
+
const tokens = normalizeDelimited(value);
|
|
252
|
+
const root = createPlanNode();
|
|
253
|
+
root.explicit = tokens.length > 0;
|
|
254
|
+
if (tokens.length === 0) return root;
|
|
255
|
+
|
|
256
|
+
for (const token of tokens) {
|
|
257
|
+
if (token === '*') { root.all = true; continue; }
|
|
258
|
+
if (token === '*.*') {
|
|
259
|
+
root.all = true;
|
|
260
|
+
for (const [alias, descriptor] of relationDescriptorsForCollection(snapshot, collection)) {
|
|
261
|
+
if (!relationAllowedAtRoot(descriptor, sourceSchema, permission)) continue;
|
|
262
|
+
root.relations.set(alias, descriptor);
|
|
263
|
+
if (descriptor.kind !== 'to_one') root.internalFields.add(descriptor.sourceKey);
|
|
264
|
+
const child = root.children.get(alias) ?? createPlanNode();
|
|
265
|
+
child.all = true;
|
|
266
|
+
child.explicit = true;
|
|
267
|
+
root.children.set(alias, child);
|
|
268
|
+
}
|
|
131
269
|
continue;
|
|
132
270
|
}
|
|
133
271
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const byKey = new Map();
|
|
141
|
-
for (const target of targetRows) {
|
|
142
|
-
if (!Object.hasOwn(target, targetKey)) {
|
|
143
|
-
throw expansionError(
|
|
144
|
-
'FORBIDDEN_FIELD',
|
|
145
|
-
`Expanded relation key is not readable: ${targetCollection}.${targetKey}`,
|
|
146
|
-
`expand.${field}`,
|
|
147
|
-
);
|
|
272
|
+
const parts = token.split('.');
|
|
273
|
+
if (parts.some((part) => !part)) throw expansionError('INVALID_QUERY', `Invalid fields path: ${token}`, `fields.${token}`);
|
|
274
|
+
if (parts.length === 1) {
|
|
275
|
+
if (!readableSourceField(sourceSchema, permission, parts[0])) {
|
|
276
|
+
throw expansionError('INVALID_QUERY', `Unknown field: ${parts[0]}`, `fields.${token}`);
|
|
148
277
|
}
|
|
149
|
-
|
|
278
|
+
root.fields.add(assertFieldToken(parts[0], `fields.${token}`));
|
|
279
|
+
continue;
|
|
150
280
|
}
|
|
281
|
+
const descriptor = relationDescriptor(snapshot, collection, parts[0], `fields.${token}`);
|
|
282
|
+
if (!relationAllowedAtRoot(descriptor, sourceSchema, permission)) {
|
|
283
|
+
throw expansionError('INVALID_QUERY', `Relation source key is not readable: ${parts[0]}`, `fields.${token}`);
|
|
284
|
+
}
|
|
285
|
+
addPath({ node: root, snapshot, collection, parts, path: `fields.${token}` });
|
|
286
|
+
}
|
|
151
287
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
? null
|
|
156
|
-
: (byKey.get(String(row[field])) ?? null),
|
|
157
|
-
}));
|
|
288
|
+
const relationCount = countChildren(root);
|
|
289
|
+
if (relationCount > MAX_EXPAND_FIELDS) {
|
|
290
|
+
throw expansionError('INVALID_QUERY', `Relation expansion cannot contain more than ${MAX_EXPAND_FIELDS} relation nodes`, 'fields');
|
|
158
291
|
}
|
|
292
|
+
return root;
|
|
293
|
+
}
|
|
159
294
|
|
|
160
|
-
|
|
295
|
+
function addLegacyExpansions({ query, root, snapshot, collection, sourceSchema, permission }) {
|
|
296
|
+
for (const field of parseExpandInput(query.expand)) {
|
|
297
|
+
const descriptor = relationDescriptor(snapshot, collection, field, `expand.${field}`);
|
|
298
|
+
if (!relationAllowedAtRoot(descriptor, sourceSchema, permission)) {
|
|
299
|
+
throw expansionError('INVALID_QUERY', `Relation source key is not readable: ${field}`, `expand.${field}`);
|
|
300
|
+
}
|
|
301
|
+
root.relations.set(field, descriptor);
|
|
302
|
+
if (descriptor.kind !== 'to_one') root.internalFields.add(descriptor.sourceKey);
|
|
303
|
+
const child = root.children.get(field) ?? createPlanNode();
|
|
304
|
+
child.all = true;
|
|
305
|
+
child.explicit = true;
|
|
306
|
+
root.children.set(field, child);
|
|
307
|
+
}
|
|
308
|
+
if (countChildren(root) > MAX_EXPAND_FIELDS) {
|
|
309
|
+
throw expansionError('INVALID_QUERY', `Relation expansion cannot contain more than ${MAX_EXPAND_FIELDS} relation nodes`, 'expand');
|
|
310
|
+
}
|
|
311
|
+
return root;
|
|
161
312
|
}
|
|
162
313
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
314
|
+
function visibleSelectionForNode(node) {
|
|
315
|
+
if (node.all) return ['*'];
|
|
316
|
+
const fields = new Set(node.fields);
|
|
317
|
+
for (const [alias] of node.children) {
|
|
318
|
+
const descriptor = node.relations.get(alias);
|
|
319
|
+
if (descriptor?.kind === 'to_one') fields.add(descriptor.sourceField);
|
|
320
|
+
}
|
|
321
|
+
return [...fields];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function baseSelectionForNode(node) {
|
|
325
|
+
const visible = visibleSelectionForNode(node);
|
|
326
|
+
if (visible.includes('*')) return visible;
|
|
327
|
+
return [...new Set([...visible, ...node.internalFields])];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function buildSelectionPlan({ collection, query, options, service }) {
|
|
331
|
+
const snapshot = await schemaSnapshot(options);
|
|
332
|
+
const sourceSchema = snapshot.collections?.[collection];
|
|
333
|
+
if (!sourceSchema) throw expansionError('COLLECTION_NOT_FOUND', `Unknown collection: ${collection}`);
|
|
334
|
+
const permission = await service.resolvePermission('read');
|
|
335
|
+
const root = parseFieldsPlan({ value: query.fields, snapshot, collection, sourceSchema, permission });
|
|
336
|
+
addLegacyExpansions({ query, root, snapshot, collection, sourceSchema, permission });
|
|
337
|
+
const relationCount = countChildren(root);
|
|
338
|
+
const toManyCount = countToMany(root);
|
|
339
|
+
const relationDepth = maxPlanDepth(root);
|
|
340
|
+
assertQueryCost(
|
|
341
|
+
{ ...query, limit: Number(query.limit ?? QUERY_LIMITS.defaultLimit) },
|
|
342
|
+
{ relationCount: relationCount + toManyCount, relationDepth },
|
|
343
|
+
);
|
|
344
|
+
return { root, snapshot };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function maxPlanDepth(node) {
|
|
348
|
+
if (node.children.size === 0) return 0;
|
|
349
|
+
return 1 + Math.max(...[...node.children.values()].map(maxPlanDepth));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function baseQuery(query, root) {
|
|
353
|
+
const base = { ...query };
|
|
354
|
+
delete base.expand;
|
|
355
|
+
const selection = baseSelectionForNode(root);
|
|
356
|
+
if (selection.length === 0) delete base.fields;
|
|
357
|
+
else base.fields = selection;
|
|
358
|
+
return base;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function projectTarget(target, node, visibleFields) {
|
|
362
|
+
const projectedFields = node.all
|
|
363
|
+
? [...new Set([...visibleFields, ...node.children.keys()])]
|
|
364
|
+
: [...new Set([...node.fields, ...node.children.keys()])];
|
|
365
|
+
return Object.fromEntries(
|
|
366
|
+
projectedFields.filter((field) => Object.hasOwn(target, field)).map((field) => [field, target[field]]),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function schemaForFields(schema, allowedFields) {
|
|
371
|
+
if (allowedFields == null) return schema;
|
|
372
|
+
return {
|
|
373
|
+
...schema,
|
|
374
|
+
fields: Object.fromEntries(
|
|
375
|
+
allowedFields.filter((field) => schema.fields[field]).map((field) => [field, schema.fields[field]]),
|
|
376
|
+
),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function combineCompiledFilters(...filters) {
|
|
381
|
+
const active = filters.filter((filter) => filter?.sql);
|
|
382
|
+
if (active.length === 0) return { sql: '', params: [] };
|
|
383
|
+
return {
|
|
384
|
+
sql: ` WHERE ${active.map((filter) => `(${filter.sql.replace(/^ WHERE /, '')})`).join(' AND ')}`,
|
|
385
|
+
params: active.flatMap((filter) => filter.params),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function readRowsByLookup({
|
|
390
|
+
service,
|
|
391
|
+
fields,
|
|
392
|
+
internalFields = [],
|
|
393
|
+
lookupField,
|
|
394
|
+
values,
|
|
395
|
+
maxRows = MAX_TO_MANY_ROWS,
|
|
396
|
+
}) {
|
|
397
|
+
if (!Array.isArray(values) || values.length === 0) return { data: [], visibleFields: [] };
|
|
398
|
+
const schema = await service.getCollectionSchema();
|
|
399
|
+
if (!schema.fields?.[lookupField]) {
|
|
400
|
+
throw expansionError('INVALID_QUERY', `Unknown relation lookup field: ${lookupField}`, lookupField);
|
|
401
|
+
}
|
|
402
|
+
const permission = await service.resolvePermission('read');
|
|
403
|
+
const accessSchema = schemaForFields(schema, permission.fields);
|
|
404
|
+
let visibleFields = [];
|
|
405
|
+
if (fields?.includes('*')) {
|
|
406
|
+
visibleFields = Object.keys(accessSchema.fields);
|
|
407
|
+
} else if (Array.isArray(fields) && fields.length > 0) {
|
|
408
|
+
visibleFields = compileSelectFields(fields, accessSchema).fields;
|
|
409
|
+
}
|
|
410
|
+
const trustedInternal = [...new Set([lookupField, ...internalFields])];
|
|
411
|
+
for (const field of trustedInternal) {
|
|
412
|
+
if (!schema.fields?.[field]) throw expansionError('INVALID_QUERY', `Unknown internal relation field: ${field}`, field);
|
|
413
|
+
}
|
|
414
|
+
const internalSchema = {
|
|
415
|
+
...accessSchema,
|
|
416
|
+
fields: {
|
|
417
|
+
...accessSchema.fields,
|
|
418
|
+
...Object.fromEntries(trustedInternal.map((field) => [field, schema.fields[field]])),
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
const selection = compileSelectFields([...new Set([...visibleFields, ...trustedInternal])], internalSchema);
|
|
422
|
+
const permissionFilter = compileFilter(permission.filter, schema);
|
|
423
|
+
const table = quoteIdentifier(service.collection, 'collection name');
|
|
424
|
+
const data = [];
|
|
425
|
+
|
|
426
|
+
for (let offset = 0; offset < values.length; offset += QUERY_LIMITS.maxInValues) {
|
|
427
|
+
const chunk = values.slice(offset, offset + QUERY_LIMITS.maxInValues);
|
|
428
|
+
const filter = combineCompiledFilters(
|
|
429
|
+
permissionFilter,
|
|
430
|
+
compileFilter({ [lookupField]: { _in: chunk } }, schema),
|
|
431
|
+
);
|
|
432
|
+
const remaining = maxRows - data.length;
|
|
433
|
+
if (remaining <= 0) throw expansionError('QUERY_RELATION_ROW_LIMIT', `Relation expansion cannot load more than ${maxRows} rows`);
|
|
434
|
+
const [rows] = await service.database.query(
|
|
435
|
+
`SELECT ${selection.sql} FROM ${table}${filter.sql} LIMIT ?`,
|
|
436
|
+
[...filter.params, remaining + 1],
|
|
437
|
+
);
|
|
438
|
+
data.push(...rows);
|
|
439
|
+
if (data.length > maxRows) {
|
|
440
|
+
throw expansionError('QUERY_RELATION_ROW_LIMIT', `Relation expansion cannot load more than ${maxRows} rows`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return { data, visibleFields };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function expandToOne({ rows, field, childNode, descriptor, options, ItemsServiceClass, snapshot, depth }) {
|
|
447
|
+
const values = [...new Set(rows
|
|
448
|
+
.map((row) => row[descriptor.sourceField])
|
|
449
|
+
.filter((value) => value != null && value !== '')
|
|
450
|
+
.map((value) => String(value)))];
|
|
451
|
+
if (values.length === 0) return rows.map((row) => ({ ...row, [field]: null }));
|
|
452
|
+
|
|
453
|
+
const targetService = new ItemsServiceClass(descriptor.targetCollection, options);
|
|
454
|
+
const targetResult = await targetService.readManyForRelation({
|
|
455
|
+
fields: [...new Set([...visibleSelectionForNode(childNode), ...childNode.internalFields])],
|
|
456
|
+
lookupField: descriptor.targetKey,
|
|
457
|
+
values,
|
|
458
|
+
});
|
|
459
|
+
const nestedTargets = await expandRows({
|
|
460
|
+
collection: descriptor.targetCollection,
|
|
461
|
+
rows: targetResult.data,
|
|
462
|
+
node: childNode,
|
|
178
463
|
options,
|
|
179
464
|
ItemsServiceClass,
|
|
180
465
|
snapshot,
|
|
466
|
+
depth: depth + 1,
|
|
181
467
|
});
|
|
182
|
-
|
|
468
|
+
const byKey = new Map();
|
|
469
|
+
for (const target of nestedTargets) {
|
|
470
|
+
if (!Object.hasOwn(target, descriptor.targetKey)) {
|
|
471
|
+
throw expansionError('FORBIDDEN_FIELD', `Expanded relation key is not readable: ${descriptor.targetCollection}.${descriptor.targetKey}`, `fields.${field}`);
|
|
472
|
+
}
|
|
473
|
+
byKey.set(String(target[descriptor.targetKey]), projectTarget(target, childNode, targetResult.visibleFields));
|
|
474
|
+
}
|
|
475
|
+
return rows.map((row) => ({
|
|
476
|
+
...row,
|
|
477
|
+
[field]: row[descriptor.sourceField] == null || row[descriptor.sourceField] === ''
|
|
478
|
+
? null
|
|
479
|
+
: (byKey.get(String(row[descriptor.sourceField])) ?? null),
|
|
480
|
+
}));
|
|
183
481
|
}
|
|
184
482
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
for (const key of Object.keys(query ?? {})) {
|
|
194
|
-
if (!['fields', 'expand'].includes(key)) {
|
|
195
|
-
throw expansionError('INVALID_QUERY', `Unknown query parameter: ${key}`, key);
|
|
196
|
-
}
|
|
483
|
+
async function expandReverse({ rows, field, childNode, descriptor, options, ItemsServiceClass, snapshot, depth }) {
|
|
484
|
+
const values = [...new Set(rows
|
|
485
|
+
.map((row) => row[descriptor.sourceKey])
|
|
486
|
+
.filter((value) => value != null && value !== '')
|
|
487
|
+
.map((value) => String(value)))];
|
|
488
|
+
if (values.length === 0) {
|
|
489
|
+
const empty = descriptor.kind === 'reverse_to_one' ? null : [];
|
|
490
|
+
return rows.map((row) => ({ ...row, [field]: empty }));
|
|
197
491
|
}
|
|
492
|
+
const targetService = new ItemsServiceClass(descriptor.targetCollection, options);
|
|
493
|
+
const targetResult = await readRowsByLookup({
|
|
494
|
+
service: targetService,
|
|
495
|
+
fields: visibleSelectionForNode(childNode),
|
|
496
|
+
internalFields: [...childNode.internalFields, descriptor.targetKey],
|
|
497
|
+
lookupField: descriptor.targetForeignKey,
|
|
498
|
+
values,
|
|
499
|
+
});
|
|
500
|
+
const nestedTargets = await expandRows({
|
|
501
|
+
collection: descriptor.targetCollection,
|
|
502
|
+
rows: targetResult.data,
|
|
503
|
+
node: childNode,
|
|
504
|
+
options,
|
|
505
|
+
ItemsServiceClass,
|
|
506
|
+
snapshot,
|
|
507
|
+
depth: depth + 1,
|
|
508
|
+
});
|
|
509
|
+
const grouped = new Map();
|
|
510
|
+
for (const target of nestedTargets) {
|
|
511
|
+
const sourceValue = target[descriptor.targetForeignKey];
|
|
512
|
+
if (sourceValue == null) continue;
|
|
513
|
+
const key = String(sourceValue);
|
|
514
|
+
const list = grouped.get(key) ?? [];
|
|
515
|
+
list.push(projectTarget(target, childNode, targetResult.visibleFields));
|
|
516
|
+
grouped.set(key, list);
|
|
517
|
+
}
|
|
518
|
+
return rows.map((row) => {
|
|
519
|
+
const matches = grouped.get(String(row[descriptor.sourceKey])) ?? [];
|
|
520
|
+
return {
|
|
521
|
+
...row,
|
|
522
|
+
[field]: descriptor.kind === 'reverse_to_one' ? (matches[0] ?? null) : matches,
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
}
|
|
198
526
|
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
527
|
+
async function expandM2M({ rows, field, childNode, descriptor, options, ItemsServiceClass, snapshot, depth }) {
|
|
528
|
+
const values = [...new Set(rows
|
|
529
|
+
.map((row) => row[descriptor.sourceKey])
|
|
530
|
+
.filter((value) => value != null && value !== '')
|
|
531
|
+
.map((value) => String(value)))];
|
|
532
|
+
if (values.length === 0) return rows.map((row) => ({ ...row, [field]: [] }));
|
|
533
|
+
|
|
534
|
+
const junctionService = new ItemsServiceClass(descriptor.junctionCollection, options);
|
|
535
|
+
const junction = await readRowsByLookup({
|
|
536
|
+
service: junctionService,
|
|
537
|
+
fields: [],
|
|
538
|
+
internalFields: [descriptor.sourceJunctionField, descriptor.targetJunctionField],
|
|
539
|
+
lookupField: descriptor.sourceJunctionField,
|
|
540
|
+
values,
|
|
541
|
+
});
|
|
542
|
+
const targetValues = [...new Set(junction.data
|
|
543
|
+
.map((row) => row[descriptor.targetJunctionField])
|
|
544
|
+
.filter((value) => value != null && value !== '')
|
|
545
|
+
.map((value) => String(value)))];
|
|
546
|
+
if (targetValues.length === 0) return rows.map((row) => ({ ...row, [field]: [] }));
|
|
547
|
+
|
|
548
|
+
const targetService = new ItemsServiceClass(descriptor.targetCollection, options);
|
|
549
|
+
const targetResult = await targetService.readManyForRelation({
|
|
550
|
+
fields: [...new Set([...visibleSelectionForNode(childNode), ...childNode.internalFields])],
|
|
551
|
+
lookupField: descriptor.targetKey,
|
|
552
|
+
values: targetValues,
|
|
553
|
+
});
|
|
554
|
+
const nestedTargets = await expandRows({
|
|
555
|
+
collection: descriptor.targetCollection,
|
|
556
|
+
rows: targetResult.data,
|
|
557
|
+
node: childNode,
|
|
209
558
|
options,
|
|
210
559
|
ItemsServiceClass,
|
|
211
560
|
snapshot,
|
|
561
|
+
depth: depth + 1,
|
|
562
|
+
});
|
|
563
|
+
const byTarget = new Map();
|
|
564
|
+
for (const target of nestedTargets) {
|
|
565
|
+
if (!Object.hasOwn(target, descriptor.targetKey)) continue;
|
|
566
|
+
byTarget.set(String(target[descriptor.targetKey]), projectTarget(target, childNode, targetResult.visibleFields));
|
|
567
|
+
}
|
|
568
|
+
const grouped = new Map();
|
|
569
|
+
for (const link of junction.data) {
|
|
570
|
+
const sourceValue = link[descriptor.sourceJunctionField];
|
|
571
|
+
const targetValue = link[descriptor.targetJunctionField];
|
|
572
|
+
if (sourceValue == null || targetValue == null) continue;
|
|
573
|
+
const target = byTarget.get(String(targetValue));
|
|
574
|
+
if (!target) continue;
|
|
575
|
+
const key = String(sourceValue);
|
|
576
|
+
const list = grouped.get(key) ?? [];
|
|
577
|
+
list.push(target);
|
|
578
|
+
grouped.set(key, list);
|
|
579
|
+
}
|
|
580
|
+
return rows.map((row) => ({
|
|
581
|
+
...row,
|
|
582
|
+
[field]: grouped.get(String(row[descriptor.sourceKey])) ?? [],
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function expandRows({ collection, rows, node, options, ItemsServiceClass, snapshot, depth = 0 }) {
|
|
587
|
+
if (node.children.size === 0 || rows.length === 0) return rows;
|
|
588
|
+
if (depth >= MAX_RELATION_DEPTH) {
|
|
589
|
+
throw expansionError('QUERY_RELATION_DEPTH_LIMIT', `Relation depth cannot exceed ${MAX_RELATION_DEPTH}`);
|
|
590
|
+
}
|
|
591
|
+
let expandedRows = rows.map((row) => ({ ...row }));
|
|
592
|
+
|
|
593
|
+
for (const [field, childNode] of node.children) {
|
|
594
|
+
const descriptor = node.relations.get(field) ?? relationDescriptor(snapshot, collection, field);
|
|
595
|
+
if (descriptor.kind === 'to_one') {
|
|
596
|
+
expandedRows = await expandToOne({ rows: expandedRows, field, childNode, descriptor, options, ItemsServiceClass, snapshot, depth });
|
|
597
|
+
} else if (descriptor.kind === 'm2m') {
|
|
598
|
+
expandedRows = await expandM2M({ rows: expandedRows, field, childNode, descriptor, options, ItemsServiceClass, snapshot, depth });
|
|
599
|
+
} else {
|
|
600
|
+
expandedRows = await expandReverse({ rows: expandedRows, field, childNode, descriptor, options, ItemsServiceClass, snapshot, depth });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return expandedRows;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function stripRootInternalFields(rows, root) {
|
|
607
|
+
if (root.all || root.internalFields.size === 0) return rows;
|
|
608
|
+
return rows.map((row) => {
|
|
609
|
+
const copy = { ...row };
|
|
610
|
+
for (const field of root.internalFields) {
|
|
611
|
+
if (!root.fields.has(field)) delete copy[field];
|
|
612
|
+
}
|
|
613
|
+
return copy;
|
|
212
614
|
});
|
|
213
|
-
return expanded;
|
|
214
615
|
}
|
|
215
616
|
|
|
216
|
-
export {
|
|
617
|
+
export async function readManyWithRelations({ collection, query = {}, options = {}, ItemsServiceClass = ItemsService } = {}) {
|
|
618
|
+
assertIdentifier(collection, 'collection name');
|
|
619
|
+
const service = new ItemsServiceClass(collection, options);
|
|
620
|
+
const plan = await buildSelectionPlan({ collection, query, options, service });
|
|
621
|
+
const result = await service.readManyWithMeta(baseQuery(query, plan.root));
|
|
622
|
+
const expanded = await expandRows({ collection, rows: result.data, node: plan.root, options, ItemsServiceClass, snapshot: plan.snapshot });
|
|
623
|
+
return { ...result, data: stripRootInternalFields(expanded, plan.root) };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export async function readOneWithRelations({ collection, id, query = {}, options = {}, ItemsServiceClass = ItemsService } = {}) {
|
|
627
|
+
assertIdentifier(collection, 'collection name');
|
|
628
|
+
for (const key of Object.keys(query ?? {})) {
|
|
629
|
+
if (!['fields', 'expand'].includes(key)) throw expansionError('INVALID_QUERY', `Unknown query parameter: ${key}`, key);
|
|
630
|
+
}
|
|
631
|
+
const service = new ItemsServiceClass(collection, options);
|
|
632
|
+
const plan = await buildSelectionPlan({ collection, query, options, service });
|
|
633
|
+
const selection = baseSelectionForNode(plan.root);
|
|
634
|
+
const record = await service.readOne(id, { fields: selection.length === 0 ? null : selection });
|
|
635
|
+
if (!record) return null;
|
|
636
|
+
const [expanded] = await expandRows({ collection, rows: [record], node: plan.root, options, ItemsServiceClass, snapshot: plan.snapshot });
|
|
637
|
+
return stripRootInternalFields([expanded], plan.root)[0];
|
|
638
|
+
}
|