@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.
@@ -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>, useSortKey?: boolean, index?: keyof TableIndexes): Promise<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>, newValue: Partial<T>): Promise<T | undefined>;
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 useSortKey
113
- * @param index
248
+ * @param options
114
249
  * @throws MissingKeyValuesError
115
250
  */
116
- public async queryItems(
117
- item: Partial<SHAPE>,
118
- useSortKey: boolean = false,
119
- index?: keyof TableIndexes,
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('#skName = :skValue');
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 in partialItem
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
- partialItem: Partial<SHAPE>,
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 propValue = `:${modelProperty}`;
345
+ const propValuePlaceHolder = `:${modelProperty}`;
190
346
 
191
- updateExpression.push(`${propName} = ${propValue}`);
347
+ updateExpression.push(`${propName} = ${propValuePlaceHolder}`);
192
348
  expressionAttributeNames[propName] = modelProperty;
193
-
194
- expressionAttributeValues[propValue] = newValue[modelProperty as keyof SHAPE] ?? null;
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(partialItem),
201
- [this.keys.sk.attributeName]: this.getSk(partialItem),
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(value: DATA_CLASS, transaction: Transaction = {}): Promise<DATA_CLASS> {
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