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..af95a991c501 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; @@ -31,6 +30,8 @@ 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.RecordWithVersion; +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.DynamoDbAsyncClient; @@ -41,6 +42,7 @@ import software.amazon.awssdk.services.dynamodb.model.ReturnItemCollectionMetrics; import software.amazon.awssdk.services.dynamodb.model.ReturnValue; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; public class AsyncCrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegrationTestBase { @@ -56,6 +58,7 @@ public class AsyncCrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegr private static DynamoDbAsyncClient dynamoDbClient; private static DynamoDbEnhancedAsyncClient enhancedClient; private static DynamoDbAsyncTable mappedTable; + private static DynamoDbAsyncTable recordWithVersionMappedTable; @BeforeClass public static void beforeClass() { @@ -63,6 +66,7 @@ public static void beforeClass() { enhancedClient = DynamoDbEnhancedAsyncClient.builder().dynamoDbClient(dynamoDbClient).build(); mappedTable = enhancedClient.table(TABLE_NAME, TABLE_SCHEMA); mappedTable.createTable(r -> r.localSecondaryIndices(LOCAL_SECONDARY_INDEX)).join(); + recordWithVersionMappedTable = enhancedClient.table(TABLE_NAME, RECORD_WITH_VERSION_TABLE_SCHEMA); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)).join(); } @@ -341,4 +345,164 @@ public void getItem_withoutReturnConsumedCapacity() { GetItemEnhancedResponse response = mappedTable.getItemWithResponse(req -> req.key(key)).join(); assertThat(response.consumedCapacity()).isNull(); } + + @Test + public void deleteItemWithoutVersion_andOptimisticLockingEnabled_shouldSucceed() { + 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).join(); + + // Retrieve the item + Record retrievedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, retrievedItem, true) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithoutVersion_andOptimisticLockingDisabled_shouldSucceed() { + 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).join(); + + // Retrieve the item + Record retrievedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, retrievedItem, false) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingEnabled_ifVersionMatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + recordWithVersionMappedTable.putItem(originalItem).join(); + + // Retrieve the item + RecordWithVersion retrievedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, retrievedItem, true) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingEnabled_ifVersionMismatch_shouldFail() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + + recordWithVersionMappedTable.putItem(originalItem).join(); + + // Retrieve the item and modify it separately + RecordWithVersion modifiedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + modifiedItem.setStringAttribute("Updated Item"); + + // Update the item, which will increment the version + recordWithVersionMappedTable.updateItem(modifiedItem); + + // Now attempt to delete the original item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, modifiedItem, true) + .build(); + + assertThatThrownBy(() -> enhancedClient.transactWriteItems(request).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> + assertThat(((TransactionCanceledException) e.getCause()) + .cancellationReasons() + .stream() + .anyMatch(reason -> + "ConditionalCheckFailed".equals(reason.code()) + && "The conditional request failed".equals(reason.message()))) + .isTrue()); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingDisabled_ifVersionMatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + recordWithVersionMappedTable.putItem(originalItem).join(); + + // Retrieve the item + RecordWithVersion retrievedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, retrievedItem, false) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingDisabled_ifVersionMismatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + + recordWithVersionMappedTable.putItem(originalItem).join(); + + // Retrieve the item and modify it separately + RecordWithVersion modifiedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + modifiedItem.setStringAttribute("Updated Item"); + + // Update the item, which will increment the version + recordWithVersionMappedTable.updateItem(modifiedItem); + + // Now attempt to delete the original item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, modifiedItem, false) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } } 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..93168737599e 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,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import org.assertj.core.data.Offset; import org.junit.After; @@ -30,6 +33,8 @@ 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.RecordWithVersion; +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 +45,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 { @@ -56,6 +62,7 @@ public class CrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegration private static DynamoDbClient dynamoDbClient; private static DynamoDbEnhancedClient enhancedClient; private static DynamoDbTable mappedTable; + private static DynamoDbTable recordWithVersionMappedTable; @BeforeClass public static void beforeClass() { @@ -63,6 +70,7 @@ public static void beforeClass() { enhancedClient = DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build(); mappedTable = enhancedClient.table(TABLE_NAME, TABLE_SCHEMA); mappedTable.createTable(r -> r.localSecondaryIndices(LOCAL_SECONDARY_INDEX)); + recordWithVersionMappedTable = enhancedClient.table(TABLE_NAME, RECORD_WITH_VERSION_TABLE_SCHEMA); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)); } @@ -309,4 +317,162 @@ public void getItem_set_stronglyConsistent() { // A strongly consistent read request of an item up to 4 KB requires one read request unit. assertThat(consumedCapacity.capacityUnits()).isCloseTo(20.0, Offset.offset(1.0)); } + + @Test + public void deleteItemWithoutVersion_andOptimisticLockingEnabled_shouldSucceed() { + 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, true) + .build(); + + enhancedClient.transactWriteItems(request); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithoutVersion_andOptimisticLockingDisabled_shouldSucceed() { + 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, false) + .build(); + + enhancedClient.transactWriteItems(request); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingEnabled_ifVersionMatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + recordWithVersionMappedTable.putItem(originalItem); + + // Retrieve the item + RecordWithVersion retrievedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, retrievedItem, true) + .build(); + + enhancedClient.transactWriteItems(request); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingEnabled_ifVersionMismatch_shouldFail() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + + recordWithVersionMappedTable.putItem(originalItem); + + // Retrieve the item and modify it separately + RecordWithVersion modifiedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)); + modifiedItem.setStringAttribute("Updated Item"); + + // Update the item, which will increment the version + recordWithVersionMappedTable.updateItem(modifiedItem); + + // Now attempt to delete the original item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, modifiedItem, true) + .build(); + + TransactionCanceledException ex = assertThrows( + TransactionCanceledException.class, + () -> enhancedClient.transactWriteItems(request)); + + assertTrue(ex.hasCancellationReasons()); + assertEquals(1, ex.cancellationReasons().size()); + assertEquals("ConditionalCheckFailed", ex.cancellationReasons().get(0).code()); + assertEquals("The conditional request failed", ex.cancellationReasons().get(0).message()); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingDisabled_ifVersionMatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + recordWithVersionMappedTable.putItem(originalItem); + + // Retrieve the item + RecordWithVersion retrievedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, retrievedItem, false) + .build(); + + enhancedClient.transactWriteItems(request); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingDisabled_ifVersionMismatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + + recordWithVersionMappedTable.putItem(originalItem); + + // Retrieve the item and modify it separately + RecordWithVersion modifiedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)); + modifiedItem.setStringAttribute("Updated Item"); + + // Update the item, which will increment the version + recordWithVersionMappedTable.updateItem(modifiedItem); + + // Now attempt to delete the original item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, modifiedItem, false) + .build(); + + enhancedClient.transactWriteItems(request); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } } 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..407004066612 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; @@ -27,6 +28,7 @@ import java.util.stream.IntStream; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; import software.amazon.awssdk.enhanced.dynamodb.model.Record; +import software.amazon.awssdk.enhanced.dynamodb.model.RecordWithVersion; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.testutils.service.AwsIntegrationTestBase; @@ -75,6 +77,37 @@ protected static DynamoDbAsyncClient createAsyncDynamoDbClient() { .setter(Record::setStringAttribute)) .build(); + protected static final TableSchema RECORD_WITH_VERSION_TABLE_SCHEMA = + StaticTableSchema.builder(RecordWithVersion.class) + .newItemSupplier(RecordWithVersion::new) + .addAttribute(String.class, a -> a.name("id") + .getter(RecordWithVersion::getId) + .setter(RecordWithVersion::setId) + .tags(primaryPartitionKey(), secondaryPartitionKey("index1"))) + .addAttribute(Integer.class, a -> a.name("sort") + .getter(RecordWithVersion::getSort) + .setter(RecordWithVersion::setSort) + .tags(primarySortKey(), secondarySortKey("index1"))) + .addAttribute(Integer.class, a -> a.name("value") + .getter(RecordWithVersion::getValue) + .setter(RecordWithVersion::setValue)) + .addAttribute(String.class, a -> a.name("gsi_id") + .getter(RecordWithVersion::getGsiId) + .setter(RecordWithVersion::setGsiId) + .tags(secondaryPartitionKey("gsi_keys_only"))) + .addAttribute(Integer.class, a -> a.name("gsi_sort") + .getter(RecordWithVersion::getGsiSort) + .setter(RecordWithVersion::setGsiSort) + .tags(secondarySortKey("gsi_keys_only"))) + .addAttribute(String.class, a -> a.name("stringAttribute") + .getter(RecordWithVersion::getStringAttribute) + .setter(RecordWithVersion::setStringAttribute)) + .addAttribute(Integer.class, a -> a.name("version") + .getter(RecordWithVersion::getVersion) + .setter(RecordWithVersion::setVersion) + .tags(versionAttribute())) + .build(); + protected static final List RECORDS = IntStream.range(0, 9) diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/RecordWithVersion.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/RecordWithVersion.java new file mode 100644 index 000000000000..18b87843129f --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/RecordWithVersion.java @@ -0,0 +1,120 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +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 RecordWithVersion { + + private String id; + private Integer sort; + private Integer value; + private String gsiId; + private Integer gsiSort; + + private String stringAttribute; + private Integer version; + + public String getId() { + return id; + } + + public RecordWithVersion setId(String id) { + this.id = id; + return this; + } + + public Integer getSort() { + return sort; + } + + public RecordWithVersion setSort(Integer sort) { + this.sort = sort; + return this; + } + + public Integer getValue() { + return value; + } + + public RecordWithVersion setValue(Integer value) { + this.value = value; + return this; + } + + public String getGsiId() { + return gsiId; + } + + public RecordWithVersion setGsiId(String gsiId) { + this.gsiId = gsiId; + return this; + } + + public Integer getGsiSort() { + return gsiSort; + } + + public RecordWithVersion setGsiSort(Integer gsiSort) { + this.gsiSort = gsiSort; + return this; + } + + public String getStringAttribute() { + return stringAttribute; + } + + public RecordWithVersion setStringAttribute(String stringAttribute) { + this.stringAttribute = stringAttribute; + return this; + } + + @DynamoDbVersionAttribute + public Integer getVersion() { + return version; + } + + public RecordWithVersion setVersion(Integer version) { + this.version = version; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RecordWithVersion recordWithVersion = (RecordWithVersion) o; + return Objects.equals(id, recordWithVersion.id) && + Objects.equals(sort, recordWithVersion.sort) && + Objects.equals(value, recordWithVersion.value) && + Objects.equals(gsiId, recordWithVersion.gsiId) && + Objects.equals(stringAttribute, recordWithVersion.stringAttribute) && + Objects.equals(gsiSort, recordWithVersion.gsiSort) && + Objects.equals(version, recordWithVersion.version); + } + + @Override + public int hashCode() { + 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..7a44c2a46485 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,12 +35,14 @@ 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; import software.amazon.awssdk.services.dynamodb.model.ReturnItemCollectionMetrics; import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem; import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; /** @@ -272,16 +275,53 @@ public Builder addDeleteItem(MappedTableResource mappedTableResource, Key } /** - * Adds a primary lookup key for the item to delete, and it's associated table, to the transaction. For more information - * on the delete action, see the low-level operation description in for instance - * {@link DynamoDbTable#deleteItem(DeleteItemEnhancedRequest)}. + * Adds a primary lookup key for the item to delete, and its associated table, to the transaction. + *

+ * This method does not enable optimistic locking: the deletion will proceed regardless of the + * item's version, and no version check is applied. + *

* * @param mappedTableResource the table where the key is located - * @param keyItem an item that will have its key fields used to match a record to retrieve from the database + * @param keyItem an item whose key fields identify the record to delete * @param the type of modelled objects in the table + * * @return a builder of this type */ public Builder addDeleteItem(MappedTableResource mappedTableResource, T keyItem) { + return addDeleteItem(mappedTableResource, keyItem, false); + } + + /** + * Overload of {@link #addDeleteItem(MappedTableResource, Object)} that adds optional optimistic locking support. + *

+ * When {@code useOptimisticLocking} is {@code true}, the delete operation will include a condition + * expression to verify the item's version attribute matches the expected value. If the version in + * the database differs, a {@link TransactionCanceledException} will be thrown. + * Otherwise, this method behaves exactly like the original overload and deletes + * the item without any version check. + *

+ * + * @param mappedTableResource the table where the key is located + * @param keyItem an item whose key fields identify the record to delete + * @param useOptimisticLocking whether to enable optimistic locking (version check) for deletion + * @param the type of modelled objects in the table + * + * @throws TransactionCanceledException if {@code useOptimisticLocking} is {@code true} and the + * version of the specified object does not match the version + * persisted in the database + * + * @return a builder of this type + */ + public Builder addDeleteItem(MappedTableResource mappedTableResource, T keyItem, boolean useOptimisticLocking) { + if (useOptimisticLocking) { + TransactDeleteItemEnhancedRequest request = + TransactDeleteItemEnhancedRequest.builder().key(mappedTableResource.keyFrom(keyItem)).build(); + itemSupplierList.add(() -> generateTransactDeleteItem(mappedTableResource, DeleteItemOperation.create(request), + mappedTableResource.tableSchema().itemToMap(keyItem, + true))); + return this; + } + return addDeleteItem(mappedTableResource, mappedTableResource.keyFrom(keyItem)); } @@ -454,5 +494,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 b7cbb4eb428a..4740e457feef 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_ifVersioPnAttributeIsWron .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); + } }