@squiz/db-lib 1.71.3 → 1.73.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/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
|