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..4b1e6b134464 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 @@ -13,9 +13,9 @@ * permissions and limitations under the License. */ + package software.amazon.awssdk.enhanced.dynamodb; -import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -31,6 +31,8 @@ import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.Record; +import software.amazon.awssdk.enhanced.dynamodb.model.RecordWithVersion; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; @@ -41,6 +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,6 +59,7 @@ public class AsyncCrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegr private static DynamoDbAsyncClient dynamoDbClient; private static DynamoDbEnhancedAsyncClient enhancedClient; private static DynamoDbAsyncTable mappedTable; + private static DynamoDbAsyncTable recordWithVersionMappedTable; @BeforeClass public static void beforeClass() { @@ -63,6 +67,7 @@ public static void beforeClass() { enhancedClient = DynamoDbEnhancedAsyncClient.builder().dynamoDbClient(dynamoDbClient).build(); mappedTable = enhancedClient.table(TABLE_NAME, TABLE_SCHEMA); mappedTable.createTable(r -> r.localSecondaryIndices(LOCAL_SECONDARY_INDEX)).join(); + recordWithVersionMappedTable = enhancedClient.table(TABLE_NAME, RECORD_WITH_VERSION_TABLE_SCHEMA); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)).join(); } @@ -341,4 +346,167 @@ public void getItem_withoutReturnConsumedCapacity() { GetItemEnhancedResponse response = mappedTable.getItemWithResponse(req -> req.key(key)).join(); assertThat(response.consumedCapacity()).isNull(); } + + @Test + public void deleteItemWithoutVersion_andOptimisticLockingEnabled_shouldSucceed() { + Record originalItem = new Record().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + mappedTable.putItem(originalItem).join(); + + // Retrieve the item + Record retrievedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, retrievedItem) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithoutVersion_andOptimisticLockingDisabled_shouldSucceed() { + Record originalItem = new Record().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + mappedTable.putItem(originalItem).join(); + + // Retrieve the item + Record retrievedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, retrievedItem) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingEnabled_ifVersionMatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + recordWithVersionMappedTable.putItem(originalItem).join(); + + // Retrieve the item + RecordWithVersion retrievedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, retrievedItem) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingEnabled_ifVersionMismatch_shouldFail() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + + recordWithVersionMappedTable.putItem(originalItem).join(); + + // Retrieve the item and modify it separately + RecordWithVersion modifiedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + modifiedItem.setStringAttribute("Updated Item"); + + // Update the item, which will increment the version + recordWithVersionMappedTable.updateItem(modifiedItem); + + + // Now attempt to delete the original item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, modifiedItem) + .build(); + + // enhancedClient.transactWriteItems(request).join(); + + assertThatThrownBy(() -> enhancedClient.transactWriteItems(request).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> + assertThat(((TransactionCanceledException) e.getCause()) + .cancellationReasons() + .stream() + .anyMatch(reason -> + "ConditionalCheckFailed".equals(reason.code()) + && "The conditional request failed".equals(reason.message()))) + .isTrue()); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingDisabled_ifVersionMatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + recordWithVersionMappedTable.putItem(originalItem).join(); + + // Retrieve the item + RecordWithVersion retrievedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + + // Delete the item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, retrievedItem) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + @Test + public void deleteItemWithVersion_andOptimisticLockingDisabled_ifVersionMismatch_shouldSucceed() { + RecordWithVersion originalItem = new RecordWithVersion().setId("123").setSort(10).setStringAttribute("Original Item"); + Key recordKey = Key.builder() + .partitionValue(originalItem.getId()) + .sortValue(originalItem.getSort()) + .build(); + + recordWithVersionMappedTable.putItem(originalItem).join(); + + // Retrieve the item and modify it separately + RecordWithVersion modifiedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + modifiedItem.setStringAttribute("Updated Item"); + + // Update the item, which will increment the version + recordWithVersionMappedTable.updateItem(modifiedItem); + + // Now attempt to delete the original item using a transaction + TransactWriteItemsEnhancedRequest request = + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(recordWithVersionMappedTable, modifiedItem) + .build(); + + enhancedClient.transactWriteItems(request).join(); + + RecordWithVersion deletedItem = recordWithVersionMappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } } diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java index 8a8e35470c20..6150add019a3 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 @@ -19,6 +19,7 @@ import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primarySortKey; import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondaryPartitionKey; import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondarySortKey; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension.AttributeTags.versionAttribute; import java.util.Arrays; import java.util.List; @@ -26,6 +27,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.RecordWithVersion; import software.amazon.awssdk.enhanced.dynamodb.model.Record; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; @@ -102,4 +104,34 @@ protected static String getStringAttrValue(int numChars) { return new String(chars); } + protected static final TableSchema RECORD_WITH_VERSION_TABLE_SCHEMA = + StaticTableSchema.builder(RecordWithVersion.class) + .newItemSupplier(RecordWithVersion::new) + .addAttribute(String.class, a -> a.name("id") + .getter(RecordWithVersion::getId) + .setter(RecordWithVersion::setId) + .tags(primaryPartitionKey(), secondaryPartitionKey("index1"))) + .addAttribute(Integer.class, a -> a.name("sort") + .getter(RecordWithVersion::getSort) + .setter(RecordWithVersion::setSort) + .tags(primarySortKey(), secondarySortKey("index1"))) + .addAttribute(Integer.class, a -> a.name("value") + .getter(RecordWithVersion::getValue) + .setter(RecordWithVersion::setValue)) + .addAttribute(String.class, a -> a.name("gsi_id") + .getter(RecordWithVersion::getGsiId) + .setter(RecordWithVersion::setGsiId) + .tags(secondaryPartitionKey("gsi_keys_only"))) + .addAttribute(Integer.class, a -> a.name("gsi_sort") + .getter(RecordWithVersion::getGsiSort) + .setter(RecordWithVersion::setGsiSort) + .tags(secondarySortKey("gsi_keys_only"))) + .addAttribute(String.class, a -> a.name("stringAttribute") + .getter(RecordWithVersion::getStringAttribute) + .setter(RecordWithVersion::setStringAttribute)) + .addAttribute(Integer.class, a -> a.name("version") + .getter(RecordWithVersion::getVersion) + .setter(RecordWithVersion::setVersion) + .tags(versionAttribute(0L, 1L, true))) // startAt=0, incrementBy=1, + .build(); } diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/RecordWithVersion.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/RecordWithVersion.java new file mode 100644 index 000000000000..f47bc9cb6a04 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/RecordWithVersion.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.Objects; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class RecordWithVersion { + + private String id; + private Integer sort; + private Integer value; + private String gsiId; + private Integer gsiSort; + private String stringAttribute; + private Integer version; + + public String getId() { + return id; + } + + public RecordWithVersion setId(String id) { + this.id = id; + return this; + } + + public Integer getSort() { + return sort; + } + + public RecordWithVersion setSort(Integer sort) { + this.sort = sort; + return this; + } + + public Integer getValue() { + return value; + } + + public RecordWithVersion setValue(Integer value) { + this.value = value; + return this; + } + + public String getGsiId() { + return gsiId; + } + + public RecordWithVersion setGsiId(String gsiId) { + this.gsiId = gsiId; + return this; + } + + public Integer getGsiSort() { + return gsiSort; + } + + public RecordWithVersion setGsiSort(Integer gsiSort) { + this.gsiSort = gsiSort; + return this; + } + + public String getStringAttribute() { + return stringAttribute; + } + + public RecordWithVersion setStringAttribute(String stringAttribute) { + this.stringAttribute = stringAttribute; + return this; + } + + @DynamoDbVersionAttribute + public Integer getVersion() { + return version; + } + + public RecordWithVersion setVersion(Integer version) { + this.version = version; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RecordWithVersion recordWithVersion = (RecordWithVersion) o; + return Objects.equals(id, recordWithVersion.id) && + Objects.equals(sort, recordWithVersion.sort) && + Objects.equals(value, recordWithVersion.value) && + Objects.equals(gsiId, recordWithVersion.gsiId) && + Objects.equals(stringAttribute, recordWithVersion.stringAttribute) && + Objects.equals(gsiSort, recordWithVersion.gsiSort) && + Objects.equals(version, recordWithVersion.version); + } + + @Override + public int hashCode() { + return Objects.hash(id, sort, value, gsiId, gsiSort, stringAttribute, version); + } +} \ No newline at end of file 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 b603fe03faa7..2e92c817a8f0 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 @@ -31,6 +31,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; @@ -64,8 +65,9 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt private final long startAt; private final long incrementBy; + private final boolean optimisticLockingOnDelete; - private VersionedRecordExtension(Long startAt, Long incrementBy) { + private VersionedRecordExtension(Long startAt, Long incrementBy, Boolean optimisticLockingOnDelete) { Validate.isNotNegativeOrNull(startAt, "startAt"); if (incrementBy != null && incrementBy < 1) { @@ -74,6 +76,7 @@ private VersionedRecordExtension(Long startAt, Long incrementBy) { this.startAt = startAt != null ? startAt : 0L; this.incrementBy = incrementBy != null ? incrementBy : 1L; + this.optimisticLockingOnDelete = optimisticLockingOnDelete != null ? optimisticLockingOnDelete : false; } public static Builder builder() { @@ -91,23 +94,37 @@ public static StaticAttributeTag versionAttribute() { public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy) { return new VersionAttribute(startAt, incrementBy); } + + public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy, Boolean optimisticLockingOnDelete) { + return new VersionAttribute(startAt, incrementBy, optimisticLockingOnDelete); + } } private static final class VersionAttribute implements StaticAttributeTag { private static final String START_AT_METADATA_KEY = "VersionedRecordExtension:StartAt"; private static final String INCREMENT_BY_METADATA_KEY = "VersionedRecordExtension:IncrementBy"; + private static final String OPTIMISTIC_LOCKING_ON_DELETE_METADATA_KEY = "VersionedRecordExtension:OptimisticLockingOnDelete"; private final Long startAt; private final Long incrementBy; + private final Boolean optimisticLockingOnDelete; private VersionAttribute() { this.startAt = null; this.incrementBy = null; + this.optimisticLockingOnDelete = null; } private VersionAttribute(Long startAt, Long incrementBy) { this.startAt = startAt; this.incrementBy = incrementBy; + this.optimisticLockingOnDelete = null; + } + + private VersionAttribute(Long startAt, Long incrementBy, Boolean optimisticLockingOnDelete) { + this.startAt = startAt; + this.incrementBy = incrementBy; + this.optimisticLockingOnDelete = optimisticLockingOnDelete; } @Override @@ -128,6 +145,7 @@ public Consumer modifyMetadata(String attributeName return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName) .addCustomMetadataObject(START_AT_METADATA_KEY, startAt) .addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy) + .addCustomMetadataObject(OPTIMISTIC_LOCKING_ON_DELETE_METADATA_KEY, optimisticLockingOnDelete) .markAttributeAsKey(attributeName, attributeValueType); } } @@ -141,8 +159,28 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex return WriteModification.builder().build(); } - Map itemToTransform = new HashMap<>(context.items()); + // Check if optimistic locking is enabled for delete operations + // First check attribute-level setting, then fall back to extension-level setting + Boolean attributeLevelOptimisticLocking = context.tableMetadata() + .customMetadataObject(VersionAttribute.OPTIMISTIC_LOCKING_ON_DELETE_METADATA_KEY, Boolean.class) + .orElse(null); + boolean shouldApplyOptimisticLocking = attributeLevelOptimisticLocking != null + ? attributeLevelOptimisticLocking + : this.optimisticLockingOnDelete; + + // Handle DELETE operations with optimistic locking if enabled + if (context.operationName() == OperationName.DELETE_ITEM && shouldApplyOptimisticLocking) { + 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()); AttributeValue newVersionValue; Expression condition; @@ -152,9 +190,8 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, Long.class) .orElse(this.startAt); Long versionIncrementByFromAnnotation = context.tableMetadata() - .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, Long.class) - .orElse(this.incrementBy); - + .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, Long.class) + .orElse(this.incrementBy); if (isInitialVersion(existingVersionValue, versionStartAtFromAnnotation)) { newVersionValue = AttributeValue.builder() @@ -167,19 +204,13 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex } else { // Existing record, increment version if (existingVersionValue.n() == null) { - // In this case a non-null version attribute is present, but it's not an N throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); } long existingVersion = Long.parseLong(existingVersionValue.n()); String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get()); - long increment = versionIncrementByFromAnnotation; - /* - Since the new incrementBy and StartAt functionality can now accept any positive number, though unlikely - to happen in a real life scenario, we should add overflow protection. - */ if (existingVersion > Long.MAX_VALUE - increment) { throw new IllegalStateException( String.format("Version overflow detected. Current version %d + increment %d would exceed Long.MAX_VALUE", @@ -191,8 +222,7 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex condition = Expression.builder() .expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey)) .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) - .expressionValues(Collections.singletonMap(existingVersionValueKey, - existingVersionValue)) + .expressionValues(Collections.singletonMap(existingVersionValueKey, existingVersionValue)) .build(); } @@ -204,6 +234,31 @@ 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; @@ -211,7 +266,6 @@ private boolean isInitialVersion(AttributeValue existingVersionValue, Long versi if (existingVersionValue.n() != null) { long currentVersion = Long.parseLong(existingVersionValue.n()); - // If annotation value is present, use it, otherwise fall back to the extension's value Long effectiveStartAt = versionStartAtFromAnnotation != null ? versionStartAtFromAnnotation : this.startAt; return currentVersion == effectiveStartAt; } @@ -223,6 +277,7 @@ private boolean isInitialVersion(AttributeValue existingVersionValue, Long versi public static final class Builder { private Long startAt; private Long incrementBy; + private Boolean optimisticLockingOnDelete; private Builder() { } @@ -251,8 +306,21 @@ public Builder incrementBy(Long incrementBy) { return this; } + /** + * Enables or disables optimistic locking for delete operations. + * When enabled, delete operations will include a condition to check the version attribute. + * Default value - {@code false} (for backward compatibility). + * + * @param optimisticLockingOnDelete true to enable optimistic locking on deletes, false to disable + * @return the builder instance + */ + public Builder optimisticLockingOnDelete(Boolean optimisticLockingOnDelete) { + this.optimisticLockingOnDelete = optimisticLockingOnDelete; + return this; + } + public VersionedRecordExtension build() { - return new VersionedRecordExtension(this.startAt, this.incrementBy); + return new VersionedRecordExtension(this.startAt, this.incrementBy, this.optimisticLockingOnDelete); } } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java index 09ab6eb00159..6228749aa1f2 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java @@ -15,6 +15,7 @@ package software.amazon.awssdk.enhanced.dynamodb.extensions.annotations; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -23,11 +24,6 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.VersionRecordAttributeTags; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.BeanTableSchemaAttributeTag; -/** - * Denotes this attribute as recording the version record number to be used for optimistic locking. Every time a record - * with this attribute is written to the database it will be incremented and a condition added to the request to check - * for an exact match of the old version. - */ @SdkPublicApi @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @@ -49,4 +45,12 @@ */ long incrementBy() default 1; + /** + * Whether to enable optimistic locking for delete operations. + * When enabled, delete operations will include a condition to check the version attribute. + * Default value - {@code false} (for backward compatibility). + * + * @return true to enable optimistic locking on deletes, false to disable + */ + boolean optimisticLockingOnDelete() default false; } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java index d81cf268afff..eb14dc1d6dc5 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java @@ -26,6 +26,10 @@ private VersionRecordAttributeTags() { } public static StaticAttributeTag attributeTagFor(DynamoDbVersionAttribute annotation) { - return VersionedRecordExtension.AttributeTags.versionAttribute(annotation.startAt(), annotation.incrementBy()); + return VersionedRecordExtension.AttributeTags.versionAttribute( + annotation.startAt(), + annotation.incrementBy(), + annotation.optimisticLockingOnDelete() + ); } } 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..02a5057951da 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,18 +15,22 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.operations; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Function; import software.amazon.awssdk.annotations.SdkInternalApi; 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.Key; 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.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.TransactDeleteItemEnhancedRequest; @@ -48,13 +52,13 @@ public class DeleteItemOperation TransactableWriteOperation, BatchableWriteOperation { - private final Either request; + private final Either> request; private DeleteItemOperation(DeleteItemEnhancedRequest request) { this.request = Either.left(request); } - private DeleteItemOperation(TransactDeleteItemEnhancedRequest request) { + private DeleteItemOperation(TransactDeleteItemEnhancedRequest request) { this.request = Either.right(request); } @@ -62,7 +66,7 @@ public static DeleteItemOperation create(DeleteItemEnhancedRequest reques return new DeleteItemOperation<>(request); } - public static DeleteItemOperation create(TransactDeleteItemEnhancedRequest request) { + public static DeleteItemOperation create(TransactDeleteItemEnhancedRequest request) { return new DeleteItemOperation<>(request); } @@ -82,21 +86,79 @@ public DeleteItemRequest generateRequest(TableSchema tableSchema, Key key = request.map(DeleteItemEnhancedRequest::key, TransactDeleteItemEnhancedRequest::key); + + Map keyAttributes = key.keyMap(tableSchema, operationContext.indexName()); + + // Create item map for extension processing + Map itemForExtensions = new HashMap<>(keyAttributes); + + // If item is provided, use full item attributes for extension processing (includes version) + if (request.right().isPresent() && request.right().get().item() != null) { + T item = request.right().get().item(); + itemForExtensions = tableSchema.itemToMap(item, false); + } + + // Apply extensions to potentially add version conditions + WriteModification writeModification = null; + if (extension != null) { + DynamoDbExtensionContext.BeforeWrite beforeWriteContext = + DefaultDynamoDbExtensionContext.builder() + .items(itemForExtensions) + .operationContext(operationContext) + .tableMetadata(tableSchema.tableMetadata()) + .tableSchema(tableSchema) + .operationName(OperationName.DELETE_ITEM) + .build(); + + writeModification = extension.beforeWrite(beforeWriteContext); + } + DeleteItemRequest.Builder requestBuilder = DeleteItemRequest.builder() .tableName(operationContext.tableName()) - .key(key.keyMap(tableSchema, operationContext.indexName())) + .key(keyAttributes) .returnValues(ReturnValue.ALL_OLD); if (request.left().isPresent()) { requestBuilder = addPlainDeleteItemParameters(requestBuilder, request.left().get()); } + requestBuilder = addExpressionsIfExist(requestBuilder, writeModification); + return requestBuilder.build(); + } + + private DeleteItemRequest.Builder addExpressionsIfExist(DeleteItemRequest.Builder requestBuilder, + WriteModification writeModification) { + Expression conditionExpression = request.map(r -> Optional.ofNullable(r.conditionExpression()), + r -> Optional.ofNullable(r.conditionExpression())) + .orElse(null); - requestBuilder = addExpressionsIfExist(requestBuilder); + // Merge extension condition with user-provided condition + if (writeModification != null && writeModification.additionalConditionalExpression() != null) { + Expression extensionCondition = writeModification.additionalConditionalExpression(); + if (conditionExpression != null) { + conditionExpression = conditionExpression.and(extensionCondition); + } else { + conditionExpression = extensionCondition; + } + } - return requestBuilder.build(); + if (conditionExpression != null) { + requestBuilder = requestBuilder.conditionExpression(conditionExpression.expression()); + Map expressionNames = conditionExpression.expressionNames(); + Map expressionValues = conditionExpression.expressionValues(); + + if (expressionNames != null && !expressionNames.isEmpty()) { + requestBuilder = requestBuilder.expressionAttributeNames(expressionNames); + } + + if (expressionValues != null && !expressionValues.isEmpty()) { + requestBuilder = requestBuilder.expressionAttributeValues(expressionValues); + } + } + return requestBuilder; } + @Override public DeleteItemEnhancedResponse transformResponse(DeleteItemResponse response, TableSchema tableSchema, 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..7e41f51e6280 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 @@ -37,14 +37,16 @@ */ @SdkPublicApi @ThreadSafe -public final class TransactDeleteItemEnhancedRequest { +public final class TransactDeleteItemEnhancedRequest { private final Key key; + private final T item; private final Expression conditionExpression; private final String returnValuesOnConditionCheckFailure; - private TransactDeleteItemEnhancedRequest(Builder builder) { + private TransactDeleteItemEnhancedRequest(Builder builder) { this.key = builder.key; + this.item = builder.item; this.conditionExpression = builder.conditionExpression; this.returnValuesOnConditionCheckFailure = builder.returnValuesOnConditionCheckFailure; } @@ -56,13 +58,25 @@ 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 builder().key(key) - .conditionExpression(conditionExpression) - .returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure); + public Builder toBuilder() { + return new Builder().key(key) + .item(item) + .conditionExpression(conditionExpression) + .returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailureAsString()); } /** @@ -72,6 +86,13 @@ 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. */ @@ -116,11 +137,14 @@ public boolean equals(Object o) { return false; } - TransactDeleteItemEnhancedRequest that = (TransactDeleteItemEnhancedRequest) o; + TransactDeleteItemEnhancedRequest that = (TransactDeleteItemEnhancedRequest) 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; } @@ -130,6 +154,7 @@ public boolean equals(Object o) { @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; @@ -138,11 +163,12 @@ public int hashCode() { /** * A builder that is used to create a request with the desired parameters. *

- * Note: A valid request builder must define a {@link Key}. + * Note: A valid request builder must define either a {@link Key} or an item. */ @NotThreadSafe - public static final class Builder { + public static final class Builder { private Key key; + private T item; private Expression conditionExpression; private String returnValuesOnConditionCheckFailure; @@ -155,7 +181,7 @@ private Builder() { * @param key the primary key to use in the request. * @return a builder of this type */ - public Builder key(Key key) { + public Builder key(Key key) { this.key = key; return this; } @@ -167,12 +193,24 @@ public Builder key(Key key) { * @param keyConsumer a {@link Consumer} of {@link Key} * @return a builder of this type */ - public Builder key(Consumer keyConsumer) { + 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. @@ -182,7 +220,7 @@ public Builder key(Consumer keyConsumer) { * @param conditionExpression a condition written as an {@link Expression} * @return a builder of this type */ - public Builder conditionExpression(Expression conditionExpression) { + public Builder conditionExpression(Expression conditionExpression) { this.conditionExpression = conditionExpression; return this; } @@ -195,7 +233,7 @@ public Builder conditionExpression(Expression conditionExpression) { * @param returnValuesOnConditionCheckFailure What values to return on condition check failure. * @return a builder of this type */ - public Builder returnValuesOnConditionCheckFailure( + public Builder returnValuesOnConditionCheckFailure( ReturnValuesOnConditionCheckFailure returnValuesOnConditionCheckFailure) { this.returnValuesOnConditionCheckFailure = returnValuesOnConditionCheckFailure == null ? null : returnValuesOnConditionCheckFailure.toString(); @@ -210,14 +248,14 @@ public Builder returnValuesOnConditionCheckFailure( * @param returnValuesOnConditionCheckFailure What values to return on condition check failure. * @return a builder of this type */ - public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditionCheckFailure) { + public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditionCheckFailure) { this.returnValuesOnConditionCheckFailure = returnValuesOnConditionCheckFailure; return this; } - public TransactDeleteItemEnhancedRequest build() { + public TransactDeleteItemEnhancedRequest build() { return new TransactDeleteItemEnhancedRequest(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..c6ab1db8b6ce 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 @@ -282,7 +282,11 @@ public Builder addDeleteItem(MappedTableResource mappedTableResource, Key * @return a builder of this type */ public Builder addDeleteItem(MappedTableResource mappedTableResource, T keyItem) { - return addDeleteItem(mappedTableResource, mappedTableResource.keyFrom(keyItem)); + + + return addDeleteItem(mappedTableResource, + TransactDeleteItemEnhancedRequest.builder().key( mappedTableResource.keyFrom(keyItem)).item(keyItem).build()); + // return addDeleteItem(mappedTableResource, mappedTableResource.keyFrom(keyItem)); } /** 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..38898542aa36 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 @@ -531,7 +531,7 @@ public void generateTransactWriteItem_returnValuesOnConditionCheckFailure_genera DeleteItemOperation deleteItemOperation = spy(DeleteItemOperation.create(TransactDeleteItemEnhancedRequest.builder() - .key(k -> k.partitionValue(fakeItem.getId())) + // .key(k -> k.partitionValue(fakeItem.getId())) .returnValuesOnConditionCheckFailure(returnValues) .build())); OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName());