@kollors/deep-json-server 0.1.1 → 0.2.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 +36 -2
- package/README.ru.md +36 -2
- package/index.js +3 -579
- package/package.json +6 -4
- package/src/cli.js +70 -0
- package/src/openapi.js +258 -0
- package/src/query.js +233 -0
- package/src/relations.js +116 -0
- package/src/server.js +153 -0
- package/src/utils.js +57 -0
package/src/openapi.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { stringify } from 'yaml';
|
|
4
|
+
import { getResourceNames, isObject, singularize, toPascalCase } from './utils.js';
|
|
5
|
+
|
|
6
|
+
const readJson = async(path, label) => {
|
|
7
|
+
const value = JSON.parse(await readFile(resolve(path), 'utf8'));
|
|
8
|
+
|
|
9
|
+
if (!isObject(value)) {
|
|
10
|
+
throw new Error(`${label} должен содержать JSON-объект`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const mergeSchemas = (schemas) => {
|
|
17
|
+
const uniqueSchemas = [...new Map(schemas.map((schema) => [JSON.stringify(schema), schema])).values()];
|
|
18
|
+
const nullable = uniqueSchemas.some((schema) => schema.type === 'null');
|
|
19
|
+
const nonNullSchemas = uniqueSchemas.filter((schema) => schema.type !== 'null');
|
|
20
|
+
|
|
21
|
+
if (nonNullSchemas.length === 0) {
|
|
22
|
+
return { nullable: true };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (nonNullSchemas.length === 1) {
|
|
26
|
+
return nullable ? { ...nonNullSchemas[0], nullable: true } : nonNullSchemas[0];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { oneOf: nonNullSchemas, ...(nullable && { nullable: true }) };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const inferSchema = (values, path, options) => {
|
|
33
|
+
if (values.every(Array.isArray)) {
|
|
34
|
+
const items = values.flat();
|
|
35
|
+
|
|
36
|
+
return { items: items.length === 0 ? {} : inferSchema(items, path, options), type: 'array' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (values.every(isObject)) {
|
|
40
|
+
return inferObjectSchema(values, path, options);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const schemas = values.map((value) => {
|
|
44
|
+
if (value === null) {
|
|
45
|
+
return { type: 'null' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof value === 'number') {
|
|
49
|
+
return { type: Number.isInteger(value) ? 'integer' : 'number' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const schema = { type: typeof value };
|
|
53
|
+
const format = options.formats[path];
|
|
54
|
+
|
|
55
|
+
return typeof value === 'string' && format != null ? { ...schema, format } : schema;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return mergeSchemas(schemas);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function inferObjectSchema(values, path, options) {
|
|
62
|
+
const keys = [...new Set(values.flatMap((value) => Object.keys(value)))].sort((left, right) => left === 'id' ? -1 : right === 'id' ? 1 : left.localeCompare(right));
|
|
63
|
+
const properties = Object.fromEntries(keys.map((key) => {
|
|
64
|
+
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
65
|
+
const fieldValues = values.filter((value) => Object.hasOwn(value, key)).map((value) => value[key]);
|
|
66
|
+
|
|
67
|
+
return [key, inferSchema(fieldValues, fieldPath, options)];
|
|
68
|
+
}));
|
|
69
|
+
const required = keys.filter((key) => {
|
|
70
|
+
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
71
|
+
|
|
72
|
+
return !options.optional.has(fieldPath) && values.every((value) => Object.hasOwn(value, key));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { properties, type: 'object', ...(required.length > 0 && { required }) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const resolveRelationResource = (resources, relation, sourceResource) => {
|
|
79
|
+
const resource = resources.find((resourceName) => resourceName === relation) ?? resources.find((resourceName) => singularize(resourceName) === relation);
|
|
80
|
+
|
|
81
|
+
if (resource != null) {
|
|
82
|
+
return resource;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return ['child', 'children', 'parent', 'parents'].includes(relation) ? sourceResource : undefined;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const addRelationSchemas = (schema, resources, componentNames, sourceResource) => {
|
|
89
|
+
if (schema.type === 'array') {
|
|
90
|
+
return { ...schema, items: addRelationSchemas(schema.items, resources, componentNames, sourceResource) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (schema.type !== 'object' || schema.properties == null) {
|
|
94
|
+
return schema;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const properties = Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, addRelationSchemas(value, resources, componentNames, sourceResource)]));
|
|
98
|
+
|
|
99
|
+
Object.keys(schema.properties).forEach((key) => {
|
|
100
|
+
const match = key.match(/^(.+)(Id|Ids)$/);
|
|
101
|
+
|
|
102
|
+
if (match == null) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const [, relation, suffix] = match;
|
|
107
|
+
const targetResource = resolveRelationResource(resources, suffix === 'Ids' ? `${relation}s` : relation, sourceResource);
|
|
108
|
+
|
|
109
|
+
if (targetResource == null) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const relationName = suffix === 'Ids' ? `${relation}s` : relation;
|
|
114
|
+
const reference = { $ref: `#/components/schemas/${componentNames[targetResource]}` };
|
|
115
|
+
|
|
116
|
+
properties[relationName] = suffix === 'Ids' ? { items: reference, type: 'array' } : reference;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { ...schema, properties };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const omitId = (schema, keepRequired) => {
|
|
123
|
+
const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).filter(([key]) => key !== 'id'));
|
|
124
|
+
const required = keepRequired ? schema.required?.filter((key) => key !== 'id') : undefined;
|
|
125
|
+
|
|
126
|
+
const result = { ...schema, properties };
|
|
127
|
+
|
|
128
|
+
delete result.required;
|
|
129
|
+
|
|
130
|
+
return required?.length > 0 ? { ...result, required } : result;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const createParameters = () => ({
|
|
134
|
+
Embed: { description: 'Comma-separated relationship paths to embed', in: 'query', name: '_embed', schema: { type: 'string' } },
|
|
135
|
+
Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
|
|
136
|
+
Page: { in: 'query', name: '_page', schema: { minimum: 1, type: 'integer' } },
|
|
137
|
+
PerPage: { in: 'query', name: '_per_page', schema: { minimum: 1, type: 'integer' } },
|
|
138
|
+
Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
|
|
139
|
+
Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const jsonContent = (schema) => ({ content: { 'application/json': { schema } } });
|
|
143
|
+
const response = (description, schema) => ({ description, ...(schema != null && jsonContent(schema)) });
|
|
144
|
+
const reference = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
145
|
+
const parameter = (name) => ({ $ref: `#/components/parameters/${name}` });
|
|
146
|
+
|
|
147
|
+
const createResourcePaths = (resource, componentName) => {
|
|
148
|
+
const tag = componentName;
|
|
149
|
+
const listSchema = { oneOf: [{ items: reference(componentName), type: 'array' }, reference(`${componentName}Page`)] };
|
|
150
|
+
const body = (name) => ({ required: true, ...jsonContent(reference(name)) });
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
[`/${resource}`]: {
|
|
154
|
+
get: {
|
|
155
|
+
operationId: `get${toPascalCase(resource)}`,
|
|
156
|
+
parameters: ['Page', 'PerPage', 'Sort', 'Where', 'Embed'].map(parameter),
|
|
157
|
+
responses: { 200: response('Successful response', listSchema), 400: response('Invalid query', reference('Error')) },
|
|
158
|
+
tags: [tag],
|
|
159
|
+
},
|
|
160
|
+
post: {
|
|
161
|
+
operationId: `create${componentName}`,
|
|
162
|
+
requestBody: body(`${componentName}Create`),
|
|
163
|
+
responses: { 201: response('Created', reference(componentName)), 400: response('Invalid request', reference('Error')) },
|
|
164
|
+
tags: [tag],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
[`/${resource}/{id}`]: {
|
|
168
|
+
delete: {
|
|
169
|
+
operationId: `delete${componentName}`,
|
|
170
|
+
parameters: [parameter('Id')],
|
|
171
|
+
responses: { 200: response('Deleted', reference(componentName)), 404: response('Not found', reference('Error')) },
|
|
172
|
+
tags: [tag],
|
|
173
|
+
},
|
|
174
|
+
get: {
|
|
175
|
+
operationId: `get${componentName}ById`,
|
|
176
|
+
parameters: [parameter('Id'), parameter('Embed')],
|
|
177
|
+
responses: { 200: response('Successful response', reference(componentName)), 404: response('Not found', reference('Error')) },
|
|
178
|
+
tags: [tag],
|
|
179
|
+
},
|
|
180
|
+
patch: {
|
|
181
|
+
operationId: `update${componentName}`,
|
|
182
|
+
parameters: [parameter('Id')],
|
|
183
|
+
requestBody: body(`${componentName}Update`),
|
|
184
|
+
responses: { 200: response('Updated', reference(componentName)), 404: response('Not found', reference('Error')) },
|
|
185
|
+
tags: [tag],
|
|
186
|
+
},
|
|
187
|
+
put: {
|
|
188
|
+
operationId: `replace${componentName}`,
|
|
189
|
+
parameters: [parameter('Id')],
|
|
190
|
+
requestBody: body(`${componentName}Create`),
|
|
191
|
+
responses: { 200: response('Replaced', reference(componentName)), 404: response('Not found', reference('Error')) },
|
|
192
|
+
tags: [tag],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export function createOpenApiDocument(database, schemaConfig = {}) {
|
|
199
|
+
if (!isObject(database) || !isObject(schemaConfig)) {
|
|
200
|
+
throw new Error('База данных и её схема должны содержать JSON-объекты');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const resources = getResourceNames(database);
|
|
204
|
+
const componentNames = Object.fromEntries(resources.map((resource) => [resource, toPascalCase(singularize(resource))]));
|
|
205
|
+
const schemas = {
|
|
206
|
+
Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
resources.forEach((resource) => {
|
|
210
|
+
const componentName = componentNames[resource];
|
|
211
|
+
const resourceConfig = isObject(schemaConfig[resource]) ? schemaConfig[resource] : {};
|
|
212
|
+
const options = {
|
|
213
|
+
formats: isObject(resourceConfig.formats) ? resourceConfig.formats : {},
|
|
214
|
+
optional: new Set(Array.isArray(resourceConfig.optional) ? resourceConfig.optional : []),
|
|
215
|
+
};
|
|
216
|
+
const values = database[resource].filter(isObject);
|
|
217
|
+
const rawSchema = values.length === 0 ? { additionalProperties: true, type: 'object' } : inferObjectSchema(values, '', options);
|
|
218
|
+
const responseSchema = addRelationSchemas(rawSchema, resources, componentNames, resource);
|
|
219
|
+
|
|
220
|
+
schemas[componentName] = responseSchema;
|
|
221
|
+
schemas[`${componentName}Create`] = omitId(rawSchema, true);
|
|
222
|
+
schemas[`${componentName}Update`] = omitId(rawSchema, false);
|
|
223
|
+
schemas[`${componentName}Page`] = {
|
|
224
|
+
properties: {
|
|
225
|
+
data: { items: reference(componentName), type: 'array' },
|
|
226
|
+
first: { type: 'integer' },
|
|
227
|
+
items: { type: 'integer' },
|
|
228
|
+
last: { type: 'integer' },
|
|
229
|
+
next: { nullable: true, type: 'integer' },
|
|
230
|
+
pages: { type: 'integer' },
|
|
231
|
+
prev: { nullable: true, type: 'integer' },
|
|
232
|
+
},
|
|
233
|
+
required: ['data', 'first', 'items', 'last', 'next', 'pages', 'prev'],
|
|
234
|
+
type: 'object',
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
components: { parameters: createParameters(), schemas },
|
|
240
|
+
info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
|
|
241
|
+
openapi: '3.0.3',
|
|
242
|
+
paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource]))),
|
|
243
|
+
servers: Array.isArray(schemaConfig.$servers) ? schemaConfig.$servers : [{ url: 'http://127.0.0.1:4001' }],
|
|
244
|
+
tags: resources.map((resource) => ({ name: componentNames[resource] })),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function generateOpenApi({ databasePath, outputPath, schemaPath }) {
|
|
249
|
+
const database = await readJson(databasePath, 'База данных');
|
|
250
|
+
const schemaConfig = await readJson(schemaPath, 'Схема базы данных');
|
|
251
|
+
const document = createOpenApiDocument(database, schemaConfig);
|
|
252
|
+
const resolvedOutputPath = resolve(outputPath);
|
|
253
|
+
|
|
254
|
+
await mkdir(dirname(resolvedOutputPath), { recursive: true });
|
|
255
|
+
await writeFile(resolvedOutputPath, stringify(document, { lineWidth: 0 }), 'utf8');
|
|
256
|
+
|
|
257
|
+
return document;
|
|
258
|
+
}
|
package/src/query.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createHttpError, isEqual, isObject, isSafeKey, toArray } from './utils.js';
|
|
2
|
+
|
|
3
|
+
const FIELD_OPERATORS = new Set(['contains', 'endsWith', 'eq', 'every', 'gt', 'gte', 'in', 'lt', 'lte', 'ne', 'none', 'not', 'some', 'startsWith']);
|
|
4
|
+
const RESERVED_QUERY_KEYS = new Set(['_embed', '_page', '_per_page', '_sort', '_where']);
|
|
5
|
+
|
|
6
|
+
const compareValues = (left, right) => {
|
|
7
|
+
if (Object.is(left, right)) {
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (left == null) {
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (right == null) {
|
|
16
|
+
return -1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof left === 'number' && typeof right === 'number') {
|
|
20
|
+
return left - right;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const getValueByPath = (value, path) => path.split('.').reduce((currentValue, key) => {
|
|
27
|
+
return isSafeKey(key) && currentValue != null ? currentValue[key] : undefined;
|
|
28
|
+
}, value);
|
|
29
|
+
|
|
30
|
+
const matchesOperator = (field, operator, expectedValue) => {
|
|
31
|
+
switch (operator) {
|
|
32
|
+
case 'contains':
|
|
33
|
+
return typeof field === 'string'
|
|
34
|
+
? field.toLowerCase().includes(String(expectedValue).toLowerCase())
|
|
35
|
+
: Array.isArray(field) && field.some((value) => isEqual(value, expectedValue));
|
|
36
|
+
case 'endsWith':
|
|
37
|
+
return typeof field === 'string' && field.toLowerCase().endsWith(String(expectedValue).toLowerCase());
|
|
38
|
+
case 'eq':
|
|
39
|
+
return isEqual(field, expectedValue);
|
|
40
|
+
case 'every':
|
|
41
|
+
return Array.isArray(field) && field.every((value) => matchesValue(value, expectedValue));
|
|
42
|
+
case 'gt':
|
|
43
|
+
return field != null && field > expectedValue;
|
|
44
|
+
case 'gte':
|
|
45
|
+
return field != null && field >= expectedValue;
|
|
46
|
+
case 'in': {
|
|
47
|
+
const expectedValues = toArray(expectedValue);
|
|
48
|
+
|
|
49
|
+
return Array.isArray(field)
|
|
50
|
+
? field.some((value) => expectedValues.some((expectedItem) => isEqual(value, expectedItem)))
|
|
51
|
+
: expectedValues.some((expectedItem) => isEqual(field, expectedItem));
|
|
52
|
+
}
|
|
53
|
+
case 'lt':
|
|
54
|
+
return field != null && field < expectedValue;
|
|
55
|
+
case 'lte':
|
|
56
|
+
return field != null && field <= expectedValue;
|
|
57
|
+
case 'ne':
|
|
58
|
+
return !isEqual(field, expectedValue);
|
|
59
|
+
case 'none':
|
|
60
|
+
return Array.isArray(field) && !field.some((value) => matchesValue(value, expectedValue));
|
|
61
|
+
case 'not':
|
|
62
|
+
return !matchesValue(field, expectedValue);
|
|
63
|
+
case 'some':
|
|
64
|
+
return Array.isArray(field) && field.some((value) => matchesValue(value, expectedValue));
|
|
65
|
+
case 'startsWith':
|
|
66
|
+
return typeof field === 'string' && field.toLowerCase().startsWith(String(expectedValue).toLowerCase());
|
|
67
|
+
default:
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function matchesValue(field, condition) {
|
|
73
|
+
if (!isObject(condition)) {
|
|
74
|
+
return isEqual(field, condition);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const conditionEntries = Object.entries(condition);
|
|
78
|
+
const operatorEntries = conditionEntries.filter(([operator]) => FIELD_OPERATORS.has(operator));
|
|
79
|
+
const nestedEntries = conditionEntries.filter(([key]) => !FIELD_OPERATORS.has(key));
|
|
80
|
+
|
|
81
|
+
if (!operatorEntries.every(([operator, expectedValue]) => matchesOperator(field, operator, expectedValue))) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return nestedEntries.length === 0 || isObject(field) && matchesWhere(field, Object.fromEntries(nestedEntries));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function matchesWhere(value, where) {
|
|
89
|
+
if (!isObject(value) || !isObject(where)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Object.entries(where).every(([key, condition]) => {
|
|
94
|
+
if (key === 'and') {
|
|
95
|
+
return Array.isArray(condition) && condition.every((nestedWhere) => matchesWhere(value, nestedWhere));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (key === 'or') {
|
|
99
|
+
return Array.isArray(condition) && condition.length > 0 && condition.some((nestedWhere) => matchesWhere(value, nestedWhere));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (key === 'not') {
|
|
103
|
+
return isObject(condition) && !matchesWhere(value, condition);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return isSafeKey(key) && matchesValue(value[key], condition);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const parsePrimitive = (value) => {
|
|
111
|
+
if (value === 'true') {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (value === 'false') {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return value === 'null' ? null : value;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const parseFilterKey = (key) => {
|
|
123
|
+
const colonIndex = key.lastIndexOf(':');
|
|
124
|
+
|
|
125
|
+
if (colonIndex !== -1) {
|
|
126
|
+
const path = key.slice(0, colonIndex);
|
|
127
|
+
const operator = key.slice(colonIndex + 1);
|
|
128
|
+
|
|
129
|
+
return FIELD_OPERATORS.has(operator) ? { operator, path } : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const legacyOperator = key.match(/^(.*)_([a-zA-Z]+)$/);
|
|
133
|
+
|
|
134
|
+
if (legacyOperator?.[1] != null && legacyOperator[2] != null && FIELD_OPERATORS.has(legacyOperator[2])) {
|
|
135
|
+
return { operator: legacyOperator[2], path: legacyOperator[1] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { operator: 'eq', path: key };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const setWhereOperator = (where, path, operator, value) => {
|
|
142
|
+
const keys = path.split('.').filter(Boolean);
|
|
143
|
+
|
|
144
|
+
if (keys.length === 0 || keys.some((key) => !isSafeKey(key))) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const fieldKey = keys.pop();
|
|
149
|
+
let currentValue = where;
|
|
150
|
+
|
|
151
|
+
keys.forEach((key) => {
|
|
152
|
+
currentValue[key] = isObject(currentValue[key]) ? currentValue[key] : {};
|
|
153
|
+
currentValue = currentValue[key];
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
currentValue[fieldKey] = isObject(currentValue[fieldKey]) ? currentValue[fieldKey] : {};
|
|
157
|
+
currentValue[fieldKey][operator] = operator === 'in' && typeof value === 'string' ? value.split(',').map((item) => parsePrimitive(item.trim())) : parsePrimitive(value);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const parseWhere = (query) => {
|
|
161
|
+
const rawWhere = toArray(query._where).at(-1);
|
|
162
|
+
|
|
163
|
+
if (rawWhere != null) {
|
|
164
|
+
try {
|
|
165
|
+
const where = JSON.parse(rawWhere);
|
|
166
|
+
|
|
167
|
+
if (!isObject(where)) {
|
|
168
|
+
throw new Error();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return where;
|
|
172
|
+
} catch {
|
|
173
|
+
throw createHttpError(400, 'Параметр _where должен содержать JSON-объект');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const where = {};
|
|
178
|
+
|
|
179
|
+
Object.entries(query).forEach(([key, rawValue]) => {
|
|
180
|
+
if (RESERVED_QUERY_KEYS.has(key)) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const filterKey = parseFilterKey(key);
|
|
185
|
+
|
|
186
|
+
if (filterKey != null) {
|
|
187
|
+
toArray(rawValue).forEach((value) => setWhereOperator(where, filterKey.path, filterKey.operator, value));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return where;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const paginateItems = (items, page, pageSize) => {
|
|
195
|
+
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : 10;
|
|
196
|
+
const pages = Math.max(1, Math.ceil(items.length / safePageSize));
|
|
197
|
+
const safePage = Math.max(1, Math.min(Number.isFinite(page) ? Math.floor(page) : 1, pages));
|
|
198
|
+
const offset = (safePage - 1) * safePageSize;
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
data: items.slice(offset, offset + safePageSize),
|
|
202
|
+
first: 1,
|
|
203
|
+
items: items.length,
|
|
204
|
+
last: pages,
|
|
205
|
+
next: safePage < pages ? safePage + 1 : null,
|
|
206
|
+
pages,
|
|
207
|
+
prev: safePage > 1 ? safePage - 1 : null,
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const sortItems = (items, sort) => {
|
|
212
|
+
const sortRules = typeof sort === 'string' ? sort.split(',').filter(Boolean) : [];
|
|
213
|
+
|
|
214
|
+
if (sortRules.length === 0) {
|
|
215
|
+
return [...items];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return [...items].sort((left, right) => {
|
|
219
|
+
for (const sortRule of sortRules) {
|
|
220
|
+
const isDescending = sortRule.startsWith('-');
|
|
221
|
+
const path = isDescending ? sortRule.slice(1) : sortRule;
|
|
222
|
+
const comparison = compareValues(getValueByPath(left, path), getValueByPath(right, path));
|
|
223
|
+
|
|
224
|
+
if (comparison !== 0) {
|
|
225
|
+
return isDescending ? -comparison : comparison;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return 0;
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export { matchesWhere };
|
package/src/relations.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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`]))];
|
|
15
|
+
|
|
16
|
+
const findLocalRelation = (item, relation, targetResource) => {
|
|
17
|
+
const relationKey = getRelationKeys(relation, singularize(relation), targetResource, singularize(targetResource)).find((key) => Object.hasOwn(item, key));
|
|
18
|
+
|
|
19
|
+
if (relationKey == null) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { ids: toArray(item[relationKey]).filter((id) => id != null), isMany: relationKey.endsWith('Ids') || Array.isArray(item[relationKey]) };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const hasReference = (value, relationKeys, id) => {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.some((item) => hasReference(item, relationKeys, id));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!isObject(value)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Object.entries(value).some(([key, nestedValue]) => {
|
|
36
|
+
if (!isSafeKey(key)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (relationKeys.includes(key)) {
|
|
41
|
+
return toArray(nestedValue).some((nestedId) => isEqual(nestedId, id));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return hasReference(nestedValue, relationKeys, id);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const findRelatedValue = (database, item, sourceResource, relation, targetResource) => {
|
|
49
|
+
const targetItems = database.data[targetResource];
|
|
50
|
+
const localRelation = findLocalRelation(item, relation, targetResource);
|
|
51
|
+
|
|
52
|
+
if (!Array.isArray(targetItems)) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (localRelation != null) {
|
|
57
|
+
const relatedItems = localRelation.ids.map((id) => targetItems.find((targetItem) => isObject(targetItem) && isEqual(targetItem.id, id))).filter((targetItem) => targetItem != null);
|
|
58
|
+
|
|
59
|
+
return localRelation.isMany ? relatedItems : relatedItems[0] ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (item.id == null) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const reverseRelationKeys = getRelationKeys(singularize(sourceResource));
|
|
67
|
+
|
|
68
|
+
if (relation === 'child' || relation === 'children') {
|
|
69
|
+
reverseRelationKeys.push('parentId', 'parentIds');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return targetItems.filter((targetItem) => hasReference(targetItem, reverseRelationKeys, item.id));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const embedPath = (database, item, sourceResource, [relation, ...nestedRelations]) => {
|
|
76
|
+
if (relation == null || !isSafeKey(relation)) {
|
|
77
|
+
return item;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const currentValue = item[relation];
|
|
81
|
+
const nestedSourceResource = resolveResource(database, relation, sourceResource) ?? relation;
|
|
82
|
+
|
|
83
|
+
if (Array.isArray(currentValue)) {
|
|
84
|
+
return nestedRelations.length === 0 ? item : { ...item, [relation]: currentValue.map((value) => (isObject(value) ? embedPath(database, value, nestedSourceResource, nestedRelations) : value)) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isObject(currentValue)) {
|
|
88
|
+
return nestedRelations.length === 0 ? item : { ...item, [relation]: embedPath(database, currentValue, nestedSourceResource, nestedRelations) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const targetResource = resolveResource(database, relation, sourceResource);
|
|
92
|
+
|
|
93
|
+
if (targetResource == null) {
|
|
94
|
+
return item;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const relatedValue = findRelatedValue(database, item, sourceResource, relation, targetResource);
|
|
98
|
+
|
|
99
|
+
if (relatedValue == null || nestedRelations.length === 0) {
|
|
100
|
+
return relatedValue === undefined ? item : { ...item, [relation]: relatedValue };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...item,
|
|
105
|
+
[relation]: Array.isArray(relatedValue)
|
|
106
|
+
? relatedValue.map((value) => embedPath(database, value, targetResource, nestedRelations))
|
|
107
|
+
: embedPath(database, relatedValue, targetResource, nestedRelations),
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const parseEmbedPaths = (embed) => toArray(embed)
|
|
112
|
+
.flatMap((value) => (typeof value === 'string' ? value.split(',') : []))
|
|
113
|
+
.map((path) => path.split('.').filter(Boolean))
|
|
114
|
+
.filter((path) => path.length > 0 && path.every(isSafeKey));
|
|
115
|
+
|
|
116
|
+
export const embedItem = (database, item, resource, embedPaths) => embedPaths.reduce((embeddedItem, path) => embedPath(database, embeddedItem, resource, path), item);
|