@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.
@@ -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