@kollors/deep-json-server 0.3.2 → 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.
@@ -1,4 +1,4 @@
1
- import { createHttpError, isEqual, isObject, isSafeKey, toArray } from './utils.js';
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
- if (legacyOperator?.[1] != null && legacyOperator[2] != null && FIELD_OPERATORS.has(legacyOperator[2])) {
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
- return;
207
- }
175
+ if (!RESERVED_QUERY_KEYS.has(key)) {
176
+ const filterKey = parseFilterKey(key);
208
177
 
209
- const filterKey = parseFilterKey(key);
210
-
211
- if (filterKey != null) {
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) => validateWhere(nestedWhere, samples, path));
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
- const nestedSamples = samples.flatMap((sample) => Array.isArray(sample) ? sample : []);
241
-
242
- validateCondition(value, nestedSamples, `${path}.${key}`);
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) => validateWhere(nestedWhere, items, path));
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,3 @@
1
+ export { matchesWhere, parseWhere, validateWhere } from './filter.js';
2
+ export { paginateItems, parsePagination } from './pagination.js';
3
+ export { sortItems } from './sort.js';
@@ -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 { getResourceNames, isEqual, isObject, isSafeKey, singularize, toArray } from './utils.js';
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) => isEqual(nestedId, id));
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 = resolveResource(database, relation, sourceResource);
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 ? item : { ...item, [relation]: currentValue.map((value) => (isObject(value) ? embedPath(database, value, nestedSourceResource, nestedRelations, indexes) : value)) };
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) => toArray(embed)
131
- .flatMap((value) => (typeof value === 'string' ? value.split(',') : []))
132
- .map((path) => path.split('.').filter(Boolean))
133
- .filter((path) => path.length > 0 && path.every(isSafeKey));
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);