diff --git a/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-64b922e.json b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-64b922e.json new file mode 100644 index 000000000000..495f9be9eea3 --- /dev/null +++ b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-64b922e.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Optimistic delete while using DynamoDbEnhancedClient - DeleteItem with DeleteItemEnhancedRequest and 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..328124caee58 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; @@ -24,6 +23,7 @@ import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedLocalSecondaryIndex; @@ -31,8 +31,10 @@ 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.enhanced.dynamodb.model.VersionedRecord; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; import software.amazon.awssdk.services.dynamodb.model.Projection; @@ -41,6 +43,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,13 +59,18 @@ public class AsyncCrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegr private static DynamoDbAsyncClient dynamoDbClient; private static DynamoDbEnhancedAsyncClient enhancedClient; private static DynamoDbAsyncTable mappedTable; + private static DynamoDbAsyncTable versionedRecordTable; @BeforeClass public static void beforeClass() { dynamoDbClient = createAsyncDynamoDbClient(); - enhancedClient = DynamoDbEnhancedAsyncClient.builder().dynamoDbClient(dynamoDbClient).build(); + enhancedClient = DynamoDbEnhancedAsyncClient.builder() + .dynamoDbClient(dynamoDbClient) + .extensions(VersionedRecordExtension.builder().build()) + .build(); mappedTable = enhancedClient.table(TABLE_NAME, TABLE_SCHEMA); mappedTable.createTable(r -> r.localSecondaryIndices(LOCAL_SECONDARY_INDEX)).join(); + versionedRecordTable = enhancedClient.table(TABLE_NAME, VERSIONED_RECORD_TABLE_SCHEMA); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)).join(); } @@ -72,6 +80,11 @@ public void tearDown() { .items() .subscribe(record -> mappedTable.deleteItem(record).join()) .join(); + + versionedRecordTable.scan() + .items() + .subscribe(versionedRecord -> versionedRecordTable.deleteItem(versionedRecord).join()) + .join(); } @AfterClass @@ -341,4 +354,149 @@ public void getItem_withoutReturnConsumedCapacity() { GetItemEnhancedResponse response = mappedTable.getItemWithResponse(req -> req.key(key)).join(); assertThat(response.consumedCapacity()).isNull(); } + + @Test + public void transactWriteItems_recordWithoutVersion_andOptimisticLockingOnDeleteOnDelete_shouldSucceed() { + Record originalItem = new Record().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + mappedTable.putItem(originalItem).join(); + + // Retrieve the item, modify it separately and update it, which will increment the version + Record savedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + savedItem.setStringAttribute("Updated Item"); + mappedTable.updateItem(savedItem).join(); + + // Get the updated item and try to delete it + Record updatedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, updatedItem) + .build()).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void transactWriteItems_recordWithVersion_andOptimisticLockingOnDelete_ifVersionMatch_shouldSucceed() { + VersionedRecord originalItem = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + versionedRecordTable.putItem(originalItem).join(); + + // Retrieve the item, modify it separately and update it, which will increment the version + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Get the updated item and try to delete it + VersionedRecord updatedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, updatedItem) + .build()).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void transactWriteItems_recordWithVersion_andOptimisticLockingOnDelete_ifVersionMismatch_shouldFail() { + VersionedRecord originalItem = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + versionedRecordTable.putItem(originalItem).join(); + + // Retrieve the item, modify it separately and update it, which will increment the version + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Get the updated item and try to delete it + VersionedRecord updatedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + updatedItem.setVersion(3); // Intentionally set a version that does not match the current version + + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, updatedItem) + .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 delete_recordWithoutVersion_andOptimisticLockingOnDelete_shouldSucceed() { + Record originalItem = new Record().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + mappedTable.putItem(originalItem).join(); + + // Retrieve the item, modify it separately and update it, which will increment the version + Record savedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + savedItem.setStringAttribute("Updated Item"); + mappedTable.updateItem(savedItem).join(); + + // Get the updated item and try to delete it + Record updatedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + mappedTable.deleteItem(updatedItem).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void delete_recordWithVersion_andOptimisticLockingOnDelete_ifVersionMatch_shouldSucceed() { + VersionedRecord originalItem = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + versionedRecordTable.putItem(originalItem).join(); + + // Retrieve the item, modify it separately and update it, which will increment the version + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Get the updated item and try to delete it + VersionedRecord updatedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + versionedRecordTable.deleteItem(updatedItem).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void delete_recordWithoutVersion_andOptimisticLockingOnDelete_ifVersionMismatch_shouldFail() { + VersionedRecord originalItem = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + versionedRecordTable.putItem(originalItem).join(); + + // Retrieve the item, modify it separately and update it, which will increment the version + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Get the updated item and try to delete it + VersionedRecord updatedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + updatedItem.setVersion(3); // Intentionally set a version that does not match the current version + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(updatedItem).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> + assertThat(e.getMessage()).contains("The conditional request failed")); + } } 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 4685569ebf21..1b7a601e7dd2 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,12 +17,16 @@ 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; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedLocalSecondaryIndex; @@ -30,8 +34,10 @@ 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.enhanced.dynamodb.model.VersionedRecord; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity; @@ -40,6 +46,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,13 +63,18 @@ public class CrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegration private static DynamoDbClient dynamoDbClient; private static DynamoDbEnhancedClient enhancedClient; private static DynamoDbTable mappedTable; + private static DynamoDbTable versionedRecordTable; @BeforeClass public static void beforeClass() { dynamoDbClient = createDynamoDbClient(); - enhancedClient = DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build(); + enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .extensions(VersionedRecordExtension.builder().build()) + .build(); mappedTable = enhancedClient.table(TABLE_NAME, TABLE_SCHEMA); mappedTable.createTable(r -> r.localSecondaryIndices(LOCAL_SECONDARY_INDEX)); + versionedRecordTable = enhancedClient.table(TABLE_NAME, VERSIONED_RECORD_TABLE_SCHEMA); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)); } @@ -71,6 +83,10 @@ public void tearDown() { mappedTable.scan() .items() .forEach(record -> mappedTable.deleteItem(record)); + + versionedRecordTable.scan() + .items() + .forEach(versionedRecord -> versionedRecordTable.deleteItem(versionedRecord)); } @AfterClass @@ -321,4 +337,146 @@ 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 transactWriteItems_recordWithoutVersion_andOptimisticLockingOnDelete_shouldSucceed() { + Record originalItem = new Record().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + mappedTable.putItem(originalItem); + + // Retrieve the item, modify it separately and update it, which will increment the version + Record savedItem = mappedTable.getItem(r -> r.key(recordKey)); + savedItem.setStringAttribute("Updated Item"); + mappedTable.updateItem(savedItem); + + // Get the updated item and try to delete it + Record updatedItem = mappedTable.getItem(r -> r.key(recordKey)); + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, updatedItem) + .build()); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + @Test + public void transactWriteItems_recordWithVersion_andOptimisticLockingOnDelete_ifVersionMatch_shouldSucceed() { + VersionedRecord originalItem = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + versionedRecordTable.putItem(originalItem); + + // Retrieve the item, modify it separately and update it, which will increment the version + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Get the updated item and try to delete it + VersionedRecord updatedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, updatedItem) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + @Test + public void transactWriteItems_recordWithVersion_andOptimisticLockingOnDelete_ifVersionMismatch_shouldFail() { + VersionedRecord originalItem = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + versionedRecordTable.putItem(originalItem); + + // Retrieve the item, modify it separately and update it, which will increment the version + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Get the updated item and try to delete it + VersionedRecord updatedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + updatedItem.setVersion(3); // Intentionally set a version that does not match the current version + + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, updatedItem) + .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 delete_recordWithoutVersion_andOptimisticLockingOnDelete_shouldSucceed() { + Record originalItem = new Record().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + mappedTable.putItem(originalItem); + + // Retrieve the item, modify it separately and update it, which will increment the version + Record savedItem = mappedTable.getItem(r -> r.key(recordKey)); + savedItem.setStringAttribute("Updated Item"); + mappedTable.updateItem(savedItem); + + // Get the updated item and try to delete it + Record updatedItem = mappedTable.getItem(r -> r.key(recordKey)); + mappedTable.deleteItem(updatedItem); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + @Test + public void delete_recordWithVersion_andOptimisticLockingOnDelete_ifVersionMatch_shouldSucceed() { + VersionedRecord originalItem = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + versionedRecordTable.putItem(originalItem); + + // Retrieve the item, modify it separately and update it, which will increment the version + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Get the updated item and try to delete it + VersionedRecord updatedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + versionedRecordTable.deleteItem(updatedItem); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + @Test + public void delete_recordWithoutVersion_andOptimisticLockingOnDelete_ifVersionMismatch_shouldFail() { + VersionedRecord originalItem = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder().partitionValue(originalItem.getId()).sortValue(originalItem.getSort()).build(); + + // Put the item + versionedRecordTable.putItem(originalItem); + + // Retrieve the item, modify it separately and update it, which will increment the version + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Get the updated item and try to delete it + VersionedRecord updatedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + updatedItem.setVersion(3); // Intentionally set a version that does not match the current version + + ConditionalCheckFailedException ex = assertThrows( + ConditionalCheckFailedException.class, + () -> versionedRecordTable.deleteItem(updatedItem)); + assertThat(ex.getMessage()).contains("The conditional request failed"); + } } 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..eb858266428c 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.VersionedRecord; 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 VERSIONED_RECORD_TABLE_SCHEMA = + StaticTableSchema.builder(VersionedRecord.class) + .newItemSupplier(VersionedRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(VersionedRecord::getId) + .setter(VersionedRecord::setId) + .tags(primaryPartitionKey(), secondaryPartitionKey("index1"))) + .addAttribute(Integer.class, a -> a.name("sort") + .getter(VersionedRecord::getSort) + .setter(VersionedRecord::setSort) + .tags(primarySortKey(), secondarySortKey("index1"))) + .addAttribute(Integer.class, a -> a.name("value") + .getter(VersionedRecord::getValue) + .setter(VersionedRecord::setValue)) + .addAttribute(String.class, a -> a.name("gsi_id") + .getter(VersionedRecord::getGsiId) + .setter(VersionedRecord::setGsiId) + .tags(secondaryPartitionKey("gsi_keys_only"))) + .addAttribute(Integer.class, a -> a.name("gsi_sort") + .getter(VersionedRecord::getGsiSort) + .setter(VersionedRecord::setGsiSort) + .tags(secondarySortKey("gsi_keys_only"))) + .addAttribute(String.class, a -> a.name("stringAttribute") + .getter(VersionedRecord::getStringAttribute) + .setter(VersionedRecord::setStringAttribute)) + .addAttribute(Integer.class, a -> a.name("version") + .getter(VersionedRecord::getVersion) + .setter(VersionedRecord::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/VersionedRecord.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/VersionedRecord.java new file mode 100644 index 000000000000..38ff5cbb57bc --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/VersionedRecord.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 VersionedRecord { + + 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 VersionedRecord setId(String id) { + this.id = id; + return this; + } + + public Integer getSort() { + return sort; + } + + public VersionedRecord setSort(Integer sort) { + this.sort = sort; + return this; + } + + public Integer getValue() { + return value; + } + + public VersionedRecord setValue(Integer value) { + this.value = value; + return this; + } + + public String getGsiId() { + return gsiId; + } + + public VersionedRecord setGsiId(String gsiId) { + this.gsiId = gsiId; + return this; + } + + public Integer getGsiSort() { + return gsiSort; + } + + public VersionedRecord setGsiSort(Integer gsiSort) { + this.gsiSort = gsiSort; + return this; + } + + public String getStringAttribute() { + return stringAttribute; + } + + public VersionedRecord setStringAttribute(String stringAttribute) { + this.stringAttribute = stringAttribute; + return this; + } + + @DynamoDbVersionAttribute + public Integer getVersion() { + return version; + } + + public VersionedRecord 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; + } + VersionedRecord versionedRecord = (VersionedRecord) o; + return Objects.equals(id, versionedRecord.id) && + Objects.equals(sort, versionedRecord.sort) && + Objects.equals(value, versionedRecord.value) && + Objects.equals(gsiId, versionedRecord.gsiId) && + Objects.equals(stringAttribute, versionedRecord.stringAttribute) && + Objects.equals(gsiSort, versionedRecord.gsiSort) && + Objects.equals(version, versionedRecord.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/DynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java index e193fe681df8..bf86947592df 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java @@ -21,6 +21,7 @@ import software.amazon.awssdk.annotations.ThreadSafe; import software.amazon.awssdk.enhanced.dynamodb.model.CreateTableEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.DeleteCompleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; @@ -176,6 +177,39 @@ default CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { throw new UnsupportedOperationException(); } + /** + * Deletes a single item from the mapped table using a supplied primary {@link Key}, while also providing the full item for + * use in conditional expressions such as optimistic locking. + *

+ * This method is typically used when a versioned record extension or other condition-based delete workflow requires both the + * key and the original item to enforce constraints (e.g., ensuring that the item has not been modified since it was read). + *

+ * The additional configuration parameters that the enhanced client supports are defined in the + * {@link DeleteCompleteItemEnhancedRequest}. + *

+ * This operation calls the low-level DynamoDB API DeleteItem operation. Consult the DeleteItem documentation for further + * details and constraints. + *

+ * Example: + *

+     * {@code
+     *
+     * MyItem previouslyPersistedItem = mappedTable.delete(
+     *     DeleteItemEnhancedRequestWithItem.builder(MyItem.class)
+     *         .item(itemReadEarlier)
+     *         .build()
+     * ).join();
+     * }
+     * 
+ * + * @param request A {@link DeleteCompleteItemEnhancedRequest} with key, item, and optional directives for deleting an item + * from the table. + * @return a {@link CompletableFuture} of the item that was persisted in the database before it was deleted. + */ + default CompletableFuture deleteItem(DeleteCompleteItemEnhancedRequest request) { + throw new UnsupportedOperationException(); + } + /** * Deletes a single item from the mapped table using a supplied primary {@link Key}. *

diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java index 6e94e6726c2f..7c45edb62152 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java @@ -21,6 +21,7 @@ import software.amazon.awssdk.core.pagination.sync.SdkIterable; import software.amazon.awssdk.enhanced.dynamodb.model.CreateTableEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.DeleteCompleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; @@ -174,6 +175,39 @@ default T deleteItem(DeleteItemEnhancedRequest request) { throw new UnsupportedOperationException(); } + /** + * Deletes a single item from the mapped table using a supplied {@link DeleteCompleteItemEnhancedRequest}. + *

+ * Unlike {@link #deleteItem(DeleteItemEnhancedRequest)}, this variant allows you to provide the full item being deleted. If + * the table is configured with a version attribute (e.g., when using the {@code VersionedRecordExtension}), the enhanced + * client will apply optimistic locking semantics to ensure that the delete operation only succeeds if the + * provided item’s version matches the one currently stored in the table. + *

+ * The additional configuration parameters that the enhanced client supports are defined in the + * {@link DeleteCompleteItemEnhancedRequest}. + *

+ * This operation calls the low-level DynamoDB API DeleteItem operation. Consult the DeleteItem documentation for further + * details and constraints. + *

+ * Example: + *

+     * {@code
+     *
+     * MyItem itemToDelete = ...; // previously loaded item
+     * MyItem deletedItem = mappedTable.deleteItem(
+     *     DeleteItemEnhancedRequestWithItem.builder(MyItem.class)
+     *                                     .item(itemToDelete)
+     *                                     .build());
+     * }
+     * 
+ * + * @param request A {@link DeleteCompleteItemEnhancedRequest} with the item and optional directives for deleting it. + * @return The item that was persisted in the database before it was deleted. + */ + default T deleteItem(DeleteCompleteItemEnhancedRequest request) { + throw new UnsupportedOperationException(); + } + /** * Deletes a single item from the mapped table using a supplied primary {@link Key}. *

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 5f5cdb02c354..30ea6883129f 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 @@ -32,6 +32,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.OperationName; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -143,6 +144,17 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex return WriteModification.builder().build(); } + // Handle DELETE operations with optimistic locking + if (context.operationName() == OperationName.DELETE_ITEM) { + return handleOptimisticDelete(context, versionAttributeKey.get()); + } + + // For non-delete operations, skip version handling if it's a delete + if (context.operationName() == OperationName.DELETE_ITEM) { + return WriteModification.builder().build(); + } + + // Existing logic for other operations Map itemToTransform = new HashMap<>(context.items()); String attributeKeyRef = keyRef(versionAttributeKey.get()); @@ -206,6 +218,30 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .build(); } + private WriteModification handleOptimisticDelete(DynamoDbExtensionContext.BeforeWrite context, String versionAttributeKey) { + // Look for version in the items map + AttributeValue versionValue = context.items().get(versionAttributeKey); + + if (versionValue != null && versionValue.n() != null) { + // Build condition for the specific version + String attributeKeyRef = keyRef(versionAttributeKey); + String valueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey); + + Expression condition = Expression.builder() + .expression(String.format("%s = %s", attributeKeyRef, valueKey)) + .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey)) + .expressionValues(Collections.singletonMap(valueKey, versionValue)) + .build(); + + return WriteModification.builder() + .additionalConditionalExpression(condition) + .build(); + } + + // If no version value is provided, don't add any condition (backward compatible) + return WriteModification.builder().build(); + } + private boolean isInitialVersion(AttributeValue existingVersionValue, Long versionStartAtFromAnnotation) { if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) { return true; diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java index cd281dec3d24..0cf65b467b53 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java @@ -40,6 +40,7 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation; import software.amazon.awssdk.enhanced.dynamodb.model.CreateTableEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.DeleteCompleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; @@ -131,6 +132,13 @@ public CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { .thenApply(DeleteItemEnhancedResponse::attributes); } + @Override + public CompletableFuture deleteItem(DeleteCompleteItemEnhancedRequest request) { + TableOperation> operation = DeleteItemOperation.create(request); + return operation.executeOnPrimaryIndexAsync(tableSchema, tableName, extension, dynamoDbClient) + .thenApply(DeleteItemEnhancedResponse::attributes); + } + @Override public CompletableFuture deleteItem(Consumer requestConsumer) { DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder(); @@ -145,7 +153,10 @@ public CompletableFuture deleteItem(Key key) { @Override public CompletableFuture deleteItem(T keyItem) { - return deleteItem(keyFrom(keyItem)); + return deleteItem(DeleteCompleteItemEnhancedRequest.builder() + .key(keyFrom(keyItem)) + .item(keyItem) + .build()); } @Override diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java index 31ce811b3483..ebf4d9d95ccd 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java @@ -39,6 +39,7 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation; import software.amazon.awssdk.enhanced.dynamodb.model.CreateTableEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.DeleteCompleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; @@ -132,6 +133,12 @@ public T deleteItem(DeleteItemEnhancedRequest request) { return operation.executeOnPrimaryIndex(tableSchema, tableName, extension, dynamoDbClient).attributes(); } + @Override + public T deleteItem(DeleteCompleteItemEnhancedRequest request) { + TableOperation> operation = DeleteItemOperation.create(request); + return operation.executeOnPrimaryIndex(tableSchema, tableName, extension, dynamoDbClient).attributes(); + } + @Override public T deleteItem(Consumer requestConsumer) { DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder(); @@ -146,7 +153,10 @@ public T deleteItem(Key key) { @Override public T deleteItem(T keyItem) { - return deleteItem(keyFrom(keyItem)); + return (T) deleteItem(DeleteCompleteItemEnhancedRequest.builder() + .key(keyFrom(keyItem)) + .item(keyItem) + .build()); } @Override 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..7df8aa3ddfe5 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,11 @@ 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.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -26,10 +31,14 @@ import software.amazon.awssdk.enhanced.dynamodb.OperationContext; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification; import software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils; +import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.model.DeleteCompleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.TransactDeleteItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactDeleteCompleteItemEnhancedRequest; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -48,29 +57,60 @@ public class DeleteItemOperation TransactableWriteOperation, BatchableWriteOperation { - private final Either request; + private final Either, TransactDeleteCompleteItemEnhancedRequest> request; - private DeleteItemOperation(DeleteItemEnhancedRequest request) { + private DeleteItemOperation(DeleteCompleteItemEnhancedRequest request) { this.request = Either.left(request); } - private DeleteItemOperation(TransactDeleteItemEnhancedRequest request) { + private DeleteItemOperation(TransactDeleteCompleteItemEnhancedRequest request) { this.request = Either.right(request); } public static DeleteItemOperation create(DeleteItemEnhancedRequest request) { - return new DeleteItemOperation<>(request); + return new DeleteItemOperation<>(adapt(request)); } public static DeleteItemOperation create(TransactDeleteItemEnhancedRequest request) { + return new DeleteItemOperation<>(adapt(request)); + } + + public static DeleteItemOperation create(DeleteCompleteItemEnhancedRequest request) { return new DeleteItemOperation<>(request); } + public static DeleteItemOperation create(TransactDeleteCompleteItemEnhancedRequest request) { + return new DeleteItemOperation<>(request); + } + + private static DeleteCompleteItemEnhancedRequest adapt(DeleteItemEnhancedRequest request) { + return DeleteCompleteItemEnhancedRequest.builder() + .key(request.key()) + .conditionExpression(request.conditionExpression()) + .returnConsumedCapacity(request.returnConsumedCapacityAsString()) + .returnItemCollectionMetrics(request.returnItemCollectionMetricsAsString()) + .returnValuesOnConditionCheckFailure( + request.returnValuesOnConditionCheckFailureAsString()) + .build(); + } + + private static TransactDeleteCompleteItemEnhancedRequest adapt(TransactDeleteItemEnhancedRequest request) { + return TransactDeleteCompleteItemEnhancedRequest.builder() + .key(request.key()) + .conditionExpression(request.conditionExpression()) + .returnValuesOnConditionCheckFailure( + request.returnValuesOnConditionCheckFailureAsString()) + .build(); + } + @Override public OperationName operationName() { return OperationName.DELETE_ITEM; } + /** + * Builds the DeleteItemRequest, including optimistic delete check logic, if applicable. + */ @Override public DeleteItemRequest generateRequest(TableSchema tableSchema, OperationContext operationContext, @@ -80,7 +120,7 @@ public DeleteItemRequest generateRequest(TableSchema tableSchema, throw new IllegalArgumentException("DeleteItem cannot be executed against a secondary index."); } - Key key = request.map(DeleteItemEnhancedRequest::key, TransactDeleteItemEnhancedRequest::key); + Key key = request.map(DeleteCompleteItemEnhancedRequest::key, TransactDeleteCompleteItemEnhancedRequest::key); DeleteItemRequest.Builder requestBuilder = DeleteItemRequest.builder() @@ -93,6 +133,7 @@ public DeleteItemRequest generateRequest(TableSchema tableSchema, } requestBuilder = addExpressionsIfExist(requestBuilder); + performBeforeDeleteChecks(tableSchema, operationContext, extension, requestBuilder); return requestBuilder.build(); } @@ -148,7 +189,7 @@ public TransactWriteItem generateTransactWriteItem(TableSchema tableSchema, .expressionAttributeNames(deleteItemRequest.expressionAttributeNames()); request.right() - .map(TransactDeleteItemEnhancedRequest::returnValuesOnConditionCheckFailureAsString) + .map(TransactDeleteCompleteItemEnhancedRequest::returnValuesOnConditionCheckFailureAsString) .ifPresent(builder::returnValuesOnConditionCheckFailure); return TransactWriteItem.builder() @@ -179,11 +220,57 @@ private DeleteItemRequest.Builder addExpressionsIfExist(DeleteItemRequest.Builde } private DeleteItemRequest.Builder addPlainDeleteItemParameters(DeleteItemRequest.Builder requestBuilder, - DeleteItemEnhancedRequest enhancedRequest) { + DeleteCompleteItemEnhancedRequest enhancedRequest) { requestBuilder = requestBuilder.returnConsumedCapacity(enhancedRequest.returnConsumedCapacityAsString()); requestBuilder = requestBuilder.returnItemCollectionMetrics(enhancedRequest.returnItemCollectionMetricsAsString()); requestBuilder = requestBuilder.returnValuesOnConditionCheckFailure(enhancedRequest.returnValuesOnConditionCheckFailureAsString()); return requestBuilder; } + + private void performBeforeDeleteChecks(TableSchema tableSchema, + OperationContext operationContext, + DynamoDbEnhancedClientExtension extension, + DeleteItemRequest.Builder requestBuilder) { + + Key key = request.map(DeleteCompleteItemEnhancedRequest::key, TransactDeleteCompleteItemEnhancedRequest::key); + Map keyAttributes = key.keyMap(tableSchema, operationContext.indexName()); + + // Create item map for extension processing + Map itemForExtensions = new HashMap<>(keyAttributes); + + // If an item is provided (plain or transact), use full item attributes for extension processing (includes version) + T item = request.left() + .map(r -> (T) r.item()) + .orElseGet(() -> request.right() + .map(r -> (T) r.item()) + .orElse(null)); + + if (item != null) { + itemForExtensions = tableSchema.itemToMap(item, false); + } + + WriteModification beforeDeleteConditionExpression = + extension != null ? extension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(itemForExtensions) + .operationContext(operationContext) + .tableMetadata(tableSchema.tableMetadata()) + .tableSchema(tableSchema) + .operationName(operationName()) + .build()) + : null; + + if (beforeDeleteConditionExpression != null) { + Expression expression = beforeDeleteConditionExpression.additionalConditionalExpression(); + if (expression != null) { + requestBuilder.conditionExpression(joinExpressions(requestBuilder.build().conditionExpression(), + expression.expression(), " AND ")) + .expressionAttributeNames(joinNames(requestBuilder.build().expressionAttributeNames(), + expression.expressionNames())) + .expressionAttributeValues(joinValues(requestBuilder.build().expressionAttributeValues(), + expression.expressionValues())); + } + } + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteCompleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteCompleteItemEnhancedRequest.java new file mode 100644 index 000000000000..c34599505e66 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteCompleteItemEnhancedRequest.java @@ -0,0 +1,330 @@ +/* + * 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 java.util.function.Consumer; +import software.amazon.awssdk.annotations.NotThreadSafe; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.Key; +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.ReturnValuesOnConditionCheckFailure; + +/** + * Defines parameters used to remove an item from a DynamoDb table using the deleteItem() operation (such as + * {@link DynamoDbTable#deleteItem(DeleteItemEnhancedRequest)} or + * {@link DynamoDbAsyncTable#deleteItem(DeleteItemEnhancedRequest)}). + *

+ * A valid request object must contain a primary {@link Key} to reference the item to delete. + */ +@SdkPublicApi +@ThreadSafe +public final class DeleteCompleteItemEnhancedRequest { + + private final Key key; + private final T item; + private final Expression conditionExpression; + private final String returnConsumedCapacity; + private final String returnItemCollectionMetrics; + private final String returnValuesOnConditionCheckFailure; + + private DeleteCompleteItemEnhancedRequest(Builder builder) { + this.key = builder.key; + this.item = builder.item; + this.conditionExpression = builder.conditionExpression; + this.returnConsumedCapacity = builder.returnConsumedCapacity; + this.returnItemCollectionMetrics = builder.returnItemCollectionMetrics; + this.returnValuesOnConditionCheckFailure = builder.returnValuesOnConditionCheckFailure; + } + + /** + * Creates a newly initialized builder for a request object. + */ + public static Builder builder() { + return new Builder(); + } + + + /** + * Creates a newly initialized builder for a request object. + * + * @param itemClass the class that items in this table map to + * @param The type of the modelled object, corresponding to itemClass + * @return a TransactDeleteItemEnhancedRequest builder + */ + public static Builder builder(Class itemClass) { + return new Builder<>(); + } + + /** + * Returns a builder initialized with all existing values on the request object. + */ + public Builder toBuilder() { + return new Builder().key(key) + .conditionExpression(conditionExpression) + .returnConsumedCapacity(returnConsumedCapacity) + .returnItemCollectionMetrics(returnItemCollectionMetrics) + .returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure); + } + + /** + * Returns the primary {@link Key} for the item to delete. + */ + public Key key() { + return key; + } + + /** + * Returns the item for this delete operation request. + */ + public T item() { + return item; + } + + /** + * Returns the condition {@link Expression} set on this request object, or null if it doesn't exist. + */ + public Expression conditionExpression() { + return conditionExpression; + } + + /** + * Whether to return the capacity consumed by this operation. + * + * @see PutItemRequest#returnConsumedCapacity() + */ + public ReturnConsumedCapacity returnConsumedCapacity() { + return ReturnConsumedCapacity.fromValue(returnConsumedCapacity); + } + + /** + * Whether to return the capacity consumed by this operation. + *

+ * Similar to {@link #returnConsumedCapacity()} but return the value as a string. This is useful in situations where the value + * is not defined in {@link ReturnConsumedCapacity}. + */ + public String returnConsumedCapacityAsString() { + return returnConsumedCapacity; + } + + /** + * Whether to return the item collection metrics. + * + * @see DeleteItemRequest#returnItemCollectionMetrics() + */ + public ReturnItemCollectionMetrics returnItemCollectionMetrics() { + return ReturnItemCollectionMetrics.fromValue(returnItemCollectionMetrics); + } + + /** + * Whether to return the item collection metrics. + *

+ * Similar to {@link #returnItemCollectionMetrics()} but return the value as a string. This is useful in situations where the + * value is not defined in {@link ReturnItemCollectionMetrics}. + */ + public String returnItemCollectionMetricsAsString() { + return returnItemCollectionMetrics; + } + + /** + * Whether to return the item on condition check failure. + * + * @see DeleteItemRequest#returnValuesOnConditionCheckFailure() + */ + public ReturnValuesOnConditionCheckFailure returnValuesOnConditionCheckFailure() { + return ReturnValuesOnConditionCheckFailure.fromValue(returnValuesOnConditionCheckFailure); + } + + /** + * Whether to return the item on condition check failure. + *

+ * Similar to {@link #returnValuesOnConditionCheckFailure()} but return the value as a string. This is useful in situations + * where the value is not defined in {@link ReturnValuesOnConditionCheckFailure}. + */ + public String returnValuesOnConditionCheckFailureAsString() { + return returnValuesOnConditionCheckFailure; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DeleteCompleteItemEnhancedRequest that = (DeleteCompleteItemEnhancedRequest) o; + return Objects.equals(key, that.key) + && Objects.equals(conditionExpression, that.conditionExpression) + && Objects.equals(returnConsumedCapacity, that.returnConsumedCapacity) + && Objects.equals(returnItemCollectionMetrics, that.returnItemCollectionMetrics) + && Objects.equals(returnValuesOnConditionCheckFailure, that.returnValuesOnConditionCheckFailure); + } + + @Override + public int hashCode() { + int result = key != null ? key.hashCode() : 0; + result = 31 * result + Objects.hashCode(item); + result = 31 * result + (conditionExpression != null ? conditionExpression.hashCode() : 0); + result = 31 * result + (returnConsumedCapacity != null ? returnConsumedCapacity.hashCode() : 0); + result = 31 * result + (returnItemCollectionMetrics != null ? returnItemCollectionMetrics.hashCode() : 0); + result = 31 * result + (returnValuesOnConditionCheckFailure != null ? returnValuesOnConditionCheckFailure.hashCode() : 0); + return result; + } + + /** + * A builder that is used to create a request with the desired parameters. + *

+ * Note: A valid request builder must define a {@link Key}. + */ + @NotThreadSafe + public static final class Builder { + private Key key; + private T item; + private Expression conditionExpression; + private String returnConsumedCapacity; + private String returnItemCollectionMetrics; + private String returnValuesOnConditionCheckFailure; + + private Builder() { + } + + /** + * Sets the primary {@link Key} that will be used to match the item to delete. + * + * @param key the primary key to use in the request. + * @return a builder of this type + */ + public Builder key(Key key) { + this.key = key; + return this; + } + + /** + * Sets the primary {@link Key} that will be used to match the item to delete on the builder by accepting a consumer of + * {@link Key.Builder}. + * + * @param keyConsumer a {@link Consumer} of {@link Key} + * @return a builder of this type + */ + public Builder key(Consumer keyConsumer) { + Key.Builder builder = Key.builder(); + keyConsumer.accept(builder); + return key(builder.build()); + } + + /** + * Sets the item to delete from DynamoDB. The key will be extracted from this item. This is useful when you want to delete + * an item with version checking. + * + * @param item the item to delete + * @return a builder of this type + */ + public Builder item(T item) { + this.item = item; + return this; + } + + /** + * Defines a logical expression on an item's attribute values which, if evaluating to true, will allow the delete + * operation to succeed. If evaluating to false, the operation will not succeed. + *

+ * See {@link Expression} for condition syntax and examples. + * + * @param conditionExpression a condition written as an {@link Expression} + * @return a builder of this type + */ + public Builder conditionExpression(Expression conditionExpression) { + this.conditionExpression = conditionExpression; + return this; + } + + /** + * Whether to return the capacity consumed by this operation. + * + * @see DeleteItemRequest.Builder#returnConsumedCapacity(ReturnConsumedCapacity) + */ + public Builder returnConsumedCapacity(ReturnConsumedCapacity returnConsumedCapacity) { + this.returnConsumedCapacity = returnConsumedCapacity == null ? null : returnConsumedCapacity.toString(); + return this; + } + + /** + * Whether to return the capacity consumed by this operation. + * + * @see DeleteItemRequest.Builder#returnConsumedCapacity(String) + */ + public Builder returnConsumedCapacity(String returnConsumedCapacity) { + this.returnConsumedCapacity = returnConsumedCapacity; + return this; + } + + /** + * Whether to return the item collection metrics. + * + * @see DeleteItemRequest.Builder#returnItemCollectionMetrics(ReturnItemCollectionMetrics) + */ + public Builder returnItemCollectionMetrics(ReturnItemCollectionMetrics returnItemCollectionMetrics) { + this.returnItemCollectionMetrics = returnItemCollectionMetrics == null ? null : + returnItemCollectionMetrics.toString(); + return this; + } + + /** + * Whether to return the item collection metrics. + * + * @see DeleteItemRequest.Builder#returnItemCollectionMetrics(String) + */ + public Builder returnItemCollectionMetrics(String returnItemCollectionMetrics) { + this.returnItemCollectionMetrics = returnItemCollectionMetrics; + return this; + } + + /** + * Whether to return the item on condition check failure. + * + * @see DeleteItemRequest.Builder#returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure) + */ + public Builder returnValuesOnConditionCheckFailure( + ReturnValuesOnConditionCheckFailure returnValuesOnConditionCheckFailure) { + + this.returnValuesOnConditionCheckFailure = returnValuesOnConditionCheckFailure == null ? null : + returnValuesOnConditionCheckFailure.toString(); + return this; + } + + /** + * Whether to return the item on condition check failure. + * + * @see DeleteItemRequest.Builder#returnValuesOnConditionCheckFailure(String) + */ + public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditionCheckFailure) { + this.returnValuesOnConditionCheckFailure = returnValuesOnConditionCheckFailure; + return this; + } + + public DeleteCompleteItemEnhancedRequest build() { + return new DeleteCompleteItemEnhancedRequest<>(this); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteCompleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteCompleteItemEnhancedRequest.java new file mode 100644 index 000000000000..48c5e85b2234 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteCompleteItemEnhancedRequest.java @@ -0,0 +1,256 @@ +/* + * 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 java.util.function.Consumer; +import software.amazon.awssdk.annotations.NotThreadSafe; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; + +/** + * Defines parameters used to delete an item from a DynamoDb table using the + * {@link DynamoDbEnhancedClient#transactWriteItems(TransactWriteItemsEnhancedRequest)} or + * {@link DynamoDbEnhancedAsyncClient#transactWriteItems(TransactWriteItemsEnhancedRequest)} operation. + *

+ * A valid request object must contain a primary {@link Key} to reference the item to delete. + */ +@SdkPublicApi +@ThreadSafe +public final class TransactDeleteCompleteItemEnhancedRequest { + + private final Key key; + private final T item; + private final Expression conditionExpression; + private final String returnValuesOnConditionCheckFailure; + + private TransactDeleteCompleteItemEnhancedRequest(Builder builder) { + this.key = builder.key; + this.item = builder.item; + this.conditionExpression = builder.conditionExpression; + this.returnValuesOnConditionCheckFailure = builder.returnValuesOnConditionCheckFailure; + } + + /** + * Creates a newly initialized builder for a request object. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a newly initialized builder for a request object. + * + * @param itemClass the class that items in this table map to + * @param The type of the modelled object, corresponding to itemClass + * @return a TransactDeleteItemEnhancedRequest builder + */ + public static Builder builder(Class itemClass) { + return new Builder<>(); + } + + /** + * Returns a builder initialized with all existing values on the request object. + */ + public Builder toBuilder() { + return new Builder().key(key) + .item(item) + .conditionExpression(conditionExpression) + .returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailureAsString()); + } + + /** + * Returns the primary {@link Key} for the item to delete. + */ + public Key key() { + return key; + } + + /** + * Returns the item for this delete operation request. + */ + public T item() { + return item; + } + + /** + * Returns the condition {@link Expression} set on this request object, or null if it doesn't exist. + */ + public Expression conditionExpression() { + return conditionExpression; + } + + /** + * Returns what values to return if the condition check fails. + *

+ * If the service returns an enum value that is not available in the current SDK version, + * {@link #returnValuesOnConditionCheckFailure} will return + * {@link ReturnValuesOnConditionCheckFailure#UNKNOWN_TO_SDK_VERSION}. The raw value returned by the service is available from + * {@link #returnValuesOnConditionCheckFailureAsString}. + * + * @return What values to return on condition check failure. + */ + public ReturnValuesOnConditionCheckFailure returnValuesOnConditionCheckFailure() { + return ReturnValuesOnConditionCheckFailure.fromValue(returnValuesOnConditionCheckFailure); + } + + /** + * Returns what values to return if the condition check fails. + *

+ * If the service returns an enum value that is not available in the current SDK version, + * {@link #returnValuesOnConditionCheckFailure} will return + * {@link ReturnValuesOnConditionCheckFailure#UNKNOWN_TO_SDK_VERSION}. The raw value returned by the service is available from + * {@link #returnValuesOnConditionCheckFailureAsString}. + * + * @return What values to return on condition check failure. + */ + public String returnValuesOnConditionCheckFailureAsString() { + return returnValuesOnConditionCheckFailure; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + TransactDeleteCompleteItemEnhancedRequest that = (TransactDeleteCompleteItemEnhancedRequest) o; + + if (!Objects.equals(key, that.key)) { + return false; + } + if (!Objects.equals(item, that.item)) { + return false; + } + if (!Objects.equals(conditionExpression, that.conditionExpression)) { + return false; + } + return Objects.equals(returnValuesOnConditionCheckFailure, that.returnValuesOnConditionCheckFailure); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(key); + result = 31 * result + Objects.hashCode(item); + result = 31 * result + Objects.hashCode(conditionExpression); + result = 31 * result + Objects.hashCode(returnValuesOnConditionCheckFailure); + return result; + } + + /** + * A builder that is used to create a request with the desired parameters. + *

+ * Note: A valid request builder must define either a {@link Key} or an item. + */ + @NotThreadSafe + public static final class Builder { + private Key key; + private T item; + private Expression conditionExpression; + private String returnValuesOnConditionCheckFailure; + + private Builder() { + } + + /** + * Sets the primary {@link Key} that will be used to match the item to delete. + * + * @param key the primary key to use in the request. + * @return a builder of this type + */ + public Builder key(Key key) { + this.key = key; + return this; + } + + /** + * Sets the primary {@link Key} that will be used to match the item to delete on the builder by accepting a consumer of + * {@link Key.Builder}. + * + * @param keyConsumer a {@link Consumer} of {@link Key} + * @return a builder of this type + */ + public Builder key(Consumer keyConsumer) { + Key.Builder builder = Key.builder(); + keyConsumer.accept(builder); + return key(builder.build()); + } + + /** + * Sets the item to delete from DynamoDB. The key will be extracted from this item. This is useful when you want to delete + * an item with version checking. + * + * @param item the item to delete + * @return a builder of this type + */ + public Builder item(T item) { + this.item = item; + return this; + } + + /** + * Defines a logical expression on an item's attribute values which, if evaluating to true, will allow the delete + * operation to succeed. If evaluating to false, the operation will not succeed. + *

+ * See {@link Expression} for condition syntax and examples. + * + * @param conditionExpression a condition written as an {@link Expression} + * @return a builder of this type + */ + public Builder conditionExpression(Expression conditionExpression) { + this.conditionExpression = conditionExpression; + return this; + } + + /** + * Use ReturnValuesOnConditionCheckFailure to get the item attributes if the Delete condition + * fails. For ReturnValuesOnConditionCheckFailure, the valid values are: NONE and ALL_OLD. + * + * @param returnValuesOnConditionCheckFailure What values to return on condition check failure. + * @return a builder of this type + */ + public Builder returnValuesOnConditionCheckFailure( + ReturnValuesOnConditionCheckFailure returnValuesOnConditionCheckFailure) { + this.returnValuesOnConditionCheckFailure = returnValuesOnConditionCheckFailure == null ? null : + returnValuesOnConditionCheckFailure.toString(); + return this; + } + + /** + * Use ReturnValuesOnConditionCheckFailure to get the item attributes if the Delete condition + * fails. For ReturnValuesOnConditionCheckFailure, the valid values are: NONE and ALL_OLD. + * + * @param returnValuesOnConditionCheckFailure What values to return on condition check failure. + * @return a builder of this type + */ + public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditionCheckFailure) { + this.returnValuesOnConditionCheckFailure = returnValuesOnConditionCheckFailure; + return this; + } + + public TransactDeleteCompleteItemEnhancedRequest build() { + return new TransactDeleteCompleteItemEnhancedRequest<>(this); + } + } +} \ No newline at end of file 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..14c3a56e93d2 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 @@ -257,6 +257,30 @@ public Builder addDeleteItem(MappedTableResource mappedTableResource, Tra return this; } + /** + * Adds a delete operation to the transaction for the supplied item in the given table. + *

+ * Unlike {@link #addDeleteItem(MappedTableResource, TransactDeleteItemEnhancedRequest)}, this variant allows you to + * provide the full item being deleted. If the table is configured with a version attribute (for example, when using the + * {@code VersionedRecordExtension}), the enhanced client will apply + * optimistic locking semantics to ensure that the delete operation only succeeds if the + * provided item’s version matches the one currently stored in the table. + *

+ * For more information on the delete action, see the low-level operation description in + * {@link DynamoDbTable#deleteItem(DeleteItemEnhancedRequest)} and how to construct the low-level request in + * {@link TransactDeleteCompleteItemEnhancedRequest}. + * + * @param mappedTableResource the table from which the item will be deleted + * @param request a {@link TransactDeleteCompleteItemEnhancedRequest} containing the item to delete + * @param the type of modeled objects in the table + * @return a builder of this type + */ + public Builder addDeleteItem(MappedTableResource mappedTableResource, + TransactDeleteCompleteItemEnhancedRequest request) { + itemSupplierList.add(() -> generateTransactWriteItem(mappedTableResource, DeleteItemOperation.create(request))); + return this; + } + /** * 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 @@ -272,17 +296,30 @@ 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 + * Adds the supplied item and its associated table to the transaction for deletion. + *

+ * Unlike {@link #addDeleteItem(MappedTableResource, Key)}, this variant allows you to provide the full modeled item + * instead of only its primary key. If the table is configured with a version attribute (for example, when using the + * {@code VersionedRecordExtension}), the enhanced client will apply + * optimistic locking semantics to ensure that the delete operation only succeeds if the + * provided item’s version matches the one currently stored in the table. + *

+ * For more information on the delete action, see the low-level operation description in for instance * {@link DynamoDbTable#deleteItem(DeleteItemEnhancedRequest)}. * - * @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 the type of modelled objects in the table + * @param mappedTableResource the table where the item is located + * @param keyItem the modeled item to be deleted as part of the transaction + * @param the type of modeled objects in the table * @return a builder of this type */ public Builder addDeleteItem(MappedTableResource mappedTableResource, T keyItem) { - return addDeleteItem(mappedTableResource, mappedTableResource.keyFrom(keyItem)); + TransactDeleteCompleteItemEnhancedRequest request = + TransactDeleteCompleteItemEnhancedRequest.builder() + .key(mappedTableResource.keyFrom(keyItem)) + .item(keyItem) + .build(); + + return addDeleteItem(mappedTableResource, request); } /** 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 4feb23a43943..2f3c57167b44 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 @@ -41,6 +41,7 @@ import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeVersionedStaticImmutableItem; import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.OperationName; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -324,6 +325,103 @@ public void beforeWrite_versionEqualsAnnotationStartAt_isTreatedAsInitialVersion is("attribute_not_exists(#AMZN_MAPPED_version)")); } + @Test + public void deleteWithOptimisticLockingOnDelete_andVersionNull_skipsCondition() { + Map map = new HashMap<>(FakeItem.getTableSchema() + .itemToMap(createUniqueFakeItem(), true)); + map.put("version", null); + + VersionedRecordExtension extension = VersionedRecordExtension.builder() + .build(); + + WriteModification result = extension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(map) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT) + .operationName(OperationName.DELETE_ITEM) + .build()); + + assertThat(result.additionalConditionalExpression(), is((Expression) null)); + } + + @Test + public void deleteWithOptimisticLockingOnDelete_andVersionWrongType_skipsCondition() { + FakeItem fakeItem = createUniqueFakeItem(); + Map map = new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + map.put("version", AttributeValue.builder().s("bad").build()); + + VersionedRecordExtension extension = VersionedRecordExtension.builder() + .build(); + + WriteModification result = extension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(map) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT) + .operationName(OperationName.DELETE_ITEM) + .build()); + + assertThat(result.additionalConditionalExpression(), is((Expression) null)); + } + + @Test + public void deleteWithOptimisticLockingOnDelete_andVersionMissingOrExplicitNull_skipsCondition() { + FakeItem fakeItem = createUniqueFakeItem(); + VersionedRecordExtension extension = VersionedRecordExtension.builder() + .build(); + + Map mapMissing = new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + mapMissing.remove("version"); + + WriteModification missing = extension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(mapMissing) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT) + .operationName(OperationName.DELETE_ITEM) + .build()); + assertThat(missing.additionalConditionalExpression(), is((Expression) null)); + + Map mapNull = new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + mapNull.put("version", AttributeValue.builder().nul(true).build()); + + WriteModification explicitNull = extension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(mapNull) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT) + .operationName(OperationName.DELETE_ITEM) + .build()); + assertThat(explicitNull.additionalConditionalExpression(), is((Expression) null)); + } + + @Test + public void deleteWithOptimisticLockingOnDelete_andVersionPresent_addsEqualityCondition() { + VersionedRecordExtension extension = VersionedRecordExtension.builder() + .build(); + + FakeItem fakeItem = createUniqueFakeItem(); + fakeItem.setVersion(7); + + Map map = FakeItem.getTableSchema().itemToMap(fakeItem, true); + + WriteModification result = extension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(map) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT) + .operationName(OperationName.DELETE_ITEM) + .build()); + + assertThat(result.additionalConditionalExpression(), + is(Expression.builder() + .expression("#AMZN_MAPPED_version = :old_version_value") + .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) + .expressionValues(singletonMap(":old_version_value", + AttributeValue.builder().n("7").build())) + .build())); + } @DynamoDbBean public static class FakeVersionedThroughAnnotationItem { 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..e4ecb4da20e3 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 @@ -23,9 +23,9 @@ import static org.hamcrest.Matchers.sameInstance; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem.createUniqueFakeItem; import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort.createUniqueFakeItemWithSort; @@ -46,6 +46,7 @@ import software.amazon.awssdk.enhanced.dynamodb.OperationContext; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; import software.amazon.awssdk.enhanced.dynamodb.extensions.ReadModification; +import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemComposedClass; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort; @@ -419,7 +420,6 @@ public void generateRequest_withExtension_doesNotModifyKey() { mockDynamoDbEnhancedClientExtension); assertThat(request.key(), is(keyMap)); - verify(mockDynamoDbEnhancedClientExtension, never()).beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class)); } @Test @@ -556,4 +556,98 @@ public void generateTransactWriteItem_returnValuesOnConditionCheckFailure_genera assertThat(actualResult, is(expectedResult)); verify(deleteItemOperation).generateRequest(FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); } + + @Test + public void generateTransactWriteItem_withVersionedRecordExtension_givenReturnedCondition_setsConditionOnRequest() { + FakeItem fakeItem = createUniqueFakeItem(); + 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 = "#AMZN_MAPPED_version = :old_version_value"; + Map attributeNames = Collections.singletonMap("#AMZN_MAPPED_version", "version"); + Map attributeValues = Collections.singletonMap(":old_version_value", stringValue("7")); + + Expression expression = + Expression.builder() + .expression("#AMZN_MAPPED_version = :old_version_value") + .expressionNames(Collections.singletonMap("#AMZN_MAPPED_version", "version")) + .expressionValues(Collections.singletonMap(":old_version_value", AttributeValue.fromS("7"))) + .build(); + WriteModification writeModification = WriteModification.builder().additionalConditionalExpression(expression).build(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DefaultDynamoDbExtensionContext.class))).thenReturn(writeModification); + + TransactWriteItem actualResult = deleteItemOperation.generateTransactWriteItem(FakeItem.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + + 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(mockDynamoDbEnhancedClientExtension).beforeWrite(any(DefaultDynamoDbExtensionContext.class)); + } + + @Test + public void generateTransactWriteItem_withVersionedRecordExtension_givenNoReturnedCondition_doesNotSetConditionOnRequest() { + FakeItem fakeItem = createUniqueFakeItem(); + 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()); + + Expression emptyExpression = Expression.builder().build(); + WriteModification writeModification = + WriteModification.builder().additionalConditionalExpression(emptyExpression).build(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DefaultDynamoDbExtensionContext.class))) + .thenReturn(writeModification); + + TransactWriteItem actualResult = deleteItemOperation.generateTransactWriteItem(FakeItem.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + + TransactWriteItem expectedResult = TransactWriteItem.builder() + .delete(Delete.builder() + .key(fakeItemMap) + .tableName(TABLE_NAME) + .build()) + .build(); + assertThat(actualResult, is(expectedResult)); + verify(mockDynamoDbEnhancedClientExtension).beforeWrite(any(DefaultDynamoDbExtensionContext.class)); + } + + @Test + public void generateTransactWriteItem_withoutVersionedRecordExtension_doesNotSetConditionOnRequest() { + FakeItem fakeItem = createUniqueFakeItem(); + 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()); + + TransactWriteItem actualResult = deleteItemOperation.generateTransactWriteItem(FakeItem.getTableSchema(), + context, + null); + + TransactWriteItem expectedResult = TransactWriteItem.builder() + .delete(Delete.builder() + .key(fakeItemMap) + .tableName(TABLE_NAME) + .build()) + .build(); + assertThat(actualResult, is(expectedResult)); + verifyNoInteractions(mockDynamoDbEnhancedClientExtension); + } }