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..24729658c9f3 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 @@ -243,10 +243,22 @@ default CompletableFuture deleteItem(Key key) { * delete from the database table. * @return a {@link CompletableFuture} of the item that was persisted in the database before it was deleted. */ + @Deprecated default CompletableFuture deleteItem(T keyItem) { throw new UnsupportedOperationException(); } + /** + * 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 + */ + 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..8daf5c49eca3 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 @@ -241,10 +241,22 @@ default T deleteItem(Key key) { * delete from the database table. * @return The item that was persisted in the database before it was deleted. */ + @Deprecated default T deleteItem(T keyItem) { throw new UnsupportedOperationException(); } + /** + * 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 + */ + 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/OptimisticLockingHelper.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/OptimisticLockingHelper.java new file mode 100644 index 000000000000..b22b4862960f --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/OptimisticLockingHelper.java @@ -0,0 +1,170 @@ +/* + * 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.internal; + +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef; + +import java.util.Collections; +import java.util.Optional; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactDeleteItemEnhancedRequest; +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}. + */ +@SdkInternalApi +public final class OptimisticLockingHelper { + + private static final String CUSTOM_VERSION_METADATA_KEY = "VersionedRecordExtension:VersionAttribute"; + + private OptimisticLockingHelper() { + } + + /** + * Adds optimistic locking to a delete request. + * + * @param requestBuilder the original delete request builder + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return delete request with optimistic locking condition + */ + public static DeleteItemEnhancedRequest optimisticLocking(DeleteItemEnhancedRequest.Builder requestBuilder, + AttributeValue versionValue, String versionAttributeName) { + + return requestBuilder + .conditionExpression(createVersionCondition(versionValue, versionAttributeName)) + .build(); + } + + /** + * Adds optimistic locking to a transactional delete request. + * + * @param requestBuilder the original delete request builder + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return transactional delete request with optimistic locking condition + */ + public static TransactDeleteItemEnhancedRequest optimisticLocking(TransactDeleteItemEnhancedRequest.Builder requestBuilder, + AttributeValue versionValue, String versionAttributeName) { + + Expression conditionExpression = createVersionCondition(versionValue, versionAttributeName); + return requestBuilder + .conditionExpression(conditionExpression) + .build(); + } + + /** + * Conditionally applies optimistic locking if enabled and version information exists. + * + * @param the type of the item + * @param requestBuilder the delete request builder + * @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.Builder requestBuilder, T keyItem, TableSchema tableSchema, boolean useOptimisticLocking) { + + if (!useOptimisticLocking) { + return requestBuilder.build(); + } + + return getVersionAttributeName(tableSchema) + .map(versionAttributeName -> { + AttributeValue version = tableSchema.attributeValue(keyItem, versionAttributeName); + return version != null + ? optimisticLocking(requestBuilder, version, versionAttributeName) + : requestBuilder.build(); + + }).orElseGet(requestBuilder::build); + } + + /** + * Conditionally applies optimistic locking if enabled and version information exists. + * + * @param the type of the item + * @param requestBuilder the transactional delete request builder + * @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 TransactDeleteItemEnhancedRequest conditionallyApplyOptimisticLocking( + TransactDeleteItemEnhancedRequest.Builder requestBuilder, T keyItem, TableSchema tableSchema, + boolean useOptimisticLocking) { + + if (!useOptimisticLocking) { + return requestBuilder.build(); + } + + return getVersionAttributeName(tableSchema) + .map(versionAttributeName -> { + AttributeValue version = tableSchema.attributeValue(keyItem, versionAttributeName); + return version != null + ? optimisticLocking(requestBuilder, version, versionAttributeName) + : requestBuilder.build(); + + }).orElseGet(requestBuilder::build); + } + + + /** + * Creates a version condition expression. + * + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return version check condition expression + * @throws IllegalArgumentException if {@code versionAttributeName} or {@code versionValue} are null or empty + */ + public static Expression createVersionCondition(AttributeValue versionValue, String versionAttributeName) { + if (versionAttributeName == null || versionAttributeName.trim().isEmpty()) { + throw new IllegalArgumentException("Version attribute name must not be null or empty."); + } + + if (versionValue == null || versionValue.n() == null || versionValue.n().trim().isEmpty()) { + throw new IllegalArgumentException("Version value must not be null or empty."); + } + + String attributeKeyRef = keyRef(versionAttributeName); + String attributeValueRef = valueRef(versionAttributeName); + + return Expression.builder() + .expression(String.format("%s = %s", attributeKeyRef, attributeValueRef)) + .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeName)) + .expressionValues(Collections.singletonMap(attributeValueRef, 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(CUSTOM_VERSION_METADATA_KEY, String.class); + } +} \ No newline at end of file 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..bf72a910b3f4 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.internal.OptimisticLockingHelper.conditionallyApplyOptimisticLocking; import java.util.ArrayList; import java.util.concurrent.CompletableFuture; @@ -26,6 +27,7 @@ import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper; import software.amazon.awssdk.enhanced.dynamodb.internal.TableIndices; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.CreateTableOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DeleteItemOperation; @@ -124,6 +126,9 @@ public CompletableFuture createTable() { .build()); } + /** + * Supports optimistic locking via {@link OptimisticLockingHelper}. + */ @Override public CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { TableOperation> operation = DeleteItemOperation.create(request); @@ -131,6 +136,9 @@ public CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { .thenApply(DeleteItemEnhancedResponse::attributes); } + /** + * Supports optimistic locking via {@link OptimisticLockingHelper}. + */ @Override public CompletableFuture deleteItem(Consumer requestConsumer) { DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder(); @@ -138,16 +146,37 @@ 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)); } + /** + * 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 + */ + @Override + public CompletableFuture deleteItem(T keyItem, boolean useOptimisticLocking) { + DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)); + conditionallyApplyOptimisticLocking(builder, keyItem, tableSchema, useOptimisticLocking); + return deleteItem(builder.build()); + } + @Override public CompletableFuture> deleteItemWithResponse(DeleteItemEnhancedRequest request) { TableOperation> operation = DeleteItemOperation.create(request); @@ -311,7 +340,7 @@ public CompletableFuture updateItem(T item) { public Key keyFrom(T item) { return createKeyFromItem(item, tableSchema, TableMetadata.primaryIndexName()); } - + @Override public CompletableFuture deleteTable() { 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..b2ad8819b23d 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.internal.OptimisticLockingHelper.conditionallyApplyOptimisticLocking; import java.util.ArrayList; import java.util.function.Consumer; @@ -25,6 +26,7 @@ import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper; import software.amazon.awssdk.enhanced.dynamodb.internal.TableIndices; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.CreateTableOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DeleteItemOperation; @@ -126,12 +128,18 @@ public void createTable() { .build()); } + /** + * Supports optimistic locking via {@link 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 OptimisticLockingHelper}. + */ @Override public T deleteItem(Consumer requestConsumer) { DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder(); @@ -139,16 +147,37 @@ 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)); } + /** + * 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 + */ + @Override + public T deleteItem(T keyItem, boolean useOptimisticLocking) { + DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)); + conditionallyApplyOptimisticLocking(builder, keyItem, tableSchema, useOptimisticLocking); + return deleteItem(builder.build()); + } + @Override public DeleteItemEnhancedResponse deleteItemWithResponse(DeleteItemEnhancedRequest request) { TableOperation> operation = DeleteItemOperation.create(request); 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..cf5592326a28 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.internal.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,23 @@ public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditio return this; } + /** + * Adds optimistic locking to this delete request. + *

+ * If a {@link #conditionExpression(Expression)} was already set, this will combine it with the optimistic locking + * condition using {@code AND}. If either expression has conflicting name/value tokens, {@link Expression#join} will throw + * {@link IllegalArgumentException}. + * + * @param versionValue the expected version value that must match for the deletion 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 (and merged if needed) + */ + public Builder optimisticLocking(AttributeValue versionValue, String versionAttributeName) { + Expression optimisticLockingCondition = createVersionCondition(versionValue, versionAttributeName); + this.conditionExpression = Expression.join(this.conditionExpression, optimisticLockingCondition, " AND "); + return this; + } + public DeleteItemEnhancedRequest build() { return new DeleteItemEnhancedRequest(this); } 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..fe30e84d4629 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 @@ -15,6 +15,8 @@ package software.amazon.awssdk.enhanced.dynamodb.model; +import static software.amazon.awssdk.enhanced.dynamodb.internal.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.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 +218,22 @@ public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditio return this; } + /** + * Adds optimistic locking to this transactional delete request. + *

+ * If a {@link #conditionExpression(Expression)} was already set, this will combine it with the optimistic locking + * condition using {@code AND}. If either expression has conflicting name/value tokens, {@link Expression#join} will throw + * {@link IllegalArgumentException}. + * + * @param versionValue the expected version value that must match for the deletion 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 (and merged if needed) + */ + public Builder optimisticLocking(AttributeValue versionValue, String versionAttributeName) { + Expression optimisticLockingCondition = createVersionCondition(versionValue, versionAttributeName); + this.conditionExpression = Expression.join(this.conditionExpression, optimisticLockingCondition, " AND "); + return this; + } 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..6dbbe0d01695 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,11 @@ 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#optimisticLocking( + * 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 +277,19 @@ 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. + * + * Does not support Optimistic Locking. + *

+ * 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/OptimisticLockingAsyncCrudTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingAsyncCrudTest.java new file mode 100644 index 000000000000..948041f4e32a --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingAsyncCrudTest.java @@ -0,0 +1,956 @@ +/* + * 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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondarySortKey; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletionException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.VersionedRecord; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; +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.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; + +public class OptimisticLockingAsyncCrudTest extends LocalDynamoDbAsyncTestBase { + + private static final TableSchema TABLE_SCHEMA = + StaticTableSchema.builder(Record.class) + .newItemSupplier(Record::new) + .addAttribute(String.class, + a -> a.name("id") + .getter(Record::getId) + .setter(Record::setId) + .tags(primaryPartitionKey(), secondaryPartitionKey("index1"))) + .addAttribute(Integer.class, + a -> a.name("sort") + .getter(Record::getSort) + .setter(Record::setSort) + .tags(primarySortKey(), secondarySortKey("index1"))) + .addAttribute(Integer.class, + a -> a.name("value") + .getter(Record::getValue) + .setter(Record::setValue)) + .addAttribute(String.class, + a -> a.name("gsi_id") + .getter(Record::getGsiId) + .setter(Record::setGsiId) + .tags(secondaryPartitionKey("gsi_keys_only"))) + .addAttribute(Integer.class, + a -> a.name("gsi_sort") + .getter(Record::getGsiSort) + .setter(Record::setGsiSort) + .tags(secondarySortKey("gsi_keys_only"))) + .addAttribute(String.class, + a -> a.name("stringAttribute") + .getter(Record::getStringAttribute) + .setter(Record::setStringAttribute)) + .build(); + + private 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(); + + + private final DynamoDbEnhancedAsyncClient enhancedClient = + DynamoDbEnhancedAsyncClient.builder() + .dynamoDbClient(getDynamoDbAsyncClient()) + .build(); + + private final DynamoDbAsyncTable mappedTable = + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + private final DynamoDbAsyncTable versionedRecordTable = + enhancedClient.table(getConcreteTableName("versioned-table-name"), VERSIONED_RECORD_TABLE_SCHEMA); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())).join(); + versionedRecordTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())).join(); + } + + @After + public void deleteTable() { + getDynamoDbAsyncClient().deleteTable( + DeleteTableRequest.builder() + .tableName(getConcreteTableName("table-name")) + .build()).join(); + + getDynamoDbAsyncClient().deleteTable( + DeleteTableRequest.builder() + .tableName(getConcreteTableName("versioned-table-name")) + .build()).join(); + } + + + // 1. deleteItem(T item) - deprecated - on Non-versioned record + // -> Optimistic Locking NOT applied -> unconditionally deletes the record + @Test + public void deprecatedDeleteItem_onNonVersionedRecord_skipsOptimisticLockingAndUnconditionallyDeletes() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item); + Record savedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + mappedTable.deleteItem(savedItem).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 2. deleteItem(T item) - deprecated - on Versioned record + // -> Optimistic Locking is not applied -> unconditionally deletes the record + @Test + public void deprecatedDeleteItem_onVersionedRecordAndMatchingVersions_skipsOptimisticLockingAndUnconditionallyDeletes() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + 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) - deprecated - on Versioned record, with stale version + // -> Optimistic Locking is not applied -> unconditionally deletes the record + @Test + public void deprecatedDeleteItem_onVersionedRecordAndStaleVersion_skipsOptimisticLockingAndUnconditionallyDeletes() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + // Simulate a stale version by changing the version number + savedItem.setVersion(2); + versionedRecordTable.deleteItem(savedItem).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + // the item is deleted even though the version was stale because the old method does not apply optimistic locking + assertThat(deletedItem).isNull(); + } + + // 4. deleteItem(T item, false) on Versioned record + // -> Optimistic Locking false -> Optimistic Locking is NOT applied -> deletes the record + @Test + public void deleteItem_onVersionedRecord_noOptimisticLocking_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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 (new version = 2) + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Delete with old version (version = 1) 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(); + } + + // 5. deleteItem(T item, true) on Versioned record with Optimistic Locking true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndVersionsMatch_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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(); + } + + // 6. deleteItem(T item, true) on Versioned record with Optimistic Locking true and versions DO NOT match + // -> Optimistic Locking applied -> does NOT delete the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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 (new version = 2) + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Try to delete with old version (version = 1) 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")); + } + + // 7. deleteItem(DeleteItemEnhancedRequest) on VersionedRecord with Optimistic Locking true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithRequest_onVersionedRecord_whenVersionsMatch_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + versionedRecordTable.deleteItem(requestWithLocking).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 8. deleteItem(DeleteItemEnhancedRequest) on VersionedRecord with Optimistic Locking true and versions DO NOT match + // -> Optimistic Locking is applied -> does NOT delete the record + @Test + public void deleteItemWithRequest_onVersionedRecord_whenVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .optimisticLocking(mismatchVersion, versionAttributeName) + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 9. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions match + custom condition respected + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithRequest_whenOptimisticLockingAndCustomConditionAreRespected_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + versionedRecordTable.deleteItem(requestWithLocking).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 10. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions match + custom condition not respected + // -> Optimistic Locking is applied -> does not delete the record because of the failing custom condition + @Test + public void deleteItemWithRequest_whenOptimisticLockingConditionRespected_butCustomConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 11. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions do not match + custom condition respected + // -> does not delete the record + @Test + public void deleteItemWithRequest_whenCustomConditionRespected_butOptimisticConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item).join(); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName) + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 12. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions do not match + custom condition fails + // -> does not delete the record + @Test + public void deleteItemWithRequest_whenOptimisticLockingAndCustomConditionNotRespected_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item).join(); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName) + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 13. deleteItem(Consumer<>) on VersionedRecord with Optimistic Locking true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithBuilder_onVersionedRecord_whenVersionsMatch_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + AttributeValue matchVersion = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + versionedRecordTable.putItem(item); + + versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName)) + .join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 14. deleteItem(Consumer<>) on VersionedRecord with Optimistic Locking true and versions DO NOT match + // -> Optimistic Locking is applied -> does NOT delete the record + @Test + public void deleteItemWithBuilder_onVersionedRecord_whenVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .optimisticLocking(mismatchVersion, versionAttributeName)) + .join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(ConditionalCheckFailedException.class)) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + + } + + // 15. deleteItem(Consumer<>) with Optimistic Locking true, versions match + custom condition respected + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithBuilder_whenOptimisticLockingAndCustomConditionAreRespected_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName) + .conditionExpression(conditionExpression)) + .join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 16. deleteItem(Consumer<>) with Optimistic Locking true, versions match + custom condition not respected + // -> Optimistic Locking is applied -> does not delete the record because of the failing custom condition + @Test + public void deleteItemWithBuilder_whenOptimisticLockingConditionRespected_butCustomConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName)) + .join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(ConditionalCheckFailedException.class)) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 17. deleteItem(Consumer<>) with Optimistic Locking true, versions do not match + custom condition respected + // -> does not delete the record + @Test + public void deleteItemWithBuilder_whenCustomConditionRespected_butOptimisticConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName)).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(ConditionalCheckFailedException.class)) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 18. deleteItem(Consumer<>) with Optimistic Locking true, versions do not match + custom condition fails + // -> does not delete the record + @Test + public void deleteItemWithBuilder_whenOptimisticLockingAndCustomConditionNotRespected_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName)) + .join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(ConditionalCheckFailedException.class)) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 19. TransactWriteItems.deleteItem(T item) - deprecated - on Non-versioned record + // -> Optimistic Locking NOT applied -> deletes the record + @Test + public void transactDeleteItem_onNonVersionedRecord_deletesTheRecord() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("test"); + 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(); + } + + // 20. TransactWriteItems.deleteItem(T item) - deprecated - on Versioned record and versions match + // -> Optimistic Locking is NOT applied (old deprecated method -> does NOT support Optimistic Locking) -> deletes the record + @Test + public void transactDeleteItem_onVersionedRecord_whenVersionsMatch_skipsOptimisticLockingAndDeletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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(); + } + + // 21. TransactWriteItems.deleteItem(T item) - deprecated - on Versioned record and versions do NOT match + // -> Optimistic Locking is NOT applied (old deprecated method -> does NOT support Optimistic Locking) -> deletes the record + @Test + public void transactDeleteItem_onVersionedRecord_whenVersionsMismatch_skipsOptimisticLockingAndDeletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + Integer mismatchedVersion = 2; + savedItem.setVersion(mismatchedVersion); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, savedItem) + .build()).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 22. TransactWriteItems with builder method on Versioned record and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void transactDeleteItemWithRequest_onVersionedRecord_whenVersionsMatch_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build()).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 23. TransactWriteItems with builder method on Versioned record and versions do NOT match + // -> Optimistic Locking applied -> does NOT delete the record + @Test + public void transactDeleteItemWithRequest_onVersionedRecord_whenVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .optimisticLocking(mismatchVersion, versionAttributeName) + .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()); + } + + // 18. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions match + custom condition respected + // -> Optimistic Locking is applied -> deletes the record + @Test + public void transactDeleteItemWithRequest_whenOptimisticLockingAndCustomConditionAreRespected_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 25. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions match + custom condition not respected + // -> Optimistic Locking is applied -> does not delete the record because of the failing custom condition + @Test + public void transactDeleteItemWithRequest_whenOptimisticLockingConditionRespected_butCustomConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName) + .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()); + } + + // 26. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions do not match + custom condition respected + // -> does not delete the record + @Test + public void transactDeleteItemWithRequest_whenCustomConditionRespected_butOptimisticConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName) + .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()); + } + + // 27. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions do not match + custom condition fails + // -> does not delete the record + @Test + public void transactDeleteItemWithRequest_whenOptimisticLockingAndCustomConditionNotRespected_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item).join(); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName) + .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()); + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingCrudTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingCrudTest.java new file mode 100644 index 000000000000..97f205a61ba7 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingCrudTest.java @@ -0,0 +1,945 @@ +/* + * 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.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 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; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondarySortKey; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +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.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.VersionedRecord; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; +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.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; + +public class OptimisticLockingCrudTest extends LocalDynamoDbSyncTestBase { + + private static final TableSchema TABLE_SCHEMA = + StaticTableSchema.builder(Record.class) + .newItemSupplier(Record::new) + .addAttribute(String.class, + a -> a.name("id") + .getter(Record::getId) + .setter(Record::setId) + .tags(primaryPartitionKey(), secondaryPartitionKey("index1"))) + .addAttribute(Integer.class, + a -> a.name("sort") + .getter(Record::getSort) + .setter(Record::setSort) + .tags(primarySortKey(), secondarySortKey("index1"))) + .addAttribute(Integer.class, + a -> a.name("value") + .getter(Record::getValue) + .setter(Record::setValue)) + .addAttribute(String.class, + a -> a.name("gsi_id") + .getter(Record::getGsiId) + .setter(Record::setGsiId) + .tags(secondaryPartitionKey("gsi_keys_only"))) + .addAttribute(Integer.class, + a -> a.name("gsi_sort") + .getter(Record::getGsiSort) + .setter(Record::setGsiSort) + .tags(secondarySortKey("gsi_keys_only"))) + .addAttribute(String.class, + a -> a.name("stringAttribute") + .getter(Record::getStringAttribute) + .setter(Record::setStringAttribute)) + .build(); + + private 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(); + + + private final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + private final DynamoDbTable mappedTable = + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + private final DynamoDbTable versionedRecordTable = + enhancedClient.table(getConcreteTableName("versioned-table-name"), VERSIONED_RECORD_TABLE_SCHEMA); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + versionedRecordTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable( + DeleteTableRequest.builder() + .tableName(getConcreteTableName("table-name")) + .build()); + + getDynamoDbClient().deleteTable( + DeleteTableRequest.builder() + .tableName(getConcreteTableName("versioned-table-name")) + .build()); + } + + // 1. deleteItem(T item) - deprecated - on Non-versioned record + // -> Optimistic Locking NOT applied -> unconditionally deletes the record + @Test + public void deprecatedDeleteItem_onNonVersionedRecord_skipsOptimisticLockingAndUnconditionallyDeletes() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item); + Record savedItem = mappedTable.getItem(r -> r.key(recordKey)); + mappedTable.deleteItem(savedItem); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 2. deleteItem(T item) - deprecated - on Versioned record + // -> Optimistic Locking is not applied -> unconditionally deletes the record + @Test + public void deprecatedDeleteItem_onVersionedRecordAndMatchingVersions_skipsOptimisticLockingAndUnconditionallyDeletes() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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) - deprecated - on Versioned record, with stale version + // -> Optimistic Locking is not applied -> unconditionally deletes the record + @Test + public void deprecatedDeleteItem_onVersionedRecordAndStaleVersion_skipsOptimisticLockingAndUnconditionallyDeletes() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + // Simulate a stale version by changing the version number + savedItem.setVersion(2); + versionedRecordTable.deleteItem(savedItem); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + // the item is deleted even though the version was stale because the old method does not apply optimistic locking + assertThat(deletedItem).isNull(); + } + + // 4. deleteItem(T item, false) on Versioned record + // -> Optimistic Locking false -> Optimistic Locking is NOT applied -> deletes the record + @Test + public void deleteItem_onVersionedRecord_noOptimisticLocking_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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 (new version = 2) + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Delete with old version (version = 1) 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(); + } + + // 5. deleteItem(T item, true) on Versioned record with Optimistic Locking true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndVersionsMatch_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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(); + } + + // 6. deleteItem(T item, true) on Versioned record with Optimistic Locking true and versions DO NOT match + // -> Optimistic Locking applied -> does NOT delete the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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 (new version = 2) + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Try to delete with old version (version = 1) 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")); + } + + // 7. deleteItem(DeleteItemEnhancedRequest) on VersionedRecord with Optimistic Locking true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithRequest_onVersionedRecord_whenVersionsMatch_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + versionedRecordTable.deleteItem(requestWithLocking); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 8. deleteItem(DeleteItemEnhancedRequest) on VersionedRecord with Optimistic Locking true and versions DO NOT match + // -> Optimistic Locking is applied -> does NOT delete the record + @Test + public void deleteItemWithRequest_onVersionedRecord_whenVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .optimisticLocking(mismatchVersion, versionAttributeName) + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 9. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions match + custom condition respected + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithRequest_whenOptimisticLockingAndCustomConditionAreRespected_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + versionedRecordTable.deleteItem(requestWithLocking); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 10. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions match + custom condition not respected + // -> Optimistic Locking is applied -> does not delete the record because of the failing custom condition + @Test + public void deleteItemWithRequest_whenOptimisticLockingConditionRespected_butCustomConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 11. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions do not match + custom condition respected + // -> does not delete the record + @Test + public void deleteItemWithRequest_whenCustomConditionRespected_butOptimisticConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName) + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 12. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions do not match + custom condition fails + // -> does not delete the record + @Test + public void deleteItemWithRequest_whenOptimisticLockingAndCustomConditionNotRespected_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest requestWithLocking = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName) + .build(); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 13. deleteItem(Consumer<>) on VersionedRecord with Optimistic Locking true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithBuilder_onVersionedRecord_whenVersionsMatch_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + AttributeValue matchVersion = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + versionedRecordTable.putItem(item); + + versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName)); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 14. deleteItem(Consumer<>) on VersionedRecord with Optimistic Locking true and versions DO NOT match + // -> Optimistic Locking is applied -> does NOT delete the record + @Test + public void deleteItemWithBuilder_onVersionedRecord_whenVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .optimisticLocking(mismatchVersion, versionAttributeName)) + + ).isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 15. deleteItem(Consumer<>) with Optimistic Locking true, versions match + custom condition respected + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithBuilder_whenOptimisticLockingAndCustomConditionAreRespected_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName) + .conditionExpression(conditionExpression)); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 16. deleteItem(Consumer<>) with Optimistic Locking true, versions match + custom condition not respected + // -> Optimistic Locking is applied -> does not delete the record because of the failing custom condition + @Test + public void deleteItemWithBuilder_whenOptimisticLockingConditionRespected_butCustomConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName)) + + ).isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 17. deleteItem(Consumer<>) with Optimistic Locking true, versions do not match + custom condition respected + // -> does not delete the record + @Test + public void deleteItemWithBuilder_whenCustomConditionRespected_butOptimisticConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName)) + + ).isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 18. deleteItem(Consumer<>) with Optimistic Locking true, versions do not match + custom condition fails + // -> does not delete the record + @Test + public void deleteItemWithBuilder_whenOptimisticLockingAndCustomConditionNotRespected_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName)) + + ).isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 19. TransactWriteItems.deleteItem(T item) - deprecated - on Non-versioned record + // -> Optimistic Locking NOT applied -> deletes the record + @Test + public void transactDeleteItem_onNonVersionedRecord_deletesTheRecord() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("test"); + 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(); + } + + // 20. TransactWriteItems.deleteItem(T item) - deprecated - on Versioned record and versions match + // -> Optimistic Locking is NOT applied (old deprecated method -> does NOT support Optimistic Locking) -> deletes the record + @Test + public void transactDeleteItem_onVersionedRecord_whenVersionsMatch_skipsOptimisticLockingAndDeletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + 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(); + } + + // 21. TransactWriteItems.deleteItem(T item) - deprecated - on Versioned record and versions do NOT match + // -> Optimistic Locking is NOT applied (old deprecated method -> does NOT support Optimistic Locking) -> deletes the record + @Test + public void transactDeleteItem_onVersionedRecord_whenVersionsMismatch_skipsOptimisticLockingAndDeletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + Integer mismatchedVersion = 2; + savedItem.setVersion(mismatchedVersion); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, savedItem) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 22. TransactWriteItems with builder method on Versioned record and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void transactDeleteItemWithRequest_onVersionedRecord_whenVersionsMatch_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 23. TransactWriteItems with builder method on Versioned record and versions do NOT match + // -> Optimistic Locking applied -> does NOT delete the record + @Test + public void transactDeleteItemWithRequest_onVersionedRecord_whenVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .optimisticLocking(mismatchVersion, versionAttributeName) + .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()); + } + + // 24. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions match + custom condition respected + // -> Optimistic Locking is applied -> deletes the record + @Test + public void transactDeleteItemWithRequest_whenOptimisticLockingAndCustomConditionAreRespected_deletesTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName) + .build(); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 25. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions match + custom condition not respected + // -> Optimistic Locking is applied -> does not delete the record because of the failing custom condition + @Test + public void transactDeleteItemWithRequest_whenOptimisticLockingConditionRespected_butCustomConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + AttributeValue matchVersion = AttributeValue.builder().n(savedItem.getVersion().toString()).build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(matchVersion, versionAttributeName) + .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()); + } + + // 26. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions do not match + custom condition respected + // -> does not delete the record + @Test + public void transactDeleteItemWithRequest_whenCustomConditionRespected_butOptimisticConditionFails_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String matchingDatabaseValue = "test"; + expressionValues.put(":value", AttributeValue.fromS(matchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName) + .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()); + } + + // 27. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true, versions do not match + custom condition fails + // -> does not delete the record + @Test + public void transactDeleteItemWithRequest_whenOptimisticLockingAndCustomConditionNotRespected_doesNotDeleteTheRecord() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + String nonMatchingDatabaseValue = "nonMatchingValue"; + expressionValues.put(":value", AttributeValue.fromS(nonMatchingDatabaseValue)); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + versionedRecordTable.putItem(item); + + AttributeValue mismatchVersion = AttributeValue.builder().n("2").build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest requestWithLocking = + TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression) + .optimisticLocking(mismatchVersion, versionAttributeName) + .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/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/VersionedRecord.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/VersionedRecord.java new file mode 100644 index 000000000000..a1bec12d70e4 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/VersionedRecord.java @@ -0,0 +1,122 @@ +/* + * 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.models; + +import java.util.Objects; +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; + +@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; + + @DynamoDbPartitionKey + 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/test/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequestTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequestTest.java index 1a75d402e275..998b32d8e96a 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequestTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequestTest.java @@ -15,6 +15,8 @@ package software.amazon.awssdk.enhanced.dynamodb.model; +import static org.assertj.core.api.BDDAssertions.entry; +import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -22,11 +24,13 @@ import static org.hamcrest.Matchers.nullValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue; +import org.assertj.core.api.Assertions; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.junit.MockitoJUnitRunner; 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.ReturnConsumedCapacity; import software.amazon.awssdk.services.dynamodb.model.ReturnItemCollectionMetrics; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; @@ -123,20 +127,20 @@ public void equals_keyNotEqual() { @Test public void equals_conditionExpressionNotEqual() { Expression conditionExpression1 = Expression.builder() - .expression("#key = :value OR #key1 = :value1") - .putExpressionName("#key", "attribute") - .putExpressionName("#key1", "attribute3") - .putExpressionValue(":value", stringValue("wrong")) - .putExpressionValue(":value1", stringValue("three")) - .build(); + .expression("#key = :value OR #key1 = :value1") + .putExpressionName("#key", "attribute") + .putExpressionName("#key1", "attribute3") + .putExpressionValue(":value", stringValue("wrong")) + .putExpressionValue(":value1", stringValue("three")) + .build(); Expression conditionExpression2 = Expression.builder() - .expression("#key = :value AND #key1 = :value1") - .putExpressionName("#key", "attribute") - .putExpressionName("#key1", "attribute3") - .putExpressionValue(":value", stringValue("wrong")) - .putExpressionValue(":value1", stringValue("three")) - .build(); + .expression("#key = :value AND #key1 = :value1") + .putExpressionName("#key", "attribute") + .putExpressionName("#key1", "attribute3") + .putExpressionValue(":value", stringValue("wrong")) + .putExpressionValue(":value1", stringValue("three")) + .build(); DeleteItemEnhancedRequest builtObject1 = DeleteItemEnhancedRequest.builder() .conditionExpression(conditionExpression1) @@ -269,4 +273,23 @@ public void hashCode_returnValuesOnConditionCheckFailure() { assertThat(containsKey.hashCode(), not(equalTo(emptyRequest.hashCode()))); } + + @Test + public void optimisticLockingBuilder_addsVersionConditionExpression() { + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + + DeleteItemEnhancedRequest request = + DeleteItemEnhancedRequest.builder() + .key(Key.builder().partitionValue("id").build()) + .optimisticLocking(versionValue, "version") + .build(); + + assertThat(request.conditionExpression(), notNullValue()); + Assertions.assertThat(request.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + Assertions.assertThat(request.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + Assertions.assertThat(request.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } } 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..1a9ec7f07cac --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java @@ -0,0 +1,550 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.BDDAssertions.entry; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.numberValue; +import static software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper.conditionallyApplyOptimisticLocking; +import static software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper.createVersionCondition; +import static software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper.getVersionAttributeName; +import static software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper.optimisticLocking; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordForUpdateExpressions; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithUpdateBehaviors; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class OptimisticLockingHelperTest { + + @Test + public void optimisticLocking_onDelete_addsConditionExpression() { + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest.Builder originalRequestBuilder = + DeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + DeleteItemEnhancedRequest result = optimisticLocking(originalRequestBuilder, versionValue, versionAttributeName); + + assertThat(result).isNotNull(); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } + + @Test + public void optimisticLocking_onTransactDelete_addsConditionExpression() { + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + TransactDeleteItemEnhancedRequest.Builder originalRequestBuilder = + TransactDeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + TransactDeleteItemEnhancedRequest result = optimisticLocking(originalRequestBuilder, versionValue, versionAttributeName); + + assertThat(result).isNotNull(); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } + + @Test + public void conditionallyApplyOptimistic_onDelete_whenFlagFalse_returnsOriginalRequest() { + boolean optimisticLockingEnabled = false; + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + // versioned record + RecordWithUpdateBehaviors keyItem = new RecordWithUpdateBehaviors(); + TableSchema tableSchema = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + + DeleteItemEnhancedRequest.Builder requestBuilder = + DeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + DeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking( + requestBuilder, keyItem, tableSchema, optimisticLockingEnabled); + + assertThat(result).isNotNull(); + assertEquals(requestBuilder.build(), result); + } + + @Test + public void conditionallyApplyOptimistic_onDelete_whenFlagTrueAndVersionedRecord_returnsOriginalRequest() { + boolean optimisticLockingEnabled = true; + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + // non-versioned record + RecordForUpdateExpressions keyItem = new RecordForUpdateExpressions(); + TableSchema tableSchema = TableSchema.fromClass(RecordForUpdateExpressions.class); + + DeleteItemEnhancedRequest.Builder originalRequestBuilder = + DeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + DeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking( + originalRequestBuilder, keyItem, tableSchema, optimisticLockingEnabled); + + assertThat(result).isNotNull(); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } + + @Test + public void conditionallyApplyOptimistic_onDelete_whenFlagTrueAndVersionedRecordWithNullVersion_returnsOriginalRequest() { + boolean optimisticLockingEnabled = true; + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + // versioned record + RecordWithUpdateBehaviors keyItem = new RecordWithUpdateBehaviors(); + TableSchema tableSchema = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + + DeleteItemEnhancedRequest.Builder originalRequestBuilder = + DeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + DeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking( + originalRequestBuilder, keyItem, tableSchema, optimisticLockingEnabled); + + assertThat(result).isNotNull(); + assertEquals(originalRequestBuilder.build(), result); + } + + @Test + public void conditionallyApplyOptimistic_onDelete_whenFlagTrueAndVersionedRecordWithVersion_addsConditionExpression() { + boolean optimisticLockingEnabled = true; + Key key = Key.builder().partitionValue("id").build(); + Long version = 1L; + AttributeValue versionValue = AttributeValue.builder().n(String.valueOf(version)).build(); + String versionAttributeName = "version"; + + // versioned record + RecordWithUpdateBehaviors keyItem = new RecordWithUpdateBehaviors(); + keyItem.setVersion(version); + TableSchema tableSchema = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + + DeleteItemEnhancedRequest.Builder originalRequestBuilder = + DeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + DeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking( + originalRequestBuilder, keyItem, tableSchema, optimisticLockingEnabled); + + assertThat(result).isNotNull(); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } + + @Test + public void conditionallyApplyOptimistic_onTransactDelete_whenFlagFalse_returnsOriginalRequest() { + boolean optimisticLockingEnabled = false; + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + // versioned record + RecordWithUpdateBehaviors keyItem = new RecordWithUpdateBehaviors(); + TableSchema tableSchema = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + + TransactDeleteItemEnhancedRequest.Builder originalRequestBuilder = + TransactDeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + TransactDeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking( + originalRequestBuilder, keyItem, tableSchema, optimisticLockingEnabled); + + assertThat(result).isNotNull(); + assertEquals(originalRequestBuilder.build(), result); + } + + @Test + public void conditionallyApplyOptimistic_onTransactDelete_whenFlagTrueAndNonVersionedRecord_returnsOriginalRequest() { + boolean optimisticLockingEnabled = true; + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + // non-versioned record + RecordForUpdateExpressions keyItem = new RecordForUpdateExpressions(); + TableSchema tableSchema = TableSchema.fromClass(RecordForUpdateExpressions.class); + + TransactDeleteItemEnhancedRequest.Builder originalRequestBuilder = + TransactDeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + TransactDeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking( + originalRequestBuilder, keyItem, tableSchema, optimisticLockingEnabled); + + assertThat(result).isNotNull(); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } + + @Test + public void conditionallyApplyOptimistic_onTransactDelete_whenFlagTrueAndVersionedRecordWithNullVersion_returnsOriginalRequest() { + boolean optimisticLockingEnabled = true; + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + // versioned record + RecordWithUpdateBehaviors keyItem = new RecordWithUpdateBehaviors(); + TableSchema tableSchema = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + + TransactDeleteItemEnhancedRequest.Builder originalRequestBuilder = + TransactDeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + TransactDeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking( + originalRequestBuilder, keyItem, tableSchema, optimisticLockingEnabled); + + assertThat(result).isNotNull(); + assertEquals(originalRequestBuilder.build(), result); + } + + @Test + public void conditionallyApplyOptimistic_onTransactDelete_whenFlagTrueAndVersionedRecordWithVersion_addsConditionExpression() { + boolean optimisticLockingEnabled = true; + Key key = Key.builder().partitionValue("id").build(); + Long version = 1L; + AttributeValue versionValue = AttributeValue.builder().n(String.valueOf(version)).build(); + String versionAttributeName = "version"; + + // versioned record + RecordWithUpdateBehaviors keyItem = new RecordWithUpdateBehaviors(); + keyItem.setVersion(version); + TableSchema tableSchema = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + + TransactDeleteItemEnhancedRequest.Builder originalRequestBuilder = + TransactDeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName); + + TransactDeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking( + originalRequestBuilder, keyItem, tableSchema, optimisticLockingEnabled); + + assertThat(result).isNotNull(); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } + + @Test + public void createVersionCondition_shouldCreateCorrectExpression() { + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + Expression result = createVersionCondition(versionValue, versionAttributeName); + + assertThat(result.expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } + + @Test + public void createVersionCondition_nullVersionAttributeName_throwsIllegalArgumentException() { + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = null; + + assertThatThrownBy(() -> createVersionCondition(versionValue, versionAttributeName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Version attribute name must not be null or empty."); + } + + @Test + public void createVersionCondition_emptyVersionAttributeName_throwsIllegalArgumentException() { + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = " "; + + assertThatThrownBy(() -> createVersionCondition(versionValue, versionAttributeName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Version attribute name must not be null or empty."); + } + + @Test + public void createVersionCondition_nullVersionValue_throwsIllegalArgumentException() { + AttributeValue versionValue = null; + String versionAttributeName = "version"; + + assertThatThrownBy(() -> createVersionCondition(versionValue, versionAttributeName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Version value must not be null or empty."); + } + + @Test + public void createVersionCondition_nullVersionAttributeValue_throwsIllegalArgumentException() { + AttributeValue versionValue = AttributeValue.fromN(null); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> createVersionCondition(versionValue, versionAttributeName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Version value must not be null or empty."); + } + + @Test + public void createVersionCondition_emptyVersionAttributeValue_throwsIllegalArgumentException() { + AttributeValue versionValue = AttributeValue.fromN(" "); + String versionAttributeName = "version"; + + assertThatThrownBy(() -> createVersionCondition(versionValue, versionAttributeName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Version value must not be null or empty."); + } + + @Test + public void getVersionAttributeName_forVersionedRecord_returnsTheCorrectVersionValueFromTheTableSchema() { + // versioned record + TableSchema tableSchema = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + Optional versionAttributeNameOpt = getVersionAttributeName(tableSchema); + + assertNotNull(versionAttributeNameOpt); + assertTrue(versionAttributeNameOpt.isPresent()); + assertThat(versionAttributeNameOpt.get()).isEqualTo("version"); + } + + @Test + public void getVersionAttributeName_forNonVersionedRecord_shouldNotReturnAVersionValue() { + // non-versioned record + TableSchema tableSchema = TableSchema.fromClass(RecordForUpdateExpressions.class); + Optional versionAttributeNameOpt = getVersionAttributeName(tableSchema); + + assertNotNull(versionAttributeNameOpt); + assertFalse(versionAttributeNameOpt.isPresent()); + } + + @Test + public void buildDeleteItemEnhancedRequest_withOptimisticLocking_addsOptimisticLockingCondition() { + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest result = + DeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName) + .build(); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", versionValue)); + } + + @Test + public void buildDeleteItemEnhancedRequest_withOptimisticLockingAndCustomCondition_mergesConditions() { + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "version"; + + Map expressionNames = new HashMap<>(); + expressionNames.put("#key1", "key1"); + expressionNames.put("#key2", "key2"); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":value1", numberValue(10)); + expressionValues.put(":value2", numberValue(20)); + + Expression conditionExpression = + Expression.builder() + .expression("#key1 = :value1 OR #key2 = :value2") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + DeleteItemEnhancedRequest result = + DeleteItemEnhancedRequest.builder() + .key(key) + .conditionExpression(conditionExpression) + .optimisticLocking(versionValue, versionAttributeName) + .build(); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.conditionExpression()).isNotNull(); + + assertThat(result.conditionExpression().expression()).isEqualTo( + "(#key1 = :value1 OR #key2 = :value2) AND (#AMZN_MAPPED_version = :AMZN_MAPPED_version)"); + + Map expectedExpressionNames = new HashMap<>(); + expectedExpressionNames.put("#AMZN_MAPPED_version", "version"); + expectedExpressionNames.put("#key1", "key1"); + expectedExpressionNames.put("#key2", "key2"); + assertThat(result.conditionExpression().expressionNames()).containsExactlyInAnyOrderEntriesOf(expectedExpressionNames); + + Map expectedExpressionValues = new HashMap<>(); + expectedExpressionValues.put(":AMZN_MAPPED_version", AttributeValue.builder().n("1").build()); + expectedExpressionValues.put(":value1", AttributeValue.builder().n("10").build()); + expectedExpressionValues.put(":value2", AttributeValue.builder().n("20").build()); + assertThat(result.conditionExpression().expressionValues()).containsExactlyInAnyOrderEntriesOf(expectedExpressionValues); + } + + @Test + public void buildDeleteItemEnhancedRequest_differentVersionAttributeNames_addsCorrectExpressionOnRequest() { + Key key = Key.builder().partitionValue("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) + .optimisticLocking(versionValue, attributeName) + .build(); + + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_" + attributeName + " = :AMZN_MAPPED_" + attributeName); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_" + attributeName, attributeName)); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_" + attributeName, versionValue)); + } + } + + @Test + public void buildDeleteItemEnhancedRequest_differentVersionValues_addsCorrectExpressionOnRequest() { + 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) + .optimisticLocking(versionValue, "version") + .build(); + + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_version = :AMZN_MAPPED_version"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_version", "version")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_version", + versionValue)); + } + } + + @Test + public void buildDeleteItemEnhancedRequest_preservesExistingRequestProperties() { + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + + DeleteItemEnhancedRequest result = + DeleteItemEnhancedRequest.builder() + .key(key) + .returnConsumedCapacity("TOTAL") + .optimisticLocking(versionValue, "version") + .build(); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.returnConsumedCapacityAsString()).isEqualTo("TOTAL"); + assertThat(result.conditionExpression()).isNotNull(); + } + + @Test + public void buildTransactDeleteItemEnhancedRequest_addsCorrectExpressionOnRequest() { + Key key = Key.builder().partitionValue("id").build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + String versionAttributeName = "recordVersion"; + + TransactDeleteItemEnhancedRequest result = + TransactDeleteItemEnhancedRequest.builder() + .key(key) + .optimisticLocking(versionValue, versionAttributeName) + .build(); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo( + "#AMZN_MAPPED_recordVersion = :AMZN_MAPPED_recordVersion"); + assertThat(result.conditionExpression().expressionNames()).containsExactly( + entry("#AMZN_MAPPED_recordVersion", "recordVersion")); + assertThat(result.conditionExpression().expressionValues()).containsExactly( + entry(":AMZN_MAPPED_recordVersion", versionValue)); + } +} + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequestTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequestTest.java index dc33f4c07696..5ccb0c3b87f8 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequestTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequestTest.java @@ -111,6 +111,16 @@ public void equals_self() { assertThat(builtObject, equalTo(builtObject)); } + @Test + public void equals_NullObject() { + Key key1 = Key.builder().partitionValue("key1").build(); + + TransactDeleteItemEnhancedRequest builtObject1 = TransactDeleteItemEnhancedRequest.builder().key(key1).build(); + TransactDeleteItemEnhancedRequest builtObject2 = null; + + assertThat(builtObject1, not(equalTo(builtObject2))); + } + @Test public void equals_keyNotEqual() { Key key1 = Key.builder().partitionValue("key1").build();