diff --git a/services-custom/dynamodb-enhanced/README.md b/services-custom/dynamodb-enhanced/README.md index 121f42eae063..1679ee173609 100644 --- a/services-custom/dynamodb-enhanced/README.md +++ b/services-custom/dynamodb-enhanced/README.md @@ -340,6 +340,14 @@ number tag a numeric attribute in the TableSchema: public Integer getVersion() {...}; public void setVersion(Integer version) {...}; ``` + +To enable optimistic locking on delete operations, set `useVersionOnDelete = true`: +```java + @DynamoDbVersionAttribute(useVersionOnDelete = true) + public Integer getVersion() {...}; + public void setVersion(Integer version) {...}; +``` + Or using a StaticTableSchema: ```java .addAttribute(Integer.class, a -> a.name("version") @@ -349,6 +357,15 @@ Or using a StaticTableSchema: .tags(versionAttribute()) ``` +For delete optimistic locking with StaticTableSchema: +```java + .addAttribute(Integer.class, a -> a.name("version") + .getter(Customer::getVersion) + .setter(Customer::setVersion) + // Apply the 'version' tag with delete locking enabled + .tags(versionAttribute(0L, 1L, true)) +``` + ### AtomicCounterExtension This extension is loaded by default and will increment numerical attributes each time records are written to the diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java index f6c4d3fd40bf..1b8a972a50a2 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java @@ -15,7 +15,6 @@ package software.amazon.awssdk.enhanced.dynamodb; -import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -113,8 +112,8 @@ public void putItem_returnItemCollectionMetrics_set_itemCollectionMetricsNotNull public void updateItem_returnItemCollectionMetrics_set_itemCollectionMetricsNull() { Record record = new Record().setId("1").setSort(10); UpdateItemEnhancedRequest request = UpdateItemEnhancedRequest.builder(Record.class) - .item(record) - .build(); + .item(record) + .build(); UpdateItemEnhancedResponse response = mappedTable.updateItemWithResponse(request).join(); @@ -196,8 +195,8 @@ public void updateItem_returnValues_all_old() { UpdateItemEnhancedResponse response = mappedTable.updateItemWithResponse(r -> r.item(updatedRecord) - .returnValues(ReturnValue.ALL_OLD)) - .join(); + .returnValues(ReturnValue.ALL_OLD)) + .join(); assertThat(response.attributes().getId()).isEqualTo(record.getId()); assertThat(response.attributes().getSort()).isEqualTo(record.getSort()); 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..c2052fddd21d 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 @@ -231,6 +231,11 @@ default CompletableFuture deleteItem(Key key) { * This operation calls the low-level DynamoDB API DeleteItem operation. Consult the DeleteItem documentation for * further details and constraints. *

+ * Optimistic Locking: If the item has a version attribute annotated with + * {@link software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute} and + * {@code useVersionOnDelete = true}, optimistic locking will be automatically applied. The deletion will only + * succeed if the version matches the current version in the database. + *

* Example: *

      * {@code
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..51bfa1958f3f 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
@@ -229,6 +229,9 @@ default T deleteItem(Key key) {
      * This operation calls the low-level DynamoDB API DeleteItem operation. Consult the DeleteItem documentation for
      * further details and constraints.
      * 

+ * For versioned records, optimistic locking behavior is controlled by the {@code useVersionOnDelete} parameter + * in the {@link software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute} annotation. + *

* Example: *

      * {@code
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java
index 907ef521b1ec..173c6d0a41a3 100644
--- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java
@@ -96,25 +96,33 @@ public static StaticAttributeTag versionAttribute() {
         }
 
         public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy) {
-            return new VersionAttribute(startAt, incrementBy);
+            return new VersionAttribute(startAt, incrementBy, false);
+        }
+
+        public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy, Boolean useVersionOnDelete) {
+            return new VersionAttribute(startAt, incrementBy, useVersionOnDelete);
         }
     }
 
     private static final class VersionAttribute implements StaticAttributeTag {
         private static final String START_AT_METADATA_KEY = "VersionedRecordExtension:StartAt";
         private static final String INCREMENT_BY_METADATA_KEY = "VersionedRecordExtension:IncrementBy";
+        private static final String USE_VERSION_ON_DELETE_METADATA_KEY = "VersionedRecordExtension:UseVersionOnDelete";
 
         private final Long startAt;
         private final Long incrementBy;
+        private final Boolean useVersionOnDelete;
 
         private VersionAttribute() {
             this.startAt = null;
             this.incrementBy = null;
+            this.useVersionOnDelete = null;
         }
 
-        private VersionAttribute(Long startAt, Long incrementBy) {
+        private VersionAttribute(Long startAt, Long incrementBy, Boolean useVersionOnDelete) {
             this.startAt = startAt;
             this.incrementBy = incrementBy;
+            this.useVersionOnDelete = useVersionOnDelete;
         }
 
         @Override
@@ -137,6 +145,7 @@ public Consumer modifyMetadata(String attributeName
             return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName)
                                        .addCustomMetadataObject(START_AT_METADATA_KEY, startAt)
                                        .addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy)
+                                       .addCustomMetadataObject(USE_VERSION_ON_DELETE_METADATA_KEY, useVersionOnDelete)
                                        .markAttributeAsKey(attributeName, attributeValueType);
         }
     }
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java
index 1d74d20d8341..8cc9616c77ec 100644
--- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java
@@ -53,4 +53,12 @@
      */
     long incrementBy() default 1;
 
+    /**
+     * Whether to use version checking on delete operations. When true, delete operations will apply optimistic locking using the
+     * version attribute. Default value - {@code false} for backwards compatibility.
+     *
+     * @return true if version checking should be used on delete operations
+     */
+    boolean useVersionOnDelete() default false;
+
 }
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..651af1658fe0
--- /dev/null
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/OptimisticLockingHelper.java
@@ -0,0 +1,165 @@
+/*
+ * 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 static final String USE_VERSION_ON_DELETE_METADATA_KEY = "VersionedRecordExtension:UseVersionOnDelete"; + + private OptimisticLockingHelper() { + } + + /** + * Adds optimistic locking to a delete request. + *

+ * If a condition expression is already set on the request builder, this method merges it with the optimistic locking + * condition using {@code AND}. + * + * @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) { + + Expression mergedCondition = mergeConditions( + requestBuilder.build().conditionExpression(), + createVersionCondition(versionValue, versionAttributeName)); + + return requestBuilder + .conditionExpression(mergedCondition) + .build(); + } + + /** + * Adds optimistic locking to a transactional delete request. + *

+ * If a condition expression is already set on the request builder, this method merges it with the optimistic locking + * condition using {@code AND}. + * + * @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 mergedCondition = mergeConditions( + requestBuilder.build().conditionExpression(), + createVersionCondition(versionValue, versionAttributeName)); + + return requestBuilder + .conditionExpression(mergedCondition) + .build(); + } + + /** + * Conditionally applies optimistic locking based on annotation setting. + * + * @param the type of the item + * @param requestBuilder the delete request builder + * @param keyItem the item containing version information + * @param tableSchema the table schema + * @return delete request with optimistic locking if annotation enables it and version exists, otherwise original request + * @throws IllegalStateException if optimistic locking is enabled but the version attribute is null + */ + public static DeleteItemEnhancedRequest conditionallyApplyOptimisticLocking( + DeleteItemEnhancedRequest.Builder requestBuilder, T keyItem, TableSchema tableSchema) { + + return getVersionAttributeName(tableSchema) + .map(versionAttributeName -> { + Boolean useVersionOnDelete = tableSchema.tableMetadata() + .customMetadataObject(USE_VERSION_ON_DELETE_METADATA_KEY, Boolean.class) + .orElse(false); + + if (!useVersionOnDelete) { + return requestBuilder.build(); + } + + AttributeValue version = tableSchema.attributeValue(keyItem, versionAttributeName); + if (version == null) { + throw new IllegalStateException( + "Optimistic locking is enabled for delete, but version attribute is null: " + versionAttributeName); + } + return optimisticLocking(requestBuilder, version, versionAttributeName); + + }).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(); + } + + public static Expression mergeConditions(Expression initialCondition, Expression optimisticLockingCondition) { + return Expression.join(initialCondition, optimisticLockingCondition, " AND "); + } + + /** + * 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..9e99906aee9b 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,8 @@ 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.extensions.annotations.DynamoDbVersionAttribute; +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 +127,9 @@ public CompletableFuture createTable() { .build()); } + /** + * Supports optimistic locking via {@link OptimisticLockingHelper}. + */ @Override public CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { TableOperation> operation = DeleteItemOperation.create(request); @@ -131,6 +137,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,14 +147,25 @@ public CompletableFuture deleteItem(Consumer deleteItem(Key key) { return deleteItem(r -> r.key(key)); } + /** + * Deletes an item from the table. + *

+ * Optimistic Locking: If the item has a version attribute annotated with + * {@link DynamoDbVersionAttribute} and {@code useVersionOnDelete = true}, optimistic locking will be automatically applied. + */ @Override public CompletableFuture deleteItem(T keyItem) { - return deleteItem(keyFrom(keyItem)); + DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)); + DeleteItemEnhancedRequest request = conditionallyApplyOptimisticLocking(builder, keyItem, tableSchema); + return deleteItem(request); } @Override diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java index 31ce811b3483..dc855b851b74 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,14 +147,22 @@ public T deleteItem(Consumer requestConsumer) return deleteItem(builder.build()); } + /** + * Does not support optimistic locking. + */ @Override public T deleteItem(Key key) { return deleteItem(r -> r.key(key)); } + /** + * Supports optimistic locking based on annotation configuration via {@link OptimisticLockingHelper}. + */ @Override public T deleteItem(T keyItem) { - return deleteItem(keyFrom(keyItem)); + DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)); + DeleteItemEnhancedRequest request = conditionallyApplyOptimisticLocking(builder, keyItem, tableSchema); + return deleteItem(request); } @Override diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java index d81cf268afff..dfa6fe835faf 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java @@ -26,6 +26,8 @@ private VersionRecordAttributeTags() { } public static StaticAttributeTag attributeTagFor(DynamoDbVersionAttribute annotation) { - return VersionedRecordExtension.AttributeTags.versionAttribute(annotation.startAt(), annotation.incrementBy()); + return VersionedRecordExtension.AttributeTags.versionAttribute(annotation.startAt(), + annotation.incrementBy(), + annotation.useVersionOnDelete()); } } 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..ddd363553137 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,9 @@ package software.amazon.awssdk.enhanced.dynamodb.model; +import static software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper.createVersionCondition; +import static software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper.mergeConditions; + import java.util.Objects; import java.util.function.Consumer; import software.amazon.awssdk.annotations.NotThreadSafe; @@ -24,6 +27,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 +293,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 = mergeConditions(this.conditionExpression, optimisticLockingCondition); + 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..43839ffea29b 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,9 @@ package software.amazon.awssdk.enhanced.dynamodb.model; +import static software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper.createVersionCondition; +import static software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper.mergeConditions; + import java.util.Objects; import java.util.function.Consumer; import software.amazon.awssdk.annotations.NotThreadSafe; @@ -24,6 +27,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 +219,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 = mergeConditions(this.conditionExpression, optimisticLockingCondition); + 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..494cc64b0320 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingAsyncCrudTest.java @@ -0,0 +1,1129 @@ +/* + * 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.internal.OptimisticLockingHelper.optimisticLocking; +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.functionaltests.models.VersionedRecordWithDeleteOptimisticLocking; +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 static final TableSchema VERSIONED_RECORD_WITH_DELETE_LOCKING_TABLE_SCHEMA = + StaticTableSchema.builder(VersionedRecordWithDeleteOptimisticLocking.class) + .newItemSupplier(VersionedRecordWithDeleteOptimisticLocking::new) + .addAttribute(String.class, + a -> a.name("id") + .getter(VersionedRecordWithDeleteOptimisticLocking::getId) + .setter(VersionedRecordWithDeleteOptimisticLocking::setId) + .tags(primaryPartitionKey(), secondaryPartitionKey("index1"))) + .addAttribute(Integer.class, + a -> a.name("sort") + .getter(VersionedRecordWithDeleteOptimisticLocking::getSort) + .setter(VersionedRecordWithDeleteOptimisticLocking::setSort) + .tags(primarySortKey(), secondarySortKey("index1"))) + .addAttribute(Integer.class, + a -> a.name("value") + .getter(VersionedRecordWithDeleteOptimisticLocking::getValue) + .setter(VersionedRecordWithDeleteOptimisticLocking::setValue)) + .addAttribute(String.class, + a -> a.name("gsi_id") + .getter(VersionedRecordWithDeleteOptimisticLocking::getGsiId) + .setter(VersionedRecordWithDeleteOptimisticLocking::setGsiId) + .tags(secondaryPartitionKey("gsi_keys_only"))) + .addAttribute(Integer.class, + a -> a.name("gsi_sort") + .getter(VersionedRecordWithDeleteOptimisticLocking::getGsiSort) + .setter(VersionedRecordWithDeleteOptimisticLocking::setGsiSort) + .tags(secondarySortKey("gsi_keys_only"))) + .addAttribute(String.class, + a -> a.name("stringAttribute") + .getter(VersionedRecordWithDeleteOptimisticLocking::getStringAttribute) + .setter(VersionedRecordWithDeleteOptimisticLocking::setStringAttribute)) + .addAttribute(Integer.class, + a -> a.name("version") + .getter(VersionedRecordWithDeleteOptimisticLocking::getVersion) + .setter(VersionedRecordWithDeleteOptimisticLocking::setVersion) + .tags(versionAttribute(null, null, true))) + .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); + private final DynamoDbAsyncTable versionedRecordWithDeleteLockingTable = + enhancedClient.table(getConcreteTableName("versioned-with-delete-locking-table-name"), + VERSIONED_RECORD_WITH_DELETE_LOCKING_TABLE_SCHEMA); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())).join(); + versionedRecordTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())).join(); + versionedRecordWithDeleteLockingTable.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(); + + getDynamoDbAsyncClient().deleteTable( + DeleteTableRequest.builder() + .tableName(getConcreteTableName("versioned-with-delete-locking-table-name")) + .build()).join(); + } + + + // 1. deleteItem(T item) - on Non-versioned record + // -> Optimistic Locking NOT applied -> unconditionally deletes the record + @Test + public void deleteItem_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) on Versioned record with useVersionOnDelete=false (default) + // -> Optimistic Locking is not applied -> unconditionally deletes the record + @Test + public void deleteItem_onVersionedRecordWithoutDeleteLocking_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) on Versioned record with useVersionOnDelete=false (default), with stale version + // -> Optimistic Locking is not applied -> unconditionally deletes the record + @Test + public void deleteItem_onVersionedRecordWithoutDeleteLockingAndStaleVersion_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) on Versioned record with useVersionOnDelete=false + // -> Optimistic Locking 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) - should succeed (no optimistic locking because useVersionOnDelete=false) + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + versionedRecordTable.deleteItem(oldVersionItem).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 5. deleteItem(T item) on Versioned record with useVersionOnDelete=true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndVersionsMatch_deletesTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item).join(); + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)).join(); + versionedRecordWithDeleteLockingTable.deleteItem(savedItem).join(); + + VersionedRecordWithDeleteOptimisticLocking deletedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 6. deleteItem(T item) on Versioned record with useVersionOnDelete=true and versions DO NOT match + // -> Optimistic Locking applied -> does NOT delete the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item).join(); + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)).join(); + + // Update the item to change its version (new version = 2) + savedItem.setStringAttribute("Updated Item"); + versionedRecordWithDeleteLockingTable.updateItem(savedItem).join(); + + // Try to delete with old version (version = 1) - should fail + VersionedRecordWithDeleteOptimisticLocking oldVersionItem = new VersionedRecordWithDeleteOptimisticLocking().setId("123" + ).setSort(10).setVersion(1); + + assertThatThrownBy(() -> versionedRecordWithDeleteLockingTable.deleteItem(oldVersionItem).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(ConditionalCheckFailedException.class)) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 7. deleteItem(T item) on Versioned record with useVersionOnDelete=true and NULL version + // -> Optimistic Locking applied -> does NOT delete the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndNullVersion_throwsException() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item).join(); + + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)).join(); + savedItem.setVersion(null); + + assertThatThrownBy(() -> versionedRecordWithDeleteLockingTable.deleteItem(savedItem).join()) + .isInstanceOf(IllegalStateException.class) + .satisfies(e -> assertThat(e.getMessage()).contains( + "Optimistic locking is enabled for delete, but version attribute is null: version")); + } + + // 8. deleteItem(T item) on Versioned record with useVersionOnDelete=true, but item not found in DB + // -> Optimistic Locking is applied -> does NOT delete the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingButItemNotFoundInDb_doesNotDeleteTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + versionedRecordWithDeleteLockingTable.putItem(item); + + VersionedRecordWithDeleteOptimisticLocking nonExistingItem = new VersionedRecordWithDeleteOptimisticLocking().setId( + "123").setSort(20).setStringAttribute("test").setVersion(1); + + assertThatThrownBy(() -> versionedRecordWithDeleteLockingTable.deleteItem(nonExistingItem).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(ConditionalCheckFailedException.class)) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 9. 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(); + } + + // 10. 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")); + } + + // 11. 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(); + } + + // 12. 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")); + } + + // 13. 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")); + } + + // 14. 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")); + } + + // 15. deleteItem(Consumer<>) on VersionedRecord with Optimistic Locking true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithConsumer_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(); + } + + // 16. 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 deleteItemWithConsumer_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")); + + } + + // 17. deleteItem(Consumer<>) with Optimistic Locking true, versions match + custom condition respected + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithConsumer_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"; + + versionedRecordTable.deleteItem(r -> r + .key(recordKey) + .optimisticLocking(matchVersion, versionAttributeName) + .conditionExpression(conditionExpression)) + .join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 18. 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 deleteItemWithConsumer_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")); + } + + // 19. deleteItem(Consumer<>) with Optimistic Locking true, versions do not match + custom condition respected + // -> does not delete the record + @Test + public void deleteItemWithConsumer_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")); + } + + // 20. deleteItem(Consumer<>) with Optimistic Locking true, versions do not match + custom condition fails + // -> does not delete the record + @Test + public void deleteItemWithConsumer_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")); + } + + // 21. TransactWriteItems.deleteItem(T item) - 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(); + } + + // 22. TransactWriteItems.deleteItem(T item) - on Versioned record and versions match + // -> Optimistic Locking is NOT applied (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(); + } + + // 23. TransactWriteItems.deleteItem(T item) - on Versioned record and versions do NOT match + // -> Optimistic Locking is NOT applied (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(); + } + + // 24. 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(); + } + + // 25. 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()); + } + + // 26. 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).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"; + + 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(); + } + + // 27. 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).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"; + + 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()); + } + + // 28. 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()); + } + + // 29. 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()); + } + + // 30. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true + custom condition respected + // -> deletes the record + @Test + public void deleteItemWithRequest_whenOptimisticLockingHelperMergesConditionAndCustomConditionRespected_deletesTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item).join(); + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)).join(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":value", AttributeValue.fromS("test")); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + DeleteItemEnhancedRequest.Builder requestBuilder = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression); + + DeleteItemEnhancedRequest requestWithMergedConditions = + optimisticLocking(requestBuilder, + AttributeValue.builder().n(savedItem.getVersion().toString()).build(), + "version"); + + versionedRecordWithDeleteLockingTable.deleteItem(requestWithMergedConditions).join(); + + VersionedRecordWithDeleteOptimisticLocking deletedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 31. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true + custom condition fails + // -> does NOT delete the record + @Test + public void deleteItemWithRequest_whenOptimisticLockingHelperMergesConditionAndCustomConditionFails_doesNotDeleteTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item).join(); + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)).join(); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":value", AttributeValue.fromS("nonMatchingValue")); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + DeleteItemEnhancedRequest.Builder requestBuilder = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression); + + DeleteItemEnhancedRequest requestWithMergedConditions = + optimisticLocking(requestBuilder, + AttributeValue.builder().n(savedItem.getVersion().toString()).build(), + "version"); + + assertThatThrownBy(() -> versionedRecordWithDeleteLockingTable.deleteItem(requestWithMergedConditions).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } +} \ 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..1ea898acb3ec --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingCrudTest.java @@ -0,0 +1,1118 @@ +/* + * 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.internal.OptimisticLockingHelper.optimisticLocking; +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.functionaltests.models.VersionedRecordWithDeleteOptimisticLocking; +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 static final TableSchema VERSIONED_RECORD_WITH_DELETE_LOCKING_TABLE_SCHEMA = + StaticTableSchema.builder(VersionedRecordWithDeleteOptimisticLocking.class) + .newItemSupplier(VersionedRecordWithDeleteOptimisticLocking::new) + .addAttribute(String.class, + a -> a.name("id") + .getter(VersionedRecordWithDeleteOptimisticLocking::getId) + .setter(VersionedRecordWithDeleteOptimisticLocking::setId) + .tags(primaryPartitionKey(), secondaryPartitionKey("index1"))) + .addAttribute(Integer.class, + a -> a.name("sort") + .getter(VersionedRecordWithDeleteOptimisticLocking::getSort) + .setter(VersionedRecordWithDeleteOptimisticLocking::setSort) + .tags(primarySortKey(), secondarySortKey("index1"))) + .addAttribute(Integer.class, + a -> a.name("value") + .getter(VersionedRecordWithDeleteOptimisticLocking::getValue) + .setter(VersionedRecordWithDeleteOptimisticLocking::setValue)) + .addAttribute(String.class, + a -> a.name("gsi_id") + .getter(VersionedRecordWithDeleteOptimisticLocking::getGsiId) + .setter(VersionedRecordWithDeleteOptimisticLocking::setGsiId) + .tags(secondaryPartitionKey("gsi_keys_only"))) + .addAttribute(Integer.class, + a -> a.name("gsi_sort") + .getter(VersionedRecordWithDeleteOptimisticLocking::getGsiSort) + .setter(VersionedRecordWithDeleteOptimisticLocking::setGsiSort) + .tags(secondarySortKey("gsi_keys_only"))) + .addAttribute(String.class, + a -> a.name("stringAttribute") + .getter(VersionedRecordWithDeleteOptimisticLocking::getStringAttribute) + .setter(VersionedRecordWithDeleteOptimisticLocking::setStringAttribute)) + .addAttribute(Integer.class, + a -> a.name("version") + .getter(VersionedRecordWithDeleteOptimisticLocking::getVersion) + .setter(VersionedRecordWithDeleteOptimisticLocking::setVersion) + .tags(versionAttribute(null, null, true))) + .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); + private final DynamoDbTable versionedRecordWithDeleteLockingTable = + enhancedClient.table(getConcreteTableName("versioned-delete-locking-table-name"), + VERSIONED_RECORD_WITH_DELETE_LOCKING_TABLE_SCHEMA); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + versionedRecordTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + versionedRecordWithDeleteLockingTable.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()); + + getDynamoDbClient().deleteTable( + DeleteTableRequest.builder() + .tableName(getConcreteTableName("versioned-delete-locking-table-name")) + .build()); + } + + // 1. deleteItem(T item) - on Non-versioned record + // -> Optimistic Locking NOT applied -> unconditionally deletes the record + @Test + public void dleteItem_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) on Versioned record with useVersionOnDelete=false (default) + // -> Optimistic Locking is not applied -> unconditionally deletes the record + @Test + public void deleteItem_onVersionedRecordWithoutDeleteLocking_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) on Versioned record with useVersionOnDelete=false (default), with stale version + // -> Optimistic Locking is not applied -> unconditionally deletes the record + @Test + public void deleteItem_onVersionedRecordWithoutDeleteLockingAndStaleVersion_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) on Versioned record with useVersionOnDelete=false + // -> Optimistic Locking 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) - should succeed (no optimistic locking because useVersionOnDelete=false) + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + versionedRecordTable.deleteItem(oldVersionItem); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 5. deleteItem(T item) on Versioned record with useVersionOnDelete=true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndVersionsMatch_deletesTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item); + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)); + versionedRecordWithDeleteLockingTable.deleteItem(savedItem); + + VersionedRecordWithDeleteOptimisticLocking deletedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 6. deleteItem(T item) on Versioned record with useVersionOnDelete=true and versions DO NOT match + // -> Optimistic Locking applied -> does NOT delete the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndVersionsMismatch_doesNotDeleteTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item); + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)); + + // Update the item to change its version (new version = 2) + savedItem.setStringAttribute("Updated Item"); + versionedRecordWithDeleteLockingTable.updateItem(savedItem); + + // Try to delete with old version (version = 1) - should fail + VersionedRecordWithDeleteOptimisticLocking oldVersionItem = new VersionedRecordWithDeleteOptimisticLocking().setId("123" + ).setSort(10).setVersion(1); + + assertThatThrownBy(() -> versionedRecordWithDeleteLockingTable.deleteItem(oldVersionItem)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 7. deleteItem(T item) on Versioned record with useVersionOnDelete=true and NULL version + // -> Optimistic Locking applied -> does NOT delete the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingAndNullVersion_throwsException() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item); + + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)); + savedItem.setVersion(null); + + assertThatThrownBy(() -> versionedRecordWithDeleteLockingTable.deleteItem(savedItem)) + .isInstanceOf(IllegalStateException.class) + .satisfies(e -> assertThat(e.getMessage()).contains( + "Optimistic locking is enabled for delete, but version attribute is null: version")); + } + + // 8. deleteItem(T item) on Versioned record with useVersionOnDelete=true, but item not found in DB + // -> Optimistic Locking is applied -> does NOT delete the record + @Test + public void deleteItem_onVersionedRecord_optimisticLockingButItemNotFoundInDb_doesNotDeleteTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + versionedRecordWithDeleteLockingTable.putItem(item); + + VersionedRecordWithDeleteOptimisticLocking nonExistingItem = new VersionedRecordWithDeleteOptimisticLocking().setId( + "123").setSort(20).setStringAttribute("test").setVersion(1); + + assertThatThrownBy(() -> versionedRecordWithDeleteLockingTable.deleteItem(nonExistingItem)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 9. 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(); + } + + // 10. 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")); + } + + // 11. 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(); + } + + // 12. 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")); + } + + // 13. 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")); + } + + // 14. 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")); + } + + // 15. deleteItem(Consumer<>) on VersionedRecord with Optimistic Locking true and versions match + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithConsumer_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(); + } + + // 16. 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 deleteItemWithConsumer_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")); + } + + // 17. deleteItem(Consumer<>) with Optimistic Locking true, versions match + custom condition respected + // -> Optimistic Locking is applied -> deletes the record + @Test + public void deleteItemWithConsumer_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(); + } + + // 18. 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 deleteItemWithConsumer_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")); + } + + // 19. deleteItem(Consumer<>) with Optimistic Locking true, versions do not match + custom condition respected + // -> does not delete the record + @Test + public void deleteItemWithConsumer_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")); + } + + // 20. deleteItem(Consumer<>) with Optimistic Locking true, versions do not match + custom condition fails + // -> does not delete the record + @Test + public void deleteItemWithConsumer_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")); + } + + // 21. TransactWriteItems.deleteItem(T item) - 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(); + } + + // 22. TransactWriteItems.deleteItem(T item) - on Versioned record and versions match + // -> Optimistic Locking is NOT applied (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(); + } + + // 23. TransactWriteItems.deleteItem(T item) - on Versioned record and versions do NOT match + // -> Optimistic Locking is NOT applied (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(); + } + + // 24. 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(); + } + + // 25. 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()); + } + + // 26. 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(); + } + + // 27. 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()); + } + + // 28. 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()); + } + + // 29. 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()); + } + + // 30. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true + custom condition respected + // -> deletes the record + @Test + public void deleteItemWithRequest_whenOptimisticLockingHelperMergesConditionAndCustomConditionRespected_deletesTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item); + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":value", AttributeValue.fromS("test")); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + DeleteItemEnhancedRequest.Builder requestBuilder = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression); + + DeleteItemEnhancedRequest requestWithMergedConditions = + optimisticLocking(requestBuilder, + AttributeValue.builder().n(savedItem.getVersion().toString()).build(), + "version"); + + versionedRecordWithDeleteLockingTable.deleteItem(requestWithMergedConditions); + + VersionedRecordWithDeleteOptimisticLocking deletedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 31. deleteItem(DeleteItemEnhancedRequest) with Optimistic Locking true + custom condition fails + // -> does NOT delete the record + @Test + public void deleteItemWithRequest_whenOptimisticLockingHelperMergesConditionAndCustomConditionFails_doesNotDeleteTheRecord() { + VersionedRecordWithDeleteOptimisticLocking item = + new VersionedRecordWithDeleteOptimisticLocking().setId("123").setSort(10).setStringAttribute("test"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordWithDeleteLockingTable.putItem(item); + VersionedRecordWithDeleteOptimisticLocking savedItem = + versionedRecordWithDeleteLockingTable.getItem(r -> r.key(recordKey)); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#stringAttribute", "stringAttribute"); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":value", AttributeValue.fromS("nonMatchingValue")); + + Expression conditionExpression = + Expression.builder() + .expression("#stringAttribute = :value") + .expressionNames(Collections.unmodifiableMap(expressionNames)) + .expressionValues(Collections.unmodifiableMap(expressionValues)) + .build(); + + DeleteItemEnhancedRequest.Builder requestBuilder = + DeleteItemEnhancedRequest.builder() + .key(recordKey) + .conditionExpression(conditionExpression); + + DeleteItemEnhancedRequest requestWithMergedConditions = + optimisticLocking(requestBuilder, + AttributeValue.builder().n(savedItem.getVersion().toString()).build(), + "version"); + + assertThatThrownBy(() -> versionedRecordWithDeleteLockingTable.deleteItem(requestWithMergedConditions)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } +} \ 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/functionaltests/models/VersionedRecordWithDeleteOptimisticLocking.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/VersionedRecordWithDeleteOptimisticLocking.java new file mode 100644 index 000000000000..88ead292b8ce --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/VersionedRecordWithDeleteOptimisticLocking.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 VersionedRecordWithDeleteOptimisticLocking { + + 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 VersionedRecordWithDeleteOptimisticLocking setId(String id) { + this.id = id; + return this; + } + + public Integer getSort() { + return sort; + } + + public VersionedRecordWithDeleteOptimisticLocking setSort(Integer sort) { + this.sort = sort; + return this; + } + + public Integer getValue() { + return value; + } + + public VersionedRecordWithDeleteOptimisticLocking setValue(Integer value) { + this.value = value; + return this; + } + + public String getGsiId() { + return gsiId; + } + + public VersionedRecordWithDeleteOptimisticLocking setGsiId(String gsiId) { + this.gsiId = gsiId; + return this; + } + + public Integer getGsiSort() { + return gsiSort; + } + + public VersionedRecordWithDeleteOptimisticLocking setGsiSort(Integer gsiSort) { + this.gsiSort = gsiSort; + return this; + } + + public String getStringAttribute() { + return stringAttribute; + } + + public VersionedRecordWithDeleteOptimisticLocking setStringAttribute(String stringAttribute) { + this.stringAttribute = stringAttribute; + return this; + } + + @DynamoDbVersionAttribute(useVersionOnDelete = true) + public Integer getVersion() { + return version; + } + + public VersionedRecordWithDeleteOptimisticLocking 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; + } + VersionedRecordWithDeleteOptimisticLocking versionedRecord = (VersionedRecordWithDeleteOptimisticLocking) 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); + } +} \ No newline at end of file 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..1b3edf7b30c3 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java @@ -0,0 +1,433 @@ +/* + * 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.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension.AttributeTags.versionAttribute; +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 static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +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.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class OptimisticLockingHelperTest { + + private static final TableSchema OPTIMISTIC_LOCKING_SCHEMA = + StaticTableSchema.builder(OptimisticLockingTestItem.class) + .newItemSupplier(OptimisticLockingTestItem::new) + .addAttribute(String.class, + a -> a.name("id") + .getter(OptimisticLockingTestItem::getId) + .setter(OptimisticLockingTestItem::setId) + .addTag(primaryPartitionKey())) + .addAttribute(Long.class, + a -> a.name("version") + .getter(OptimisticLockingTestItem::getVersion) + .setter(OptimisticLockingTestItem::setVersion) + .addTag(versionAttribute(null, null, true))) + .build(); + + @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); + + 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); + + 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 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)); + } + + @Test + public void conditionallyApplyOptimisticLocking_withoutVersionAttribute_returnsOriginalRequest() { + Key key = Key.builder().partitionValue("id").build(); + RecordForUpdateExpressions keyItem = new RecordForUpdateExpressions(); + TableSchema tableSchema = TableSchema.fromClass(RecordForUpdateExpressions.class); + + DeleteItemEnhancedRequest.Builder requestBuilder = DeleteItemEnhancedRequest.builder().key(key); + DeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking(requestBuilder, keyItem, tableSchema); + + assertThat(result).isEqualTo(requestBuilder.build()); + assertThat(result.conditionExpression()).isNull(); + } + + @Test + public void conditionallyApplyOptimisticLocking_useVersionOnDeleteFalse_returnsOriginalRequest() { + Key key = Key.builder().partitionValue("id").build(); + RecordWithUpdateBehaviors keyItem = new RecordWithUpdateBehaviors(); + keyItem.setVersion(1L); + TableSchema tableSchema = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + + DeleteItemEnhancedRequest.Builder requestBuilder = DeleteItemEnhancedRequest.builder().key(key); + DeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking(requestBuilder, keyItem, tableSchema); + + assertThat(result).isEqualTo(requestBuilder.build()); + assertThat(result.conditionExpression()).isNull(); + } + + @Test + public void conditionallyApplyOptimisticLocking_useVersionOnDeleteTrueAndNullVersion_throwsException() { + Key key = Key.builder().partitionValue("id").build(); + OptimisticLockingTestItem keyItem = new OptimisticLockingTestItem(); + keyItem.setId("id"); + + DeleteItemEnhancedRequest.Builder requestBuilder = DeleteItemEnhancedRequest.builder().key(key); + + assertThatThrownBy(() -> conditionallyApplyOptimisticLocking(requestBuilder, keyItem, OPTIMISTIC_LOCKING_SCHEMA)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Optimistic locking is enabled for delete, but version attribute is null: version"); + } + + @Test + public void conditionallyApplyOptimisticLocking_useVersionOnDeleteTrueAndVersionPresent_appliesCondition() { + Key key = Key.builder().partitionValue("id").build(); + OptimisticLockingTestItem keyItem = new OptimisticLockingTestItem(); + keyItem.setId("id"); + keyItem.setVersion(1L); + + DeleteItemEnhancedRequest.Builder requestBuilder = DeleteItemEnhancedRequest.builder().key(key); + DeleteItemEnhancedRequest result = conditionallyApplyOptimisticLocking(requestBuilder, keyItem, + OPTIMISTIC_LOCKING_SCHEMA); + + 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", AttributeValue.builder().n("1").build())); + } + + private static class OptimisticLockingTestItem { + private String id; + private Long version; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } + } +} 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();