@kollors/deep-json-server 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -8
- package/README.ru.md +14 -8
- package/bin/deep-json-server.js +11 -0
- package/index.js +1 -11
- package/package.json +22 -5
- package/src/cli.js +19 -15
- package/src/constants.js +4 -0
- package/src/database.js +128 -0
- package/src/openapi/config.js +110 -0
- package/src/openapi/document.js +287 -0
- package/src/openapi/index.js +25 -0
- package/src/openapi/inference.js +187 -0
- package/src/{query.js → query/filter.js} +23 -113
- package/src/query/index.js +3 -0
- package/src/query/pagination.js +50 -0
- package/src/query/sort.js +63 -0
- package/src/relation-metadata.js +40 -0
- package/src/relations.js +62 -24
- package/src/server.js +108 -114
- package/src/utils.js +8 -5
- package/types/index.d.ts +2 -0
- package/types/src/constants.d.ts +4 -0
- package/types/src/database.d.ts +8 -0
- package/types/src/openapi/config.d.ts +3 -0
- package/types/src/openapi/document.d.ts +11 -0
- package/types/src/openapi/index.d.ts +13 -0
- package/types/src/openapi/inference.d.ts +9 -0
- package/types/src/query/filter.d.ts +3 -0
- package/types/src/query/index.d.ts +3 -0
- package/types/src/query/pagination.d.ts +13 -0
- package/types/src/query/sort.d.ts +1 -0
- package/types/src/relation-metadata.d.ts +9 -0
- package/types/src/relations.d.ts +3 -0
- package/types/src/server.d.ts +24 -0
- package/types/src/utils.d.ts +10 -0
- package/src/openapi.js +0 -518
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHttpError, isEqual, isObject, isSafeKey, toArray } from '
|
|
1
|
+
import { createHttpError, isEqual, isObject, isSafeKey, toArray } from '../utils.js';
|
|
2
2
|
|
|
3
3
|
const FIELD_OPERATORS = new Set(['contains', 'endsWith', 'eq', 'every', 'gt', 'gte', 'in', 'lt', 'lte', 'ne', 'none', 'not', 'some', 'startsWith']);
|
|
4
4
|
const RESERVED_QUERY_KEYS = new Set(['_embed', '_page', '_perPage', '_sort', '_where']);
|
|
@@ -16,36 +16,10 @@ const isFilterEqual = (left, right) => {
|
|
|
16
16
|
return typeof right === 'number' && typeof left === 'string' && NUMBER_PATTERN.test(left) && right === Number(left);
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
const compareValues = (left, right) => {
|
|
20
|
-
if (Object.is(left, right)) {
|
|
21
|
-
return 0;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (left == null) {
|
|
25
|
-
return 1;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (right == null) {
|
|
29
|
-
return -1;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (typeof left === 'number' && typeof right === 'number') {
|
|
33
|
-
return left - right;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const getValueByPath = (value, path) => path.split('.').reduce((currentValue, key) => {
|
|
40
|
-
return isSafeKey(key) && currentValue != null ? currentValue[key] : undefined;
|
|
41
|
-
}, value);
|
|
42
|
-
|
|
43
19
|
const matchesOperator = (field, operator, expectedValue) => {
|
|
44
20
|
switch (operator) {
|
|
45
21
|
case 'contains':
|
|
46
|
-
return typeof field === 'string'
|
|
47
|
-
? field.toLowerCase().includes(String(expectedValue).toLowerCase())
|
|
48
|
-
: Array.isArray(field) && field.some((value) => isFilterEqual(value, expectedValue));
|
|
22
|
+
return typeof field === 'string' ? field.toLowerCase().includes(String(expectedValue).toLowerCase()) : Array.isArray(field) && field.some((value) => isFilterEqual(value, expectedValue));
|
|
49
23
|
case 'endsWith':
|
|
50
24
|
return typeof field === 'string' && field.toLowerCase().endsWith(String(expectedValue).toLowerCase());
|
|
51
25
|
case 'eq':
|
|
@@ -95,10 +69,10 @@ function matchesValue(field, condition) {
|
|
|
95
69
|
return false;
|
|
96
70
|
}
|
|
97
71
|
|
|
98
|
-
return nestedEntries.length === 0 || isObject(field) && matchesWhere(field, Object.fromEntries(nestedEntries));
|
|
72
|
+
return nestedEntries.length === 0 || (isObject(field) && matchesWhere(field, Object.fromEntries(nestedEntries)));
|
|
99
73
|
}
|
|
100
74
|
|
|
101
|
-
function matchesWhere(value, where) {
|
|
75
|
+
export function matchesWhere(value, where) {
|
|
102
76
|
if (!isObject(value) || !isObject(where)) {
|
|
103
77
|
return false;
|
|
104
78
|
}
|
|
@@ -156,11 +130,7 @@ const parseFilterKey = (key) => {
|
|
|
156
130
|
|
|
157
131
|
const legacyOperator = key.match(/^(.*)_([a-zA-Z]+)$/);
|
|
158
132
|
|
|
159
|
-
|
|
160
|
-
return { operator: legacyOperator[2], path: legacyOperator[1] };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return { operator: 'eq', path: key };
|
|
133
|
+
return legacyOperator?.[1] != null && legacyOperator[2] != null && FIELD_OPERATORS.has(legacyOperator[2]) ? { operator: legacyOperator[2], path: legacyOperator[1] } : { operator: 'eq', path: key };
|
|
164
134
|
};
|
|
165
135
|
|
|
166
136
|
const setWhereOperator = (where, path, operator, value) => {
|
|
@@ -202,14 +172,12 @@ export const parseWhere = (query) => {
|
|
|
202
172
|
const where = {};
|
|
203
173
|
|
|
204
174
|
Object.entries(query).forEach(([key, rawValue]) => {
|
|
205
|
-
if (RESERVED_QUERY_KEYS.has(key)) {
|
|
206
|
-
|
|
207
|
-
}
|
|
175
|
+
if (!RESERVED_QUERY_KEYS.has(key)) {
|
|
176
|
+
const filterKey = parseFilterKey(key);
|
|
208
177
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
toArray(rawValue).forEach((value) => setWhereOperator(where, filterKey.path, filterKey.operator, value));
|
|
178
|
+
toArray(rawValue).forEach((value) => {
|
|
179
|
+
setWhereOperator(where, filterKey.path, filterKey.operator, value);
|
|
180
|
+
});
|
|
213
181
|
}
|
|
214
182
|
});
|
|
215
183
|
|
|
@@ -223,11 +191,13 @@ const validateCondition = (condition, samples, path) => {
|
|
|
223
191
|
|
|
224
192
|
Object.entries(condition).forEach(([key, value]) => {
|
|
225
193
|
if (key === 'and' || key === 'or') {
|
|
226
|
-
if (!Array.isArray(value) || key === 'or' && value.length === 0 || value.some((nestedWhere) => !isObject(nestedWhere))) {
|
|
194
|
+
if (!Array.isArray(value) || (key === 'or' && value.length === 0) || value.some((nestedWhere) => !isObject(nestedWhere))) {
|
|
227
195
|
throw createHttpError(400, `Оператор «${key}» должен содержать ${key === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
|
|
228
196
|
}
|
|
229
197
|
|
|
230
|
-
value.forEach((nestedWhere) =>
|
|
198
|
+
value.forEach((nestedWhere) => {
|
|
199
|
+
validateWhere(nestedWhere, samples, path);
|
|
200
|
+
});
|
|
231
201
|
return;
|
|
232
202
|
}
|
|
233
203
|
|
|
@@ -237,9 +207,11 @@ const validateCondition = (condition, samples, path) => {
|
|
|
237
207
|
throw createHttpError(400, `Оператор «${key}» в фильтре «${path}» можно применить только к массиву`);
|
|
238
208
|
}
|
|
239
209
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
210
|
+
validateCondition(
|
|
211
|
+
value,
|
|
212
|
+
samples.flatMap((sample) => (Array.isArray(sample) ? sample : [])),
|
|
213
|
+
`${path}.${key}`,
|
|
214
|
+
);
|
|
243
215
|
} else if (key === 'not') {
|
|
244
216
|
validateCondition(value, samples, `${path}.not`);
|
|
245
217
|
} else if (['endsWith', 'startsWith'].includes(key) && samples.length > 0 && !samples.some((sample) => typeof sample === 'string')) {
|
|
@@ -274,11 +246,13 @@ const validateCondition = (condition, samples, path) => {
|
|
|
274
246
|
export const validateWhere = (where, items, path = '') => {
|
|
275
247
|
Object.entries(where).forEach(([key, condition]) => {
|
|
276
248
|
if (key === 'and' || key === 'or') {
|
|
277
|
-
if (!Array.isArray(condition) || key === 'or' && condition.length === 0 || condition.some((nestedWhere) => !isObject(nestedWhere))) {
|
|
249
|
+
if (!Array.isArray(condition) || (key === 'or' && condition.length === 0) || condition.some((nestedWhere) => !isObject(nestedWhere))) {
|
|
278
250
|
throw createHttpError(400, `Оператор «${key}» должен содержать ${key === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
|
|
279
251
|
}
|
|
280
252
|
|
|
281
|
-
condition.forEach((nestedWhere) =>
|
|
253
|
+
condition.forEach((nestedWhere) => {
|
|
254
|
+
validateWhere(nestedWhere, items, path);
|
|
255
|
+
});
|
|
282
256
|
return;
|
|
283
257
|
}
|
|
284
258
|
|
|
@@ -306,67 +280,3 @@ export const validateWhere = (where, items, path = '') => {
|
|
|
306
280
|
validateCondition(condition, samples, fieldPath);
|
|
307
281
|
});
|
|
308
282
|
};
|
|
309
|
-
|
|
310
|
-
const parsePositiveInteger = (value, name, defaultValue) => {
|
|
311
|
-
if (value == null) {
|
|
312
|
-
return defaultValue;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
if (Array.isArray(value) || !/^[1-9]\d*$/.test(String(value))) {
|
|
316
|
-
throw createHttpError(400, `Параметр ${name} должен быть положительным целым числом`);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const number = Number(value);
|
|
320
|
-
|
|
321
|
-
if (!Number.isSafeInteger(number)) {
|
|
322
|
-
throw createHttpError(400, `Параметр ${name} должен быть положительным целым числом`);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return number;
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
export const parsePagination = (query) => ({
|
|
329
|
-
page: parsePositiveInteger(query._page, '_page', 1),
|
|
330
|
-
pageSize: parsePositiveInteger(query._perPage, '_perPage', 10),
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
export const paginateItems = (items, page, pageSize) => {
|
|
334
|
-
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : 10;
|
|
335
|
-
const pages = Math.max(1, Math.ceil(items.length / safePageSize));
|
|
336
|
-
const safePage = Math.max(1, Math.min(Number.isFinite(page) ? Math.floor(page) : 1, pages));
|
|
337
|
-
const offset = (safePage - 1) * safePageSize;
|
|
338
|
-
|
|
339
|
-
return {
|
|
340
|
-
data: items.slice(offset, offset + safePageSize),
|
|
341
|
-
first: 1,
|
|
342
|
-
items: items.length,
|
|
343
|
-
last: pages,
|
|
344
|
-
next: safePage < pages ? safePage + 1 : null,
|
|
345
|
-
pages,
|
|
346
|
-
prev: safePage > 1 ? safePage - 1 : null,
|
|
347
|
-
};
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
export const sortItems = (items, sort) => {
|
|
351
|
-
const sortRules = typeof sort === 'string' ? sort.split(',').filter(Boolean) : [];
|
|
352
|
-
|
|
353
|
-
if (sortRules.length === 0) {
|
|
354
|
-
return [...items];
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return [...items].sort((left, right) => {
|
|
358
|
-
for (const sortRule of sortRules) {
|
|
359
|
-
const isDescending = sortRule.startsWith('-');
|
|
360
|
-
const path = isDescending ? sortRule.slice(1) : sortRule;
|
|
361
|
-
const comparison = compareValues(getValueByPath(left, path), getValueByPath(right, path));
|
|
362
|
-
|
|
363
|
-
if (comparison !== 0) {
|
|
364
|
-
return isDescending ? -comparison : comparison;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return 0;
|
|
369
|
-
});
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
export { matchesWhere };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js';
|
|
2
|
+
import { createHttpError } from '../utils.js';
|
|
3
|
+
|
|
4
|
+
const parsePositiveInteger = (value, name, defaultValue) => {
|
|
5
|
+
if (value == null) {
|
|
6
|
+
return defaultValue;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (Array.isArray(value) || !/^[1-9]\d*$/.test(String(value))) {
|
|
10
|
+
throw createHttpError(400, `Параметр ${name} должен быть положительным целым числом`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const number = Number(value);
|
|
14
|
+
|
|
15
|
+
if (!Number.isSafeInteger(number)) {
|
|
16
|
+
throw createHttpError(400, `Параметр ${name} должен быть положительным целым числом`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const parsePagination = (query, maxPageSize = MAX_PAGE_SIZE) => {
|
|
23
|
+
const page = parsePositiveInteger(query._page, '_page', 1);
|
|
24
|
+
const pageSize = parsePositiveInteger(query._perPage, '_perPage', DEFAULT_PAGE_SIZE);
|
|
25
|
+
|
|
26
|
+
if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
|
|
27
|
+
throw new Error('Максимальный размер страницы должен быть положительным целым числом');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (pageSize > maxPageSize) {
|
|
31
|
+
throw createHttpError(400, `Параметр _perPage не должен превышать ${maxPageSize}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { page, pageSize };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const paginateItems = (items, page, pageSize) => {
|
|
38
|
+
const pages = Math.max(1, Math.ceil(items.length / pageSize));
|
|
39
|
+
const offset = (page - 1) * pageSize;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
data: items.slice(offset, offset + pageSize),
|
|
43
|
+
first: 1,
|
|
44
|
+
items: items.length,
|
|
45
|
+
last: pages,
|
|
46
|
+
next: page < pages ? page + 1 : null,
|
|
47
|
+
pages,
|
|
48
|
+
prev: page > 1 ? Math.min(page - 1, pages) : null,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHttpError, isObject, isSafeKey } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
const compareValues = (left, right) => {
|
|
4
|
+
if (Object.is(left, right)) {
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (left == null) {
|
|
9
|
+
return 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (right == null) {
|
|
13
|
+
return -1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const getValueByPath = (value, path) => path.split('.').reduce((currentValue, key) => (isObject(currentValue) && isSafeKey(key) ? currentValue[key] : undefined), value);
|
|
20
|
+
|
|
21
|
+
const parseSortRules = (sort, items) => {
|
|
22
|
+
if (sort == null || sort === '') {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof sort !== 'string') {
|
|
27
|
+
throw createHttpError(400, 'Параметр _sort должен содержать строку');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return sort.split(',').map((sortRule) => {
|
|
31
|
+
const isDescending = sortRule.startsWith('-');
|
|
32
|
+
const path = isDescending ? sortRule.slice(1) : sortRule;
|
|
33
|
+
const keys = path.split('.');
|
|
34
|
+
|
|
35
|
+
if (keys.some((key) => key === '' || !isSafeKey(key))) {
|
|
36
|
+
throw createHttpError(400, `Недопустимое поле сортировки «${path}»`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (items.length > 0 && !items.some((item) => getValueByPath(item, path) !== undefined)) {
|
|
40
|
+
throw createHttpError(400, `Неизвестное поле сортировки «${path}»`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { isDescending, path };
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const sortItems = (items, sort, validationItems = items) => {
|
|
48
|
+
const sortRules = parseSortRules(sort, validationItems);
|
|
49
|
+
|
|
50
|
+
return sortRules.length === 0
|
|
51
|
+
? [...items]
|
|
52
|
+
: [...items].sort((left, right) => {
|
|
53
|
+
for (const { isDescending, path } of sortRules) {
|
|
54
|
+
const comparison = compareValues(getValueByPath(left, path), getValueByPath(right, path));
|
|
55
|
+
|
|
56
|
+
if (comparison !== 0) {
|
|
57
|
+
return isDescending ? -comparison : comparison;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return 0;
|
|
62
|
+
});
|
|
63
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { singularize } from './utils.js';
|
|
2
|
+
|
|
3
|
+
const SELF_RELATIONS = new Set(['child', 'children', 'parent', 'parents']);
|
|
4
|
+
|
|
5
|
+
export const getRelationKeys = (...names) => [...new Set(names.flatMap((name) => [`${name}Id`, `${name}Ids`]))];
|
|
6
|
+
|
|
7
|
+
export const resolveRelationResource = (resourceNames, relation, sourceResource) => {
|
|
8
|
+
const resource = resourceNames.find((resourceName) => resourceName === relation) ?? resourceNames.find((resourceName) => singularize(resourceName) === relation);
|
|
9
|
+
|
|
10
|
+
if (resource != null) {
|
|
11
|
+
return resource;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return SELF_RELATIONS.has(relation) && resourceNames.includes(sourceResource) ? sourceResource : undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const getRelationMetadata = (key, resourceNames, sourceResource) => {
|
|
18
|
+
const match = key.match(/^(.+)(Id|Ids)$/);
|
|
19
|
+
|
|
20
|
+
if (match == null) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [, relation, suffix] = match;
|
|
25
|
+
const isMany = suffix === 'Ids';
|
|
26
|
+
const relationName = isMany ? (resourceNames.find((resource) => singularize(resource) === relation) ?? `${relation}s`) : relation;
|
|
27
|
+
const targetResource = resolveRelationResource(resourceNames, relationName, sourceResource);
|
|
28
|
+
|
|
29
|
+
if (targetResource == null) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
isMany,
|
|
35
|
+
relationName,
|
|
36
|
+
reverseRelationName: ['parent', 'parents'].includes(relationName) ? 'children' : sourceResource,
|
|
37
|
+
sourceResource,
|
|
38
|
+
targetResource,
|
|
39
|
+
};
|
|
40
|
+
};
|
package/src/relations.js
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const resolveResource = (database, relation, sourceResource) => {
|
|
4
|
-
const resourceNames = getResourceNames(database.data);
|
|
5
|
-
const resource = resourceNames.find((resourceName) => resourceName === relation) ?? resourceNames.find((resourceName) => singularize(resourceName) === relation);
|
|
6
|
-
|
|
7
|
-
if (resource != null) {
|
|
8
|
-
return resource;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
return ['child', 'children', 'parent', 'parents'].includes(relation) && resourceNames.includes(sourceResource) ? sourceResource : undefined;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const getRelationKeys = (...names) => [...new Set(names.flatMap((name) => [`${name}Id`, `${name}Ids`]))];
|
|
1
|
+
import { getRelationKeys, resolveRelationResource } from './relation-metadata.js';
|
|
2
|
+
import { createHttpError, getResourceNames, isIdEqual, isObject, isSafeKey, singularize, toArray } from './utils.js';
|
|
15
3
|
|
|
16
4
|
const findLocalRelation = (item, relation, targetResource) => {
|
|
17
5
|
const relationKey = getRelationKeys(relation, singularize(relation), targetResource, singularize(targetResource)).find((key) => Object.hasOwn(item, key));
|
|
@@ -38,7 +26,7 @@ const hasReference = (value, relationKeys, id) => {
|
|
|
38
26
|
}
|
|
39
27
|
|
|
40
28
|
if (relationKeys.includes(key)) {
|
|
41
|
-
return toArray(nestedValue).some((nestedId) =>
|
|
29
|
+
return toArray(nestedValue).some((nestedId) => isIdEqual(nestedId, id));
|
|
42
30
|
}
|
|
43
31
|
|
|
44
32
|
return hasReference(nestedValue, relationKeys, id);
|
|
@@ -51,7 +39,7 @@ const getResourceIndex = (database, resource, indexes) => {
|
|
|
51
39
|
|
|
52
40
|
database.data[resource].forEach((item) => {
|
|
53
41
|
if (isObject(item) && item.id != null && !index.has(item.id)) {
|
|
54
|
-
index.set(item.id, item);
|
|
42
|
+
index.set(String(item.id), item);
|
|
55
43
|
}
|
|
56
44
|
});
|
|
57
45
|
|
|
@@ -71,9 +59,9 @@ const findRelatedValue = (database, item, sourceResource, relation, targetResour
|
|
|
71
59
|
|
|
72
60
|
if (localRelation != null) {
|
|
73
61
|
const targetIndex = getResourceIndex(database, targetResource, indexes);
|
|
74
|
-
const relatedItems = localRelation.ids.map((id) => targetIndex.get(id)).filter((targetItem) => targetItem != null);
|
|
62
|
+
const relatedItems = localRelation.ids.map((id) => targetIndex.get(String(id))).filter((targetItem) => targetItem != null);
|
|
75
63
|
|
|
76
|
-
return localRelation.isMany ? relatedItems : relatedItems[0] ?? null;
|
|
64
|
+
return localRelation.isMany ? relatedItems : (relatedItems[0] ?? null);
|
|
77
65
|
}
|
|
78
66
|
|
|
79
67
|
if (item.id == null) {
|
|
@@ -95,13 +83,15 @@ const embedPath = (database, item, sourceResource, [relation, ...nestedRelations
|
|
|
95
83
|
}
|
|
96
84
|
|
|
97
85
|
const currentValue = item[relation];
|
|
98
|
-
const targetResource =
|
|
86
|
+
const targetResource = resolveRelationResource(getResourceNames(database.data), relation, sourceResource);
|
|
99
87
|
const nestedSourceResource = targetResource ?? relation;
|
|
100
88
|
const localRelation = targetResource == null ? undefined : findLocalRelation(item, relation, targetResource);
|
|
101
89
|
|
|
102
90
|
if (localRelation == null) {
|
|
103
91
|
if (Array.isArray(currentValue)) {
|
|
104
|
-
return nestedRelations.length === 0
|
|
92
|
+
return nestedRelations.length === 0
|
|
93
|
+
? item
|
|
94
|
+
: { ...item, [relation]: currentValue.map((value) => (isObject(value) ? embedPath(database, value, nestedSourceResource, nestedRelations, indexes) : value)) };
|
|
105
95
|
}
|
|
106
96
|
|
|
107
97
|
if (isObject(currentValue)) {
|
|
@@ -127,9 +117,57 @@ const embedPath = (database, item, sourceResource, [relation, ...nestedRelations
|
|
|
127
117
|
};
|
|
128
118
|
};
|
|
129
119
|
|
|
130
|
-
export const parseEmbedPaths = (embed) =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
120
|
+
export const parseEmbedPaths = (embed) => {
|
|
121
|
+
if (embed == null) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const values = toArray(embed);
|
|
126
|
+
|
|
127
|
+
if (values.some((value) => typeof value !== 'string')) {
|
|
128
|
+
throw createHttpError(400, 'Параметр _embed должен содержать строковые пути связей');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return values
|
|
132
|
+
.flatMap((value) => value.split(','))
|
|
133
|
+
.map((path) => {
|
|
134
|
+
const keys = path.split('.');
|
|
135
|
+
|
|
136
|
+
if (keys.some((key) => key === '' || !isSafeKey(key))) {
|
|
137
|
+
throw createHttpError(400, `Недопустимый путь связи «${path}»`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return keys;
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const getNestedSamples = (samples, relation) =>
|
|
145
|
+
samples.flatMap((sample) => {
|
|
146
|
+
if (!isObject(sample) || !Object.hasOwn(sample, relation)) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return toArray(sample[relation]).filter(isObject);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const validateEmbedPath = (database, sourceResource, samples, [relation, ...nestedRelations], path = '') => {
|
|
154
|
+
const relationPath = path === '' ? relation : `${path}.${relation}`;
|
|
155
|
+
const targetResource = resolveRelationResource(getResourceNames(database.data), relation, sourceResource);
|
|
156
|
+
const nestedSamples = targetResource == null ? getNestedSamples(samples, relation) : database.data[targetResource];
|
|
157
|
+
|
|
158
|
+
if (targetResource == null && nestedSamples.length === 0) {
|
|
159
|
+
throw createHttpError(400, `Неизвестный путь связи «${relationPath}»`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (nestedRelations.length > 0) {
|
|
163
|
+
validateEmbedPath(database, targetResource ?? relation, nestedSamples, nestedRelations, relationPath);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const validateEmbedPaths = (database, resource, items, embedPaths) => {
|
|
168
|
+
embedPaths.forEach((path) => {
|
|
169
|
+
validateEmbedPath(database, resource, items, path);
|
|
170
|
+
});
|
|
171
|
+
};
|
|
134
172
|
|
|
135
173
|
export const embedItem = (database, item, resource, embedPaths, indexes = new Map()) => embedPaths.reduce((embeddedItem, path) => embedPath(database, embeddedItem, resource, path, indexes), item);
|