@twin.org/entity-storage-connector-dynamodb 0.0.3-next.3 → 0.0.3-next.30
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/README.md +8 -10
- package/dist/es/dynamoDbEntityStorageConnector.js +628 -58
- package/dist/es/dynamoDbEntityStorageConnector.js.map +1 -1
- package/dist/es/models/IDynamoDbEntityStorageConnectorConfig.js.map +1 -1
- package/dist/types/dynamoDbEntityStorageConnector.d.ts +63 -5
- package/dist/types/models/IDynamoDbEntityStorageConnectorConfig.d.ts +5 -0
- package/docs/changelog.md +629 -58
- package/docs/examples.md +96 -1
- package/docs/reference/classes/DynamoDbEntityStorageConnector.md +284 -20
- package/docs/reference/interfaces/IDynamoDbEntityStorageConnectorConfig.md +21 -12
- package/docs/reference/interfaces/IDynamoDbEntityStorageConnectorConstructorOptions.md +6 -6
- package/locales/en.json +17 -3
- package/package.json +8 -8
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Copyright 2024 IOTA Stiftung.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
-
import { DynamoDB, QueryCommand, waitUntilTableExists } from "@aws-sdk/client-dynamodb";
|
|
4
|
-
import { DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
|
|
3
|
+
import { BatchWriteItemCommand, DynamoDB, QueryCommand, ScanCommand as RawScanCommand, waitUntilTableExists, waitUntilTableNotExists } from "@aws-sdk/client-dynamodb";
|
|
4
|
+
import { BatchWriteCommand, DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
|
|
5
5
|
import { unmarshall } from "@aws-sdk/util-dynamodb";
|
|
6
6
|
import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
|
|
7
|
-
import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
|
|
7
|
+
import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, HealthStatus, Is, ObjectHelper, Validation } from "@twin.org/core";
|
|
8
8
|
import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, LogicalOperator, SortDirection } from "@twin.org/entity";
|
|
9
|
+
import { EntityStorageHelper } from "@twin.org/entity-storage-models";
|
|
9
10
|
/**
|
|
10
11
|
* Class for performing entity storage operations using Dynamo DB.
|
|
11
12
|
*/
|
|
@@ -29,6 +30,11 @@ export class DynamoDbEntityStorageConnector {
|
|
|
29
30
|
* @internal
|
|
30
31
|
*/
|
|
31
32
|
static _PARTITION_KEY_VALUE = "root";
|
|
33
|
+
/**
|
|
34
|
+
* The name for the schema.
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
_entitySchemaName;
|
|
32
38
|
/**
|
|
33
39
|
* The schema for the entity.
|
|
34
40
|
* @internal
|
|
@@ -64,8 +70,9 @@ export class DynamoDbEntityStorageConnector {
|
|
|
64
70
|
}
|
|
65
71
|
Guards.stringValue(DynamoDbEntityStorageConnector.CLASS_NAME, "options.config.region", options.config.region);
|
|
66
72
|
Guards.stringValue(DynamoDbEntityStorageConnector.CLASS_NAME, "options.config.tableName", options.config.tableName);
|
|
67
|
-
this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
|
|
68
73
|
this._partitionContextIds = options.partitionContextIds;
|
|
74
|
+
this._entitySchemaName = options.entitySchema;
|
|
75
|
+
this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
|
|
69
76
|
this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
|
|
70
77
|
this._config = options.config;
|
|
71
78
|
this._config.endpoint = Is.stringValue(this._config.endpoint)
|
|
@@ -79,6 +86,35 @@ export class DynamoDbEntityStorageConnector {
|
|
|
79
86
|
className() {
|
|
80
87
|
return DynamoDbEntityStorageConnector.CLASS_NAME;
|
|
81
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Returns the health status of the component.
|
|
91
|
+
* @returns The health status of the component.
|
|
92
|
+
*/
|
|
93
|
+
async health() {
|
|
94
|
+
try {
|
|
95
|
+
const dbConnection = this.createConnection();
|
|
96
|
+
await dbConnection.describeTable({ TableName: this._config.tableName });
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
source: DynamoDbEntityStorageConnector.CLASS_NAME,
|
|
100
|
+
status: HealthStatus.Ok,
|
|
101
|
+
description: "healthDescription",
|
|
102
|
+
data: { tableName: this._config.tableName }
|
|
103
|
+
}
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
source: DynamoDbEntityStorageConnector.CLASS_NAME,
|
|
110
|
+
status: HealthStatus.Error,
|
|
111
|
+
description: "healthDescription",
|
|
112
|
+
message: "connectionFailed",
|
|
113
|
+
data: { tableName: this._config.tableName }
|
|
114
|
+
}
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
82
118
|
/**
|
|
83
119
|
* Get the schema for the entities.
|
|
84
120
|
* @returns The schema for the entities.
|
|
@@ -174,7 +210,7 @@ export class DynamoDbEntityStorageConnector {
|
|
|
174
210
|
// Wait for table to exist
|
|
175
211
|
await waitUntilTableExists({
|
|
176
212
|
client: dbConnection,
|
|
177
|
-
maxWaitTime:
|
|
213
|
+
maxWaitTime: 60
|
|
178
214
|
}, {
|
|
179
215
|
TableName: this._config.tableName
|
|
180
216
|
});
|
|
@@ -250,8 +286,12 @@ export class DynamoDbEntityStorageConnector {
|
|
|
250
286
|
}
|
|
251
287
|
});
|
|
252
288
|
const response = await docClient.send(getCommand);
|
|
253
|
-
|
|
254
|
-
|
|
289
|
+
if (response.Item) {
|
|
290
|
+
return EntityStorageHelper.unPrepareEntity(response.Item, [
|
|
291
|
+
DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
return undefined;
|
|
255
295
|
}
|
|
256
296
|
const finalConditions = {
|
|
257
297
|
conditions: []
|
|
@@ -296,17 +336,21 @@ export class DynamoDbEntityStorageConnector {
|
|
|
296
336
|
Guards.object(DynamoDbEntityStorageConnector.CLASS_NAME, "entity", entity);
|
|
297
337
|
const contextIds = await ContextIdStore.getContextIds();
|
|
298
338
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
299
|
-
|
|
300
|
-
|
|
339
|
+
const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, partitionKey
|
|
340
|
+
? [{ property: DynamoDbEntityStorageConnector._PARTITION_KEY, value: partitionKey }]
|
|
341
|
+
: [
|
|
342
|
+
{
|
|
343
|
+
property: DynamoDbEntityStorageConnector._PARTITION_KEY,
|
|
344
|
+
value: DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
345
|
+
}
|
|
346
|
+
], { nullBehavior: "omit" });
|
|
347
|
+
const id = prepared[this._primaryKey.property];
|
|
301
348
|
try {
|
|
302
349
|
const docClient = this.createDocClient();
|
|
303
350
|
const { conditionExpression, attributeNames, attributeValues } = this.buildConditionExpression(conditions);
|
|
304
351
|
const putCommand = new PutCommand({
|
|
305
352
|
TableName: this._config.tableName,
|
|
306
|
-
Item:
|
|
307
|
-
[DynamoDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
308
|
-
...entity
|
|
309
|
-
},
|
|
353
|
+
Item: prepared,
|
|
310
354
|
// Only set the condition expression if we have conditions to match
|
|
311
355
|
// and the primary key exists, otherwise we are creating a new object
|
|
312
356
|
ConditionExpression: Is.stringValue(conditionExpression)
|
|
@@ -331,6 +375,93 @@ export class DynamoDbEntityStorageConnector {
|
|
|
331
375
|
}, err);
|
|
332
376
|
}
|
|
333
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* Set multiple entities in a batch.
|
|
380
|
+
* @param entities The entities to set.
|
|
381
|
+
* @returns Nothing.
|
|
382
|
+
*/
|
|
383
|
+
async setBatch(entities) {
|
|
384
|
+
Guards.arrayValue(DynamoDbEntityStorageConnector.CLASS_NAME, "entities", entities);
|
|
385
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
386
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
387
|
+
const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, partitionKey
|
|
388
|
+
? [{ property: DynamoDbEntityStorageConnector._PARTITION_KEY, value: partitionKey }]
|
|
389
|
+
: [
|
|
390
|
+
{
|
|
391
|
+
property: DynamoDbEntityStorageConnector._PARTITION_KEY,
|
|
392
|
+
value: DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
393
|
+
}
|
|
394
|
+
], { nullBehavior: "omit" }));
|
|
395
|
+
try {
|
|
396
|
+
const docClient = this.createDocClient();
|
|
397
|
+
const chunkSize = 25;
|
|
398
|
+
for (let i = 0; i < preparedEntities.length; i += chunkSize) {
|
|
399
|
+
const chunk = preparedEntities.slice(i, i + chunkSize);
|
|
400
|
+
await docClient.send(new BatchWriteCommand({
|
|
401
|
+
RequestItems: {
|
|
402
|
+
[this._config.tableName]: chunk.map(entity => ({
|
|
403
|
+
PutRequest: {
|
|
404
|
+
Item: entity
|
|
405
|
+
}
|
|
406
|
+
}))
|
|
407
|
+
}
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
if (BaseError.isErrorCode(err, "ResourceNotFoundException")) {
|
|
413
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "tableDoesNotExist", { tableName: this._config.tableName }, err);
|
|
414
|
+
}
|
|
415
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Empty the entity storage.
|
|
420
|
+
* @returns Nothing.
|
|
421
|
+
*/
|
|
422
|
+
async empty() {
|
|
423
|
+
try {
|
|
424
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
425
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
426
|
+
const pKey = partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
427
|
+
const docClient = this.createDocClient();
|
|
428
|
+
const chunkSize = 25;
|
|
429
|
+
let exclusiveStartKey;
|
|
430
|
+
do {
|
|
431
|
+
const scanResult = await docClient.send(new ScanCommand({
|
|
432
|
+
TableName: this._config.tableName,
|
|
433
|
+
FilterExpression: "#partitionId = :partitionId",
|
|
434
|
+
ExpressionAttributeNames: {
|
|
435
|
+
"#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
436
|
+
},
|
|
437
|
+
ExpressionAttributeValues: {
|
|
438
|
+
":partitionId": pKey
|
|
439
|
+
},
|
|
440
|
+
ExclusiveStartKey: exclusiveStartKey
|
|
441
|
+
}));
|
|
442
|
+
const items = scanResult.Items ?? [];
|
|
443
|
+
for (let i = 0; i < items.length; i += chunkSize) {
|
|
444
|
+
const chunk = items.slice(i, i + chunkSize);
|
|
445
|
+
await docClient.send(new BatchWriteCommand({
|
|
446
|
+
RequestItems: {
|
|
447
|
+
[this._config.tableName]: chunk.map((item) => ({
|
|
448
|
+
DeleteRequest: {
|
|
449
|
+
Key: {
|
|
450
|
+
[DynamoDbEntityStorageConnector._PARTITION_KEY]: item[DynamoDbEntityStorageConnector._PARTITION_KEY],
|
|
451
|
+
[this._primaryKey.property]: item[this._primaryKey.property]
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}))
|
|
455
|
+
}
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
exclusiveStartKey = scanResult.LastEvaluatedKey;
|
|
459
|
+
} while (exclusiveStartKey);
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
334
465
|
/**
|
|
335
466
|
* Remove the entity.
|
|
336
467
|
* @param id The id of the entity to remove.
|
|
@@ -370,6 +501,77 @@ export class DynamoDbEntityStorageConnector {
|
|
|
370
501
|
}, err);
|
|
371
502
|
}
|
|
372
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* Remove multiple entities by their IDs in a batch.
|
|
506
|
+
* @param ids The ids of the entities to remove.
|
|
507
|
+
* @returns Nothing.
|
|
508
|
+
*/
|
|
509
|
+
async removeBatch(ids) {
|
|
510
|
+
Guards.arrayValue(DynamoDbEntityStorageConnector.CLASS_NAME, "ids", ids);
|
|
511
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
512
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
513
|
+
try {
|
|
514
|
+
const docClient = this.createDocClient();
|
|
515
|
+
const chunkSize = 25;
|
|
516
|
+
const primaryKeyProperty = this._primaryKey.property;
|
|
517
|
+
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
518
|
+
const chunk = ids.slice(i, i + chunkSize);
|
|
519
|
+
await docClient.send(new BatchWriteCommand({
|
|
520
|
+
RequestItems: {
|
|
521
|
+
[this._config.tableName]: chunk.map(id => ({
|
|
522
|
+
DeleteRequest: {
|
|
523
|
+
Key: {
|
|
524
|
+
[primaryKeyProperty]: id,
|
|
525
|
+
[DynamoDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}))
|
|
529
|
+
}
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Teardown the entity storage by deleting the underlying table.
|
|
539
|
+
* @param nodeLoggingComponentType The node logging component type.
|
|
540
|
+
* @returns True if the teardown process was successful.
|
|
541
|
+
*/
|
|
542
|
+
async teardown(nodeLoggingComponentType) {
|
|
543
|
+
const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
|
|
544
|
+
await nodeLogging?.log({
|
|
545
|
+
level: "info",
|
|
546
|
+
source: DynamoDbEntityStorageConnector.CLASS_NAME,
|
|
547
|
+
ts: Date.now(),
|
|
548
|
+
message: "tableDeleting",
|
|
549
|
+
data: { tableName: this._config.tableName }
|
|
550
|
+
});
|
|
551
|
+
try {
|
|
552
|
+
const dbConnection = this.createConnection();
|
|
553
|
+
await dbConnection.deleteTable({ TableName: this._config.tableName });
|
|
554
|
+
await waitUntilTableNotExists({ client: dbConnection, maxWaitTime: 60 }, { TableName: this._config.tableName });
|
|
555
|
+
await nodeLogging?.log({
|
|
556
|
+
level: "info",
|
|
557
|
+
source: DynamoDbEntityStorageConnector.CLASS_NAME,
|
|
558
|
+
ts: Date.now(),
|
|
559
|
+
message: "tableDeleted",
|
|
560
|
+
data: { tableName: this._config.tableName }
|
|
561
|
+
});
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
await nodeLogging?.log({
|
|
566
|
+
level: "error",
|
|
567
|
+
source: DynamoDbEntityStorageConnector.CLASS_NAME,
|
|
568
|
+
ts: Date.now(),
|
|
569
|
+
message: "teardownFailed",
|
|
570
|
+
error: BaseError.fromError(err)
|
|
571
|
+
});
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
373
575
|
/**
|
|
374
576
|
* Find all the entities which match the conditions.
|
|
375
577
|
* @param conditions The conditions to match for the entities.
|
|
@@ -383,18 +585,204 @@ export class DynamoDbEntityStorageConnector {
|
|
|
383
585
|
async query(conditions, sortProperties, properties, cursor, limit) {
|
|
384
586
|
const contextIds = await ContextIdStore.getContextIds();
|
|
385
587
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
588
|
+
if (!Is.empty(limit)) {
|
|
589
|
+
const validationFailures = [];
|
|
590
|
+
Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
|
|
591
|
+
Validation.asValidationError(DynamoDbEntityStorageConnector.CLASS_NAME, "query", validationFailures);
|
|
592
|
+
}
|
|
593
|
+
EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
|
|
594
|
+
EntityStorageHelper.validateProperties(this._entitySchema, properties);
|
|
386
595
|
return this.internalQuery(conditions, sortProperties, properties, cursor, limit, undefined, partitionKey);
|
|
387
596
|
}
|
|
388
597
|
/**
|
|
389
|
-
*
|
|
390
|
-
* @
|
|
598
|
+
* Count all the entities which match the conditions.
|
|
599
|
+
* @param conditions The optional conditions to match for the entities.
|
|
600
|
+
* @returns The total count of entities in the storage.
|
|
391
601
|
*/
|
|
392
|
-
async
|
|
602
|
+
async count(conditions) {
|
|
393
603
|
try {
|
|
604
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
605
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
606
|
+
const attributeNames = {
|
|
607
|
+
"#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
608
|
+
};
|
|
609
|
+
const attributeValues = {
|
|
610
|
+
":partitionId": {
|
|
611
|
+
S: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
const expressions = this.buildQueryParameters("", conditions, attributeNames, attributeValues);
|
|
615
|
+
if (expressions.noResults) {
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
394
618
|
const dbConnection = this.createConnection();
|
|
395
|
-
|
|
619
|
+
let total = 0;
|
|
620
|
+
let exclusiveStartKey;
|
|
621
|
+
do {
|
|
622
|
+
const result = await dbConnection.send(new QueryCommand({
|
|
623
|
+
TableName: this._config.tableName,
|
|
624
|
+
Select: "COUNT",
|
|
625
|
+
KeyConditionExpression: "#partitionId = :partitionId",
|
|
626
|
+
FilterExpression: Is.stringValue(expressions.filterCondition)
|
|
627
|
+
? expressions.filterCondition
|
|
628
|
+
: undefined,
|
|
629
|
+
ExpressionAttributeNames: attributeNames,
|
|
630
|
+
ExpressionAttributeValues: attributeValues,
|
|
631
|
+
ExclusiveStartKey: exclusiveStartKey
|
|
632
|
+
}));
|
|
633
|
+
total += result.Count ?? 0;
|
|
634
|
+
exclusiveStartKey = result.LastEvaluatedKey;
|
|
635
|
+
} while (exclusiveStartKey);
|
|
636
|
+
return total;
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Get a unique list of all the context ids from the storage.
|
|
644
|
+
* @returns The list of unique context ids.
|
|
645
|
+
*/
|
|
646
|
+
async getPartitionContextIds() {
|
|
647
|
+
if (!Is.arrayValue(this._partitionContextIds)) {
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
const contextIdsMap = {};
|
|
651
|
+
try {
|
|
652
|
+
const docClient = this.createDocClient();
|
|
653
|
+
let exclusiveStartKey;
|
|
654
|
+
do {
|
|
655
|
+
const scanResult = await docClient.send(new ScanCommand({
|
|
656
|
+
TableName: this._config.tableName,
|
|
657
|
+
ProjectionExpression: "#partitionId",
|
|
658
|
+
ExpressionAttributeNames: {
|
|
659
|
+
"#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
660
|
+
},
|
|
661
|
+
ExclusiveStartKey: exclusiveStartKey
|
|
662
|
+
}));
|
|
663
|
+
for (const item of scanResult.Items ?? []) {
|
|
664
|
+
const partitionId = item[DynamoDbEntityStorageConnector._PARTITION_KEY];
|
|
665
|
+
if (Is.stringValue(partitionId) && !(partitionId in contextIdsMap)) {
|
|
666
|
+
contextIdsMap[partitionId] = ContextIdHelper.shortSplit(this._partitionContextIds ?? [], partitionId);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
exclusiveStartKey = scanResult.LastEvaluatedKey;
|
|
670
|
+
} while (exclusiveStartKey);
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
|
|
674
|
+
}
|
|
675
|
+
return Object.values(contextIdsMap);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Create the target connector for performing the migration it will use a temporary storage location.
|
|
679
|
+
* @param newEntitySchema The name of the new entity schema to create the connector for.
|
|
680
|
+
* @returns Connector for performing the migration.
|
|
681
|
+
*/
|
|
682
|
+
async createTargetConnector(newEntitySchema) {
|
|
683
|
+
// We create a new table for the migration with a unique name to avoid conflicts with the existing table
|
|
684
|
+
// This table will be swapped with the existing table once the migration is finalized.
|
|
685
|
+
const migrationTableName = `${this._config.tableName}Migration${Date.now()}`;
|
|
686
|
+
return new DynamoDbEntityStorageConnector({
|
|
687
|
+
entitySchema: newEntitySchema,
|
|
688
|
+
config: {
|
|
689
|
+
...this._config,
|
|
690
|
+
tableName: migrationTableName
|
|
691
|
+
},
|
|
692
|
+
partitionContextIds: this._partitionContextIds
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Finalize the migration by tearing down the old connector and replacing it with the new one.
|
|
697
|
+
* @param targetConnector The target connector to finalize the migration with.
|
|
698
|
+
* @param options The options to control how the migration is finalized.
|
|
699
|
+
* @param loggingComponentType The logging component type to use for logging during the migration finalization.
|
|
700
|
+
* @returns A promise that resolves when the migration is finalized.
|
|
701
|
+
*/
|
|
702
|
+
async finalizeMigration(targetConnector, options, loggingComponentType) {
|
|
703
|
+
// There is no rename operation in DynamoDB so we have to create a new table with the original name and copy the data over
|
|
704
|
+
// Teardown the existing table with the original name to free up the name for the new table
|
|
705
|
+
await this.teardown(loggingComponentType);
|
|
706
|
+
// Create a new connector with the original table name but with the new schema
|
|
707
|
+
// and copy the data from the migration table to the new table using batch operations
|
|
708
|
+
const finalConnector = new DynamoDbEntityStorageConnector({
|
|
709
|
+
entitySchema: targetConnector._entitySchemaName,
|
|
710
|
+
config: this._config,
|
|
711
|
+
partitionContextIds: this._partitionContextIds
|
|
712
|
+
});
|
|
713
|
+
if (await finalConnector.bootstrap(loggingComponentType)) {
|
|
714
|
+
// Since there is no rename, we need to copy the data from the migration table to the new table
|
|
715
|
+
const partitions = await targetConnector.getPartitionContextIds();
|
|
716
|
+
const batchSize = options?.batchSize ?? DynamoDbEntityStorageConnector._DEFAULT_LIMIT;
|
|
717
|
+
await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
|
|
718
|
+
await targetConnector.teardown(loggingComponentType);
|
|
719
|
+
return finalConnector;
|
|
720
|
+
}
|
|
721
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Cleanup the migration if a migration fails or needs to be aborted.
|
|
725
|
+
* @param targetConnector The target connector to cleanup the migration with.
|
|
726
|
+
* @param options The options to control how the migration is cleaned up.
|
|
727
|
+
* @param loggingComponentType The optional component type to use for logging the migration progress.
|
|
728
|
+
* @returns A promise that resolves when the migration is cleaned up.
|
|
729
|
+
*/
|
|
730
|
+
async cleanupMigration(targetConnector, options, loggingComponentType) {
|
|
731
|
+
// If something failed the only thing to cleanup is the migration table
|
|
732
|
+
await targetConnector?.teardown?.(loggingComponentType);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Copy all entities from sourceConnector to destConnector, paging through each partition.
|
|
736
|
+
* @param sourceConnector The connector to read entities from.
|
|
737
|
+
* @param destConnector The connector to write entities to.
|
|
738
|
+
* @param partitions The partition list returned by getPartitionContextIds.
|
|
739
|
+
* @param batchSize The number of entities to read per page.
|
|
740
|
+
* @internal
|
|
741
|
+
*/
|
|
742
|
+
async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
|
|
743
|
+
let partitionList;
|
|
744
|
+
if (Is.arrayValue(partitions)) {
|
|
745
|
+
partitionList = partitions;
|
|
746
|
+
}
|
|
747
|
+
else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
|
|
748
|
+
partitionList = [];
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
partitionList = [{}];
|
|
752
|
+
}
|
|
753
|
+
const dbConnection = sourceConnector.createConnection();
|
|
754
|
+
const chunkSize = 25;
|
|
755
|
+
for (let i = 0; i < partitionList.length; i++) {
|
|
756
|
+
const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds) ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
757
|
+
let exclusiveStartKey;
|
|
758
|
+
do {
|
|
759
|
+
const { Items: items, LastEvaluatedKey: lastKey } = await dbConnection.send(new QueryCommand({
|
|
760
|
+
TableName: sourceConnector._config.tableName,
|
|
761
|
+
KeyConditionExpression: `#${DynamoDbEntityStorageConnector._PARTITION_KEY} = :${DynamoDbEntityStorageConnector._PARTITION_KEY}`,
|
|
762
|
+
ExpressionAttributeNames: {
|
|
763
|
+
[`#${DynamoDbEntityStorageConnector._PARTITION_KEY}`]: DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
764
|
+
},
|
|
765
|
+
ExpressionAttributeValues: {
|
|
766
|
+
[`:${DynamoDbEntityStorageConnector._PARTITION_KEY}`]: { S: partitionKey }
|
|
767
|
+
},
|
|
768
|
+
Limit: batchSize,
|
|
769
|
+
ExclusiveStartKey: exclusiveStartKey
|
|
770
|
+
}));
|
|
771
|
+
exclusiveStartKey = lastKey;
|
|
772
|
+
if (Is.arrayValue(items)) {
|
|
773
|
+
for (let j = 0; j < items.length; j += chunkSize) {
|
|
774
|
+
const chunk = items.slice(j, j + chunkSize);
|
|
775
|
+
await dbConnection.send(new BatchWriteItemCommand({
|
|
776
|
+
RequestItems: {
|
|
777
|
+
[destConnector._config.tableName]: chunk.map(item => ({
|
|
778
|
+
PutRequest: { Item: item }
|
|
779
|
+
}))
|
|
780
|
+
}
|
|
781
|
+
}));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
} while (exclusiveStartKey);
|
|
396
785
|
}
|
|
397
|
-
catch { }
|
|
398
786
|
}
|
|
399
787
|
/**
|
|
400
788
|
* Create the parameters for a query.
|
|
@@ -402,6 +790,7 @@ export class DynamoDbEntityStorageConnector {
|
|
|
402
790
|
* @param condition The conditions to create the query from.
|
|
403
791
|
* @param attributeNames The attribute names to use in the query.
|
|
404
792
|
* @param attributeValues The attribute values to use in the query.
|
|
793
|
+
* @param secondaryIndex The optional secondary index to use for the query.
|
|
405
794
|
* @returns The condition clause.
|
|
406
795
|
* @internal
|
|
407
796
|
*/
|
|
@@ -410,19 +799,79 @@ export class DynamoDbEntityStorageConnector {
|
|
|
410
799
|
if (Is.undefined(condition)) {
|
|
411
800
|
return {
|
|
412
801
|
keyCondition: "",
|
|
413
|
-
filterCondition: ""
|
|
802
|
+
filterCondition: "",
|
|
803
|
+
requiresScan: false
|
|
414
804
|
};
|
|
415
805
|
}
|
|
416
806
|
if ("conditions" in condition) {
|
|
417
807
|
if (condition.conditions.length === 0) {
|
|
418
808
|
return {
|
|
419
809
|
keyCondition: "",
|
|
420
|
-
filterCondition: ""
|
|
810
|
+
filterCondition: "",
|
|
811
|
+
requiresScan: false
|
|
421
812
|
};
|
|
422
813
|
}
|
|
814
|
+
// Snapshot before the entire group. Used by the AND path to undo
|
|
815
|
+
// surviving siblings' attribute registrations when the AND is dead (#141).
|
|
816
|
+
const preGroupNames = new Set(Object.keys(attributeNames));
|
|
817
|
+
const preGroupValues = new Set(Object.keys(attributeValues));
|
|
423
818
|
// It's a group of comparisons, so check the individual items and combine with the logical operator
|
|
424
|
-
const joinConditions = condition.conditions.map(c =>
|
|
819
|
+
const joinConditions = condition.conditions.map(c => {
|
|
820
|
+
// Snapshot before each branch. When a branch is dead (noResults),
|
|
821
|
+
// undo its attribute registrations so the final expressions stay
|
|
822
|
+
// consistent — DynamoDB rejects unused ExpressionAttributeNames (#141).
|
|
823
|
+
const preBranchNames = new Set(Object.keys(attributeNames));
|
|
824
|
+
const preBranchValues = new Set(Object.keys(attributeValues));
|
|
825
|
+
const result = this.buildQueryParameters(objectPath, c, attributeNames, attributeValues, secondaryIndex);
|
|
826
|
+
if (result.noResults) {
|
|
827
|
+
for (const key of Object.keys(attributeNames)) {
|
|
828
|
+
if (!preBranchNames.has(key)) {
|
|
829
|
+
delete attributeNames[key];
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
for (const key of Object.keys(attributeValues)) {
|
|
833
|
+
if (!preBranchValues.has(key)) {
|
|
834
|
+
delete attributeValues[key];
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return result;
|
|
839
|
+
});
|
|
425
840
|
const logicalOperator = this.mapConditionalOperator(condition.logicalOperator);
|
|
841
|
+
// DynamoDB does not support OR in KeyConditionExpression, so when the operator
|
|
842
|
+
// is OR we must move all conditions (including key conditions) into FilterExpression.
|
|
843
|
+
if (condition.logicalOperator === LogicalOperator.Or) {
|
|
844
|
+
// OR: only empty if ALL branches are guaranteed empty (e.g. all empty IN lists).
|
|
845
|
+
// If only some are empty they are naturally filtered out of `parts` below,
|
|
846
|
+
// which is correct — false OR x = x (#141).
|
|
847
|
+
if (joinConditions.every(j => j.noResults)) {
|
|
848
|
+
return { keyCondition: "", filterCondition: "", requiresScan: false, noResults: true };
|
|
849
|
+
}
|
|
850
|
+
const parts = joinConditions
|
|
851
|
+
.map(j => {
|
|
852
|
+
// A branch marked noResults (e.g. a dead AND group containing In [])
|
|
853
|
+
// must contribute nothing to the OR — false OR x = x (#141).
|
|
854
|
+
if (j.noResults) {
|
|
855
|
+
return "";
|
|
856
|
+
}
|
|
857
|
+
const subParts = [j.keyCondition.trim(), j.filterCondition.trim()].filter(s => s.length > 0);
|
|
858
|
+
if (subParts.length === 0) {
|
|
859
|
+
return "";
|
|
860
|
+
}
|
|
861
|
+
if (subParts.length === 1) {
|
|
862
|
+
return subParts[0];
|
|
863
|
+
}
|
|
864
|
+
return `(${subParts.join(" AND ")})`;
|
|
865
|
+
})
|
|
866
|
+
.filter(s => s.length > 0);
|
|
867
|
+
const hasKeyConditions = joinConditions.some(j => j.keyCondition.length > 0);
|
|
868
|
+
const filterCondition = parts.join(" OR ");
|
|
869
|
+
return {
|
|
870
|
+
keyCondition: "",
|
|
871
|
+
filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : "",
|
|
872
|
+
requiresScan: hasKeyConditions
|
|
873
|
+
};
|
|
874
|
+
}
|
|
426
875
|
const keyCondition = joinConditions
|
|
427
876
|
.filter(j => j.keyCondition.length > 0)
|
|
428
877
|
.map(j => j.keyCondition)
|
|
@@ -431,18 +880,46 @@ export class DynamoDbEntityStorageConnector {
|
|
|
431
880
|
.filter(j => j.filterCondition.length > 0)
|
|
432
881
|
.map(j => j.filterCondition)
|
|
433
882
|
.join(` ${logicalOperator} `);
|
|
883
|
+
// AND: if any sub-condition is a guaranteed empty result (e.g. empty IN list),
|
|
884
|
+
// the whole AND group is also empty (#141). Restore the attribute maps to the
|
|
885
|
+
// pre-group snapshot so surviving siblings' registrations are also undone —
|
|
886
|
+
// per-branch cleanup above only undoes dead branches, not live ones whose AND
|
|
887
|
+
// partner was dead.
|
|
888
|
+
const noResults = joinConditions.some(j => j.noResults);
|
|
889
|
+
if (noResults) {
|
|
890
|
+
for (const key of Object.keys(attributeNames)) {
|
|
891
|
+
if (!preGroupNames.has(key)) {
|
|
892
|
+
delete attributeNames[key];
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
for (const key of Object.keys(attributeValues)) {
|
|
896
|
+
if (!preGroupValues.has(key)) {
|
|
897
|
+
delete attributeValues[key];
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return { keyCondition: "", filterCondition: "", requiresScan: false, noResults: true };
|
|
901
|
+
}
|
|
434
902
|
return {
|
|
435
903
|
keyCondition: Is.stringValue(keyCondition) ? ` (${keyCondition}) ` : "",
|
|
436
|
-
filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : ""
|
|
904
|
+
filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : "",
|
|
905
|
+
requiresScan: joinConditions.some(j => j.requiresScan)
|
|
437
906
|
};
|
|
438
907
|
}
|
|
439
908
|
const schemaProp = this._entitySchema.properties?.find(p => p.property === condition.property);
|
|
909
|
+
// Empty IN list: DynamoDB has no `IN ()` syntax — short-circuit to empty result (#141).
|
|
910
|
+
if ("comparison" in condition &&
|
|
911
|
+
condition.comparison === ComparisonOperator.In &&
|
|
912
|
+
Is.array(condition.value) &&
|
|
913
|
+
condition.value.length === 0) {
|
|
914
|
+
return { keyCondition: "", filterCondition: "", requiresScan: false, noResults: true };
|
|
915
|
+
}
|
|
440
916
|
// It's a single value so just create the property comparison for the condition
|
|
441
917
|
const comparison = this.mapComparisonOperator(objectPath, condition, schemaProp?.type, attributeNames, attributeValues);
|
|
442
918
|
const isKey = schemaProp?.isPrimary ?? (schemaProp?.isSecondary && schemaProp?.property === secondaryIndex);
|
|
443
919
|
return {
|
|
444
920
|
keyCondition: isKey ? comparison : "",
|
|
445
|
-
filterCondition: !isKey ? comparison : ""
|
|
921
|
+
filterCondition: !isKey ? comparison : "",
|
|
922
|
+
requiresScan: false
|
|
446
923
|
};
|
|
447
924
|
}
|
|
448
925
|
/**
|
|
@@ -464,6 +941,8 @@ export class DynamoDbEntityStorageConnector {
|
|
|
464
941
|
prop += comparator.property;
|
|
465
942
|
let attributeName = this.populateAttributeNames(prop, attributeNames);
|
|
466
943
|
if (Is.empty(comparator.value)) {
|
|
944
|
+
// With "omit" storage, optional null/undefined fields are absent from the item entirely.
|
|
945
|
+
// attribute_not_exists matches absent attributes; attribute_exists matches present ones.
|
|
467
946
|
if (comparator.comparison === ComparisonOperator.Equals) {
|
|
468
947
|
return `attribute_not_exists(${attributeName})`;
|
|
469
948
|
}
|
|
@@ -471,7 +950,13 @@ export class DynamoDbEntityStorageConnector {
|
|
|
471
950
|
return `attribute_exists(${attributeName})`;
|
|
472
951
|
}
|
|
473
952
|
}
|
|
474
|
-
|
|
953
|
+
const basePropName = `:${attributeName.replace(/\./g, "").replace(/#/g, "")}`;
|
|
954
|
+
let propName = basePropName;
|
|
955
|
+
let propSuffix = 0;
|
|
956
|
+
while (!Is.undefined(attributeValues[propName])) {
|
|
957
|
+
propSuffix++;
|
|
958
|
+
propName = `${basePropName}${propSuffix}`;
|
|
959
|
+
}
|
|
475
960
|
if (Is.array(comparator.value)) {
|
|
476
961
|
const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
|
|
477
962
|
const arrAttributeNames = [];
|
|
@@ -508,7 +993,7 @@ export class DynamoDbEntityStorageConnector {
|
|
|
508
993
|
return `contains(${attributeName}, ${propName})`;
|
|
509
994
|
}
|
|
510
995
|
else if (comparator.comparison === ComparisonOperator.NotIncludes) {
|
|
511
|
-
return `
|
|
996
|
+
return `NOT contains(${attributeName}, ${propName})`;
|
|
512
997
|
}
|
|
513
998
|
else if (comparator.comparison === ComparisonOperator.In) {
|
|
514
999
|
return `${propName} IN ${attributeName}`;
|
|
@@ -577,6 +1062,12 @@ export class DynamoDbEntityStorageConnector {
|
|
|
577
1062
|
else if (type === "boolean") {
|
|
578
1063
|
return { BOOL: Coerce.boolean(value) ?? false };
|
|
579
1064
|
}
|
|
1065
|
+
if (Is.boolean(value)) {
|
|
1066
|
+
return { BOOL: value };
|
|
1067
|
+
}
|
|
1068
|
+
else if (Is.number(value)) {
|
|
1069
|
+
return { N: value.toString() };
|
|
1070
|
+
}
|
|
580
1071
|
return { S: Coerce.string(value) ?? "" };
|
|
581
1072
|
}
|
|
582
1073
|
/**
|
|
@@ -608,9 +1099,9 @@ export class DynamoDbEntityStorageConnector {
|
|
|
608
1099
|
* @internal
|
|
609
1100
|
*/
|
|
610
1101
|
createConnectionConfig() {
|
|
611
|
-
const requestHandler =
|
|
612
|
-
requestTimeout: this._config.connectionTimeoutMs
|
|
613
|
-
|
|
1102
|
+
const requestHandler = Is.number(this._config.connectionTimeoutMs)
|
|
1103
|
+
? { requestTimeout: this._config.connectionTimeoutMs }
|
|
1104
|
+
: undefined;
|
|
614
1105
|
if (Is.stringValue(this._config.secretAccessKey) &&
|
|
615
1106
|
Is.stringValue(this._config.accessKeyId) &&
|
|
616
1107
|
this._config.authMode === "credentials") {
|
|
@@ -621,13 +1112,15 @@ export class DynamoDbEntityStorageConnector {
|
|
|
621
1112
|
},
|
|
622
1113
|
endpoint: this._config.endpoint,
|
|
623
1114
|
region: this._config.region,
|
|
624
|
-
requestHandler
|
|
1115
|
+
requestHandler,
|
|
1116
|
+
maxAttempts: this._config.maxAttempts
|
|
625
1117
|
};
|
|
626
1118
|
}
|
|
627
1119
|
return {
|
|
628
1120
|
endpoint: this._config.endpoint,
|
|
629
1121
|
region: this._config.region,
|
|
630
|
-
requestHandler
|
|
1122
|
+
requestHandler,
|
|
1123
|
+
maxAttempts: this._config.maxAttempts
|
|
631
1124
|
};
|
|
632
1125
|
}
|
|
633
1126
|
/**
|
|
@@ -639,8 +1132,9 @@ export class DynamoDbEntityStorageConnector {
|
|
|
639
1132
|
async tableExists(tableName) {
|
|
640
1133
|
try {
|
|
641
1134
|
const dbConnection = this.createConnection();
|
|
642
|
-
await dbConnection.describeTable({ TableName: tableName });
|
|
643
|
-
|
|
1135
|
+
const result = await dbConnection.describeTable({ TableName: tableName });
|
|
1136
|
+
// A table in DELETING state should not be treated as existing
|
|
1137
|
+
return result.Table?.TableStatus !== "DELETING";
|
|
644
1138
|
}
|
|
645
1139
|
catch {
|
|
646
1140
|
return false;
|
|
@@ -665,6 +1159,8 @@ export class DynamoDbEntityStorageConnector {
|
|
|
665
1159
|
let indexName = Is.stringValue(secondaryIndex)
|
|
666
1160
|
? `${secondaryIndex}Index`
|
|
667
1161
|
: undefined;
|
|
1162
|
+
// The attribute whose value must appear in ExclusiveStartKey when querying a GSI
|
|
1163
|
+
let gsiAttribute = secondaryIndex;
|
|
668
1164
|
// If we have a sortable property defined in the descriptor then we must use
|
|
669
1165
|
// the secondary index for the query
|
|
670
1166
|
let scanAscending = true;
|
|
@@ -674,17 +1170,14 @@ export class DynamoDbEntityStorageConnector {
|
|
|
674
1170
|
}
|
|
675
1171
|
for (const sortProperty of sortProperties) {
|
|
676
1172
|
const propertySchema = this._entitySchema.properties?.find(e => e.property === sortProperty.property);
|
|
677
|
-
if (
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1173
|
+
if (propertySchema?.isPrimary) {
|
|
1174
|
+
indexName = undefined;
|
|
1175
|
+
gsiAttribute = undefined;
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
indexName = `${sortProperty.property}Index`;
|
|
1179
|
+
gsiAttribute = sortProperty.property;
|
|
684
1180
|
}
|
|
685
|
-
indexName = propertySchema.isPrimary
|
|
686
|
-
? undefined
|
|
687
|
-
: `${sortProperty.property}Index`;
|
|
688
1181
|
scanAscending = sortProperty.sortDirection === SortDirection.Ascending;
|
|
689
1182
|
}
|
|
690
1183
|
}
|
|
@@ -694,11 +1187,59 @@ export class DynamoDbEntityStorageConnector {
|
|
|
694
1187
|
S: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
695
1188
|
}
|
|
696
1189
|
};
|
|
697
|
-
const expressions = this.buildQueryParameters("", conditions, attributeNames, attributeValues,
|
|
1190
|
+
const expressions = this.buildQueryParameters("", conditions, attributeNames, attributeValues, gsiAttribute);
|
|
1191
|
+
if (expressions.noResults) {
|
|
1192
|
+
return { entities: [], cursor: undefined };
|
|
1193
|
+
}
|
|
1194
|
+
// OR conditions on primary key attributes can't use KeyConditionExpression or
|
|
1195
|
+
// FilterExpression in a QueryCommand — fall back to a full table ScanCommand.
|
|
1196
|
+
if (expressions.requiresScan) {
|
|
1197
|
+
let scanFilter = "#partitionId = :partitionId";
|
|
1198
|
+
if (Is.stringValue(expressions.filterCondition)) {
|
|
1199
|
+
scanFilter += ` AND ${expressions.filterCondition.trim()}`;
|
|
1200
|
+
}
|
|
1201
|
+
const dbConnection = this.createConnection();
|
|
1202
|
+
const matchingItems = [];
|
|
1203
|
+
let scanStartKey = Is.empty(cursor)
|
|
1204
|
+
? undefined
|
|
1205
|
+
: ObjectHelper.fromBytes(Converter.base64ToBytes(cursor));
|
|
1206
|
+
const scanProjection = this.buildProjectionExpression(properties, attributeNames);
|
|
1207
|
+
do {
|
|
1208
|
+
const scanResult = await dbConnection.send(new RawScanCommand({
|
|
1209
|
+
TableName: this._config.tableName,
|
|
1210
|
+
FilterExpression: scanFilter,
|
|
1211
|
+
ExpressionAttributeNames: attributeNames,
|
|
1212
|
+
ExpressionAttributeValues: attributeValues,
|
|
1213
|
+
ProjectionExpression: scanProjection,
|
|
1214
|
+
ExclusiveStartKey: scanStartKey
|
|
1215
|
+
}));
|
|
1216
|
+
matchingItems.push(...(scanResult.Items ?? []));
|
|
1217
|
+
scanStartKey = scanResult.LastEvaluatedKey;
|
|
1218
|
+
} while (!Is.empty(scanStartKey));
|
|
1219
|
+
const hasMore = matchingItems.length > returnSize;
|
|
1220
|
+
const returnedRawItems = hasMore ? matchingItems.slice(0, returnSize) : matchingItems;
|
|
1221
|
+
let resultCursor;
|
|
1222
|
+
if (hasMore) {
|
|
1223
|
+
const lastRawItem = returnedRawItems[returnedRawItems.length - 1];
|
|
1224
|
+
const syntheticKey = {
|
|
1225
|
+
[DynamoDbEntityStorageConnector._PARTITION_KEY]: lastRawItem[DynamoDbEntityStorageConnector._PARTITION_KEY],
|
|
1226
|
+
[this._primaryKey.property]: lastRawItem[this._primaryKey.property]
|
|
1227
|
+
};
|
|
1228
|
+
resultCursor = Converter.bytesToBase64(ObjectHelper.toBytes(syntheticKey));
|
|
1229
|
+
}
|
|
1230
|
+
const scanEntities = returnedRawItems.map(item => {
|
|
1231
|
+
const unmarshalled = unmarshall(item);
|
|
1232
|
+
return EntityStorageHelper.unPrepareEntity(unmarshalled, [
|
|
1233
|
+
DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
1234
|
+
]);
|
|
1235
|
+
});
|
|
1236
|
+
return { entities: scanEntities, cursor: resultCursor };
|
|
1237
|
+
}
|
|
698
1238
|
let keyExpression = "#partitionId = :partitionId";
|
|
699
1239
|
if (expressions.keyCondition.length > 0) {
|
|
700
1240
|
keyExpression += ` AND ${expressions.keyCondition}`;
|
|
701
1241
|
}
|
|
1242
|
+
const queryProjection = this.buildProjectionExpression(properties, attributeNames);
|
|
702
1243
|
const query = new QueryCommand({
|
|
703
1244
|
TableName: this._config.tableName,
|
|
704
1245
|
IndexName: indexName,
|
|
@@ -708,8 +1249,8 @@ export class DynamoDbEntityStorageConnector {
|
|
|
708
1249
|
: undefined,
|
|
709
1250
|
ExpressionAttributeNames: attributeNames,
|
|
710
1251
|
ExpressionAttributeValues: attributeValues,
|
|
711
|
-
ProjectionExpression:
|
|
712
|
-
Limit: returnSize,
|
|
1252
|
+
ProjectionExpression: queryProjection,
|
|
1253
|
+
Limit: returnSize + 1,
|
|
713
1254
|
ScanIndexForward: scanAscending,
|
|
714
1255
|
ExclusiveStartKey: Is.empty(cursor)
|
|
715
1256
|
? undefined
|
|
@@ -717,20 +1258,28 @@ export class DynamoDbEntityStorageConnector {
|
|
|
717
1258
|
});
|
|
718
1259
|
const connection = this.createDocClient();
|
|
719
1260
|
const results = await connection.send(query);
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1261
|
+
const rawItems = results.Items ?? [];
|
|
1262
|
+
const hasMore = rawItems.length > returnSize;
|
|
1263
|
+
const returnedRawItems = hasMore ? rawItems.slice(0, returnSize) : rawItems;
|
|
1264
|
+
let resultCursor;
|
|
1265
|
+
if (hasMore) {
|
|
1266
|
+
const lastRawItem = returnedRawItems[returnedRawItems.length - 1];
|
|
1267
|
+
const syntheticKey = {
|
|
1268
|
+
[DynamoDbEntityStorageConnector._PARTITION_KEY]: lastRawItem[DynamoDbEntityStorageConnector._PARTITION_KEY],
|
|
1269
|
+
[this._primaryKey.property]: lastRawItem[this._primaryKey.property]
|
|
1270
|
+
};
|
|
1271
|
+
if (Is.stringValue(gsiAttribute)) {
|
|
1272
|
+
syntheticKey[gsiAttribute] = lastRawItem[gsiAttribute];
|
|
1273
|
+
}
|
|
1274
|
+
resultCursor = Converter.bytesToBase64(ObjectHelper.toBytes(syntheticKey));
|
|
727
1275
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
};
|
|
1276
|
+
const entities = returnedRawItems.map(item => {
|
|
1277
|
+
const unmarshalled = unmarshall(item);
|
|
1278
|
+
return EntityStorageHelper.unPrepareEntity(unmarshalled, [
|
|
1279
|
+
DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
1280
|
+
]);
|
|
1281
|
+
});
|
|
1282
|
+
return { entities, cursor: resultCursor };
|
|
734
1283
|
}
|
|
735
1284
|
catch (err) {
|
|
736
1285
|
if (BaseError.isErrorCode(err, "ResourceNotFoundException")) {
|
|
@@ -773,5 +1322,26 @@ export class DynamoDbEntityStorageConnector {
|
|
|
773
1322
|
}
|
|
774
1323
|
return { conditionExpression, attributeNames, attributeValues };
|
|
775
1324
|
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Build a ProjectionExpression string and register a safe alias in attributeNames for every
|
|
1327
|
+
* projected property, preventing ValidationException when a property name is a DynamoDB
|
|
1328
|
+
* reserved word (e.g. "role", "name", "status").
|
|
1329
|
+
* @param properties The properties to project, or undefined to return all attributes.
|
|
1330
|
+
* @param attributeNames The expression attribute names map to mutate with the aliases.
|
|
1331
|
+
* @returns The ProjectionExpression string, or undefined when no projection is needed.
|
|
1332
|
+
* @internal
|
|
1333
|
+
*/
|
|
1334
|
+
buildProjectionExpression(properties, attributeNames) {
|
|
1335
|
+
if (!Is.arrayValue(properties)) {
|
|
1336
|
+
return undefined;
|
|
1337
|
+
}
|
|
1338
|
+
return properties
|
|
1339
|
+
.map(p => {
|
|
1340
|
+
const alias = `#p_${p}`;
|
|
1341
|
+
attributeNames[alias] = p;
|
|
1342
|
+
return alias;
|
|
1343
|
+
})
|
|
1344
|
+
.join(", ");
|
|
1345
|
+
}
|
|
776
1346
|
}
|
|
777
1347
|
//# sourceMappingURL=dynamoDbEntityStorageConnector.js.map
|