@squiz/db-lib 1.68.0 → 1.70.0
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +16 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts +3 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts +2 -0
- package/lib/error/InvalidDataFormatError.d.ts +5 -0
- package/lib/index.js +1721 -28
- package/lib/index.js.map +4 -4
- package/package.json +2 -1
- package/src/dynamodb/AbstractDynamoDbRepository.spec.ts +44 -0
- package/src/dynamodb/AbstractDynamoDbRepository.ts +29 -2
- package/src/dynamodb/DynamoDbManager.ts +21 -4
- package/src/error/InvalidDataFormatError.ts +8 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@squiz/db-lib",
|
3
|
-
"version": "1.
|
3
|
+
"version": "1.70.0",
|
4
4
|
"description": "",
|
5
5
|
"main": "lib/index.js",
|
6
6
|
"private": false,
|
@@ -35,6 +35,7 @@
|
|
35
35
|
"@aws-sdk/client-dynamodb": "^3.632.0",
|
36
36
|
"@aws-sdk/client-secrets-manager": "3.614.0",
|
37
37
|
"@aws-sdk/lib-dynamodb": "^3.632.0",
|
38
|
+
"@opentelemetry/api": "^1.6.0",
|
38
39
|
"@squiz/dx-common-lib": "^1.66.4",
|
39
40
|
"@squiz/dx-logger-lib": "^1.64.0",
|
40
41
|
"dotenv": "16.0.3",
|
@@ -23,6 +23,7 @@ import { mockClient } from 'aws-sdk-client-mock';
|
|
23
23
|
import 'aws-sdk-client-mock-jest';
|
24
24
|
import { DynamoDB } from '@aws-sdk/client-dynamodb';
|
25
25
|
import crypto from 'crypto';
|
26
|
+
import { InvalidDataFormatError } from '../error/InvalidDataFormatError';
|
26
27
|
|
27
28
|
const ddbClientMock = mockClient(DynamoDBDocumentClient);
|
28
29
|
const ddbDoc = DynamoDBDocument.from(new DynamoDB({}));
|
@@ -32,6 +33,7 @@ interface ITestItem {
|
|
32
33
|
age: number;
|
33
34
|
country: string;
|
34
35
|
data?: object;
|
36
|
+
data2?: object;
|
35
37
|
}
|
36
38
|
|
37
39
|
class TestItem implements ITestItem {
|
@@ -39,12 +41,14 @@ class TestItem implements ITestItem {
|
|
39
41
|
public age: number;
|
40
42
|
public country: string;
|
41
43
|
public data: object;
|
44
|
+
public data2?: object;
|
42
45
|
|
43
46
|
constructor(data: Partial<ITestItem> = {}) {
|
44
47
|
this.name = data.name ?? 'default name';
|
45
48
|
this.age = data.age ?? 0;
|
46
49
|
this.country = data.country ?? 'default country';
|
47
50
|
this.data = data.data ?? {};
|
51
|
+
this.data2 = data.data2 ?? {};
|
48
52
|
|
49
53
|
if (typeof this.name !== 'string') {
|
50
54
|
throw Error('Invalid "name"');
|
@@ -92,6 +96,8 @@ const TEST_ITEM_ENTITY_DEFINITION = {
|
|
92
96
|
},
|
93
97
|
},
|
94
98
|
},
|
99
|
+
// field to be stored as JSON string
|
100
|
+
fieldsAsJsonString: ['data2'],
|
95
101
|
};
|
96
102
|
|
97
103
|
class TestItemRepository extends AbstractDynamoDbRepository<ITestItem, TestItem> {
|
@@ -134,6 +140,8 @@ describe('AbstractRepository', () => {
|
|
134
140
|
age: 99,
|
135
141
|
country: 'au',
|
136
142
|
data: {},
|
143
|
+
// "data2" property is defined to be stored as JSON string
|
144
|
+
data2: '{"foo":"bar","num":123}',
|
137
145
|
},
|
138
146
|
ConditionExpression: `attribute_not_exists(pk)`,
|
139
147
|
};
|
@@ -143,6 +151,10 @@ describe('AbstractRepository', () => {
|
|
143
151
|
age: 99,
|
144
152
|
country: 'au',
|
145
153
|
data: {},
|
154
|
+
data2: {
|
155
|
+
foo: 'bar',
|
156
|
+
num: 123,
|
157
|
+
},
|
146
158
|
};
|
147
159
|
const result = await repository.createItem(item);
|
148
160
|
expect(ddbClientMock).toHaveReceivedCommandWith(PutCommand, input);
|
@@ -152,6 +164,10 @@ describe('AbstractRepository', () => {
|
|
152
164
|
age: 99,
|
153
165
|
country: 'au',
|
154
166
|
data: {},
|
167
|
+
data2: {
|
168
|
+
foo: 'bar',
|
169
|
+
num: 123,
|
170
|
+
},
|
155
171
|
}),
|
156
172
|
);
|
157
173
|
});
|
@@ -367,6 +383,7 @@ describe('AbstractRepository', () => {
|
|
367
383
|
age: 99,
|
368
384
|
country: 'au',
|
369
385
|
data: {},
|
386
|
+
data2: '{"foo":"bar","num":123}',
|
370
387
|
},
|
371
388
|
});
|
372
389
|
const input: GetCommandInput = {
|
@@ -383,6 +400,10 @@ describe('AbstractRepository', () => {
|
|
383
400
|
age: 99,
|
384
401
|
country: 'au',
|
385
402
|
data: {},
|
403
|
+
data2: {
|
404
|
+
foo: 'bar',
|
405
|
+
num: 123,
|
406
|
+
},
|
386
407
|
}),
|
387
408
|
);
|
388
409
|
});
|
@@ -427,6 +448,29 @@ describe('AbstractRepository', () => {
|
|
427
448
|
new MissingKeyValuesError('Key field "name" must be specified in the input item in entity test-item-entity'),
|
428
449
|
);
|
429
450
|
});
|
451
|
+
|
452
|
+
it('should throw error if JSON string field has non-string data', async () => {
|
453
|
+
ddbClientMock.on(GetCommand).resolves({
|
454
|
+
$metadata: {
|
455
|
+
httpStatusCode: 200,
|
456
|
+
},
|
457
|
+
Item: {
|
458
|
+
name: 'foo',
|
459
|
+
age: 99, // should be number
|
460
|
+
country: 'au',
|
461
|
+
data: {},
|
462
|
+
data2: {
|
463
|
+
foo: 'bar',
|
464
|
+
num: 123,
|
465
|
+
},
|
466
|
+
},
|
467
|
+
});
|
468
|
+
|
469
|
+
const partialItem = { name: 'foo' };
|
470
|
+
await expect(repository.getItem(partialItem)).rejects.toEqual(
|
471
|
+
new InvalidDataFormatError(`Field 'data2' defined as JSON String has a non-string data`),
|
472
|
+
);
|
473
|
+
});
|
430
474
|
});
|
431
475
|
|
432
476
|
describe('queryItems()', () => {
|
@@ -7,6 +7,7 @@ import {
|
|
7
7
|
} from '@aws-sdk/lib-dynamodb';
|
8
8
|
|
9
9
|
import { Transaction, DynamoDbManager, MissingKeyValuesError, InvalidDbSchemaError } from '..';
|
10
|
+
import { InvalidDataFormatError } from '../error/InvalidDataFormatError';
|
10
11
|
|
11
12
|
interface Reader<T> {
|
12
13
|
queryItems(partialItem: Partial<T>, useSortKey?: boolean, index?: keyof TableIndexes): Promise<T[]>;
|
@@ -41,6 +42,7 @@ export type KeysFormat = Record<keyof TableKeys | keyof TableIndexes, string>;
|
|
41
42
|
export type EntityDefinition = {
|
42
43
|
keys: TableKeys;
|
43
44
|
indexes: TableIndexes;
|
45
|
+
fieldsAsJsonString: string[];
|
44
46
|
};
|
45
47
|
|
46
48
|
export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLASS extends SHAPE>
|
@@ -52,6 +54,9 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
52
54
|
protected indexes: TableIndexes;
|
53
55
|
protected keysFormat: KeysFormat;
|
54
56
|
|
57
|
+
// fields listed in this property are stored as a JSON string value in db
|
58
|
+
protected fieldsAsJsonString: string[];
|
59
|
+
|
55
60
|
constructor(
|
56
61
|
protected tableName: string,
|
57
62
|
protected dbManager: DynamoDbManager<Repositories>,
|
@@ -63,6 +68,7 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
63
68
|
|
64
69
|
this.keys = entityDefinition.keys;
|
65
70
|
this.indexes = entityDefinition.indexes;
|
71
|
+
this.fieldsAsJsonString = entityDefinition.fieldsAsJsonString;
|
66
72
|
|
67
73
|
this.keysFormat = {
|
68
74
|
[this.keys.pk.attributeName]: this.keys.pk.format,
|
@@ -170,7 +176,10 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
170
176
|
return undefined;
|
171
177
|
}
|
172
178
|
|
173
|
-
|
179
|
+
const value = { ...oldValue, ...newValue };
|
180
|
+
this.assertValueMatchesModel(value);
|
181
|
+
|
182
|
+
this.convertSelectedValuesToJsonString(newValue);
|
174
183
|
|
175
184
|
const updateExpression = [];
|
176
185
|
const expressionAttributeNames: Record<string, string> = {};
|
@@ -203,7 +212,7 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
203
212
|
this.dbManager.addWriteTransactionItem(transaction.id, {
|
204
213
|
Update: updateCommandInput,
|
205
214
|
});
|
206
|
-
return new this.classRef(
|
215
|
+
return new this.classRef(value);
|
207
216
|
}
|
208
217
|
|
209
218
|
let output: UpdateCommandOutput;
|
@@ -243,6 +252,7 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
243
252
|
for (const modelProperty of Object.keys(value)) {
|
244
253
|
columns[modelProperty] = value[modelProperty as keyof DATA_CLASS];
|
245
254
|
}
|
255
|
+
this.convertSelectedValuesToJsonString(columns);
|
246
256
|
|
247
257
|
const keyFields: Record<string, unknown> = {
|
248
258
|
[this.keys.pk.attributeName]: this.getPk(value),
|
@@ -322,9 +332,26 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
322
332
|
* @returns
|
323
333
|
*/
|
324
334
|
protected hydrateItem(item: Record<string, unknown>): DATA_CLASS {
|
335
|
+
for (const fieldName of Object.keys(item)) {
|
336
|
+
if (this.fieldsAsJsonString.includes(fieldName)) {
|
337
|
+
if (typeof item[fieldName] === 'string') {
|
338
|
+
item[fieldName] = JSON.parse(item[fieldName] as string);
|
339
|
+
} else {
|
340
|
+
throw new InvalidDataFormatError(`Field '${fieldName}' defined as JSON String has a non-string data`);
|
341
|
+
}
|
342
|
+
}
|
343
|
+
}
|
325
344
|
return new this.classRef(item);
|
326
345
|
}
|
327
346
|
|
347
|
+
protected convertSelectedValuesToJsonString(item: Record<string, unknown>) {
|
348
|
+
for (const fieldName of Object.keys(item)) {
|
349
|
+
if (this.fieldsAsJsonString.includes(fieldName)) {
|
350
|
+
item[fieldName] = JSON.stringify(item[fieldName]);
|
351
|
+
}
|
352
|
+
}
|
353
|
+
}
|
354
|
+
|
328
355
|
/**
|
329
356
|
* Evaluate the partition key value from the partial item
|
330
357
|
* @param item
|
@@ -1,6 +1,9 @@
|
|
1
1
|
import { DynamoDBDocument, TransactWriteCommandInput } from '@aws-sdk/lib-dynamodb';
|
2
2
|
import { randomUUID } from 'crypto';
|
3
3
|
import { TransactionError } from '../error/TransactionError';
|
4
|
+
import { trace } from '@opentelemetry/api';
|
5
|
+
|
6
|
+
const tracer = trace.getTracer('db-lib:DynamoDbManager');
|
4
7
|
|
5
8
|
export type Transaction = {
|
6
9
|
id?: string;
|
@@ -47,10 +50,24 @@ export class DynamoDbManager<TRepositories> {
|
|
47
50
|
throw new TransactionError(`No items in transaction '${transactionId}' to execute`);
|
48
51
|
}
|
49
52
|
|
50
|
-
return
|
51
|
-
|
52
|
-
|
53
|
-
|
53
|
+
return tracer.startActiveSpan(
|
54
|
+
'executeTransaction',
|
55
|
+
{
|
56
|
+
attributes: {
|
57
|
+
'transactionItems.length': this.transactionItems[transactionId].length,
|
58
|
+
},
|
59
|
+
},
|
60
|
+
async (span) => {
|
61
|
+
try {
|
62
|
+
return await this.client.transactWrite({
|
63
|
+
ClientRequestToken: transactionId,
|
64
|
+
TransactItems: this.transactionItems[transactionId],
|
65
|
+
});
|
66
|
+
} finally {
|
67
|
+
span.end();
|
68
|
+
}
|
69
|
+
},
|
70
|
+
);
|
54
71
|
}
|
55
72
|
|
56
73
|
private startTransaction(transactionId: string) {
|