@yunsoft/yuncms-core 0.1.5 → 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,9 +1,16 @@
1
- import { assertIdentifier } from './identifier.js';
2
- import { QUERY_LIMITS } from './query.js';
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 directRelation(snapshot, collection, field, path = `fields.${field}`) {
76
- const relation = relationFromSnapshot(snapshot, collection, field);
77
- if (!relation) {
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
- if (!isDirectRelation(relation)) {
86
- throw expansionError(
87
- 'UNSUPPORTED_RELATION_EXPANSION',
88
- `Only direct M2O/O2O fields can be expanded: ${collection}.${field}`,
89
- path,
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 relation;
89
+ return null;
93
90
  }
94
91
 
95
- function readableSourceField(sourceSchema, permission, field) {
96
- return Boolean(sourceSchema.fields?.[field]
97
- && (!permission.fields || permission.fields.includes(field)));
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 directReadableRelationFields(snapshot, collection, sourceSchema, permission) {
101
- const prefix = `${collection}.`;
102
- const fields = [];
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(prefix) || !isDirectRelation(relation)) continue;
105
- const field = key.slice(prefix.length);
106
- if (readableSourceField(sourceSchema, permission, field)) fields.push(field);
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
- return [...new Set(fields)];
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 mergeExpansionSelection(expansions, relationField, targetField) {
112
- const current = expansions.get(relationField) ?? [];
113
- if (current.includes('*')) return;
114
- if (targetField === '*') {
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
- expansions.set(relationField, [...new Set([...current, targetField])]);
185
+ return descriptor;
119
186
  }
120
187
 
121
- function assertExpansionLimit(expansions, path = 'fields') {
122
- if (expansions.size > MAX_EXPAND_FIELDS) {
123
- throw expansionError(
124
- 'INVALID_QUERY',
125
- `Relation expansion cannot contain more than ${MAX_EXPAND_FIELDS} fields`,
126
- path,
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 parseFieldsPlan({ value, snapshot, collection, sourceSchema, permission }) {
132
- const tokens = normalizeDelimited(value);
133
- if (tokens.length === 0) {
134
- return { sourceFields: null, expansions: new Map() };
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
- let sourceAll = false;
138
- const sourceFields = [];
139
- const expansions = new Map();
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
- for (const token of tokens) {
142
- if (token === '*') {
143
- sourceAll = true;
144
- continue;
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
- sourceAll = true;
149
- for (const relationField of directReadableRelationFields(
150
- snapshot,
151
- collection,
152
- sourceSchema,
153
- permission,
154
- )) {
155
- mergeExpansionSelection(expansions, relationField, '*');
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.length !== 2 || !parts[0] || !parts[1]) {
168
- throw expansionError(
169
- 'UNSUPPORTED_RELATION_EXPANSION',
170
- `Only one relation depth is supported in fields: ${token}`,
171
- `fields.${token}`,
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
- const relationField = assertFieldToken(parts[0], `fields.${token}`);
176
- const targetField = parts[1] === '*'
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
- directRelation(snapshot, collection, relationField, `fields.${token}`);
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
- return {
190
- sourceFields: sourceAll ? ['*'] : [...new Set(sourceFields)],
191
- expansions,
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, plan, snapshot, collection, sourceSchema, permission }) {
295
+ function addLegacyExpansions({ query, root, snapshot, collection, sourceSchema, permission }) {
196
296
  for (const field of parseExpandInput(query.expand)) {
197
- if (!readableSourceField(sourceSchema, permission, field)) {
198
- throw expansionError('INVALID_QUERY', `Unknown field: ${field}`, `expand.${field}`);
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 plan;
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 plan = parseFieldsPlan({
219
- value: query.fields,
220
- snapshot,
221
- collection,
222
- sourceSchema,
223
- permission,
224
- });
225
- addLegacyExpansions({ query, plan, snapshot, collection, sourceSchema, permission });
226
- return { ...plan, snapshot };
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 baseQuery(query, sourceFields) {
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
- if (sourceFields == null) delete base.fields;
233
- else base.fields = sourceFields;
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, selection, visibleFields) {
238
- const projectedFields = selection.includes('*') ? visibleFields : selection;
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
- async function expandRows({ collection, rows, expansions, options, ItemsServiceClass, snapshot }) {
247
- if (expansions.size === 0 || rows.length === 0) return rows;
248
- const effectiveSnapshot = snapshot ?? await schemaSnapshot(options);
249
- let expandedRows = rows.map((row) => ({ ...row }));
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
- for (const [field, selection] of expansions) {
252
- const relation = directRelation(effectiveSnapshot, collection, field);
253
- const targetCollection = relation.one_collection;
254
- const targetKey = relation.one_field
255
- || effectiveSnapshot.collections?.[targetCollection]?.primary_key
256
- || 'id';
257
- const values = [...new Set(
258
- expandedRows
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
- const targetService = new ItemsServiceClass(targetCollection, options);
270
- const targetResult = await targetService.readManyForRelation({
271
- fields: selection,
272
- lookupField: targetKey,
273
- values,
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
- const byKey = new Map();
277
- for (const target of targetResult.data) {
278
- if (!Object.hasOwn(target, targetKey)) {
279
- throw expansionError(
280
- 'FORBIDDEN_FIELD',
281
- `Expanded relation key is not readable: ${targetCollection}.${targetKey}`,
282
- `fields.${field}`,
283
- );
284
- }
285
- byKey.set(
286
- String(target[targetKey]),
287
- projectTarget(target, selection, targetResult.visibleFields),
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
- expandedRows = expandedRows.map((row) => ({
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]: row[field] == null || row[field] === ''
294
- ? null
295
- : (byKey.get(String(row[field])) ?? null),
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
- export async function readManyWithRelations({
303
- collection,
304
- query = {},
305
- options = {},
306
- ItemsServiceClass = ItemsService,
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.sourceFields));
312
- const data = await expandRows({
313
- collection,
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 record = await service.readOne(id, { fields: plan.sourceFields });
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
- collection,
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
  }