@node-c/data-redis 1.0.0-alpha64 → 1.0.0-beta1
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/dist/entityService/redis.entity.service.d.ts +3 -2
- package/dist/entityService/redis.entity.service.js +3 -2
- package/dist/entityService/redis.entity.service.js.map +1 -1
- package/dist/repository/redis.repository.module.js +1 -1
- package/dist/repository/redis.repository.module.js.map +1 -1
- package/dist/repository/redis.repository.service.d.ts +6 -3
- package/dist/repository/redis.repository.service.js +83 -20
- package/dist/repository/redis.repository.service.js.map +1 -1
- package/dist/store/redis.store.module.js +3 -3
- package/dist/store/redis.store.module.js.map +1 -1
- package/dist/store/redis.store.service.d.ts +2 -1
- package/dist/store/redis.store.service.js +6 -6
- package/dist/store/redis.store.service.js.map +1 -1
- package/package.json +5 -4
- package/src/common/definitions/common.constants.ts +10 -0
- package/src/common/definitions/index.ts +1 -0
- package/src/entityService/index.ts +2 -0
- package/src/entityService/redis.entity.service.definitions.ts +71 -0
- package/src/entityService/redis.entity.service.spec.ts +190 -0
- package/src/entityService/redis.entity.service.ts +291 -0
- package/src/index.ts +5 -0
- package/src/module/index.ts +2 -0
- package/src/module/redis.module.definitions.ts +18 -0
- package/src/module/redis.module.spec.ts +80 -0
- package/src/module/redis.module.ts +31 -0
- package/src/repository/index.ts +3 -0
- package/src/repository/redis.repository.definitions.ts +97 -0
- package/src/repository/redis.repository.module.spec.ts +60 -0
- package/src/repository/redis.repository.module.ts +34 -0
- package/src/repository/redis.repository.service.ts +657 -0
- package/src/repository/redis.repository.spec.ts +384 -0
- package/src/store/index.ts +3 -0
- package/src/store/redis.store.definitions.ts +25 -0
- package/src/store/redis.store.module.spec.ts +70 -0
- package/src/store/redis.store.module.ts +34 -0
- package/src/store/redis.store.service.spec.ts +392 -0
- package/src/store/redis.store.service.ts +395 -0
- package/src/vitest.config.ts +9 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AppConfigCommonDataNoSQLValidationSettings,
|
|
5
|
+
AppConfigDataNoSQL,
|
|
6
|
+
ApplicationError,
|
|
7
|
+
ConfigProviderService,
|
|
8
|
+
Constants as CoreConstants,
|
|
9
|
+
GenericObject,
|
|
10
|
+
getNested,
|
|
11
|
+
setNested
|
|
12
|
+
} from '@node-c/core';
|
|
13
|
+
|
|
14
|
+
import { ValidationSchema, registerSchema, validate } from 'class-validator';
|
|
15
|
+
import ld from 'lodash';
|
|
16
|
+
import { v4 as uuid } from 'uuid';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
EntitySchema,
|
|
20
|
+
EntitySchemaColumnType,
|
|
21
|
+
FilterItemOptions,
|
|
22
|
+
GetValuesFromResultsOptions,
|
|
23
|
+
PrepareOptions,
|
|
24
|
+
RepositoryFindOptions,
|
|
25
|
+
RepositoryFindPrivateOptions,
|
|
26
|
+
SaveOptions,
|
|
27
|
+
SaveOptionsOnConflict
|
|
28
|
+
} from './redis.repository.definitions';
|
|
29
|
+
|
|
30
|
+
import { Constants } from '../common/definitions';
|
|
31
|
+
import { RedisStoreService } from '../store';
|
|
32
|
+
|
|
33
|
+
// TODO: support "paranoid" mode
|
|
34
|
+
// TODO: support complex filtering, not just equality
|
|
35
|
+
// TODO: support indexing
|
|
36
|
+
@Injectable()
|
|
37
|
+
export class RedisRepositoryService<Entity> {
|
|
38
|
+
protected _columnNames: string[];
|
|
39
|
+
protected _innerPrimaryKeys: string[];
|
|
40
|
+
protected _innerPrimaryKeysMap: GenericObject<boolean>;
|
|
41
|
+
protected _primaryKeys: string[];
|
|
42
|
+
protected _primaryKeysMap: GenericObject<boolean>;
|
|
43
|
+
protected defaultTTL?: number;
|
|
44
|
+
protected defaultIndividualSearchEnabled: boolean;
|
|
45
|
+
protected experimentalDeletionEnabled: boolean = false;
|
|
46
|
+
protected storeDelimiter: string;
|
|
47
|
+
protected validationSchemaProperties: ValidationSchema['properties'];
|
|
48
|
+
protected validationSettings: AppConfigCommonDataNoSQLValidationSettings;
|
|
49
|
+
|
|
50
|
+
public get columnNames(): string[] {
|
|
51
|
+
return this._columnNames;
|
|
52
|
+
}
|
|
53
|
+
public get innerPrimaryKeys(): string[] {
|
|
54
|
+
return this._innerPrimaryKeys;
|
|
55
|
+
}
|
|
56
|
+
public get innerPrimaryKeysMap(): GenericObject<boolean> {
|
|
57
|
+
return this._innerPrimaryKeysMap;
|
|
58
|
+
}
|
|
59
|
+
public get dataModuleName(): string {
|
|
60
|
+
return this._dataModuleName;
|
|
61
|
+
}
|
|
62
|
+
public get primaryKeys(): string[] {
|
|
63
|
+
return this._primaryKeys;
|
|
64
|
+
}
|
|
65
|
+
public get primaryKeysMap(): GenericObject<boolean> {
|
|
66
|
+
return this._primaryKeysMap;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
protected configProvider: ConfigProviderService,
|
|
71
|
+
@Inject(CoreConstants.DATA_MODULE_NAME)
|
|
72
|
+
protected _dataModuleName: string,
|
|
73
|
+
@Inject(Constants.REDIS_REPOSITORY_SCHEMA)
|
|
74
|
+
protected schema: EntitySchema,
|
|
75
|
+
// eslint-disable-next-line no-unused-vars
|
|
76
|
+
protected store: RedisStoreService
|
|
77
|
+
) {
|
|
78
|
+
const { defaultIndividualSearchEnabled, defaultTTL, storeDelimiter, settingsPerEntity } = configProvider.config
|
|
79
|
+
.data[_dataModuleName] as AppConfigDataNoSQL;
|
|
80
|
+
const { columns, name: entityName } = schema;
|
|
81
|
+
const columnNames: string[] = [];
|
|
82
|
+
const innerPrimaryKeys: string[] = [];
|
|
83
|
+
const innerPrimaryKeysMap: GenericObject<boolean> = {};
|
|
84
|
+
const primaryKeys: string[] = [];
|
|
85
|
+
const primaryKeysMap: GenericObject<boolean> = {};
|
|
86
|
+
const validationSchemaProperties: ValidationSchema['properties'] = {};
|
|
87
|
+
for (const columnName in columns) {
|
|
88
|
+
const { isInnerPrimary, primary, primaryOrder, validationProperties } = columns[columnName];
|
|
89
|
+
columnNames.push(columnName);
|
|
90
|
+
if (primary) {
|
|
91
|
+
if (typeof primaryOrder === 'undefined') {
|
|
92
|
+
throw new ApplicationError(
|
|
93
|
+
`At schema "${entityName}", column "${columnName}": the field "primaryOrder" is required for primary key columns.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
primaryKeys.push(columnName);
|
|
97
|
+
primaryKeysMap[columnName] = true;
|
|
98
|
+
} else if (isInnerPrimary) {
|
|
99
|
+
innerPrimaryKeys.push(columnName);
|
|
100
|
+
innerPrimaryKeysMap[columnName] = true;
|
|
101
|
+
}
|
|
102
|
+
if (validationProperties) {
|
|
103
|
+
validationSchemaProperties[columnName] = validationProperties;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this._columnNames = columnNames;
|
|
107
|
+
this._innerPrimaryKeys = innerPrimaryKeys;
|
|
108
|
+
this._innerPrimaryKeysMap = innerPrimaryKeysMap;
|
|
109
|
+
this._primaryKeys = primaryKeys.sort(
|
|
110
|
+
(columnName0, columnName1) => columns[columnName0].primaryOrder! - columns[columnName1].primaryOrder!
|
|
111
|
+
);
|
|
112
|
+
this._primaryKeysMap = primaryKeysMap;
|
|
113
|
+
this.defaultTTL = settingsPerEntity?.[entityName]?.ttl || defaultTTL;
|
|
114
|
+
if (typeof settingsPerEntity?.[entityName]?.defaultIndividualSearchEnabled !== 'undefined') {
|
|
115
|
+
this.defaultIndividualSearchEnabled = settingsPerEntity?.[entityName]?.defaultIndividualSearchEnabled;
|
|
116
|
+
} else if (typeof defaultIndividualSearchEnabled !== 'undefined') {
|
|
117
|
+
this.defaultIndividualSearchEnabled = defaultIndividualSearchEnabled;
|
|
118
|
+
} else {
|
|
119
|
+
this.defaultIndividualSearchEnabled = false;
|
|
120
|
+
}
|
|
121
|
+
this.storeDelimiter = storeDelimiter || Constants.DEFAULT_STORE_DELIMITER;
|
|
122
|
+
this.validationSchemaProperties = validationSchemaProperties;
|
|
123
|
+
registerSchema({ name: entityName, properties: validationSchemaProperties });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// protected async delete
|
|
127
|
+
|
|
128
|
+
async find<ResultItem extends Entity | string = Entity>(
|
|
129
|
+
options: RepositoryFindOptions,
|
|
130
|
+
privateOptions?: RepositoryFindPrivateOptions
|
|
131
|
+
): Promise<{ items: ResultItem[]; more: boolean }> {
|
|
132
|
+
const { primaryKeys, schema, store, storeDelimiter } = this;
|
|
133
|
+
const { name: entityName, storeKey: entityStoreKey } = schema;
|
|
134
|
+
const { filters, findAll, individualSearch, withValues: optWithValues } = options;
|
|
135
|
+
const { requirePrimaryKeys } = privateOptions || {};
|
|
136
|
+
const individualSearchEnabled =
|
|
137
|
+
typeof individualSearch !== 'undefined' ? individualSearch : this.defaultIndividualSearchEnabled;
|
|
138
|
+
const primaryKeyFiltersToForceCheck: GenericObject<boolean> = {};
|
|
139
|
+
const storeEntityKeys: string[] = [];
|
|
140
|
+
const withValues = typeof optWithValues === 'undefined' || optWithValues === true ? true : false;
|
|
141
|
+
let hasNonPrimaryKeyFilters = false;
|
|
142
|
+
let primaryKeyFiltersCount = 0;
|
|
143
|
+
if (filters && Object.keys(filters).length) {
|
|
144
|
+
// set up the construction of the store keys by primary keys
|
|
145
|
+
storeEntityKeys.push('');
|
|
146
|
+
primaryKeys.forEach(field => {
|
|
147
|
+
const value = filters[field];
|
|
148
|
+
if (typeof value !== 'undefined' && typeof value !== 'object' && (typeof value !== 'string' || value.length)) {
|
|
149
|
+
primaryKeyFiltersCount++;
|
|
150
|
+
storeEntityKeys.forEach((_key, keyIndex) => {
|
|
151
|
+
storeEntityKeys[keyIndex] += `${storeDelimiter}${value}`;
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (value instanceof Array) {
|
|
156
|
+
const finalValues: (string | number)[] = [];
|
|
157
|
+
value.forEach(valueItem => {
|
|
158
|
+
if (
|
|
159
|
+
(typeof valueItem === 'string' && !valueItem.length) ||
|
|
160
|
+
(typeof valueItem !== 'string' && typeof valueItem !== 'number')
|
|
161
|
+
) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
finalValues.push(valueItem);
|
|
165
|
+
});
|
|
166
|
+
if (finalValues.length) {
|
|
167
|
+
// TODO: this will only work if the previous keys haven't been arrays
|
|
168
|
+
if (individualSearchEnabled) {
|
|
169
|
+
if (storeEntityKeys.length <= 1) {
|
|
170
|
+
const baseStoreEntityKey = storeEntityKeys[0] || '';
|
|
171
|
+
primaryKeyFiltersCount++;
|
|
172
|
+
finalValues.forEach((finalValue, finalValueIndex) => {
|
|
173
|
+
const fullFinalValue = `${baseStoreEntityKey}${storeDelimiter}${finalValue}`;
|
|
174
|
+
if (typeof storeEntityKeys[finalValueIndex] === 'undefined') {
|
|
175
|
+
storeEntityKeys.push(fullFinalValue);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
storeEntityKeys[finalValueIndex] = fullFinalValue;
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
hasNonPrimaryKeyFilters = true;
|
|
184
|
+
primaryKeyFiltersToForceCheck[field] = true;
|
|
185
|
+
storeEntityKeys[0] += `${storeDelimiter}*`;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (requirePrimaryKeys) {
|
|
191
|
+
throw new ApplicationError(
|
|
192
|
+
`[RedisRepositoryService ${entityName}][Find Error]: ` +
|
|
193
|
+
`The primary key field ${field} is required when requirePrimaryKeys is set to true.`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (individualSearchEnabled) {
|
|
197
|
+
throw new ApplicationError(
|
|
198
|
+
`[RedisRepositoryService ${entityName}][Find Error]: ` +
|
|
199
|
+
`The primary key field ${field} is required when individualSearchEnabled ` +
|
|
200
|
+
'is set to true.'
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
storeEntityKeys[0] += `${storeDelimiter}*`;
|
|
204
|
+
});
|
|
205
|
+
if (!hasNonPrimaryKeyFilters) {
|
|
206
|
+
hasNonPrimaryKeyFilters = primaryKeyFiltersCount === Object.keys(filters).length;
|
|
207
|
+
}
|
|
208
|
+
} else if (!findAll) {
|
|
209
|
+
throw new ApplicationError(
|
|
210
|
+
`[RedisRepositoryService ${entityName}][Error]: ` +
|
|
211
|
+
'Either filters or findAll is required when calling the find method.'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
// findAll logic:
|
|
215
|
+
// if doing an inidividual search, go through the store keys one by one; all PKs are required;
|
|
216
|
+
// if doing a wildcard search, iterate a cursor through the whole database until returned to the start
|
|
217
|
+
if (findAll) {
|
|
218
|
+
if (individualSearchEnabled && !primaryKeyFiltersCount && primaryKeys.length) {
|
|
219
|
+
throw new ApplicationError(
|
|
220
|
+
`[RedisRepositoryService ${entityName}][Error]: ` +
|
|
221
|
+
'Primary key filters are required when findAll and individualSearchEnabled ' +
|
|
222
|
+
'are enabled in the find method.'
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
let initialResults: ResultItem[] = [];
|
|
226
|
+
// get the base results
|
|
227
|
+
if (individualSearchEnabled) {
|
|
228
|
+
initialResults = (await Promise.all(
|
|
229
|
+
storeEntityKeys.map(key => store.get(`${entityStoreKey}${key}`, { parseToJSON: true }))
|
|
230
|
+
)) as ResultItem[];
|
|
231
|
+
} else {
|
|
232
|
+
// TODO: if no filters are provided, this will not return anything
|
|
233
|
+
// TODO: (reply, some point later) WDYIM, Rumen?
|
|
234
|
+
const scanData = await store.scan(`${entityStoreKey}${storeEntityKeys[0]}`, {
|
|
235
|
+
parseToJSON: true,
|
|
236
|
+
scanAll: findAll,
|
|
237
|
+
withValues
|
|
238
|
+
});
|
|
239
|
+
initialResults = scanData.values as ResultItem[];
|
|
240
|
+
}
|
|
241
|
+
// filter the base results by inner keys, as well as retrieve items from arrays and nested items
|
|
242
|
+
return {
|
|
243
|
+
items: this.getValuesFromResults(initialResults, {
|
|
244
|
+
filters,
|
|
245
|
+
hasNonPrimaryKeyFilters,
|
|
246
|
+
primaryKeyFiltersToForceCheck
|
|
247
|
+
}).resultItems,
|
|
248
|
+
more: false
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// non-findAll logic:
|
|
252
|
+
// if doing an inidividual search, go through the store keys one by one; all PKs are required;
|
|
253
|
+
// if doing a wildcard search, iterate a cursor through the whole database until the pagination end is reached
|
|
254
|
+
const { page, perPage } = options || {};
|
|
255
|
+
// for non-individual search, we'll only have the first key anyway
|
|
256
|
+
// TODO: check whether the above is true nad apply it to findAll if it isn't
|
|
257
|
+
const [storeEntityKey] = storeEntityKeys;
|
|
258
|
+
const count: number = perPage || 100;
|
|
259
|
+
const limit = count + 1;
|
|
260
|
+
let cursor = (page ? page - 1 : 0) * count;
|
|
261
|
+
let more = false;
|
|
262
|
+
let results: ResultItem[] = [];
|
|
263
|
+
while (results.length < limit) {
|
|
264
|
+
let endReached = false;
|
|
265
|
+
let iterationResults: ResultItem[] = [];
|
|
266
|
+
// get the base results
|
|
267
|
+
if (individualSearchEnabled) {
|
|
268
|
+
const iterationLimit = cursor + limit;
|
|
269
|
+
const iterationPromises: Promise<ResultItem>[] = [];
|
|
270
|
+
for (let i = cursor; i < iterationLimit; i++) {
|
|
271
|
+
const key = storeEntityKeys[i];
|
|
272
|
+
if (!key) {
|
|
273
|
+
endReached = true;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
iterationPromises.push(store.get(`${entityStoreKey}${key}`, { parseToJSON: true }));
|
|
277
|
+
}
|
|
278
|
+
iterationResults = (await Promise.all(iterationPromises)) as ResultItem[];
|
|
279
|
+
cursor = iterationLimit;
|
|
280
|
+
} else {
|
|
281
|
+
const { cursor: newCursor, values: innerResults } = await store.scan(`${entityStoreKey}${storeEntityKey}`, {
|
|
282
|
+
count,
|
|
283
|
+
cursor,
|
|
284
|
+
parseToJSON: true,
|
|
285
|
+
scanAll: false,
|
|
286
|
+
withValues
|
|
287
|
+
});
|
|
288
|
+
iterationResults = innerResults as ResultItem[];
|
|
289
|
+
if (newCursor === 0) {
|
|
290
|
+
endReached = true;
|
|
291
|
+
} else {
|
|
292
|
+
cursor = newCursor;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// filter the base results by inner keys, as well as retrieve items from arrays and nested items;
|
|
296
|
+
// the beauty of this approach is that it follows the rules of the pagination regardless of whether
|
|
297
|
+
// the results are coming from nested items or not
|
|
298
|
+
results = results.concat(
|
|
299
|
+
this.getValuesFromResults(iterationResults, {
|
|
300
|
+
filters,
|
|
301
|
+
hasNonPrimaryKeyFilters,
|
|
302
|
+
primaryKeyFiltersToForceCheck
|
|
303
|
+
}).resultItems
|
|
304
|
+
);
|
|
305
|
+
if (endReached) {
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// determine whether this is the end of the pagination
|
|
310
|
+
if (results.length > count) {
|
|
311
|
+
more = true;
|
|
312
|
+
results = results.slice(0, count);
|
|
313
|
+
}
|
|
314
|
+
return { items: results, more };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
protected filterItem<Item>(item: Item, filters: GenericObject<unknown>, options?: FilterItemOptions): boolean {
|
|
318
|
+
if (typeof item === 'undefined' || item === null) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
const { keysToSkip, skippableKeysToForceCheck } = options || {};
|
|
322
|
+
let filterResult = true;
|
|
323
|
+
for (const key in filters) {
|
|
324
|
+
if (keysToSkip?.[key] && !skippableKeysToForceCheck?.[key]) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const filterValue = filters[key];
|
|
328
|
+
const itemValue = (item as GenericObject<unknown>)[key];
|
|
329
|
+
if (filterValue instanceof Array) {
|
|
330
|
+
if (!filterValue.includes(itemValue)) {
|
|
331
|
+
filterResult = false;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
// TODO: filter operators
|
|
337
|
+
if (filterValue !== itemValue) {
|
|
338
|
+
filterResult = false;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return filterResult;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// TODO: reduce the large numbers of whole-array iterations by combinging the array methods used
|
|
346
|
+
// here into a big for-loop
|
|
347
|
+
protected getValuesFromResults<ResultItem>(
|
|
348
|
+
inputData: ResultItem[],
|
|
349
|
+
options?: GetValuesFromResultsOptions
|
|
350
|
+
): { indexes: number[]; resultItems: ResultItem[] } {
|
|
351
|
+
const { primaryKeysMap, schema } = this;
|
|
352
|
+
const { isArray, nestedObjectContainerPath } = schema;
|
|
353
|
+
const { filters, flattenArray, hasNonPrimaryKeyFilters, primaryKeyFiltersToForceCheck } = options || {};
|
|
354
|
+
const filteredResultIndexes: number[] = [];
|
|
355
|
+
const filteredResults: ResultItem[] = [];
|
|
356
|
+
let initialResults = [...inputData];
|
|
357
|
+
if (nestedObjectContainerPath) {
|
|
358
|
+
initialResults = initialResults.map(item => {
|
|
359
|
+
if (item && typeof item === 'object' && !(item instanceof Date)) {
|
|
360
|
+
return getNested(item, nestedObjectContainerPath, { removeNestedFieldEscapeSign: true }).unifiedValue;
|
|
361
|
+
}
|
|
362
|
+
return item;
|
|
363
|
+
}) as ResultItem[];
|
|
364
|
+
}
|
|
365
|
+
// TODO: account for the indexes when flattenning
|
|
366
|
+
if (isArray && (flattenArray || typeof flattenArray === 'undefined')) {
|
|
367
|
+
initialResults = initialResults.flat() as ResultItem[];
|
|
368
|
+
}
|
|
369
|
+
if (!hasNonPrimaryKeyFilters || !filters) {
|
|
370
|
+
initialResults.forEach((resultItem, resultItemIndex) => {
|
|
371
|
+
if (typeof resultItem !== 'undefined' && resultItem !== null) {
|
|
372
|
+
filteredResultIndexes.push(resultItemIndex);
|
|
373
|
+
filteredResults.push(resultItem);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
} else {
|
|
377
|
+
// filter by the results' object data
|
|
378
|
+
initialResults.forEach((resultItem, resultItemIndex) => {
|
|
379
|
+
const filtered = this.filterItem<ResultItem>(resultItem, filters, {
|
|
380
|
+
keysToSkip: primaryKeysMap,
|
|
381
|
+
skippableKeysToForceCheck: primaryKeyFiltersToForceCheck
|
|
382
|
+
});
|
|
383
|
+
if (filtered) {
|
|
384
|
+
filteredResultIndexes.push(resultItemIndex);
|
|
385
|
+
filteredResults.push(resultItem);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return { indexes: filteredResultIndexes, resultItems: filteredResults };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
protected async prepare(
|
|
393
|
+
data: Entity | Entity[],
|
|
394
|
+
options?: PrepareOptions
|
|
395
|
+
): Promise<{ data: Entity | Entity[]; storeEntityKey: string }> {
|
|
396
|
+
const { columnNames, primaryKeys, schema, store, storeDelimiter } = this;
|
|
397
|
+
const { columns, isArray, name: entityName, storeKey: entityStoreKey } = schema;
|
|
398
|
+
const opt = options || ({} as PrepareOptions);
|
|
399
|
+
const { generatePrimaryKeys, onConflict: optOnConflict, validate: optValidate } = opt;
|
|
400
|
+
const onConflict = optOnConflict || SaveOptionsOnConflict.ThrowError;
|
|
401
|
+
let allPKValuesExist = true;
|
|
402
|
+
let preparedData = ld.cloneDeep(data) as GenericObject | GenericObject[];
|
|
403
|
+
let storeEntityKey = '';
|
|
404
|
+
const preparedDataForPrimaryKeyFilters = (isArray && data instanceof Array ? data[0] : data) as GenericObject;
|
|
405
|
+
// set up the construction of the store keys by primary keys
|
|
406
|
+
// additionally, perform the generation of primary keys, if doing a create opearation
|
|
407
|
+
for (const columnName of primaryKeys) {
|
|
408
|
+
const { generated, type } = columns[columnName];
|
|
409
|
+
const value = preparedDataForPrimaryKeyFilters[columnName];
|
|
410
|
+
const valueExists = !(
|
|
411
|
+
typeof value === 'undefined' ||
|
|
412
|
+
(typeof value === 'string' && !value.length) ||
|
|
413
|
+
typeof value === 'object'
|
|
414
|
+
);
|
|
415
|
+
if (generated) {
|
|
416
|
+
if (valueExists) {
|
|
417
|
+
storeEntityKey += `${value}${storeDelimiter}`;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (allPKValuesExist) {
|
|
421
|
+
allPKValuesExist = false;
|
|
422
|
+
}
|
|
423
|
+
if (!generatePrimaryKeys || isArray) {
|
|
424
|
+
throw new ApplicationError(
|
|
425
|
+
`[RedisRepositoryService ${entityName}][Validation Error]: ` +
|
|
426
|
+
`A value is required for generated PK column ${columnName} when the generatePrimaryKeys is set to false ` +
|
|
427
|
+
'or isArray is set to true.'
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (type === EntitySchemaColumnType.Integer) {
|
|
431
|
+
let currentMaxValue =
|
|
432
|
+
(await store.get<number>(`${entityStoreKey}${storeDelimiter}increment${storeDelimiter}${columnName}`, {
|
|
433
|
+
parseToJSON: true
|
|
434
|
+
})) || 0;
|
|
435
|
+
currentMaxValue++;
|
|
436
|
+
await store.set(`${entityStoreKey}${storeDelimiter}increment${storeDelimiter}${columnName}`, currentMaxValue);
|
|
437
|
+
preparedDataForPrimaryKeyFilters[columnName] = currentMaxValue;
|
|
438
|
+
storeEntityKey += `${currentMaxValue}${storeDelimiter}`;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (type === EntitySchemaColumnType.UUIDV4) {
|
|
442
|
+
let newValue = uuid();
|
|
443
|
+
if (storeDelimiter === '-') {
|
|
444
|
+
newValue = newValue.replace(/-/g, '_');
|
|
445
|
+
}
|
|
446
|
+
(preparedData as GenericObject)[columnName] = newValue;
|
|
447
|
+
storeEntityKey += `${newValue}${storeDelimiter}`;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
throw new ApplicationError(
|
|
451
|
+
`[RedisRepositoryService ${entityName}][Validation Error]: ` +
|
|
452
|
+
`Unrecognized type "${type}" for PK column ${columnName}`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (!valueExists) {
|
|
456
|
+
throw new ApplicationError(
|
|
457
|
+
`[RedisRepositoryService ${entityName}][Validation Error]: ` +
|
|
458
|
+
`A value is required for non-generated PK column ${columnName}`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
storeEntityKey += `${value}${storeDelimiter}`;
|
|
462
|
+
}
|
|
463
|
+
if (storeEntityKey.endsWith(storeDelimiter)) {
|
|
464
|
+
storeEntityKey = storeEntityKey.substring(0, storeEntityKey.length - storeDelimiter.length);
|
|
465
|
+
}
|
|
466
|
+
if (optValidate) {
|
|
467
|
+
const validationErrors = await validate(entityName, data as GenericObject<unknown>);
|
|
468
|
+
if (validationErrors.length) {
|
|
469
|
+
throw new ApplicationError(
|
|
470
|
+
`[RedisRepositoryService ${entityName}][Validation Error]: ${validationErrors.join('\n')}`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// TODO: make cases other than SaveOptionsOnConflict.DoNothing work with isArray
|
|
475
|
+
if ((onConflict !== SaveOptionsOnConflict.DoNothing || isArray) && allPKValuesExist) {
|
|
476
|
+
const hasValue = await store.get<string | undefined>(storeEntityKey, { withValues: false });
|
|
477
|
+
if (hasValue) {
|
|
478
|
+
if (onConflict === SaveOptionsOnConflict.ThrowError) {
|
|
479
|
+
throw new ApplicationError(
|
|
480
|
+
`[RedisRepositoryService ${entityName}][Unique Error]: An entry already exists for key ${storeEntityKey}.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const existingData = await store.get<GenericObject<unknown> | GenericObject<unknown>[]>(storeEntityKey, {
|
|
484
|
+
parseToJSON: true
|
|
485
|
+
});
|
|
486
|
+
if (onConflict === SaveOptionsOnConflict.Update) {
|
|
487
|
+
// TODO: make this work using getValuesFromResults
|
|
488
|
+
preparedData = ld.merge(existingData, preparedData);
|
|
489
|
+
} else if (onConflict === SaveOptionsOnConflict.DoNothing && isArray) {
|
|
490
|
+
if (existingData instanceof Array && existingData.length) {
|
|
491
|
+
const innerFilters: GenericObject = {};
|
|
492
|
+
columnNames.forEach(fieldName => {
|
|
493
|
+
const fieldValue = data[fieldName as keyof typeof data];
|
|
494
|
+
if (
|
|
495
|
+
typeof fieldValue !== 'undefined' &&
|
|
496
|
+
fieldValue !== null &&
|
|
497
|
+
(typeof fieldValue !== 'string' || fieldValue.length)
|
|
498
|
+
) {
|
|
499
|
+
innerFilters[fieldName] = fieldValue;
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
if (!Object.keys(innerFilters).length) {
|
|
503
|
+
throw new ApplicationError(
|
|
504
|
+
`[RedisRepositoryService ${entityName}][Execution Error]: ` +
|
|
505
|
+
'Inner filters are required when search inside nested arrays.'
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
const innerData = this.getValuesFromResults(existingData, {
|
|
509
|
+
filters: innerFilters,
|
|
510
|
+
flattenArray: false
|
|
511
|
+
});
|
|
512
|
+
if (innerData.resultItems.length) {
|
|
513
|
+
innerData.resultItems.forEach((resultItem, resultItemIndex) => {
|
|
514
|
+
ld.set(existingData, innerData.indexes[resultItemIndex], resultItem);
|
|
515
|
+
});
|
|
516
|
+
preparedData = existingData;
|
|
517
|
+
} else {
|
|
518
|
+
preparedData = existingData.concat(preparedData instanceof Array ? preparedData : [preparedData]);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// WARNING: this disregards the current values if they're not an array
|
|
522
|
+
else if (!(preparedData instanceof Array)) {
|
|
523
|
+
preparedData = [preparedData];
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
throw new ApplicationError(
|
|
527
|
+
`[RedisRepositoryService ${entityName}][Execution Error]: ` +
|
|
528
|
+
`Invalid value "${onConflict}" provided for onConflict.`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return { data: preparedData as Entity | Entity[], storeEntityKey };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async save<ResultItem extends Entity | string = Entity>(
|
|
537
|
+
data: Entity | Entity[],
|
|
538
|
+
options?: SaveOptions
|
|
539
|
+
): Promise<ResultItem[]> {
|
|
540
|
+
const { defaultTTL, experimentalDeletionEnabled, innerPrimaryKeys, primaryKeysMap, schema, store, storeDelimiter } =
|
|
541
|
+
this;
|
|
542
|
+
const { isArray, nestedObjectContainerPath, storeKey: entityStoreKey } = schema;
|
|
543
|
+
const {
|
|
544
|
+
delete: optDelete,
|
|
545
|
+
generatePrimaryKeys,
|
|
546
|
+
onConflict,
|
|
547
|
+
transactionId,
|
|
548
|
+
ttl,
|
|
549
|
+
validate
|
|
550
|
+
} = options || ({} as SaveOptions);
|
|
551
|
+
const actualData = data instanceof Array ? data : [data];
|
|
552
|
+
if (optDelete) {
|
|
553
|
+
const prepareOptions: PrepareOptions = {
|
|
554
|
+
generatePrimaryKeys: false,
|
|
555
|
+
onConflict: SaveOptionsOnConflict.DoNothing,
|
|
556
|
+
validate: false
|
|
557
|
+
};
|
|
558
|
+
const deleteKeys: string[] = [];
|
|
559
|
+
for (const i in actualData) {
|
|
560
|
+
deleteKeys.push(
|
|
561
|
+
`${entityStoreKey}${storeDelimiter}${(await this.prepare(actualData[i], prepareOptions)).storeEntityKey}`
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
// delete from arrays and nestedObjects;
|
|
565
|
+
// this is kind of a repeat of find, but with the key paths included and with dedicated
|
|
566
|
+
// filtering by inner primary keys
|
|
567
|
+
if (
|
|
568
|
+
experimentalDeletionEnabled &&
|
|
569
|
+
(isArray || nestedObjectContainerPath) &&
|
|
570
|
+
innerPrimaryKeys.length &&
|
|
571
|
+
deleteKeys.length
|
|
572
|
+
) {
|
|
573
|
+
const results = await Promise.all(deleteKeys.map(key => store.get<ResultItem>(key, { parseToJSON: true })));
|
|
574
|
+
// const deletePromises: Promise<unknown>[] = [];
|
|
575
|
+
const newResults: ResultItem[] = [];
|
|
576
|
+
results.forEach((resultItem, resultItemIndex) => {
|
|
577
|
+
if (!resultItem || typeof resultItem !== 'object' || resultItem instanceof Date) {
|
|
578
|
+
newResults.push(resultItem);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
let innerPaths: string[] = [];
|
|
582
|
+
let innerValues: ResultItem[] = [];
|
|
583
|
+
if (nestedObjectContainerPath) {
|
|
584
|
+
const { paths, values } = getNested(resultItem, nestedObjectContainerPath, {
|
|
585
|
+
removeNestedFieldEscapeSign: true
|
|
586
|
+
});
|
|
587
|
+
innerPaths = paths;
|
|
588
|
+
innerValues = values as ResultItem[];
|
|
589
|
+
// TODO: combine with isArray
|
|
590
|
+
} else if (isArray) {
|
|
591
|
+
innerValues = resultItem as ResultItem[];
|
|
592
|
+
} else {
|
|
593
|
+
innerValues = [resultItem];
|
|
594
|
+
}
|
|
595
|
+
// TODO: complete this logic
|
|
596
|
+
innerValues.forEach((innerValue, innerValueIndex) => {
|
|
597
|
+
const shouldDelete = this.filterItem<ResultItem>(
|
|
598
|
+
innerValue,
|
|
599
|
+
actualData[resultItemIndex] as GenericObject<unknown>,
|
|
600
|
+
{
|
|
601
|
+
keysToSkip: primaryKeysMap
|
|
602
|
+
}
|
|
603
|
+
);
|
|
604
|
+
if (!shouldDelete) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (innerPaths[innerValueIndex]) {
|
|
608
|
+
setNested(results[resultItemIndex], innerPaths[innerValueIndex], undefined, {
|
|
609
|
+
removeNestedFieldEscapeSign: true
|
|
610
|
+
});
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (isArray) {
|
|
614
|
+
setNested(results, `${resultItemIndex}`, (resultItem as ResultItem[]).splice(innerValueIndex, 1), {
|
|
615
|
+
removeNestedFieldEscapeSign: true
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
results.splice(resultItemIndex, 1);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
// default use case - regular people storing regular objects in redis
|
|
625
|
+
if (deleteKeys.length) {
|
|
626
|
+
await store.delete(deleteKeys, { transactionId });
|
|
627
|
+
}
|
|
628
|
+
return deleteKeys as ResultItem[];
|
|
629
|
+
}
|
|
630
|
+
// TODO: create and update in arrays and nestedObjects
|
|
631
|
+
// TODO: differenatiate between create and update based on generatePrimaryKeys
|
|
632
|
+
const prepareOptions: PrepareOptions = {
|
|
633
|
+
generatePrimaryKeys,
|
|
634
|
+
onConflict,
|
|
635
|
+
validate
|
|
636
|
+
};
|
|
637
|
+
let results: Entity[] = [];
|
|
638
|
+
if (isArray) {
|
|
639
|
+
const { data: validatedEntity, storeEntityKey } = await this.prepare(actualData, prepareOptions);
|
|
640
|
+
await store.set(`${entityStoreKey}${storeDelimiter}${storeEntityKey}`, validatedEntity, {
|
|
641
|
+
transactionId,
|
|
642
|
+
ttl: ttl || defaultTTL
|
|
643
|
+
});
|
|
644
|
+
results = validatedEntity as Entity[];
|
|
645
|
+
} else {
|
|
646
|
+
for (const i in actualData) {
|
|
647
|
+
const { data: validatedEntity, storeEntityKey } = await this.prepare(actualData[i], prepareOptions);
|
|
648
|
+
await store.set(`${entityStoreKey}${storeDelimiter}${storeEntityKey}`, validatedEntity, {
|
|
649
|
+
transactionId,
|
|
650
|
+
ttl: ttl || defaultTTL
|
|
651
|
+
});
|
|
652
|
+
results.push(validatedEntity as Entity);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return results as ResultItem[];
|
|
656
|
+
}
|
|
657
|
+
}
|