@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.
@@ -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 = 8;
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 directRelation(snapshot, collection, field) {
73
- const relation = snapshot.relationByManyField?.get(`${collection}.${field}`);
74
- if (!relation) {
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
- if (relation.junction_collection || metadata.kind === 'm2m') {
84
- throw expansionError(
85
- 'UNSUPPORTED_RELATION_EXPANSION',
86
- `Only direct M2O fields can be expanded in V1: ${collection}.${field}`,
87
- `expand.${field}`,
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 relation;
89
+ return null;
91
90
  }
92
91
 
93
- async function validateSourceExpansions({ collection, expandFields, options, service }) {
94
- const snapshot = await schemaSnapshot(options);
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
- throw expansionError('COLLECTION_NOT_FOUND', `Unknown collection: ${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
+
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 permission = await service.resolvePermission('read');
102
- for (const field of expandFields) {
103
- if (!sourceSchema.fields?.[field] || (permission.fields && !permission.fields.includes(field))) {
104
- throw expansionError('INVALID_QUERY', `Unknown field: ${field}`, `expand.${field}`);
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
- directRelation(snapshot, collection, field);
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
- return snapshot;
176
+
177
+ return descriptors;
109
178
  }
110
179
 
111
- async function expandRows({ collection, rows, expandFields, options, ItemsServiceClass, snapshot }) {
112
- if (expandFields.length === 0 || rows.length === 0) return rows;
113
- const effectiveSnapshot = snapshot ?? await schemaSnapshot(options);
114
- let expandedRows = rows.map((row) => ({ ...row }));
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
- for (const field of expandFields) {
117
- const relation = directRelation(effectiveSnapshot, collection, field);
118
- const targetCollection = relation.one_collection;
119
- const targetKey = relation.one_field
120
- || effectiveSnapshot.collections?.[targetCollection]?.primary_key
121
- || 'id';
122
- const values = [...new Set(
123
- expandedRows
124
- .map((row) => row[field])
125
- .filter((value) => value != null && value !== '')
126
- .map((value) => String(value)),
127
- )];
128
-
129
- if (values.length === 0) {
130
- expandedRows = expandedRows.map((row) => ({ ...row, [field]: null }));
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 targetService = new ItemsServiceClass(targetCollection, options);
135
- const targetRows = await targetService.readMany({
136
- filter: { [targetKey]: { _in: values } },
137
- limit: Math.min(values.length, 500),
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
- byKey.set(String(target[targetKey]), target);
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
- expandedRows = expandedRows.map((row) => ({
153
- ...row,
154
- [field]: row[field] == null || row[field] === ''
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
- return expandedRows;
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
- export async function readManyWithRelations({
164
- collection,
165
- query = {},
166
- options = {},
167
- ItemsServiceClass = ItemsService,
168
- } = {}) {
169
- assertIdentifier(collection, 'collection name');
170
- const expandFields = parseExpandInput(query.expand);
171
- const service = new ItemsServiceClass(collection, options);
172
- const snapshot = await validateSourceExpansions({ collection, expandFields, options, service });
173
- const result = await service.readManyWithMeta(withoutExpand(query, expandFields));
174
- const data = await expandRows({
175
- collection,
176
- rows: result.data,
177
- expandFields,
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
- return { ...result, data };
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
- export async function readOneWithRelations({
186
- collection,
187
- id,
188
- query = {},
189
- options = {},
190
- ItemsServiceClass = ItemsService,
191
- } = {}) {
192
- assertIdentifier(collection, 'collection name');
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
- const expandFields = parseExpandInput(query.expand);
200
- const service = new ItemsServiceClass(collection, options);
201
- const snapshot = await validateSourceExpansions({ collection, expandFields, options, service });
202
- const fields = withExpansionFields(query.fields ?? null, expandFields);
203
- const record = await service.readOne(id, { fields });
204
- if (!record) return null;
205
- const [expanded] = await expandRows({
206
- collection,
207
- rows: [record],
208
- expandFields,
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 { MAX_EXPAND_FIELDS };
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
+ }