@squiz/db-lib 1.71.3 → 1.73.0
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +17 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts +62 -9
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.js +162 -19
- package/lib/dynamodb/AbstractDynamoDbRepository.js.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js +581 -31
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js.map +1 -1
- package/package.json +2 -2
- package/src/dynamodb/AbstractDynamoDbRepository.spec.ts +642 -37
- package/src/dynamodb/AbstractDynamoDbRepository.ts +206 -31
- package/tsconfig.tsbuildinfo +1 -1
@@ -4,20 +4,45 @@ import {
|
|
4
4
|
UpdateCommandOutput,
|
5
5
|
PutCommandInput,
|
6
6
|
DeleteCommandInput,
|
7
|
+
BatchWriteCommandInput,
|
8
|
+
BatchWriteCommandOutput,
|
9
|
+
BatchGetCommandInput,
|
10
|
+
BatchGetCommandOutput,
|
7
11
|
} from '@aws-sdk/lib-dynamodb';
|
8
12
|
|
9
13
|
import { Transaction, DynamoDbManager, MissingKeyValuesError, InvalidDbSchemaError } from '..';
|
10
14
|
import { InvalidDataFormatError } from '../error/InvalidDataFormatError';
|
11
15
|
|
16
|
+
export type QueryFilterTypeBeginsWith = {
|
17
|
+
type: 'begins_with';
|
18
|
+
keyword: string;
|
19
|
+
};
|
20
|
+
|
21
|
+
export type QueryOptions = {
|
22
|
+
// whether to match sort key along with the partition key
|
23
|
+
// if set to true this will return 1 item maximum
|
24
|
+
useSortKey?: boolean;
|
25
|
+
// table index to use (one of tables GSIs)
|
26
|
+
index?: keyof TableIndexes;
|
27
|
+
// number of items to limit in the result
|
28
|
+
limit?: number;
|
29
|
+
// result order based on the sort key
|
30
|
+
order?: 'desc' | 'asc';
|
31
|
+
// filter operation on the sort key
|
32
|
+
filter?: QueryFilterTypeBeginsWith;
|
33
|
+
};
|
34
|
+
|
12
35
|
interface Reader<T> {
|
13
|
-
queryItems(partialItem: Partial<T>,
|
36
|
+
queryItems(partialItem: Partial<T>, options?: QueryOptions): Promise<T[]>;
|
14
37
|
getItem(id: string | Partial<T>): Promise<T | undefined>;
|
38
|
+
getItems(partialItem: Partial<T>[]): Promise<T[]>;
|
15
39
|
}
|
16
40
|
|
17
41
|
interface Writer<T> {
|
18
42
|
createItem(item: Partial<T>): Promise<T>;
|
19
|
-
updateItem(partialItem: Partial<T
|
43
|
+
updateItem(partialItem: Partial<T>): Promise<T | undefined>;
|
20
44
|
deleteItem(partialItem: Partial<T>): Promise<number>;
|
45
|
+
deleteItems(partialItem: Partial<T>[]): Promise<void>;
|
21
46
|
}
|
22
47
|
|
23
48
|
type Repository<T> = Reader<T> & Writer<T>;
|
@@ -45,6 +70,8 @@ export type EntityDefinition = {
|
|
45
70
|
fieldsAsJsonString: string[];
|
46
71
|
};
|
47
72
|
|
73
|
+
const MAX_REATTEMPTS = 3;
|
74
|
+
|
48
75
|
export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLASS extends SHAPE>
|
49
76
|
implements Reader<SHAPE>, Writer<SHAPE>
|
50
77
|
{
|
@@ -104,20 +131,127 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
104
131
|
return this.hydrateItem(output.Item);
|
105
132
|
}
|
106
133
|
|
134
|
+
/**
|
135
|
+
* Get the single item each matching the key fields value in the given
|
136
|
+
* partial items. Will throw MissingKeyValuesError if key field values
|
137
|
+
* are missing
|
138
|
+
* Uses batchGet() to request 100 items in a batch
|
139
|
+
*
|
140
|
+
* @param item
|
141
|
+
*
|
142
|
+
* @throws MissingKeyValuesError
|
143
|
+
*/
|
144
|
+
public async getItems(items: Partial<SHAPE>[]): Promise<DATA_CLASS[]> {
|
145
|
+
// this is the maximum items allowed by BatchGetItem()
|
146
|
+
const batchSize = 100;
|
147
|
+
|
148
|
+
let result: DATA_CLASS[] = [];
|
149
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
150
|
+
const batchResult = await this.getBatchItems(items.slice(i, i + batchSize));
|
151
|
+
result = result.concat(batchResult);
|
152
|
+
}
|
153
|
+
return result;
|
154
|
+
}
|
155
|
+
|
156
|
+
/**
|
157
|
+
* Returns the batch items from the items primary key
|
158
|
+
*
|
159
|
+
* @param items
|
160
|
+
* @returns
|
161
|
+
*/
|
162
|
+
private async getBatchItems(items: Partial<SHAPE>[]): Promise<DATA_CLASS[]> {
|
163
|
+
let resultItems: DATA_CLASS[] = [];
|
164
|
+
|
165
|
+
let requestKeys: BatchGetCommandInput['RequestItems'] = {
|
166
|
+
[this.tableName]: {
|
167
|
+
Keys: this.getBatchKeys(items),
|
168
|
+
},
|
169
|
+
};
|
170
|
+
let reattemptsCount = 0;
|
171
|
+
while (requestKeys && Object.keys(requestKeys).length) {
|
172
|
+
if (reattemptsCount++ > MAX_REATTEMPTS) {
|
173
|
+
throw Error('Maximum allowed retries exceeded for unprocessed items');
|
174
|
+
}
|
175
|
+
const output: BatchGetCommandOutput = await this.client.batchGet({
|
176
|
+
RequestItems: requestKeys,
|
177
|
+
});
|
178
|
+
requestKeys = output.UnprocessedKeys;
|
179
|
+
if (output.Responses && output.Responses[this.tableName] && output.Responses[this.tableName].length) {
|
180
|
+
resultItems = resultItems.concat(output.Responses[this.tableName].map((i) => this.hydrateItem(i)));
|
181
|
+
}
|
182
|
+
}
|
183
|
+
|
184
|
+
return resultItems;
|
185
|
+
}
|
186
|
+
|
187
|
+
/**
|
188
|
+
* Delete items in a batch
|
189
|
+
* Uses batchWrite() with 25 items
|
190
|
+
*
|
191
|
+
* @param item
|
192
|
+
*
|
193
|
+
* @throws MissingKeyValuesError
|
194
|
+
*/
|
195
|
+
public async deleteItems(items: Partial<SHAPE>[]): Promise<void> {
|
196
|
+
// this is the maximum items allowed by BatchWriteItem()
|
197
|
+
const batchSize = 25;
|
198
|
+
|
199
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
200
|
+
const keys = this.getBatchKeys(items.slice(i, i + batchSize));
|
201
|
+
await this.deleteBatchItems(keys);
|
202
|
+
}
|
203
|
+
}
|
204
|
+
|
205
|
+
private async deleteBatchItems(keys: { [key: string]: string }[]): Promise<void> {
|
206
|
+
let requestItems: BatchWriteCommandInput['RequestItems'] = {
|
207
|
+
[this.tableName]: Object.values(keys).map((key) => {
|
208
|
+
return {
|
209
|
+
DeleteRequest: {
|
210
|
+
Key: key,
|
211
|
+
},
|
212
|
+
};
|
213
|
+
}),
|
214
|
+
};
|
215
|
+
let reattemptsCount = 0;
|
216
|
+
while (requestItems && Object.keys(requestItems).length) {
|
217
|
+
if (reattemptsCount++ > MAX_REATTEMPTS) {
|
218
|
+
throw Error('Maximum allowed retries exceeded for unprocessed items');
|
219
|
+
}
|
220
|
+
const response: BatchWriteCommandOutput = await this.client.batchWrite({
|
221
|
+
RequestItems: requestItems,
|
222
|
+
});
|
223
|
+
requestItems = response.UnprocessedItems;
|
224
|
+
}
|
225
|
+
}
|
226
|
+
|
227
|
+
private getBatchKeys(items: Partial<SHAPE>[]) {
|
228
|
+
const keys: { [key: string]: string }[] = [];
|
229
|
+
for (const item of items) {
|
230
|
+
keys.push({
|
231
|
+
[this.keys.pk.attributeName]: this.getPk(item),
|
232
|
+
[this.keys.sk.attributeName]: this.getSk(item),
|
233
|
+
});
|
234
|
+
}
|
235
|
+
// keys.push({
|
236
|
+
// [this.keys.pk.attributeName]: 'foo1',
|
237
|
+
// [this.keys.sk.attributeName]: 'foo2',
|
238
|
+
// });
|
239
|
+
|
240
|
+
return keys;
|
241
|
+
}
|
242
|
+
|
107
243
|
/**
|
108
244
|
* Finds all the items matching the partition key or
|
109
245
|
* the gsi key (when gsi index name is specified)
|
110
246
|
*
|
111
247
|
* @param item
|
112
|
-
* @param
|
113
|
-
* @param index
|
248
|
+
* @param options
|
114
249
|
* @throws MissingKeyValuesError
|
115
250
|
*/
|
116
|
-
public async queryItems(
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
): Promise<DATA_CLASS[]> {
|
251
|
+
public async queryItems(item: Partial<SHAPE>, options?: QueryOptions): Promise<DATA_CLASS[]> {
|
252
|
+
const useSortKey = options?.useSortKey || options?.filter;
|
253
|
+
const index = options?.index;
|
254
|
+
|
121
255
|
let pkName = this.keys.pk.attributeName;
|
122
256
|
let skName = this.keys.sk.attributeName;
|
123
257
|
let indexName = null;
|
@@ -136,8 +270,8 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
136
270
|
const expressionAttributeNames: Record<string, string> = { '#pkName': pkName };
|
137
271
|
const expressionAttributeValues: Record<string, unknown> = { ':pkValue': pk };
|
138
272
|
if (useSortKey) {
|
139
|
-
const sk = this.getKey(item, skName);
|
140
|
-
keyConditionExpression.push(
|
273
|
+
const sk = options?.filter?.keyword ?? this.getKey(item, skName);
|
274
|
+
keyConditionExpression.push(this.getFilterKeyConditionExpression(options?.filter));
|
141
275
|
expressionAttributeNames['#skName'] = skName;
|
142
276
|
expressionAttributeValues[':skValue'] = sk;
|
143
277
|
}
|
@@ -151,27 +285,43 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
151
285
|
if (indexName) {
|
152
286
|
queryCommandInput['IndexName'] = String(indexName);
|
153
287
|
}
|
154
|
-
const output = await this.client.query(queryCommandInput);
|
155
288
|
|
289
|
+
if (options?.order !== undefined) {
|
290
|
+
queryCommandInput.ScanIndexForward = options.order === 'asc';
|
291
|
+
}
|
292
|
+
if (options?.limit !== undefined) {
|
293
|
+
queryCommandInput.Limit = options.limit;
|
294
|
+
}
|
295
|
+
|
296
|
+
const output = await this.client.query(queryCommandInput);
|
156
297
|
return !output.Items ? [] : output.Items.map((item) => this.hydrateItem(item));
|
157
298
|
}
|
158
299
|
|
300
|
+
/**
|
301
|
+
* Evaluate filter condition for sort key
|
302
|
+
* @param filter
|
303
|
+
* @returns string
|
304
|
+
*/
|
305
|
+
private getFilterKeyConditionExpression(filter: QueryOptions['filter']): string {
|
306
|
+
if (filter === undefined) {
|
307
|
+
return '#skName = :skValue';
|
308
|
+
} else if (filter.type === 'begins_with') {
|
309
|
+
return 'begins_with(#skName, :skValue)';
|
310
|
+
}
|
311
|
+
throw new Error(`Invalid query filter type: ${(filter as any)?.type}`);
|
312
|
+
}
|
313
|
+
|
159
314
|
/**
|
160
315
|
* Update the existing item matching the key fields value
|
161
|
-
* in the passed
|
162
|
-
* @param partialItem
|
316
|
+
* in the passed newValue
|
163
317
|
* @param newValue
|
164
318
|
* @param transaction
|
165
319
|
*
|
166
320
|
* @returns Promise<SHAPE | undefined>
|
167
321
|
* @throws MissingKeyValuesError
|
168
322
|
*/
|
169
|
-
public async updateItem(
|
170
|
-
|
171
|
-
newValue: Exclude<Partial<SHAPE>, Record<string, never>>,
|
172
|
-
transaction: Transaction = {},
|
173
|
-
): Promise<DATA_CLASS | undefined> {
|
174
|
-
const oldValue = await this.getItem(partialItem);
|
323
|
+
public async updateItem(newValue: Partial<SHAPE>, transaction: Transaction = {}): Promise<DATA_CLASS | undefined> {
|
324
|
+
const oldValue = await this.getItem(newValue);
|
175
325
|
if (oldValue === undefined) {
|
176
326
|
return undefined;
|
177
327
|
}
|
@@ -185,20 +335,29 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
185
335
|
const expressionAttributeNames: Record<string, string> = {};
|
186
336
|
const expressionAttributeValues: Record<string, unknown> = {};
|
187
337
|
for (const modelProperty of Object.keys(newValue)) {
|
338
|
+
const propValue = newValue[modelProperty as keyof SHAPE] ?? null;
|
339
|
+
if (propValue === oldValue[modelProperty as keyof SHAPE]) {
|
340
|
+
// don't need to update the properties that are unchanged
|
341
|
+
continue;
|
342
|
+
}
|
343
|
+
|
188
344
|
const propName = `#${modelProperty}`;
|
189
|
-
const
|
345
|
+
const propValuePlaceHolder = `:${modelProperty}`;
|
190
346
|
|
191
|
-
updateExpression.push(`${propName} = ${
|
347
|
+
updateExpression.push(`${propName} = ${propValuePlaceHolder}`);
|
192
348
|
expressionAttributeNames[propName] = modelProperty;
|
193
|
-
|
194
|
-
|
349
|
+
expressionAttributeValues[propValuePlaceHolder] = propValue;
|
350
|
+
}
|
351
|
+
if (!updateExpression.length) {
|
352
|
+
// nothing to update
|
353
|
+
return value;
|
195
354
|
}
|
196
355
|
|
197
356
|
const updateCommandInput = {
|
198
357
|
TableName: this.tableName,
|
199
358
|
Key: {
|
200
|
-
[this.keys.pk.attributeName]: this.getPk(
|
201
|
-
[this.keys.sk.attributeName]: this.getSk(
|
359
|
+
[this.keys.pk.attributeName]: this.getPk(newValue),
|
360
|
+
[this.keys.sk.attributeName]: this.getSk(newValue),
|
202
361
|
},
|
203
362
|
UpdateExpression: 'SET ' + updateExpression.join(','),
|
204
363
|
ExpressionAttributeValues: expressionAttributeValues,
|
@@ -240,12 +399,17 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
240
399
|
*
|
241
400
|
* @param value
|
242
401
|
* @param transaction
|
402
|
+
* @param additionalValue Additional item properties that are not part of the DATA_CLASS
|
243
403
|
*
|
244
404
|
* @returns Promise<SHAPE>
|
245
405
|
* @throws DuplicateItemError
|
246
406
|
* @throws MissingKeyValuesError
|
247
407
|
*/
|
248
|
-
public async createItem(
|
408
|
+
public async createItem(
|
409
|
+
value: DATA_CLASS,
|
410
|
+
transaction: Transaction = {},
|
411
|
+
additionalValue: Partial<SHAPE> = {},
|
412
|
+
): Promise<DATA_CLASS> {
|
249
413
|
this.assertValueMatchesModel(value);
|
250
414
|
|
251
415
|
const columns: any = {};
|
@@ -255,14 +419,14 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
255
419
|
this.convertSelectedValuesToJsonString(columns);
|
256
420
|
|
257
421
|
const keyFields: Record<string, unknown> = {
|
258
|
-
[this.keys.pk.attributeName]: this.getPk(value),
|
259
|
-
[this.keys.sk.attributeName]: this.getSk(value),
|
422
|
+
[this.keys.pk.attributeName]: this.getPk({ ...value, ...additionalValue }),
|
423
|
+
[this.keys.sk.attributeName]: this.getSk({ ...value, ...additionalValue }),
|
260
424
|
};
|
261
425
|
|
262
426
|
Object.keys(this.indexes).forEach((key) => {
|
263
427
|
const index = this.indexes[key];
|
264
|
-
keyFields[index.pk.attributeName] = this.getKey(value, index.pk.attributeName);
|
265
|
-
keyFields[index.sk.attributeName] = this.getKey(value, index.sk.attributeName);
|
428
|
+
keyFields[index.pk.attributeName] = this.getKey({ ...value, ...additionalValue }, index.pk.attributeName);
|
429
|
+
keyFields[index.sk.attributeName] = this.getKey({ ...value, ...additionalValue }, index.sk.attributeName);
|
266
430
|
});
|
267
431
|
|
268
432
|
const putCommandInput: PutCommandInput = {
|
@@ -447,6 +611,17 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
447
611
|
return keyFormat;
|
448
612
|
}
|
449
613
|
|
614
|
+
/**
|
615
|
+
* Whether the given property name is part of the entity's pk/sk string
|
616
|
+
* @param propertyName
|
617
|
+
* @returns boolean
|
618
|
+
*/
|
619
|
+
private isPropertyPartOfKeys(propertyName: string) {
|
620
|
+
if (this.keysFormat[this.keys.pk.attributeName].search(`{${propertyName}}`) !== -1) return true;
|
621
|
+
if (this.keysFormat[this.keys.sk.attributeName].search(`{${propertyName}}`) !== -1) return true;
|
622
|
+
return false;
|
623
|
+
}
|
624
|
+
|
450
625
|
/**
|
451
626
|
* Validate the data matches with "DATA_MODEL"
|
452
627
|
* @param value
|