@twin.org/entity-storage-connector-dynamodb 0.0.3-next.13 → 0.0.3-next.14
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 +1 -2
- package/dist/es/dynamoDbEntityStorageConnector.js +319 -58
- package/dist/es/dynamoDbEntityStorageConnector.js.map +1 -1
- package/dist/es/models/IDynamoDbEntityStorageConnectorConfig.js.map +1 -1
- package/dist/types/dynamoDbEntityStorageConnector.d.ts +32 -3
- package/dist/types/models/IDynamoDbEntityStorageConnectorConfig.d.ts +5 -0
- package/docs/changelog.md +21 -0
- package/docs/reference/classes/DynamoDbEntityStorageConnector.md +161 -15
- package/docs/reference/interfaces/IDynamoDbEntityStorageConnectorConfig.md +9 -0
- package/locales/en.json +3 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -13,8 +13,7 @@ npm install @twin.org/entity-storage-connector-dynamodb
|
|
|
13
13
|
To perform testing of this component it may be necessary to launch a local instance to communicate with.
|
|
14
14
|
|
|
15
15
|
```shell
|
|
16
|
-
docker
|
|
17
|
-
docker run -d --name twin-entity-storage-dynamodb -p 10000:8000 amazon/dynamodb-local:latest
|
|
16
|
+
docker run -d --name twin-entity-storage-dynamodb -p 8000:8000 amazon/dynamodb-local
|
|
18
17
|
```
|
|
19
18
|
|
|
20
19
|
## Examples
|
|
@@ -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";
|
|
3
|
+
import { BatchWriteItemCommand, DynamoDB, QueryCommand, ScanCommand as RawScanCommand, waitUntilTableExists, waitUntilTableNotExists } from "@aws-sdk/client-dynamodb";
|
|
4
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
7
|
import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, HealthStatus, Is, ObjectHelper } from "@twin.org/core";
|
|
8
8
|
import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, LogicalOperator, SortDirection } from "@twin.org/entity";
|
|
9
|
+
import { EntityHelper } 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)
|
|
@@ -203,7 +210,7 @@ export class DynamoDbEntityStorageConnector {
|
|
|
203
210
|
// Wait for table to exist
|
|
204
211
|
await waitUntilTableExists({
|
|
205
212
|
client: dbConnection,
|
|
206
|
-
maxWaitTime:
|
|
213
|
+
maxWaitTime: 60
|
|
207
214
|
}, {
|
|
208
215
|
TableName: this._config.tableName
|
|
209
216
|
});
|
|
@@ -279,8 +286,12 @@ export class DynamoDbEntityStorageConnector {
|
|
|
279
286
|
}
|
|
280
287
|
});
|
|
281
288
|
const response = await docClient.send(getCommand);
|
|
282
|
-
|
|
283
|
-
|
|
289
|
+
if (response.Item) {
|
|
290
|
+
return EntityHelper.unPrepareEntity(response.Item, [
|
|
291
|
+
DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
return undefined;
|
|
284
295
|
}
|
|
285
296
|
const finalConditions = {
|
|
286
297
|
conditions: []
|
|
@@ -325,17 +336,21 @@ export class DynamoDbEntityStorageConnector {
|
|
|
325
336
|
Guards.object(DynamoDbEntityStorageConnector.CLASS_NAME, "entity", entity);
|
|
326
337
|
const contextIds = await ContextIdStore.getContextIds();
|
|
327
338
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
328
|
-
|
|
329
|
-
|
|
339
|
+
const prepared = EntityHelper.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
|
+
]);
|
|
347
|
+
const id = prepared[this._primaryKey.property];
|
|
330
348
|
try {
|
|
331
349
|
const docClient = this.createDocClient();
|
|
332
350
|
const { conditionExpression, attributeNames, attributeValues } = this.buildConditionExpression(conditions);
|
|
333
351
|
const putCommand = new PutCommand({
|
|
334
352
|
TableName: this._config.tableName,
|
|
335
|
-
Item:
|
|
336
|
-
[DynamoDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
337
|
-
...entity
|
|
338
|
-
},
|
|
353
|
+
Item: prepared,
|
|
339
354
|
// Only set the condition expression if we have conditions to match
|
|
340
355
|
// and the primary key exists, otherwise we are creating a new object
|
|
341
356
|
ConditionExpression: Is.stringValue(conditionExpression)
|
|
@@ -369,22 +384,24 @@ export class DynamoDbEntityStorageConnector {
|
|
|
369
384
|
Guards.arrayValue(DynamoDbEntityStorageConnector.CLASS_NAME, "entities", entities);
|
|
370
385
|
const contextIds = await ContextIdStore.getContextIds();
|
|
371
386
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
387
|
+
const preparedEntities = entities.map(entity => EntityHelper.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
|
+
]));
|
|
375
395
|
try {
|
|
376
396
|
const docClient = this.createDocClient();
|
|
377
397
|
const chunkSize = 25;
|
|
378
|
-
for (let i = 0; i <
|
|
379
|
-
const chunk =
|
|
398
|
+
for (let i = 0; i < preparedEntities.length; i += chunkSize) {
|
|
399
|
+
const chunk = preparedEntities.slice(i, i + chunkSize);
|
|
380
400
|
await docClient.send(new BatchWriteCommand({
|
|
381
401
|
RequestItems: {
|
|
382
402
|
[this._config.tableName]: chunk.map(entity => ({
|
|
383
403
|
PutRequest: {
|
|
384
|
-
Item:
|
|
385
|
-
[DynamoDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
386
|
-
...entity
|
|
387
|
-
}
|
|
404
|
+
Item: entity
|
|
388
405
|
}
|
|
389
406
|
}))
|
|
390
407
|
}
|
|
@@ -534,6 +551,7 @@ export class DynamoDbEntityStorageConnector {
|
|
|
534
551
|
try {
|
|
535
552
|
const dbConnection = this.createConnection();
|
|
536
553
|
await dbConnection.deleteTable({ TableName: this._config.tableName });
|
|
554
|
+
await waitUntilTableNotExists({ client: dbConnection, maxWaitTime: 60 }, { TableName: this._config.tableName });
|
|
537
555
|
await nodeLogging?.log({
|
|
538
556
|
level: "info",
|
|
539
557
|
source: DynamoDbEntityStorageConnector.CLASS_NAME,
|
|
@@ -571,12 +589,22 @@ export class DynamoDbEntityStorageConnector {
|
|
|
571
589
|
}
|
|
572
590
|
/**
|
|
573
591
|
* Count all the entities which match the conditions.
|
|
592
|
+
* @param conditions The optional conditions to match for the entities.
|
|
574
593
|
* @returns The total count of entities in the storage.
|
|
575
594
|
*/
|
|
576
|
-
async count() {
|
|
595
|
+
async count(conditions) {
|
|
577
596
|
try {
|
|
578
597
|
const contextIds = await ContextIdStore.getContextIds();
|
|
579
598
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
599
|
+
const attributeNames = {
|
|
600
|
+
"#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
601
|
+
};
|
|
602
|
+
const attributeValues = {
|
|
603
|
+
":partitionId": {
|
|
604
|
+
S: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const expressions = this.buildQueryParameters("", conditions, attributeNames, attributeValues);
|
|
580
608
|
const dbConnection = this.createConnection();
|
|
581
609
|
let total = 0;
|
|
582
610
|
let exclusiveStartKey;
|
|
@@ -585,14 +613,11 @@ export class DynamoDbEntityStorageConnector {
|
|
|
585
613
|
TableName: this._config.tableName,
|
|
586
614
|
Select: "COUNT",
|
|
587
615
|
KeyConditionExpression: "#partitionId = :partitionId",
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
S: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
594
|
-
}
|
|
595
|
-
},
|
|
616
|
+
FilterExpression: Is.stringValue(expressions.filterCondition)
|
|
617
|
+
? expressions.filterCondition
|
|
618
|
+
: undefined,
|
|
619
|
+
ExpressionAttributeNames: attributeNames,
|
|
620
|
+
ExpressionAttributeValues: attributeValues,
|
|
596
621
|
ExclusiveStartKey: exclusiveStartKey
|
|
597
622
|
}));
|
|
598
623
|
total += result.Count ?? 0;
|
|
@@ -604,6 +629,151 @@ export class DynamoDbEntityStorageConnector {
|
|
|
604
629
|
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
|
|
605
630
|
}
|
|
606
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* Get a unique list of all the context ids from the storage.
|
|
634
|
+
* @returns The list of unique context ids.
|
|
635
|
+
*/
|
|
636
|
+
async getPartitionContextIds() {
|
|
637
|
+
if (!Is.arrayValue(this._partitionContextIds)) {
|
|
638
|
+
return [];
|
|
639
|
+
}
|
|
640
|
+
const contextIdsMap = {};
|
|
641
|
+
try {
|
|
642
|
+
const docClient = this.createDocClient();
|
|
643
|
+
let exclusiveStartKey;
|
|
644
|
+
do {
|
|
645
|
+
const scanResult = await docClient.send(new ScanCommand({
|
|
646
|
+
TableName: this._config.tableName,
|
|
647
|
+
ProjectionExpression: "#partitionId",
|
|
648
|
+
ExpressionAttributeNames: {
|
|
649
|
+
"#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
650
|
+
},
|
|
651
|
+
ExclusiveStartKey: exclusiveStartKey
|
|
652
|
+
}));
|
|
653
|
+
for (const item of scanResult.Items ?? []) {
|
|
654
|
+
const partitionId = item[DynamoDbEntityStorageConnector._PARTITION_KEY];
|
|
655
|
+
if (Is.stringValue(partitionId) && !(partitionId in contextIdsMap)) {
|
|
656
|
+
contextIdsMap[partitionId] = ContextIdHelper.shortSplit(this._partitionContextIds ?? [], partitionId);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
exclusiveStartKey = scanResult.LastEvaluatedKey;
|
|
660
|
+
} while (exclusiveStartKey);
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
|
|
664
|
+
}
|
|
665
|
+
return Object.values(contextIdsMap);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Create the target connector for performing the migration it will use a temporary storage location.
|
|
669
|
+
* @param newEntitySchema The name of the new entity schema to create the connector for.
|
|
670
|
+
* @returns Connector for performing the migration.
|
|
671
|
+
*/
|
|
672
|
+
async createTargetConnector(newEntitySchema) {
|
|
673
|
+
// We create a new table for the migration with a unique name to avoid conflicts with the existing table
|
|
674
|
+
// This table will be swapped with the existing table once the migration is finalized.
|
|
675
|
+
const migrationTableName = `${this._config.tableName}Migration${Date.now()}`;
|
|
676
|
+
return new DynamoDbEntityStorageConnector({
|
|
677
|
+
entitySchema: newEntitySchema,
|
|
678
|
+
config: {
|
|
679
|
+
...this._config,
|
|
680
|
+
tableName: migrationTableName
|
|
681
|
+
},
|
|
682
|
+
partitionContextIds: this._partitionContextIds
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Finalize the migration by tearing down the old connector and replacing it with the new one.
|
|
687
|
+
* @param targetConnector The target connector to finalize the migration with.
|
|
688
|
+
* @param options The options to control how the migration is finalized.
|
|
689
|
+
* @param loggingComponentType The logging component type to use for logging during the migration finalization.
|
|
690
|
+
* @returns A promise that resolves when the migration is finalized.
|
|
691
|
+
*/
|
|
692
|
+
async finalizeMigration(targetConnector, options, loggingComponentType) {
|
|
693
|
+
// There is no rename operation in DynamoDB so we have to create a new table with the original name and copy the data over
|
|
694
|
+
// Teardown the existing table with the original name to free up the name for the new table
|
|
695
|
+
await this.teardown(loggingComponentType);
|
|
696
|
+
// Create a new connector with the original table name but with the new schema
|
|
697
|
+
// and copy the data from the migration table to the new table using batch operations
|
|
698
|
+
const finalConnector = new DynamoDbEntityStorageConnector({
|
|
699
|
+
entitySchema: targetConnector._entitySchemaName,
|
|
700
|
+
config: this._config,
|
|
701
|
+
partitionContextIds: this._partitionContextIds
|
|
702
|
+
});
|
|
703
|
+
if (await finalConnector.bootstrap(loggingComponentType)) {
|
|
704
|
+
// Since there is no rename, we need to copy the data from the migration table to the new table
|
|
705
|
+
const partitions = await targetConnector.getPartitionContextIds();
|
|
706
|
+
const batchSize = options?.batchSize ?? DynamoDbEntityStorageConnector._DEFAULT_LIMIT;
|
|
707
|
+
await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
|
|
708
|
+
await targetConnector.teardown(loggingComponentType);
|
|
709
|
+
return finalConnector;
|
|
710
|
+
}
|
|
711
|
+
throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Cleanup the migration if a migration fails or needs to be aborted.
|
|
715
|
+
* @param targetConnector The target connector to cleanup the migration with.
|
|
716
|
+
* @param options The options to control how the migration is cleaned up.
|
|
717
|
+
* @param loggingComponentType The optional component type to use for logging the migration progress.
|
|
718
|
+
* @returns A promise that resolves when the migration is cleaned up.
|
|
719
|
+
*/
|
|
720
|
+
async cleanupMigration(targetConnector, options, loggingComponentType) {
|
|
721
|
+
// If something failed the only thing to cleanup is the migration table
|
|
722
|
+
await targetConnector?.teardown?.(loggingComponentType);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Copy all entities from sourceConnector to destConnector, paging through each partition.
|
|
726
|
+
* @param sourceConnector The connector to read entities from.
|
|
727
|
+
* @param destConnector The connector to write entities to.
|
|
728
|
+
* @param partitions The partition list returned by getPartitionContextIds.
|
|
729
|
+
* @param batchSize The number of entities to read per page.
|
|
730
|
+
* @internal
|
|
731
|
+
*/
|
|
732
|
+
async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
|
|
733
|
+
let partitionList;
|
|
734
|
+
if (Is.arrayValue(partitions)) {
|
|
735
|
+
partitionList = partitions;
|
|
736
|
+
}
|
|
737
|
+
else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
|
|
738
|
+
partitionList = [];
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
partitionList = [{}];
|
|
742
|
+
}
|
|
743
|
+
const dbConnection = sourceConnector.createConnection();
|
|
744
|
+
const chunkSize = 25;
|
|
745
|
+
for (let i = 0; i < partitionList.length; i++) {
|
|
746
|
+
const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds) ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
747
|
+
let exclusiveStartKey;
|
|
748
|
+
do {
|
|
749
|
+
const { Items: items, LastEvaluatedKey: lastKey } = await dbConnection.send(new QueryCommand({
|
|
750
|
+
TableName: sourceConnector._config.tableName,
|
|
751
|
+
KeyConditionExpression: `#${DynamoDbEntityStorageConnector._PARTITION_KEY} = :${DynamoDbEntityStorageConnector._PARTITION_KEY}`,
|
|
752
|
+
ExpressionAttributeNames: {
|
|
753
|
+
[`#${DynamoDbEntityStorageConnector._PARTITION_KEY}`]: DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
754
|
+
},
|
|
755
|
+
ExpressionAttributeValues: {
|
|
756
|
+
[`:${DynamoDbEntityStorageConnector._PARTITION_KEY}`]: { S: partitionKey }
|
|
757
|
+
},
|
|
758
|
+
Limit: batchSize,
|
|
759
|
+
ExclusiveStartKey: exclusiveStartKey
|
|
760
|
+
}));
|
|
761
|
+
exclusiveStartKey = lastKey;
|
|
762
|
+
if (Is.arrayValue(items)) {
|
|
763
|
+
for (let j = 0; j < items.length; j += chunkSize) {
|
|
764
|
+
const chunk = items.slice(j, j + chunkSize);
|
|
765
|
+
await dbConnection.send(new BatchWriteItemCommand({
|
|
766
|
+
RequestItems: {
|
|
767
|
+
[destConnector._config.tableName]: chunk.map(item => ({
|
|
768
|
+
PutRequest: { Item: item }
|
|
769
|
+
}))
|
|
770
|
+
}
|
|
771
|
+
}));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
} while (exclusiveStartKey);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
607
777
|
/**
|
|
608
778
|
* Create the parameters for a query.
|
|
609
779
|
* @param objectPath The path for the nested object.
|
|
@@ -618,19 +788,44 @@ export class DynamoDbEntityStorageConnector {
|
|
|
618
788
|
if (Is.undefined(condition)) {
|
|
619
789
|
return {
|
|
620
790
|
keyCondition: "",
|
|
621
|
-
filterCondition: ""
|
|
791
|
+
filterCondition: "",
|
|
792
|
+
requiresScan: false
|
|
622
793
|
};
|
|
623
794
|
}
|
|
624
795
|
if ("conditions" in condition) {
|
|
625
796
|
if (condition.conditions.length === 0) {
|
|
626
797
|
return {
|
|
627
798
|
keyCondition: "",
|
|
628
|
-
filterCondition: ""
|
|
799
|
+
filterCondition: "",
|
|
800
|
+
requiresScan: false
|
|
629
801
|
};
|
|
630
802
|
}
|
|
631
803
|
// It's a group of comparisons, so check the individual items and combine with the logical operator
|
|
632
804
|
const joinConditions = condition.conditions.map(c => this.buildQueryParameters(objectPath, c, attributeNames, attributeValues, secondaryIndex));
|
|
633
805
|
const logicalOperator = this.mapConditionalOperator(condition.logicalOperator);
|
|
806
|
+
// DynamoDB does not support OR in KeyConditionExpression, so when the operator
|
|
807
|
+
// is OR we must move all conditions (including key conditions) into FilterExpression.
|
|
808
|
+
if (condition.logicalOperator === LogicalOperator.Or) {
|
|
809
|
+
const parts = joinConditions
|
|
810
|
+
.map(j => {
|
|
811
|
+
const subParts = [j.keyCondition.trim(), j.filterCondition.trim()].filter(s => s.length > 0);
|
|
812
|
+
if (subParts.length === 0) {
|
|
813
|
+
return "";
|
|
814
|
+
}
|
|
815
|
+
if (subParts.length === 1) {
|
|
816
|
+
return subParts[0];
|
|
817
|
+
}
|
|
818
|
+
return `(${subParts.join(" AND ")})`;
|
|
819
|
+
})
|
|
820
|
+
.filter(s => s.length > 0);
|
|
821
|
+
const hasKeyConditions = joinConditions.some(j => j.keyCondition.length > 0);
|
|
822
|
+
const filterCondition = parts.join(" OR ");
|
|
823
|
+
return {
|
|
824
|
+
keyCondition: "",
|
|
825
|
+
filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : "",
|
|
826
|
+
requiresScan: hasKeyConditions
|
|
827
|
+
};
|
|
828
|
+
}
|
|
634
829
|
const keyCondition = joinConditions
|
|
635
830
|
.filter(j => j.keyCondition.length > 0)
|
|
636
831
|
.map(j => j.keyCondition)
|
|
@@ -641,7 +836,8 @@ export class DynamoDbEntityStorageConnector {
|
|
|
641
836
|
.join(` ${logicalOperator} `);
|
|
642
837
|
return {
|
|
643
838
|
keyCondition: Is.stringValue(keyCondition) ? ` (${keyCondition}) ` : "",
|
|
644
|
-
filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : ""
|
|
839
|
+
filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : "",
|
|
840
|
+
requiresScan: joinConditions.some(j => j.requiresScan)
|
|
645
841
|
};
|
|
646
842
|
}
|
|
647
843
|
const schemaProp = this._entitySchema.properties?.find(p => p.property === condition.property);
|
|
@@ -650,7 +846,8 @@ export class DynamoDbEntityStorageConnector {
|
|
|
650
846
|
const isKey = schemaProp?.isPrimary ?? (schemaProp?.isSecondary && schemaProp?.property === secondaryIndex);
|
|
651
847
|
return {
|
|
652
848
|
keyCondition: isKey ? comparison : "",
|
|
653
|
-
filterCondition: !isKey ? comparison : ""
|
|
849
|
+
filterCondition: !isKey ? comparison : "",
|
|
850
|
+
requiresScan: false
|
|
654
851
|
};
|
|
655
852
|
}
|
|
656
853
|
/**
|
|
@@ -672,14 +869,24 @@ export class DynamoDbEntityStorageConnector {
|
|
|
672
869
|
prop += comparator.property;
|
|
673
870
|
let attributeName = this.populateAttributeNames(prop, attributeNames);
|
|
674
871
|
if (Is.empty(comparator.value)) {
|
|
872
|
+
// prepareEntity converts undefined → null before storing, so DynamoDB holds the
|
|
873
|
+
// attribute with type NULL rather than omitting it. Use attribute_type to match.
|
|
874
|
+
const nullTypePropName = `:${attributeName.replace(/\./g, "").replace(/#/g, "")}Null`;
|
|
875
|
+
attributeValues[nullTypePropName] = { S: "NULL" };
|
|
675
876
|
if (comparator.comparison === ComparisonOperator.Equals) {
|
|
676
|
-
return `
|
|
877
|
+
return `attribute_type(${attributeName}, ${nullTypePropName})`;
|
|
677
878
|
}
|
|
678
879
|
else if (comparator.comparison === ComparisonOperator.NotEquals) {
|
|
679
|
-
return `
|
|
880
|
+
return `NOT attribute_type(${attributeName}, ${nullTypePropName})`;
|
|
680
881
|
}
|
|
681
882
|
}
|
|
682
|
-
|
|
883
|
+
const basePropName = `:${attributeName.replace(/\./g, "").replace(/#/g, "")}`;
|
|
884
|
+
let propName = basePropName;
|
|
885
|
+
let propSuffix = 0;
|
|
886
|
+
while (!Is.undefined(attributeValues[propName])) {
|
|
887
|
+
propSuffix++;
|
|
888
|
+
propName = `${basePropName}${propSuffix}`;
|
|
889
|
+
}
|
|
683
890
|
if (Is.array(comparator.value)) {
|
|
684
891
|
const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
|
|
685
892
|
const arrAttributeNames = [];
|
|
@@ -716,7 +923,7 @@ export class DynamoDbEntityStorageConnector {
|
|
|
716
923
|
return `contains(${attributeName}, ${propName})`;
|
|
717
924
|
}
|
|
718
925
|
else if (comparator.comparison === ComparisonOperator.NotIncludes) {
|
|
719
|
-
return `
|
|
926
|
+
return `NOT contains(${attributeName}, ${propName})`;
|
|
720
927
|
}
|
|
721
928
|
else if (comparator.comparison === ComparisonOperator.In) {
|
|
722
929
|
return `${propName} IN ${attributeName}`;
|
|
@@ -816,9 +1023,9 @@ export class DynamoDbEntityStorageConnector {
|
|
|
816
1023
|
* @internal
|
|
817
1024
|
*/
|
|
818
1025
|
createConnectionConfig() {
|
|
819
|
-
const requestHandler =
|
|
820
|
-
requestTimeout: this._config.connectionTimeoutMs
|
|
821
|
-
|
|
1026
|
+
const requestHandler = Is.number(this._config.connectionTimeoutMs)
|
|
1027
|
+
? { requestTimeout: this._config.connectionTimeoutMs }
|
|
1028
|
+
: undefined;
|
|
822
1029
|
if (Is.stringValue(this._config.secretAccessKey) &&
|
|
823
1030
|
Is.stringValue(this._config.accessKeyId) &&
|
|
824
1031
|
this._config.authMode === "credentials") {
|
|
@@ -829,13 +1036,15 @@ export class DynamoDbEntityStorageConnector {
|
|
|
829
1036
|
},
|
|
830
1037
|
endpoint: this._config.endpoint,
|
|
831
1038
|
region: this._config.region,
|
|
832
|
-
requestHandler
|
|
1039
|
+
requestHandler,
|
|
1040
|
+
maxAttempts: this._config.maxAttempts
|
|
833
1041
|
};
|
|
834
1042
|
}
|
|
835
1043
|
return {
|
|
836
1044
|
endpoint: this._config.endpoint,
|
|
837
1045
|
region: this._config.region,
|
|
838
|
-
requestHandler
|
|
1046
|
+
requestHandler,
|
|
1047
|
+
maxAttempts: this._config.maxAttempts
|
|
839
1048
|
};
|
|
840
1049
|
}
|
|
841
1050
|
/**
|
|
@@ -847,8 +1056,9 @@ export class DynamoDbEntityStorageConnector {
|
|
|
847
1056
|
async tableExists(tableName) {
|
|
848
1057
|
try {
|
|
849
1058
|
const dbConnection = this.createConnection();
|
|
850
|
-
await dbConnection.describeTable({ TableName: tableName });
|
|
851
|
-
|
|
1059
|
+
const result = await dbConnection.describeTable({ TableName: tableName });
|
|
1060
|
+
// A table in DELETING state should not be treated as existing
|
|
1061
|
+
return result.Table?.TableStatus !== "DELETING";
|
|
852
1062
|
}
|
|
853
1063
|
catch {
|
|
854
1064
|
return false;
|
|
@@ -903,6 +1113,49 @@ export class DynamoDbEntityStorageConnector {
|
|
|
903
1113
|
}
|
|
904
1114
|
};
|
|
905
1115
|
const expressions = this.buildQueryParameters("", conditions, attributeNames, attributeValues, secondaryIndex);
|
|
1116
|
+
// OR conditions on primary key attributes can't use KeyConditionExpression or
|
|
1117
|
+
// FilterExpression in a QueryCommand — fall back to a full table ScanCommand.
|
|
1118
|
+
if (expressions.requiresScan) {
|
|
1119
|
+
let scanFilter = "#partitionId = :partitionId";
|
|
1120
|
+
if (Is.stringValue(expressions.filterCondition)) {
|
|
1121
|
+
scanFilter += ` AND ${expressions.filterCondition.trim()}`;
|
|
1122
|
+
}
|
|
1123
|
+
const dbConnection = this.createConnection();
|
|
1124
|
+
const matchingItems = [];
|
|
1125
|
+
let scanStartKey = Is.empty(cursor)
|
|
1126
|
+
? undefined
|
|
1127
|
+
: ObjectHelper.fromBytes(Converter.base64ToBytes(cursor));
|
|
1128
|
+
do {
|
|
1129
|
+
const scanResult = await dbConnection.send(new RawScanCommand({
|
|
1130
|
+
TableName: this._config.tableName,
|
|
1131
|
+
FilterExpression: scanFilter,
|
|
1132
|
+
ExpressionAttributeNames: attributeNames,
|
|
1133
|
+
ExpressionAttributeValues: attributeValues,
|
|
1134
|
+
ProjectionExpression: properties?.map(p => p).join(", "),
|
|
1135
|
+
ExclusiveStartKey: scanStartKey
|
|
1136
|
+
}));
|
|
1137
|
+
matchingItems.push(...(scanResult.Items ?? []));
|
|
1138
|
+
scanStartKey = scanResult.LastEvaluatedKey;
|
|
1139
|
+
} while (!Is.empty(scanStartKey));
|
|
1140
|
+
const hasMore = matchingItems.length > returnSize;
|
|
1141
|
+
const returnedRawItems = hasMore ? matchingItems.slice(0, returnSize) : matchingItems;
|
|
1142
|
+
let resultCursor;
|
|
1143
|
+
if (hasMore) {
|
|
1144
|
+
const lastRawItem = returnedRawItems[returnedRawItems.length - 1];
|
|
1145
|
+
const syntheticKey = {
|
|
1146
|
+
[DynamoDbEntityStorageConnector._PARTITION_KEY]: lastRawItem[DynamoDbEntityStorageConnector._PARTITION_KEY],
|
|
1147
|
+
[this._primaryKey.property]: lastRawItem[this._primaryKey.property]
|
|
1148
|
+
};
|
|
1149
|
+
resultCursor = Converter.bytesToBase64(ObjectHelper.toBytes(syntheticKey));
|
|
1150
|
+
}
|
|
1151
|
+
const scanEntities = returnedRawItems.map(item => {
|
|
1152
|
+
const unmarshalled = unmarshall(item);
|
|
1153
|
+
return EntityHelper.unPrepareEntity(unmarshalled, [
|
|
1154
|
+
DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
1155
|
+
]);
|
|
1156
|
+
});
|
|
1157
|
+
return { entities: scanEntities, cursor: resultCursor };
|
|
1158
|
+
}
|
|
906
1159
|
let keyExpression = "#partitionId = :partitionId";
|
|
907
1160
|
if (expressions.keyCondition.length > 0) {
|
|
908
1161
|
keyExpression += ` AND ${expressions.keyCondition}`;
|
|
@@ -917,7 +1170,7 @@ export class DynamoDbEntityStorageConnector {
|
|
|
917
1170
|
ExpressionAttributeNames: attributeNames,
|
|
918
1171
|
ExpressionAttributeValues: attributeValues,
|
|
919
1172
|
ProjectionExpression: properties?.map(p => p).join(", "),
|
|
920
|
-
Limit: returnSize,
|
|
1173
|
+
Limit: returnSize + 1,
|
|
921
1174
|
ScanIndexForward: scanAscending,
|
|
922
1175
|
ExclusiveStartKey: Is.empty(cursor)
|
|
923
1176
|
? undefined
|
|
@@ -925,20 +1178,28 @@ export class DynamoDbEntityStorageConnector {
|
|
|
925
1178
|
});
|
|
926
1179
|
const connection = this.createDocClient();
|
|
927
1180
|
const results = await connection.send(query);
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1181
|
+
const rawItems = results.Items ?? [];
|
|
1182
|
+
const hasMore = rawItems.length > returnSize;
|
|
1183
|
+
const returnedRawItems = hasMore ? rawItems.slice(0, returnSize) : rawItems;
|
|
1184
|
+
let resultCursor;
|
|
1185
|
+
if (hasMore) {
|
|
1186
|
+
const lastRawItem = returnedRawItems[returnedRawItems.length - 1];
|
|
1187
|
+
const syntheticKey = {
|
|
1188
|
+
[DynamoDbEntityStorageConnector._PARTITION_KEY]: lastRawItem[DynamoDbEntityStorageConnector._PARTITION_KEY],
|
|
1189
|
+
[this._primaryKey.property]: lastRawItem[this._primaryKey.property]
|
|
1190
|
+
};
|
|
1191
|
+
if (Is.stringValue(secondaryIndex)) {
|
|
1192
|
+
syntheticKey[secondaryIndex] = lastRawItem[secondaryIndex];
|
|
1193
|
+
}
|
|
1194
|
+
resultCursor = Converter.bytesToBase64(ObjectHelper.toBytes(syntheticKey));
|
|
935
1195
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
};
|
|
1196
|
+
const entities = returnedRawItems.map(item => {
|
|
1197
|
+
const unmarshalled = unmarshall(item);
|
|
1198
|
+
return EntityHelper.unPrepareEntity(unmarshalled, [
|
|
1199
|
+
DynamoDbEntityStorageConnector._PARTITION_KEY
|
|
1200
|
+
]);
|
|
1201
|
+
});
|
|
1202
|
+
return { entities, cursor: resultCursor };
|
|
942
1203
|
}
|
|
943
1204
|
catch (err) {
|
|
944
1205
|
if (BaseError.isErrorCode(err, "ResourceNotFoundException")) {
|