@yunsoft/yuncms-core 0.1.5 → 0.1.7
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 +2 -0
- package/src/config.js +102 -57
- package/src/hooks.js +117 -32
- package/src/index.js +23 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/query.js +131 -71
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +511 -223
- 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/items-service.js +80 -93
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { assertIdentifier } from './identifier.js';
|
|
2
|
-
import {
|
|
1
|
+
import { assertIdentifier, quoteIdentifier } from './identifier.js';
|
|
2
|
+
import {
|
|
3
|
+
assertQueryCost,
|
|
4
|
+
compileFilter,
|
|
5
|
+
compileSelectFields,
|
|
6
|
+
QUERY_LIMITS,
|
|
7
|
+
} from './query.js';
|
|
3
8
|
import { SchemaCache } from './schema.js';
|
|
4
9
|
import { ItemsService } from './services/items-service.js';
|
|
5
10
|
|
|
6
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;
|
|
7
14
|
const defaultSchemaCache = new SchemaCache();
|
|
8
15
|
|
|
9
16
|
function expansionError(code, message, path = null) {
|
|
@@ -20,40 +27,24 @@ function normalizeDelimited(value) {
|
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
function assertFieldToken(field, path) {
|
|
23
|
-
try {
|
|
24
|
-
assertIdentifier(field, 'field');
|
|
25
|
-
} catch {
|
|
26
|
-
throw expansionError('INVALID_QUERY', `Invalid field: ${field}`, path);
|
|
27
|
-
}
|
|
30
|
+
try { assertIdentifier(field, 'field'); } catch { throw expansionError('INVALID_QUERY', `Invalid field: ${field}`, path); }
|
|
28
31
|
return field;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export function parseExpandInput(value) {
|
|
32
35
|
const fields = [...new Set(normalizeDelimited(value))];
|
|
33
36
|
if (fields.length > MAX_EXPAND_FIELDS) {
|
|
34
|
-
throw expansionError(
|
|
35
|
-
'INVALID_QUERY',
|
|
36
|
-
`expand cannot contain more than ${MAX_EXPAND_FIELDS} entries`,
|
|
37
|
-
'expand',
|
|
38
|
-
);
|
|
37
|
+
throw expansionError('INVALID_QUERY', `expand cannot contain more than ${MAX_EXPAND_FIELDS} entries`, 'expand');
|
|
39
38
|
}
|
|
40
39
|
for (const field of fields) {
|
|
41
|
-
try {
|
|
42
|
-
assertIdentifier(field, 'expand field');
|
|
43
|
-
} catch {
|
|
44
|
-
throw expansionError('INVALID_QUERY', `Invalid expand field: ${field}`, 'expand');
|
|
45
|
-
}
|
|
40
|
+
try { assertIdentifier(field, 'expand field'); } catch { throw expansionError('INVALID_QUERY', `Invalid expand field: ${field}`, 'expand'); }
|
|
46
41
|
}
|
|
47
42
|
return fields;
|
|
48
43
|
}
|
|
49
44
|
|
|
50
45
|
function parseRelationMetadata(value) {
|
|
51
46
|
if (value == null || typeof value === 'object') return value ?? {};
|
|
52
|
-
try {
|
|
53
|
-
return JSON.parse(value);
|
|
54
|
-
} catch {
|
|
55
|
-
return {};
|
|
56
|
-
}
|
|
47
|
+
try { return JSON.parse(value); } catch { return {}; }
|
|
57
48
|
}
|
|
58
49
|
|
|
59
50
|
async function schemaSnapshot(options) {
|
|
@@ -72,279 +63,576 @@ function isDirectRelation(relation) {
|
|
|
72
63
|
return !relation.junction_collection && metadata.kind !== 'm2m';
|
|
73
64
|
}
|
|
74
65
|
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
throw expansionError(
|
|
79
|
-
'INVALID_QUERY',
|
|
80
|
-
`Field is not a direct relation and cannot be expanded: ${collection}.${field}`,
|
|
81
|
-
path,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
66
|
+
function relationKind(relation) {
|
|
67
|
+
return parseRelationMetadata(relation?.metadata).kind ?? 'm2o';
|
|
68
|
+
}
|
|
84
69
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|
|
91
88
|
}
|
|
92
|
-
return
|
|
89
|
+
return null;
|
|
93
90
|
}
|
|
94
91
|
|
|
95
|
-
function
|
|
96
|
-
return
|
|
97
|
-
|
|
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
98
|
}
|
|
99
99
|
|
|
100
|
-
function
|
|
101
|
-
const
|
|
102
|
-
|
|
100
|
+
export function relationDescriptorsForCollection(snapshot, collection) {
|
|
101
|
+
const sourceSchema = snapshot.collections?.[collection];
|
|
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
|
+
|
|
103
107
|
for (const [key, relation] of snapshot.relationByManyField?.entries?.() ?? []) {
|
|
104
|
-
if (!key.startsWith(
|
|
105
|
-
const field = key.slice(
|
|
106
|
-
|
|
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
|
+
}));
|
|
107
121
|
}
|
|
108
|
-
|
|
122
|
+
|
|
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;
|
|
149
|
+
}
|
|
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 }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return descriptors;
|
|
109
178
|
}
|
|
110
179
|
|
|
111
|
-
function
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
expansions.set(relationField, ['*']);
|
|
116
|
-
return;
|
|
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);
|
|
117
184
|
}
|
|
118
|
-
|
|
185
|
+
return descriptor;
|
|
119
186
|
}
|
|
120
187
|
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
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);
|
|
128
211
|
}
|
|
212
|
+
return count;
|
|
129
213
|
}
|
|
130
214
|
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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;
|
|
135
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
|
+
}
|
|
136
244
|
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
245
|
+
function relationAllowedAtRoot(descriptor, sourceSchema, permission) {
|
|
246
|
+
const requiredField = descriptor.kind === 'to_one' ? descriptor.sourceField : descriptor.sourceKey;
|
|
247
|
+
return readableSourceField(sourceSchema, permission, requiredField);
|
|
248
|
+
}
|
|
140
249
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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;
|
|
146
255
|
|
|
256
|
+
for (const token of tokens) {
|
|
257
|
+
if (token === '*') { root.all = true; continue; }
|
|
147
258
|
if (token === '*.*') {
|
|
148
|
-
|
|
149
|
-
for (const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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);
|
|
156
268
|
}
|
|
157
|
-
assertExpansionLimit(expansions);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (!token.includes('.')) {
|
|
162
|
-
sourceFields.push(assertFieldToken(token, `fields.${token}`));
|
|
163
269
|
continue;
|
|
164
270
|
}
|
|
165
271
|
|
|
166
272
|
const parts = token.split('.');
|
|
167
|
-
if (parts.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
);
|
|
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}`);
|
|
277
|
+
}
|
|
278
|
+
root.fields.add(assertFieldToken(parts[0], `fields.${token}`));
|
|
279
|
+
continue;
|
|
173
280
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
? '*'
|
|
178
|
-
: assertFieldToken(parts[1], `fields.${token}`);
|
|
179
|
-
|
|
180
|
-
if (!readableSourceField(sourceSchema, permission, relationField)) {
|
|
181
|
-
throw expansionError('INVALID_QUERY', `Unknown field: ${relationField}`, `fields.${token}`);
|
|
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}`);
|
|
182
284
|
}
|
|
183
|
-
|
|
184
|
-
sourceFields.push(relationField);
|
|
185
|
-
mergeExpansionSelection(expansions, relationField, targetField);
|
|
186
|
-
assertExpansionLimit(expansions);
|
|
285
|
+
addPath({ node: root, snapshot, collection, parts, path: `fields.${token}` });
|
|
187
286
|
}
|
|
188
287
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
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');
|
|
291
|
+
}
|
|
292
|
+
return root;
|
|
193
293
|
}
|
|
194
294
|
|
|
195
|
-
function addLegacyExpansions({ query,
|
|
295
|
+
function addLegacyExpansions({ query, root, snapshot, collection, sourceSchema, permission }) {
|
|
196
296
|
for (const field of parseExpandInput(query.expand)) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
directRelation(snapshot, collection, field, `expand.${field}`);
|
|
201
|
-
mergeExpansionSelection(plan.expansions, field, '*');
|
|
202
|
-
assertExpansionLimit(plan.expansions, 'expand');
|
|
203
|
-
if (plan.sourceFields && !plan.sourceFields.includes('*') && !plan.sourceFields.includes(field)) {
|
|
204
|
-
plan.sourceFields.push(field);
|
|
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}`);
|
|
205
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');
|
|
206
310
|
}
|
|
207
|
-
return
|
|
311
|
+
return root;
|
|
312
|
+
}
|
|
313
|
+
|
|
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])];
|
|
208
328
|
}
|
|
209
329
|
|
|
210
330
|
async function buildSelectionPlan({ collection, query, options, service }) {
|
|
211
331
|
const snapshot = await schemaSnapshot(options);
|
|
212
332
|
const sourceSchema = snapshot.collections?.[collection];
|
|
213
|
-
if (!sourceSchema) {
|
|
214
|
-
throw expansionError('COLLECTION_NOT_FOUND', `Unknown collection: ${collection}`);
|
|
215
|
-
}
|
|
216
|
-
|
|
333
|
+
if (!sourceSchema) throw expansionError('COLLECTION_NOT_FOUND', `Unknown collection: ${collection}`);
|
|
217
334
|
const permission = await service.resolvePermission('read');
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 };
|
|
227
345
|
}
|
|
228
346
|
|
|
229
|
-
function
|
|
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) {
|
|
230
353
|
const base = { ...query };
|
|
231
354
|
delete base.expand;
|
|
232
|
-
|
|
233
|
-
|
|
355
|
+
const selection = baseSelectionForNode(root);
|
|
356
|
+
if (selection.length === 0) delete base.fields;
|
|
357
|
+
else base.fields = selection;
|
|
234
358
|
return base;
|
|
235
359
|
}
|
|
236
360
|
|
|
237
|
-
function projectTarget(target,
|
|
238
|
-
const projectedFields =
|
|
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()])];
|
|
239
365
|
return Object.fromEntries(
|
|
240
|
-
projectedFields
|
|
241
|
-
.filter((field) => Object.hasOwn(target, field))
|
|
242
|
-
.map((field) => [field, target[field]]),
|
|
366
|
+
projectedFields.filter((field) => Object.hasOwn(target, field)).map((field) => [field, target[field]]),
|
|
243
367
|
);
|
|
244
368
|
}
|
|
245
369
|
|
|
246
|
-
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
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
|
+
}
|
|
250
379
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
.map((row) => row[field])
|
|
260
|
-
.filter((value) => value != null && value !== '')
|
|
261
|
-
.map((value) => String(value)),
|
|
262
|
-
)];
|
|
263
|
-
|
|
264
|
-
if (values.length === 0) {
|
|
265
|
-
expandedRows = expandedRows.map((row) => ({ ...row, [field]: null }));
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
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
|
+
}
|
|
268
388
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
}
|
|
275
445
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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,
|
|
463
|
+
options,
|
|
464
|
+
ItemsServiceClass,
|
|
465
|
+
snapshot,
|
|
466
|
+
depth: depth + 1,
|
|
467
|
+
});
|
|
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}`);
|
|
289
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
|
+
}));
|
|
481
|
+
}
|
|
290
482
|
|
|
291
|
-
|
|
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 }));
|
|
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 {
|
|
292
521
|
...row,
|
|
293
|
-
[field]:
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
522
|
+
[field]: descriptor.kind === 'reverse_to_one' ? (matches[0] ?? null) : matches,
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
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,
|
|
558
|
+
options,
|
|
559
|
+
ItemsServiceClass,
|
|
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}`);
|
|
297
590
|
}
|
|
591
|
+
let expandedRows = rows.map((row) => ({ ...row }));
|
|
298
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
|
+
}
|
|
299
603
|
return expandedRows;
|
|
300
604
|
}
|
|
301
605
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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;
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function readManyWithRelations({ collection, query = {}, options = {}, ItemsServiceClass = ItemsService } = {}) {
|
|
308
618
|
assertIdentifier(collection, 'collection name');
|
|
309
619
|
const service = new ItemsServiceClass(collection, options);
|
|
310
620
|
const plan = await buildSelectionPlan({ collection, query, options, service });
|
|
311
|
-
const result = await service.readManyWithMeta(baseQuery(query, plan.
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
rows: result.data,
|
|
315
|
-
expansions: plan.expansions,
|
|
316
|
-
options,
|
|
317
|
-
ItemsServiceClass,
|
|
318
|
-
snapshot: plan.snapshot,
|
|
319
|
-
});
|
|
320
|
-
return { ...result, data };
|
|
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) };
|
|
321
624
|
}
|
|
322
625
|
|
|
323
|
-
export async function readOneWithRelations({
|
|
324
|
-
collection,
|
|
325
|
-
id,
|
|
326
|
-
query = {},
|
|
327
|
-
options = {},
|
|
328
|
-
ItemsServiceClass = ItemsService,
|
|
329
|
-
} = {}) {
|
|
626
|
+
export async function readOneWithRelations({ collection, id, query = {}, options = {}, ItemsServiceClass = ItemsService } = {}) {
|
|
330
627
|
assertIdentifier(collection, 'collection name');
|
|
331
628
|
for (const key of Object.keys(query ?? {})) {
|
|
332
|
-
if (!['fields', 'expand'].includes(key)) {
|
|
333
|
-
throw expansionError('INVALID_QUERY', `Unknown query parameter: ${key}`, key);
|
|
334
|
-
}
|
|
629
|
+
if (!['fields', 'expand'].includes(key)) throw expansionError('INVALID_QUERY', `Unknown query parameter: ${key}`, key);
|
|
335
630
|
}
|
|
336
|
-
|
|
337
631
|
const service = new ItemsServiceClass(collection, options);
|
|
338
632
|
const plan = await buildSelectionPlan({ collection, query, options, service });
|
|
339
|
-
const
|
|
633
|
+
const selection = baseSelectionForNode(plan.root);
|
|
634
|
+
const record = await service.readOne(id, { fields: selection.length === 0 ? null : selection });
|
|
340
635
|
if (!record) return null;
|
|
341
|
-
const [expanded] = await expandRows({
|
|
342
|
-
|
|
343
|
-
rows: [record],
|
|
344
|
-
expansions: plan.expansions,
|
|
345
|
-
options,
|
|
346
|
-
ItemsServiceClass,
|
|
347
|
-
snapshot: plan.snapshot,
|
|
348
|
-
});
|
|
349
|
-
return expanded;
|
|
636
|
+
const [expanded] = await expandRows({ collection, rows: [record], node: plan.root, options, ItemsServiceClass, snapshot: plan.snapshot });
|
|
637
|
+
return stripRootInternalFields([expanded], plan.root)[0];
|
|
350
638
|
}
|