@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.
- package/CHANGELOG.md +11 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts +49 -9
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.js +108 -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 +257 -31
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js.map +1 -1
- package/package.json +2 -2
- package/src/dynamodb/AbstractDynamoDbRepository.spec.ts +289 -37
- package/src/dynamodb/AbstractDynamoDbRepository.ts +140 -31
- package/tsconfig.tsbuildinfo +1 -1
@@ -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>,
|
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
|
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
|
113
|
-
* @param index
|
182
|
+
* @param options
|
114
183
|
* @throws MissingKeyValuesError
|
115
184
|
*/
|
116
|
-
public async queryItems(
|
117
|
-
|
118
|
-
|
119
|
-
|
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(
|
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
|
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
|
-
|
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
|
279
|
+
const propValuePlaceHolder = `:${modelProperty}`;
|
190
280
|
|
191
|
-
updateExpression.push(`${propName} = ${
|
281
|
+
updateExpression.push(`${propName} = ${propValuePlaceHolder}`);
|
192
282
|
expressionAttributeNames[propName] = modelProperty;
|
193
|
-
|
194
|
-
|
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(
|
201
|
-
[this.keys.sk.attributeName]: this.getSk(
|
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(
|
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
|