@squiz/db-lib 1.68.0 → 1.70.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 +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) {
|