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..f0374b1ae058 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,9 +31,13 @@ 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.TransactDeleteItemEnhancedRequest; +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.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; import software.amazon.awssdk.services.dynamodb.model.Projection; import software.amazon.awssdk.services.dynamodb.model.ProjectionType; @@ -41,11 +45,13 @@ 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 { private static final String TABLE_NAME = createTestTableName(); + private static final String VERSIONED_TABLE_NAME = createTestTableName(); private static final EnhancedLocalSecondaryIndex LOCAL_SECONDARY_INDEX = EnhancedLocalSecondaryIndex.builder() .indexName("index1") .projection(Projection.builder() @@ -56,14 +62,21 @@ 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(VERSIONED_TABLE_NAME, VERSIONED_RECORD_TABLE_SCHEMA); + versionedRecordTable.createTable().join(); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)).join(); + dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(VERSIONED_TABLE_NAME)).join(); } @After @@ -72,12 +85,18 @@ public void tearDown() { .items() .subscribe(record -> mappedTable.deleteItem(record).join()) .join(); + + versionedRecordTable.scan() + .items() + .subscribe(versionedRecord -> versionedRecordTable.deleteItem(versionedRecord).join()) + .join(); } @AfterClass public static void afterClass() { try { dynamoDbClient.deleteTable(r -> r.tableName(TABLE_NAME)).join(); + dynamoDbClient.deleteTable(r -> r.tableName(VERSIONED_TABLE_NAME)).join(); } finally { dynamoDbClient.close(); } @@ -341,4 +360,211 @@ public void getItem_withoutReturnConsumedCapacity() { GetItemEnhancedResponse response = mappedTable.getItemWithResponse(req -> req.key(key)).join(); assertThat(response.consumedCapacity()).isNull(); } + + // ========== OPTIMISTIC LOCKING TESTS ========== + + // 1. deleteItem(T item) - Non-versioned record + @Test + public void deleteItem_nonVersionedRecord_shouldSucceed() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item).join(); + mappedTable.deleteItem(item).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 2. deleteItem(T item) - Versioned record, versions match + @Test + public void deleteItem_versionedRecord_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + versionedRecordTable.deleteItem(savedItem).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 3. deleteItem(T item, false) - Versioned record, should not use optimistic locking + @Test + public void deleteItem_versionedRecord_flagFalse_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + // Update the item to change its version + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Delete with old version but flag=false - should succeed (no optimistic locking) + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + versionedRecordTable.deleteItem(oldVersionItem, false).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 4. deleteItem(T item, true) - Versioned record, versions match + @Test + public void deleteItem_versionedRecord_flagTrue_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + versionedRecordTable.deleteItem(savedItem, true).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 5. deleteItem(T item, true) - Versioned record, versions mismatch + @Test + public void deleteItem_versionedRecord_flagTrue_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + // Update the item to change its version + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Try to delete with old version and flag=true - should fail + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(oldVersionItem, true).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(ConditionalCheckFailedException.class)) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + + + // 6. deleteItem(DeleteItemEnhancedRequest) with builder method - Versions match + @Test + public void deleteItemWithBuilder_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version") + .build(); + + versionedRecordTable.deleteItem(requestWithLocking).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 7. deleteItem(DeleteItemEnhancedRequest) with builder method - Versions mismatch + @Test + public void deleteItemWithBuilder_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n("999").build(), "version") + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 8. TransactWriteItems.addDeleteItem(T item) - Non-versioned record + @Test + public void transactDeleteItem_nonVersionedRecord_shouldSucceed() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item).join(); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, item) + .build()).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 9. TransactWriteItems.addDeleteItem(T item) - Versioned record, versions match + @Test + public void transactDeleteItem_versionedRecord_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, savedItem) + .build()).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + + + // 10. TransactWriteItems with builder method - versions match + @Test + public void transactDeleteItemWithBuilder_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version") + .build(); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build()).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 11. TransactWriteItems with builder method - versions mismatch + @Test + public void transactDeleteItemWithBuilder_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n("999").build(), "version") + .build(); + + assertThatThrownBy(() -> enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build()).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(((TransactionCanceledException) e.getCause()) + .cancellationReasons() + .stream() + .anyMatch(reason -> "ConditionalCheckFailed".equals(reason.code()))) + .isTrue()); + } } 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..9dfd3f9dea45 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,9 +34,13 @@ 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.TransactDeleteItemEnhancedRequest; +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.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity; import software.amazon.awssdk.services.dynamodb.model.Projection; @@ -40,10 +48,12 @@ 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 { private static final String TABLE_NAME = createTestTableName(); + private static final String VERSIONED_TABLE_NAME = createTestTableName(); private static final EnhancedLocalSecondaryIndex LOCAL_SECONDARY_INDEX = EnhancedLocalSecondaryIndex.builder() @@ -56,14 +66,21 @@ 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(VERSIONED_TABLE_NAME, VERSIONED_RECORD_TABLE_SCHEMA); + versionedRecordTable.createTable(); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)); + dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(VERSIONED_TABLE_NAME)); } @After @@ -71,12 +88,17 @@ public void tearDown() { mappedTable.scan() .items() .forEach(record -> mappedTable.deleteItem(record)); + + versionedRecordTable.scan() + .items() + .forEach(versionedRecord -> versionedRecordTable.deleteItem(versionedRecord)); } @AfterClass public static void afterClass() { try { dynamoDbClient.deleteTable(r -> r.tableName(TABLE_NAME)); + dynamoDbClient.deleteTable(r -> r.tableName(VERSIONED_TABLE_NAME)); } finally { dynamoDbClient.close(); } @@ -321,4 +343,213 @@ 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)); } -} + + // ========== OPTIMISTIC LOCKING TESTS ========== + + // 1. deleteItem(T item) - Non-versioned record + @Test + public void deleteItem_nonVersionedRecord_shouldSucceed() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item); + mappedTable.deleteItem(item); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 2. deleteItem(T item) - Versioned record, versions match + @Test + public void deleteItem_versionedRecord_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + versionedRecordTable.deleteItem(savedItem); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 3. deleteItem(T item, false) - Versioned record, should not use optimistic locking + @Test + public void deleteItem_versionedRecord_flagFalse_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + // Update the item to change its version + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Delete with old version but flag=false - should succeed (no optimistic locking) + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + versionedRecordTable.deleteItem(oldVersionItem, false); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 4. deleteItem(T item, true) - Versioned record, versions match + @Test + public void deleteItem_versionedRecord_flagTrue_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + versionedRecordTable.deleteItem(savedItem, true); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 5. deleteItem(T item, true) - Versioned record, versions mismatch + @Test + public void deleteItem_versionedRecord_flagTrue_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + // Update the item to change its version + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Try to delete with old version and flag=true - should fail + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(oldVersionItem, true)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + + + + // 6. deleteItem(DeleteItemEnhancedRequest) with builder method - versions match + @Test + public void deleteItemWithBuilder_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version") + .build(); + + versionedRecordTable.deleteItem(requestWithLocking); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 7. deleteItem(DeleteItemEnhancedRequest) with builder method - versions mismatch + @Test + public void deleteItemWithBuilder_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n("999").build(), "version") + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 8. TransactWriteItems.addDeleteItem(T item) - Non-versioned record + @Test + public void transactDeleteItem_nonVersionedRecord_shouldSucceed() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, item) + .build()); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 9. TransactWriteItems.addDeleteItem(T item) - Versioned record, versions match + @Test + public void transactDeleteItem_versionedRecord_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, savedItem) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + + + + // 10. TransactWriteItems with builder method - versions match + @Test + public void transactDeleteItemWithBuilder_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version") + .build(); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, + requestWithLocking) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 11. TransactWriteItems with builder method - versions mismatch + @Test + public void transactDeleteItemWithBuilder_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n("999").build(), "version") + .build(); + + TransactionCanceledException ex = assertThrows(TransactionCanceledException.class, + () -> enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build())); + + 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()); + } +} \ No newline at end of file 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..135302d54b05 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 @@ -247,6 +247,10 @@ default CompletableFuture deleteItem(T keyItem) { throw new UnsupportedOperationException(); } + default CompletableFuture deleteItem(T keyItem, boolean useOptimisticLocking) { + 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..49f19a8842dd 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 @@ -245,6 +245,10 @@ default T deleteItem(T keyItem) { throw new UnsupportedOperationException(); } + default T deleteItem(T keyItem, boolean useOptimisticLocking) { + 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/internal/client/DefaultDynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java index cd281dec3d24..3ecdae547928 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.conditionallyApplyOptimisticLocking; import java.util.ArrayList; import java.util.concurrent.CompletableFuture; @@ -124,6 +125,9 @@ public CompletableFuture createTable() { .build()); } + /** + * Supports optimistic locking via {@link software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper}. + */ @Override public CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { TableOperation> operation = DeleteItemOperation.create(request); @@ -131,6 +135,9 @@ public CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { .thenApply(DeleteItemEnhancedResponse::attributes); } + /** + * Supports optimistic locking via {@link software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper}. + */ @Override public CompletableFuture deleteItem(Consumer requestConsumer) { DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder(); @@ -138,14 +145,34 @@ public CompletableFuture deleteItem(Consumer deleteItem(Key key) { return deleteItem(r -> r.key(key)); } + /** + * @deprecated Use {@link #deleteItem(Object, boolean)} instead to explicitly control optimistic locking behavior. + */ @Override + @Deprecated public CompletableFuture deleteItem(T keyItem) { - return deleteItem(keyFrom(keyItem)); + return deleteItem(keyItem, false); + } + + /** + * Deletes an item from the table with optional optimistic locking. + * + * @param keyItem the item containing the key to delete + * @param useOptimisticLocking if true, applies optimistic locking if the item has version information + * @return a CompletableFuture containing the deleted item, or null if the item was not found + */ + public CompletableFuture deleteItem(T keyItem, boolean useOptimisticLocking) { + DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); + request = conditionallyApplyOptimisticLocking(request, keyItem, tableSchema, useOptimisticLocking); + return deleteItem(request); } @Override @@ -311,6 +338,11 @@ public CompletableFuture updateItem(T item) { public Key keyFrom(T item) { return createKeyFromItem(item, tableSchema, TableMetadata.primaryIndexName()); } + + private java.util.Optional getVersionAttributeName() { + return tableSchema.tableMetadata() + .customMetadataObject("VersionedRecordExtension:VersionAttribute", String.class); + } @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..f208e747d16a 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.conditionallyApplyOptimisticLocking; import java.util.ArrayList; import java.util.function.Consumer; @@ -126,12 +127,18 @@ public void createTable() { .build()); } + /** + * Supports optimistic locking via {@link software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper}. + */ @Override public T deleteItem(DeleteItemEnhancedRequest request) { TableOperation> operation = DeleteItemOperation.create(request); return operation.executeOnPrimaryIndex(tableSchema, tableName, extension, dynamoDbClient).attributes(); } + /** + * Supports optimistic locking via {@link software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper}. + */ @Override public T deleteItem(Consumer requestConsumer) { DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder(); @@ -139,14 +146,34 @@ public T deleteItem(Consumer requestConsumer) return deleteItem(builder.build()); } + /** + * Does not support optimistic locking. Use {@link #deleteItem(Object, boolean)} for optimistic locking support. + */ @Override public T deleteItem(Key key) { return deleteItem(r -> r.key(key)); } + /** + * @deprecated Use {@link #deleteItem(Object, boolean)} instead to explicitly control optimistic locking behavior. + */ @Override + @Deprecated public T deleteItem(T keyItem) { - return deleteItem(keyFrom(keyItem)); + return deleteItem(keyItem, false); + } + + /** + * Deletes an item from the table with optional optimistic locking. + * + * @param keyItem the item containing the key to delete + * @param useOptimisticLocking if true, applies optimistic locking if the item has version information + * @return the deleted item, or null if the item was not found + */ + public T deleteItem(T keyItem, boolean useOptimisticLocking) { + DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); + request = conditionallyApplyOptimisticLocking(request, keyItem, tableSchema, useOptimisticLocking); + return deleteItem(request); } @Override @@ -304,6 +331,11 @@ public UpdateItemEnhancedResponse updateItemWithResponse(Consumer getVersionAttributeName() { + return tableSchema.tableMetadata() + .customMetadataObject("VersionedRecordExtension:VersionAttribute", String.class); + } @Override public void deleteTable() { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java index 0a7a01500bfd..1874ef91ccfb 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java @@ -15,6 +15,8 @@ package software.amazon.awssdk.enhanced.dynamodb.model; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.createVersionCondition; + import java.util.Objects; import java.util.function.Consumer; import software.amazon.awssdk.annotations.NotThreadSafe; @@ -24,6 +26,7 @@ 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.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; @@ -289,6 +292,22 @@ public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditio return this; } + /** + * Adds optimistic locking to this delete request. + *

+ * This method applies a condition expression that ensures the delete operation only succeeds + * if the version attribute of the item matches the provided expected value. + * + * @param versionValue the expected version value that must match for the delete to succeed + * @param versionAttributeName the name of the version attribute in the DynamoDB table + * @return a builder of this type with optimistic locking condition applied + * @throws IllegalArgumentException if any parameter is null + */ + public Builder withOptimisticLocking(AttributeValue versionValue, String versionAttributeName) { + Expression optimisticLockingCondition = createVersionCondition(versionValue, versionAttributeName); + return conditionExpression(optimisticLockingCondition); + } + public DeleteItemEnhancedRequest build() { return new DeleteItemEnhancedRequest(this); } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java new file mode 100644 index 000000000000..b7ce047146a4 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java @@ -0,0 +1,119 @@ +/* + * 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.Optional; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Utility class for adding optimistic locking to DynamoDB delete operations. + *

+ * Optimistic locking prevents concurrent modifications by checking that an item's version hasn't changed since it was last read. + * If the version has changed, the delete operation fails with a {@code ConditionalCheckFailedException}. + */ +@SdkPublicApi +public final class OptimisticLockingHelper { + + private OptimisticLockingHelper() { + } + + /** + * Adds optimistic locking to a delete request. + * + * @param request the original delete request + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return delete request with optimistic locking condition + */ + public static DeleteItemEnhancedRequest withOptimisticLocking( + DeleteItemEnhancedRequest request, AttributeValue versionValue, String versionAttributeName) { + + Expression conditionExpression = createVersionCondition(versionValue, versionAttributeName); + return request.toBuilder() + .conditionExpression(conditionExpression) + .build(); + } + + /** + * Adds optimistic locking to a transactional delete request. + * + * @param request the original transactional delete request + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return transactional delete request with optimistic locking condition + */ + public static TransactDeleteItemEnhancedRequest withOptimisticLocking( + TransactDeleteItemEnhancedRequest request, AttributeValue versionValue, String versionAttributeName) { + + Expression conditionExpression = createVersionCondition(versionValue, versionAttributeName); + return request.toBuilder() + .conditionExpression(conditionExpression) + .build(); + } + + /** + * Conditionally applies optimistic locking if enabled and version information exists. + * + * @param the type of the item + * @param request the original delete request + * @param keyItem the item containing version information + * @param tableSchema the table schema + * @param useOptimisticLocking if true, applies optimistic locking + * @return delete request with optimistic locking if enabled and version exists, otherwise original request + */ + public static DeleteItemEnhancedRequest conditionallyApplyOptimisticLocking( + DeleteItemEnhancedRequest request, T keyItem, TableSchema tableSchema, boolean useOptimisticLocking) { + + if (!useOptimisticLocking) { + return request; + } + + return getVersionAttributeName(tableSchema) + .map(versionAttributeName -> { + AttributeValue version = tableSchema.attributeValue(keyItem, versionAttributeName); + return version != null ? withOptimisticLocking(request, version, versionAttributeName) : request; + }) + .orElse(request); + } + + /** + * Creates a version condition expression. + * + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return version check condition expression + */ + public static Expression createVersionCondition(AttributeValue versionValue, String versionAttributeName) { + return Expression.builder() + .expression(versionAttributeName + " = :version_value") + .putExpressionValue(":version_value", versionValue) + .build(); + } + + /** + * Gets the version attribute name from table schema. + * + * @param the type of the item + * @param tableSchema the table schema + * @return version attribute name if present, empty otherwise + */ + public static Optional getVersionAttributeName(TableSchema tableSchema) { + return tableSchema.tableMetadata().customMetadataObject("VersionedRecordExtension:VersionAttribute", String.class); + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java index 15c4df8cacd8..6e1c3a9e5f4b 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java @@ -24,6 +24,7 @@ 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.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; /** @@ -215,6 +216,22 @@ public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditio return this; } + /** + * Adds optimistic locking to this transactional delete request. + *

+ * This method applies a condition expression that ensures the delete operation only succeeds + * if the version attribute of the item matches the provided expected value. If the condition + * fails, the entire transaction will be cancelled. + * + * @param versionValue the expected version value that must match for the delete to succeed + * @param versionAttributeName the name of the version attribute in the DynamoDB table + * @return a builder of this type with optimistic locking condition applied + * @throws IllegalArgumentException if any parameter is null + */ + public Builder withOptimisticLocking(AttributeValue versionValue, String versionAttributeName) { + Expression optimisticLockingCondition = OptimisticLockingHelper.createVersionCondition(versionValue, versionAttributeName); + return conditionExpression(optimisticLockingCondition); + } public TransactDeleteItemEnhancedRequest build() { return new TransactDeleteItemEnhancedRequest(this); 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..a7498b686dd5 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 @@ -246,6 +246,10 @@ public Builder addDeleteItem(MappedTableResource mappedTableResource, Del * the delete action, see the low-level operation description in for instance * {@link DynamoDbTable#deleteItem(DeleteItemEnhancedRequest)} and how to construct the low-level request in * {@link TransactDeleteItemEnhancedRequest}. + *

+ * For optimistic locking support, use + * {@link TransactDeleteItemEnhancedRequest.Builder#withOptimisticLocking(software.amazon.awssdk.services.dynamodb.model.AttributeValue, String)} + * to create a request with version checking conditions before adding it to the transaction. * * @param mappedTableResource the table where the key is located * @param request A {@link TransactDeleteItemEnhancedRequest} @@ -272,13 +276,20 @@ 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) { diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java new file mode 100644 index 000000000000..e491fc9daa22 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java @@ -0,0 +1,144 @@ +/* + * 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.functionaltests; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +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.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; + +public class OptimisticLockingDeleteTest extends LocalDynamoDbSyncTestBase { + + @DynamoDbBean + public static class VersionedRecord { + private String id; + private String data; + private Integer version; + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getData() { return data; } + public void setData(String data) { this.data = data; } + + @DynamoDbVersionAttribute + public Integer getVersion() { return version; } + public void setVersion(Integer version) { this.version = version; } + } + + private DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + private DynamoDbTable mappedTable = enhancedClient.table(getConcreteTableName("versioned-table"), + TableSchema.fromClass(VersionedRecord.class)); + + @Rule + public ExpectedException exception = ExpectedException.none(); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName("versioned-table")) + .build()); + } + + @Test + public void deleteItem_withKeyItem_appliesOptimisticLocking() { + // Put initial item + VersionedRecord record = new VersionedRecord(); + record.setId("test-id"); + record.setData("initial-data"); + mappedTable.putItem(record); + + // Get the item to obtain current version + VersionedRecord retrievedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(retrievedRecord.getVersion(), is(1)); // VersionedRecordExtension starts at 1 + + // Update the item to change version + retrievedRecord.setData("updated-data"); + mappedTable.updateItem(retrievedRecord); + + // Try to delete using old version - should fail + VersionedRecord oldVersionRecord = new VersionedRecord(); + oldVersionRecord.setId("test-id"); + oldVersionRecord.setVersion(1); // Old version + + exception.expect(ConditionalCheckFailedException.class); + mappedTable.deleteItem(oldVersionRecord, true); + } + + @Test + public void deleteItem_withKeyItem_succeedsWithCorrectVersion() { + // Put initial item + VersionedRecord record = new VersionedRecord(); + record.setId("test-id"); + record.setData("initial-data"); + mappedTable.putItem(record); + + // Get the item to obtain current version + VersionedRecord retrievedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + + // Delete using correct version - should succeed + VersionedRecord deletedRecord = mappedTable.deleteItem(retrievedRecord); + assertThat(deletedRecord.getId(), is("test-id")); + + // Verify item is deleted + VersionedRecord afterDelete = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(afterDelete, is(nullValue())); + } + + @Test + public void deleteItem_withKey_doesNotApplyOptimisticLocking() { + // Put initial item + VersionedRecord record = new VersionedRecord(); + record.setId("test-id"); + record.setData("initial-data"); + mappedTable.putItem(record); + + // Update the item to change version + VersionedRecord retrievedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + retrievedRecord.setData("updated-data"); + mappedTable.updateItem(retrievedRecord); + + // Delete using key only - should succeed regardless of version + VersionedRecord deletedRecord = mappedTable.deleteItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(deletedRecord.getId(), is("test-id")); + + // Verify item is deleted + VersionedRecord afterDelete = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(afterDelete, is(nullValue())); + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java new file mode 100644 index 000000000000..f0e5f4b6d468 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java @@ -0,0 +1,128 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class OptimisticLockingHelperTest { + + @Test + public void createVersionCondition_shouldCreateCorrectExpression() { + AttributeValue versionValue = AttributeValue.builder().n("5").build(); + String versionAttributeName = "version"; + + software.amazon.awssdk.enhanced.dynamodb.Expression result = + OptimisticLockingHelper.createVersionCondition(versionValue, versionAttributeName); + + assertThat(result.expression()).isEqualTo("version = :version_value"); + assertThat(result.expressionValues()).containsEntry(":version_value", versionValue); + } + + @Test + public void withOptimisticLocking_deleteItemEnhancedRequest_shouldAddCondition() { + Key key = Key.builder().partitionValue("test-id").build(); + AttributeValue versionValue = AttributeValue.builder().n("5").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.builder() + .key(key) + .withOptimisticLocking(versionValue, versionAttributeName) + .build(); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo("version = :version_value"); + assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); + } + + @Test + public void withOptimisticLocking_transactDeleteItemEnhancedRequest_shouldAddCondition() { + Key key = Key.builder().partitionValue("test-id").build(); + AttributeValue versionValue = AttributeValue.builder().n("10").build(); + String versionAttributeName = "recordVersion"; + + TransactDeleteItemEnhancedRequest result = TransactDeleteItemEnhancedRequest.builder() + .key(key) + .withOptimisticLocking(versionValue, versionAttributeName) + .build(); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo("recordVersion = :version_value"); + assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); + } + + @Test + public void withOptimisticLocking_preservesExistingRequestProperties() { + Key key = Key.builder().partitionValue("test-id").build(); + AttributeValue versionValue = AttributeValue.builder().n("3").build(); + + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.builder() + .key(key) + .returnConsumedCapacity("TOTAL") + .withOptimisticLocking(versionValue, "version") + .build(); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.returnConsumedCapacityAsString()).isEqualTo("TOTAL"); + assertThat(result.conditionExpression()).isNotNull(); + } + + @Test + public void withOptimisticLocking_differentVersionAttributeNames_shouldWork() { + Key key = Key.builder().partitionValue("test-id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + + // Test with different attribute names + String[] attributeNames = {"version", "recordVersion", "itemVersion", "v"}; + + for (String attributeName : attributeNames) { + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.builder() + .key(key) + .withOptimisticLocking(versionValue, attributeName) + .build(); + + assertThat(result.conditionExpression().expression()).isEqualTo(attributeName + " = :version_value"); + assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); + } + } + + @Test + public void withOptimisticLocking_differentVersionValues_shouldWork() { + Key key = Key.builder().partitionValue("test-id").build(); + + // Test with different version values + AttributeValue[] versionValues = { + AttributeValue.builder().n("0").build(), + AttributeValue.builder().n("1").build(), + AttributeValue.builder().n("999").build(), + AttributeValue.builder().n("123456789").build() + }; + + for (AttributeValue versionValue : versionValues) { + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.builder() + .key(key) + .withOptimisticLocking(versionValue, "version") + .build(); + + assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); + } + } +} \ No newline at end of file