From b1fd95dee7d1074b823cf096bf7225b429dac04c Mon Sep 17 00:00:00 2001 From: ramariei Date: Thu, 17 Apr 2025 17:50:23 +0300 Subject: [PATCH] updated transact delete item flow to check the version for optimistic locking --- ...-AmazonDynamoDBEnhancedClient-64b922e.json | 6 ++ .../AsyncCrudWithResponseIntegrationTest.java | 10 +-- .../CrudWithResponseIntegrationTest.java | 51 +++++++++++++++ .../DynamoDbEnhancedIntegrationTestBase.java | 5 ++ .../dynamodb/ScanQueryIntegrationTest.java | 14 +--- .../enhanced/dynamodb/model/Record.java | 19 +++++- .../DynamoDbEnhancedClientExtension.java | 11 ++++ .../dynamodb/DynamoDbExtensionContext.java | 8 +++ .../extensions/VersionedRecordExtension.java | 34 ++++++++++ .../internal/extensions/ChainExtension.java | 31 +++++++++ .../DefaultDynamoDbExtensionContext.java | 3 +- .../operations/DeleteItemOperation.java | 48 ++++++++++++++ .../TransactWriteItemsEnhancedRequest.java | 17 ++++- .../extensions/ChainExtensionTest.java | 40 ++++++++++++ .../VersionedRecordExtensionTest.java | 65 +++++++++++++++++++ .../operations/DeleteItemOperationTest.java | 42 ++++++++++++ 16 files changed, 384 insertions(+), 20 deletions(-) create mode 100644 .changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-64b922e.json diff --git a/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-64b922e.json b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-64b922e.json new file mode 100644 index 000000000000..91c15a2abede --- /dev/null +++ b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-64b922e.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Optimistic locking while using DynamoDbEnhancedClient - DeleteItem with TransactWriteItemsEnhancedRequest" +} diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java index f6c4d3fd40bf..25545b01a3a1 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java @@ -15,7 +15,6 @@ package software.amazon.awssdk.enhanced.dynamodb; -import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -192,7 +191,7 @@ public void updateItem_returnValues_all_old() { Record record = new Record().setId("1").setSort(10); mappedTable.putItem(record).join(); - Record updatedRecord = new Record().setId("1").setSort(10).setValue(11); + Record updatedRecord = new Record().setId("1").setSort(10).setValue(11).setVersion(1); UpdateItemEnhancedResponse response = mappedTable.updateItemWithResponse(r -> r.item(updatedRecord) @@ -202,6 +201,7 @@ public void updateItem_returnValues_all_old() { assertThat(response.attributes().getId()).isEqualTo(record.getId()); assertThat(response.attributes().getSort()).isEqualTo(record.getSort()); assertThat(response.attributes().getValue()).isEqualTo(null); + assertThat(response.attributes().getVersion()).isEqualTo(1); } @Test @@ -209,7 +209,7 @@ public void updateItem_returnValues_all_new() { Record record = new Record().setId("1").setSort(10); mappedTable.putItem(record).join(); - Record updatedRecord = new Record().setId("1").setSort(10).setValue(11); + Record updatedRecord = new Record().setId("1").setSort(10).setValue(11).setVersion(1); UpdateItemEnhancedResponse response = mappedTable.updateItemWithResponse(r -> r.item(updatedRecord) @@ -219,6 +219,7 @@ public void updateItem_returnValues_all_new() { assertThat(response.attributes().getId()).isEqualTo(updatedRecord.getId()); assertThat(response.attributes().getSort()).isEqualTo(updatedRecord.getSort()); assertThat(response.attributes().getValue()).isEqualTo(updatedRecord.getValue()); + assertThat(response.attributes().getVersion()).isEqualTo(updatedRecord.getVersion() + 1); } @Test @@ -226,7 +227,7 @@ public void updateItem_returnValues_not_set() { Record record = new Record().setId("1").setSort(10); mappedTable.putItem(record).join(); - Record updatedRecord = new Record().setId("1").setSort(10).setValue(11); + Record updatedRecord = new Record().setId("1").setSort(10).setValue(11).setVersion(1); UpdateItemEnhancedResponse response = mappedTable.updateItemWithResponse(r -> r.item(updatedRecord)) @@ -235,6 +236,7 @@ public void updateItem_returnValues_not_set() { assertThat(response.attributes().getId()).isEqualTo(updatedRecord.getId()); assertThat(response.attributes().getSort()).isEqualTo(updatedRecord.getSort()); assertThat(response.attributes().getValue()).isEqualTo(updatedRecord.getValue()); + assertThat(response.attributes().getVersion()).isEqualTo(updatedRecord.getVersion() + 1); } @Test diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java index 5d12acd60918..b5114d8fe028 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertThrows; import org.assertj.core.data.Offset; import org.junit.After; @@ -30,6 +31,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.Record; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; @@ -40,6 +42,7 @@ import software.amazon.awssdk.services.dynamodb.model.ReturnConsumedCapacity; import software.amazon.awssdk.services.dynamodb.model.ReturnItemCollectionMetrics; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; public class CrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegrationTestBase { @@ -218,6 +221,54 @@ public void deleteItem_returnValuesOnConditionCheckFailure_set_returnValuesOnCon .satisfies(e -> assertThat(((ConditionalCheckFailedException) e).hasItem()).isFalse()); } + @Test + public void deleteItemWithTransactWrite_shouldFailIfVersionMismatch() { + Record originalItem = new Record().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + + mappedTable.putItem(originalItem); + + // Retrieve the item and modify it separately + Record modifiedItem = mappedTable.getItem(r -> r.key(recordKey)); + modifiedItem.setStringAttribute("Updated Item"); + + // Update the item, which will increment the version + mappedTable.updateItem(modifiedItem); + + // Now attempt to delete the original item using a transaction + TransactWriteItemsEnhancedRequest request = TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, modifiedItem) + .build(); + + assertThrows(TransactionCanceledException.class, () -> enhancedClient.transactWriteItems(request)); + } + + @Test + public void deleteItemWithTransactWrite_shouldSucceedIfVersionMatch() { + Record originalItem = new Record().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + mappedTable.putItem(originalItem); + + // Retrieve the item + Record retrievedItem = mappedTable.getItem(r -> r.key(recordKey)); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, retrievedItem) + .build(); + + enhancedClient.transactWriteItems(request); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + @Test public void deleteItem_returnValuesOnConditionCheckFailure_set_returnValuesOnConditionCheckFailureNotNull() { Record record = new Record().setId("1").setSort(10); diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java index 8a8e35470c20..1b7d43dad69e 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java @@ -15,6 +15,7 @@ package software.amazon.awssdk.enhanced.dynamodb; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension.AttributeTags.versionAttribute; import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primarySortKey; import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondaryPartitionKey; @@ -73,6 +74,10 @@ protected static DynamoDbAsyncClient createAsyncDynamoDbClient() { .addAttribute(String.class, a -> a.name("stringAttribute") .getter(Record::getStringAttribute) .setter(Record::setStringAttribute)) + .addAttribute(Integer.class, a -> a.name("version") + .getter(Record::getVersion) + .setter(Record::setVersion) + .tags(versionAttribute())) .build(); diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/ScanQueryIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/ScanQueryIntegrationTest.java index adfb9eddd2ad..84061b284236 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/ScanQueryIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/ScanQueryIntegrationTest.java @@ -57,6 +57,8 @@ public static void setup() { mappedTable = enhancedClient.table(TABLE_NAME, TABLE_SCHEMA); mappedTable.createTable(); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)); + RECORDS.forEach(record -> mappedTable.putItem(r -> r.item(record))); + RECORDS.forEach(record -> record.setVersion(1)); } @AfterClass @@ -68,14 +70,8 @@ public static void teardown() { } } - private void insertRecords() { - RECORDS.forEach(record -> mappedTable.putItem(r -> r.item(record))); - } - @Test public void scan_withoutReturnConsumedCapacity_checksPageCount() { - insertRecords(); - Iterator> results = mappedTable.scan(ScanEnhancedRequest.builder().limit(5).build()) .iterator(); Page page1 = results.next(); @@ -97,8 +93,6 @@ public void scan_withoutReturnConsumedCapacity_checksPageCount() { @Test public void scan_withReturnConsumedCapacityAndDifferentReadConsistency_checksConsumedCapacity() { - insertRecords(); - Iterator> eventualConsistencyResult = mappedTable.scan(ScanEnhancedRequest.builder().returnConsumedCapacity(ReturnConsumedCapacity.TOTAL).build()) .iterator(); @@ -122,8 +116,6 @@ public void scan_withReturnConsumedCapacityAndDifferentReadConsistency_checksCon @Test public void query_withoutReturnConsumedCapacity_checksPageCount() { - insertRecords(); - Iterator> results = mappedTable.query(QueryEnhancedRequest.builder() .queryConditional(sortBetween(k-> k.partitionValue("id-value").sortValue(2), @@ -151,8 +143,6 @@ public void query_withoutReturnConsumedCapacity_checksPageCount() { @Test public void query_withReturnConsumedCapacityAndDifferentReadConsistency_checksConsumedCapacity() { - insertRecords(); - Iterator> eventualConsistencyResult = mappedTable.query(QueryEnhancedRequest.builder() .queryConditional(sortGreaterThan(k -> k.partitionValue("id-value").sortValue(3))) diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/Record.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/Record.java index 962b5d8a10f0..66edad2ec2fc 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/Record.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/Record.java @@ -16,7 +16,10 @@ package software.amazon.awssdk.enhanced.dynamodb.model; import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +@DynamoDbBean public class Record { private String id; @@ -26,6 +29,7 @@ public class Record { private Integer gsiSort; private String stringAttribute; + private Integer version; public String getId() { return id; @@ -81,6 +85,16 @@ public Record setStringAttribute(String stringAttribute) { return this; } + @DynamoDbVersionAttribute + public Integer getVersion() { + return version; + } + + public Record setVersion(Integer version) { + this.version = version; + return this; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -91,11 +105,12 @@ public boolean equals(Object o) { Objects.equals(value, record.value) && Objects.equals(gsiId, record.gsiId) && Objects.equals(stringAttribute, record.stringAttribute) && - Objects.equals(gsiSort, record.gsiSort); + Objects.equals(gsiSort, record.gsiSort) && + Objects.equals(version, record.version); } @Override public int hashCode() { - return Objects.hash(id, sort, value, gsiId, gsiSort, stringAttribute); + return Objects.hash(id, sort, value, gsiId, gsiSort, stringAttribute, version); } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedClientExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedClientExtension.java index b66f493bdac8..9d9a9bf8c541 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedClientExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedClientExtension.java @@ -55,4 +55,15 @@ default WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite conte default ReadModification afterRead(DynamoDbExtensionContext.AfterRead context) { return ReadModification.builder().build(); } + + /** + * This hook is called just before an operation is going to delete data from the database. The extension that + * implements this method can add a condition to the delete operation. + * + * @param context The {@link DynamoDbExtensionContext.BeforeDelete} context containing the state of the execution. + * @return A {@link Expression} object that can alter the behavior of the delete operation. + */ + default Expression beforeDelete(DynamoDbExtensionContext.BeforeDelete context) { + return Expression.builder().build(); + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbExtensionContext.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbExtensionContext.java index 851c23a35cf7..210097a42bfe 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbExtensionContext.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbExtensionContext.java @@ -76,4 +76,12 @@ public interface BeforeWrite extends Context { @ThreadSafe public interface AfterRead extends Context { } + + /** + * The state of the execution when the {@link DynamoDbEnhancedClientExtension#beforeDelete} method is invoked. + */ + @SdkPublicApi + @ThreadSafe + public interface BeforeDelete extends Context { + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java index 34a6396c5109..4c3cd2965d8a 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java @@ -142,6 +142,40 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .build(); } + @Override + public Expression beforeDelete(DynamoDbExtensionContext.BeforeDelete context) { + Optional versionAttributeKey = context.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, String.class); + + if (!versionAttributeKey.isPresent()) { + return Expression.builder().build(); + } + + String attributeKeyRef = keyRef(versionAttributeKey.get()); + Expression condition; + Optional existingVersionValue = + Optional.ofNullable(context.items().get(versionAttributeKey.get())); + + if (!existingVersionValue.isPresent() || isNullAttributeValue(existingVersionValue.get())) { + throw new IllegalArgumentException("Version attribute is null."); + } else { + if (existingVersionValue.get().n() == null) { + // In this case a non-null version attribute is present, but it's not an N + throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); + } + + String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get()); + condition = Expression.builder() + .expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey)) + .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) + .expressionValues(Collections.singletonMap(existingVersionValueKey, + existingVersionValue.get())) + .build(); + } + + return condition; + } + @NotThreadSafe public static final class Builder { private Builder() { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/ChainExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/ChainExtension.java index ed4166d49a15..619efa59a053 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/ChainExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/ChainExtension.java @@ -184,4 +184,35 @@ public ReadModification afterRead(DynamoDbExtensionContext.AfterRead context) { .transformedItem(transformedItem) .build(); } + + /** + * Implementation of the {@link DynamoDbEnhancedClientExtension} interface that will call all the chained extensions + * in forward order, passing the results of each one to the next and coalescing the results into a single expression. + * Multiple conditional statements will be separated by the string " AND ". + * + * @param context A {@link DynamoDbExtensionContext.BeforeDelete} context + * @return A single {@link Expression} representing the coalesced results of all the chained extensions. + */ + @Override + public Expression beforeDelete(DynamoDbExtensionContext.BeforeDelete context) { + Expression conditionalExpression = null; + + for (DynamoDbEnhancedClientExtension extension : this.extensionChain) { + + DynamoDbExtensionContext.BeforeDelete beforeDelete = + DefaultDynamoDbExtensionContext.builder() + .items(context.items()) + .operationContext(context.operationContext()) + .tableMetadata(context.tableMetadata()) + .tableSchema(context.tableSchema()) + .build(); + + Expression additionalConditionExpression = extension.beforeDelete(beforeDelete); + + conditionalExpression = mergeConditionalExpressions(conditionalExpression, + additionalConditionExpression); + } + + return conditionalExpression; + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/DefaultDynamoDbExtensionContext.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/DefaultDynamoDbExtensionContext.java index 4de76614647e..bf909d95dd7b 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/DefaultDynamoDbExtensionContext.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/DefaultDynamoDbExtensionContext.java @@ -32,7 +32,8 @@ */ @SdkInternalApi public final class DefaultDynamoDbExtensionContext implements DynamoDbExtensionContext.BeforeWrite, - DynamoDbExtensionContext.AfterRead { + DynamoDbExtensionContext.AfterRead, + DynamoDbExtensionContext.BeforeDelete { private final Map items; private final OperationContext operationContext; private final TableMetadata tableMetadata; diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DeleteItemOperation.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DeleteItemOperation.java index 265866177f74..22ee1ecc45d8 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DeleteItemOperation.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DeleteItemOperation.java @@ -15,6 +15,10 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.operations; +import static software.amazon.awssdk.enhanced.dynamodb.Expression.joinExpressions; +import static software.amazon.awssdk.enhanced.dynamodb.Expression.joinNames; +import static software.amazon.awssdk.enhanced.dynamodb.Expression.joinValues; + import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -27,6 +31,7 @@ import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils; +import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.TransactDeleteItemEnhancedRequest; @@ -156,6 +161,49 @@ public TransactWriteItem generateTransactWriteItem(TableSchema tableSchema, .build(); } + public TransactWriteItem generateTransactDeleteItem(TableSchema tableSchema, + OperationContext operationContext, + DynamoDbEnhancedClientExtension dynamoDbEnhancedClientExtension, + Map itemMap) { + DeleteItemRequest deleteItemRequest = generateRequest(tableSchema, operationContext, dynamoDbEnhancedClientExtension); + + Expression beforeDeleteConditionExpression = + dynamoDbEnhancedClientExtension != null ? dynamoDbEnhancedClientExtension.beforeDelete( + DefaultDynamoDbExtensionContext.builder() + .items(itemMap) + .operationContext(operationContext) + .tableMetadata(tableSchema.tableMetadata()) + .tableSchema(tableSchema) + .operationName(operationName()) + .build()) + : null; + + Delete.Builder builder = Delete.builder() + .key(deleteItemRequest.key()) + .tableName(deleteItemRequest.tableName()); + + if (beforeDeleteConditionExpression != null) { + builder.conditionExpression(joinExpressions(deleteItemRequest.conditionExpression(), + beforeDeleteConditionExpression.expression(), " AND ")) + .expressionAttributeValues(joinValues(deleteItemRequest.expressionAttributeValues(), + beforeDeleteConditionExpression.expressionValues())) + .expressionAttributeNames(joinNames(deleteItemRequest.expressionAttributeNames(), + beforeDeleteConditionExpression.expressionNames())); + } else { + builder.conditionExpression(deleteItemRequest.conditionExpression()) + .expressionAttributeValues(deleteItemRequest.expressionAttributeValues()) + .expressionAttributeNames(deleteItemRequest.expressionAttributeNames()); + } + + request.right() + .map(TransactDeleteItemEnhancedRequest::returnValuesOnConditionCheckFailureAsString) + .ifPresent(builder::returnValuesOnConditionCheckFailure); + + return TransactWriteItem.builder() + .delete(builder.build()) + .build(); + } + private DeleteItemRequest.Builder addExpressionsIfExist(DeleteItemRequest.Builder requestBuilder) { Expression conditionExpression = request.map(r -> Optional.ofNullable(r.conditionExpression()), r -> Optional.ofNullable(r.conditionExpression())) diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactWriteItemsEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactWriteItemsEnhancedRequest.java index f322dd67dde2..5cf24a73bcdc 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactWriteItemsEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactWriteItemsEnhancedRequest.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.Consumer; import java.util.function.Supplier; @@ -34,6 +35,7 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.operations.PutItemOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.TransactableWriteOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; import software.amazon.awssdk.services.dynamodb.model.ReturnConsumedCapacity; @@ -282,7 +284,11 @@ public Builder addDeleteItem(MappedTableResource mappedTableResource, Key * @return a builder of this type */ public Builder addDeleteItem(MappedTableResource mappedTableResource, T keyItem) { - return addDeleteItem(mappedTableResource, mappedTableResource.keyFrom(keyItem)); + TransactDeleteItemEnhancedRequest request = + TransactDeleteItemEnhancedRequest.builder().key(mappedTableResource.keyFrom(keyItem)).build(); + itemSupplierList.add(() -> generateTransactDeleteItem(mappedTableResource, DeleteItemOperation.create(request), + mappedTableResource.tableSchema().itemToMap(keyItem, true))); + return this; } /** @@ -454,5 +460,14 @@ private TransactWriteItem generateTransactWriteItem(MappedTableResource m DefaultOperationContext.create(mappedTableResource.tableName()), mappedTableResource.mapperExtension()); } + + private TransactWriteItem generateTransactDeleteItem(MappedTableResource mappedTableResource, + DeleteItemOperation generator, + Map itemMap) { + return generator.generateTransactDeleteItem(mappedTableResource.tableSchema(), + DefaultOperationContext.create(mappedTableResource.tableName()), + mappedTableResource.mapperExtension(), + itemMap); + } } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ChainExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ChainExtensionTest.java index 4c86bc2e049c..153398a47a68 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ChainExtensionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ChainExtensionTest.java @@ -279,6 +279,42 @@ public void afterRead_noExtensions() { assertThat(result.transformedItem(), is(nullValue())); } + @Test + public void beforeDelete_multipleExtensions_multipleExpressions() { + ChainExtension extension = ChainExtension.create(mockExtension1, mockExtension2, mockExtension3); + Expression deleteExpression1 = Expression.builder().expression("one").expressionValues(ATTRIBUTE_VALUES_1).build(); + Expression deleteExpression2 = Expression.builder().expression("two").expressionValues(ATTRIBUTE_VALUES_2).build(); + Expression deleteExpression3 = Expression.builder().expression("three").expressionValues(ATTRIBUTE_VALUES_3).build(); + when(mockExtension1.beforeDelete(any(DynamoDbExtensionContext.BeforeDelete.class))).thenReturn(deleteExpression1); + when(mockExtension2.beforeDelete(any(DynamoDbExtensionContext.BeforeDelete.class))).thenReturn(deleteExpression2); + when(mockExtension3.beforeDelete(any(DynamoDbExtensionContext.BeforeDelete.class))).thenReturn(deleteExpression3); + + Map combinedMap = new HashMap<>(ATTRIBUTE_VALUES_1); + combinedMap.putAll(ATTRIBUTE_VALUES_2); + combinedMap.putAll(ATTRIBUTE_VALUES_3); + Expression expectedConditionalExpression = + Expression.builder().expression("((one) AND (two)) AND (three)").expressionValues(combinedMap).build(); + + Expression result = extension.beforeDelete(getDeleteExtensionContext(0)); + + assertThat(result, is(expectedConditionalExpression)); + + InOrder inOrder = Mockito.inOrder(mockExtension1, mockExtension2, mockExtension3); + inOrder.verify(mockExtension1).beforeDelete(getDeleteExtensionContext(0)); + inOrder.verify(mockExtension2).beforeDelete(getDeleteExtensionContext(0)); + inOrder.verify(mockExtension3).beforeDelete(getDeleteExtensionContext(0)); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void beforeDelete_noExtensions() { + ChainExtension extension = ChainExtension.create(); + + Expression result = extension.beforeDelete(getDeleteExtensionContext(0)); + + assertThat(result, is(nullValue())); + } + private DefaultDynamoDbExtensionContext getWriteExtensionContext(int i) { return getExtensionContext(i, OperationName.BATCH_WRITE_ITEM); } @@ -287,6 +323,10 @@ private DefaultDynamoDbExtensionContext getReadExtensionContext(int i) { return getExtensionContext(i, null); } + private DefaultDynamoDbExtensionContext getDeleteExtensionContext(int i) { + return getExtensionContext(i, null); + } + private DefaultDynamoDbExtensionContext getExtensionContext(int i, OperationName operationName) { DefaultDynamoDbExtensionContext.Builder context = DefaultDynamoDbExtensionContext.builder() diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java index 4f61db7487e9..8a59d68dd081 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java @@ -179,4 +179,69 @@ public void beforeWrite_throwsIllegalArgumentException_ifVersionAttributeIsWrong .tableMetadata(FakeItem.getTableMetadata()) .build()); } + + @Test + public void beforeDelete_existingVersion_expressionIsCorrect() { + FakeItem fakeItem = createUniqueFakeItem(); + fakeItem.setVersion(3); + + Expression result = + versionedRecordExtension.beforeDelete(DefaultDynamoDbExtensionContext + .builder() + .items(FakeItem.getTableSchema().itemToMap(fakeItem, true)) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result, + is(Expression.builder() + .expression("#AMZN_MAPPED_version = :old_version_value") + .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) + .expressionValues(singletonMap(":old_version_value", + AttributeValue.builder().n("3").build())) + .build())); + } + + @Test + public void beforeDelete_returnsEmptyExpression_ifVersionAttributeNotDefined() { + FakeItemWithSort fakeItemWithSort = createUniqueFakeItemWithSort(); + Map itemMap = + new HashMap<>(FakeItemWithSort.getTableSchema().itemToMap(fakeItemWithSort, true)); + + Expression deleteExpression = versionedRecordExtension.beforeDelete(DefaultDynamoDbExtensionContext.builder() + .items(itemMap) + .operationContext(PRIMARY_CONTEXT) + .tableMetadata(FakeItemWithSort.getTableMetadata()) + .build()); + assertThat(deleteExpression, is(Expression.builder().build())); + } + + @Test(expected = IllegalArgumentException.class) + public void beforeDelete_throwsIllegalArgumentException_ifVersionAttributeIsNull() { + FakeItem fakeItem = createUniqueFakeItem(); + Map fakeItemWIthBadVersion = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + fakeItemWIthBadVersion.put("version", null); + + versionedRecordExtension.beforeDelete( + DefaultDynamoDbExtensionContext.builder() + .items(fakeItemWIthBadVersion) + .operationContext(PRIMARY_CONTEXT) + .tableMetadata(FakeItem.getTableMetadata()) + .build()); + } + + @Test(expected = IllegalArgumentException.class) + public void beforeDelete_throwsIllegalArgumentException_ifVersionAttributeIsWrongType() { + FakeItem fakeItem = createUniqueFakeItem(); + Map fakeItemWIthBadVersion = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + fakeItemWIthBadVersion.put("version", AttributeValue.builder().s("14").build()); + + versionedRecordExtension.beforeDelete( + DefaultDynamoDbExtensionContext.builder() + .items(fakeItemWIthBadVersion) + .operationContext(PRIMARY_CONTEXT) + .tableMetadata(FakeItem.getTableMetadata()) + .build()); + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DeleteItemOperationTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DeleteItemOperationTest.java index c8a2ab5fb7f9..9eb7642c48de 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DeleteItemOperationTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DeleteItemOperationTest.java @@ -556,4 +556,46 @@ public void generateTransactWriteItem_returnValuesOnConditionCheckFailure_genera assertThat(actualResult, is(expectedResult)); verify(deleteItemOperation).generateRequest(FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); } + + @Test + public void generateTransactDeleteItem_conditionalRequest() { + FakeItem fakeItem = createUniqueFakeItem(); + fakeItem.setVersion(1); + Map fakeItemMap = FakeItem.getTableSchema().itemToMap(fakeItem, true); + DeleteItemOperation deleteItemOperation = + spy(DeleteItemOperation.create(DeleteItemEnhancedRequest.builder() + .key(k -> k.partitionValue(fakeItem.getId())) + .build())); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + String conditionExpression = "condition-expression"; + Map attributeValues = Collections.singletonMap("key", stringValue("value1")); + Map attributeNames = Collections.singletonMap("key", "value2"); + + DeleteItemRequest deleteItemRequest = DeleteItemRequest.builder() + .tableName(TABLE_NAME) + .key(fakeItemMap) + .conditionExpression(conditionExpression) + .expressionAttributeValues(attributeValues) + .expressionAttributeNames(attributeNames) + .build(); + doReturn(deleteItemRequest).when(deleteItemOperation).generateRequest(any(), any(), any()); + + TransactWriteItem actualResult = deleteItemOperation.generateTransactDeleteItem(FakeItem.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension, + fakeItemMap); + + TransactWriteItem expectedResult = TransactWriteItem.builder() + .delete(Delete.builder() + .key(fakeItemMap) + .tableName(TABLE_NAME) + .conditionExpression(conditionExpression) + .expressionAttributeNames(attributeNames) + .expressionAttributeValues(attributeValues) + .build()) + .build(); + assertThat(actualResult, is(expectedResult)); + verify(deleteItemOperation).generateRequest(FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + } }