@squiz/db-lib 1.71.3 → 1.72.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.
@@ -9,14 +9,34 @@ import {
9
9
  import { Transaction, DynamoDbManager, MissingKeyValuesError, InvalidDbSchemaError } from '..';
10
10
  import { InvalidDataFormatError } from '../error/InvalidDataFormatError';
11
11
 
12
+ export type QueryFilterTypeBeginsWith = {
13
+ type: 'begins_with';
14
+ keyword: string;
15
+ };
16
+
17
+ export type QueryOptions = {
18
+ // whether to match sort key along with the partition key
19
+ // if set to true this will return 1 item maximum
20
+ useSortKey?: boolean;
21
+ // table index to use (one of tables GSIs)
22
+ index?: keyof TableIndexes;
23
+ // number of items to limit in the result
24
+ limit?: number;
25
+ // result order based on the sort key
26
+ order?: 'desc' | 'asc';
27
+ // filter operation on the sort key
28
+ filter?: QueryFilterTypeBeginsWith;
29
+ };
30
+
12
31
  interface Reader<T> {
13
- queryItems(partialItem: Partial<T>, useSortKey?: boolean, index?: keyof TableIndexes): Promise<T[]>;
32
+ queryItems(partialItem: Partial<T>, options?: QueryOptions): Promise<T[]>;
33
+
14
34
  getItem(id: string | Partial<T>): Promise<T | undefined>;
15
35
  }
16
36
 
17
37
  interface Writer<T> {
18
38
  createItem(item: Partial<T>): Promise<T>;
19
- updateItem(partialItem: Partial<T>, newValue: Partial<T>): Promise<T | undefined>;
39
+ updateItem(partialItem: Partial<T>): Promise<T | undefined>;
20
40
  deleteItem(partialItem: Partial<T>): Promise<number>;
21
41
  }
22
42
 
@@ -104,20 +124,68 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
104
124
  return this.hydrateItem(output.Item);
105
125
  }
106
126
 
127
+ /**
128
+ * Get the single item each matching the key fields value in the given
129
+ * partial items. Will throw MissingKeyValuesError if key field values
130
+ * are missing
131
+ * Uses batchGet() to request 100 items in a batch
132
+ *
133
+ * @param item
134
+ *
135
+ * @throws MissingKeyValuesError
136
+ */
137
+ public async getItems(items: Partial<SHAPE>[]): Promise<DATA_CLASS[]> {
138
+ // this is the maximum items allowed by BatchGetItem()
139
+ const batchSize = 100;
140
+
141
+ let result: DATA_CLASS[] = [];
142
+ for (let i = 0; i < items.length; i += batchSize) {
143
+ const batchResult = await this.getBatchItems(items.slice(i, i + batchSize));
144
+ result = result.concat(batchResult);
145
+ }
146
+ return result;
147
+ }
148
+
149
+ /**
150
+ * Returns the batch items from the items primary key
151
+ *
152
+ * @param items
153
+ * @returns
154
+ */
155
+ private async getBatchItems(items: Partial<SHAPE>[]): Promise<DATA_CLASS[]> {
156
+ const keys: Record<string, string>[] = [];
157
+ for (const item of items) {
158
+ keys.push({
159
+ [this.keys.pk.attributeName]: this.getPk(item),
160
+ [this.keys.sk.attributeName]: this.getSk(item),
161
+ });
162
+ }
163
+ const output = await this.client.batchGet({
164
+ RequestItems: {
165
+ [this.tableName]: {
166
+ Keys: keys,
167
+ },
168
+ },
169
+ });
170
+
171
+ if (output.Responses && output.Responses[this.tableName].length) {
172
+ return output.Responses[this.tableName].map((i) => this.hydrateItem(i));
173
+ }
174
+ return [];
175
+ }
176
+
107
177
  /**
108
178
  * Finds all the items matching the partition key or
109
179
  * the gsi key (when gsi index name is specified)
110
180
  *
111
181
  * @param item
112
- * @param useSortKey
113
- * @param index
182
+ * @param options
114
183
  * @throws MissingKeyValuesError
115
184
  */
116
- public async queryItems(
117
- item: Partial<SHAPE>,
118
- useSortKey: boolean = false,
119
- index?: keyof TableIndexes,
120
- ): Promise<DATA_CLASS[]> {
185
+ public async queryItems(item: Partial<SHAPE>, options?: QueryOptions): Promise<DATA_CLASS[]> {
186
+ const useSortKey = options?.useSortKey || options?.filter;
187
+ const index = options?.index;
188
+
121
189
  let pkName = this.keys.pk.attributeName;
122
190
  let skName = this.keys.sk.attributeName;
123
191
  let indexName = null;
@@ -136,8 +204,8 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
136
204
  const expressionAttributeNames: Record<string, string> = { '#pkName': pkName };
137
205
  const expressionAttributeValues: Record<string, unknown> = { ':pkValue': pk };
138
206
  if (useSortKey) {
139
- const sk = this.getKey(item, skName);
140
- keyConditionExpression.push('#skName = :skValue');
207
+ const sk = options?.filter?.keyword ?? this.getKey(item, skName);
208
+ keyConditionExpression.push(this.getFilterKeyConditionExpression(options?.filter));
141
209
  expressionAttributeNames['#skName'] = skName;
142
210
  expressionAttributeValues[':skValue'] = sk;
143
211
  }
@@ -151,27 +219,43 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
151
219
  if (indexName) {
152
220
  queryCommandInput['IndexName'] = String(indexName);
153
221
  }
154
- const output = await this.client.query(queryCommandInput);
155
222
 
223
+ if (options?.order !== undefined) {
224
+ queryCommandInput.ScanIndexForward = options.order === 'asc';
225
+ }
226
+ if (options?.limit !== undefined) {
227
+ queryCommandInput.Limit = options.limit;
228
+ }
229
+
230
+ const output = await this.client.query(queryCommandInput);
156
231
  return !output.Items ? [] : output.Items.map((item) => this.hydrateItem(item));
157
232
  }
158
233
 
234
+ /**
235
+ * Evaluate filter condition for sort key
236
+ * @param filter
237
+ * @returns string
238
+ */
239
+ private getFilterKeyConditionExpression(filter: QueryOptions['filter']): string {
240
+ if (filter === undefined) {
241
+ return '#skName = :skValue';
242
+ } else if (filter.type === 'begins_with') {
243
+ return 'begins_with(#skName, :skValue)';
244
+ }
245
+ throw new Error(`Invalid query filter type: ${(filter as any)?.type}`);
246
+ }
247
+
159
248
  /**
160
249
  * Update the existing item matching the key fields value
161
- * in the passed in partialItem
162
- * @param partialItem
250
+ * in the passed newValue
163
251
  * @param newValue
164
252
  * @param transaction
165
253
  *
166
254
  * @returns Promise<SHAPE | undefined>
167
255
  * @throws MissingKeyValuesError
168
256
  */
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);
257
+ public async updateItem(newValue: Partial<SHAPE>, transaction: Transaction = {}): Promise<DATA_CLASS | undefined> {
258
+ const oldValue = await this.getItem(newValue);
175
259
  if (oldValue === undefined) {
176
260
  return undefined;
177
261
  }
@@ -185,20 +269,29 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
185
269
  const expressionAttributeNames: Record<string, string> = {};
186
270
  const expressionAttributeValues: Record<string, unknown> = {};
187
271
  for (const modelProperty of Object.keys(newValue)) {
272
+ const propValue = newValue[modelProperty as keyof SHAPE] ?? null;
273
+ if (propValue === oldValue[modelProperty as keyof SHAPE]) {
274
+ // don't need to update the properties that are unchanged
275
+ continue;
276
+ }
277
+
188
278
  const propName = `#${modelProperty}`;
189
- const propValue = `:${modelProperty}`;
279
+ const propValuePlaceHolder = `:${modelProperty}`;
190
280
 
191
- updateExpression.push(`${propName} = ${propValue}`);
281
+ updateExpression.push(`${propName} = ${propValuePlaceHolder}`);
192
282
  expressionAttributeNames[propName] = modelProperty;
193
-
194
- expressionAttributeValues[propValue] = newValue[modelProperty as keyof SHAPE] ?? null;
283
+ expressionAttributeValues[propValuePlaceHolder] = propValue;
284
+ }
285
+ if (!updateExpression.length) {
286
+ // nothing to update
287
+ return value;
195
288
  }
196
289
 
197
290
  const updateCommandInput = {
198
291
  TableName: this.tableName,
199
292
  Key: {
200
- [this.keys.pk.attributeName]: this.getPk(partialItem),
201
- [this.keys.sk.attributeName]: this.getSk(partialItem),
293
+ [this.keys.pk.attributeName]: this.getPk(newValue),
294
+ [this.keys.sk.attributeName]: this.getSk(newValue),
202
295
  },
203
296
  UpdateExpression: 'SET ' + updateExpression.join(','),
204
297
  ExpressionAttributeValues: expressionAttributeValues,
@@ -240,12 +333,17 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
240
333
  *
241
334
  * @param value
242
335
  * @param transaction
336
+ * @param additionalValue Additional item properties that are not part of the DATA_CLASS
243
337
  *
244
338
  * @returns Promise<SHAPE>
245
339
  * @throws DuplicateItemError
246
340
  * @throws MissingKeyValuesError
247
341
  */
248
- public async createItem(value: DATA_CLASS, transaction: Transaction = {}): Promise<DATA_CLASS> {
342
+ public async createItem(
343
+ value: DATA_CLASS,
344
+ transaction: Transaction = {},
345
+ additionalValue: Partial<SHAPE> = {},
346
+ ): Promise<DATA_CLASS> {
249
347
  this.assertValueMatchesModel(value);
250
348
 
251
349
  const columns: any = {};
@@ -255,14 +353,14 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
255
353
  this.convertSelectedValuesToJsonString(columns);
256
354
 
257
355
  const keyFields: Record<string, unknown> = {
258
- [this.keys.pk.attributeName]: this.getPk(value),
259
- [this.keys.sk.attributeName]: this.getSk(value),
356
+ [this.keys.pk.attributeName]: this.getPk({ ...value, ...additionalValue }),
357
+ [this.keys.sk.attributeName]: this.getSk({ ...value, ...additionalValue }),
260
358
  };
261
359
 
262
360
  Object.keys(this.indexes).forEach((key) => {
263
361
  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);
362
+ keyFields[index.pk.attributeName] = this.getKey({ ...value, ...additionalValue }, index.pk.attributeName);
363
+ keyFields[index.sk.attributeName] = this.getKey({ ...value, ...additionalValue }, index.sk.attributeName);
266
364
  });
267
365
 
268
366
  const putCommandInput: PutCommandInput = {
@@ -447,6 +545,17 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
447
545
  return keyFormat;
448
546
  }
449
547
 
548
+ /**
549
+ * Whether the given property name is part of the entity's pk/sk string
550
+ * @param propertyName
551
+ * @returns boolean
552
+ */
553
+ private isPropertyPartOfKeys(propertyName: string) {
554
+ if (this.keysFormat[this.keys.pk.attributeName].search(`{${propertyName}}`) !== -1) return true;
555
+ if (this.keysFormat[this.keys.sk.attributeName].search(`{${propertyName}}`) !== -1) return true;
556
+ return false;
557
+ }
558
+
450
559
  /**
451
560
  * Validate the data matches with "DATA_MODEL"
452
561
  * @param value