diff --git a/.changes/next-release/bugfix-AWSSDKforJavav2DynamoDbEnhanced-05cdcee.json b/.changes/next-release/bugfix-AWSSDKforJavav2DynamoDbEnhanced-05cdcee.json new file mode 100644 index 00000000000..5d288440ef3 --- /dev/null +++ b/.changes/next-release/bugfix-AWSSDKforJavav2DynamoDbEnhanced-05cdcee.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "AWS SDK for Java v2 - DynamoDb Enhanced", + "contributor": "", + "description": "Changes the default behavior of the DynamoDb Enhanced atomic counter extension to automatically filter out any counter attributes in the item to be updated. This allows users to read and update items without DynamoDb collision errors." +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AtomicCounterExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AtomicCounterExtension.java index 0337ba209cb..69a7807bb97 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AtomicCounterExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AtomicCounterExtension.java @@ -19,8 +19,10 @@ import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.ifNotExists; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -37,12 +39,12 @@ import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.utils.CollectionUtils; +import software.amazon.awssdk.utils.Logger; /** - * This extension enables atomic counter attributes to be written to the database. - * The extension is loaded by default when you instantiate a - * {@link DynamoDbEnhancedClient} and only needs to be added to the client if you - * are adding custom extensions to the client. + * This extension enables atomic counter attributes to be changed in DynamoDb by creating instructions for modifying + * an existing value or setting a start value. The extension is loaded by default when you instantiate a + * {@link DynamoDbEnhancedClient} and only needs to be added to the client if you are adding custom extensions to the client. *

* To utilize atomic counters, first create a field in your model that will be used to store the counter. * This class field should of type {@link Long} and you need to tag it as an atomic counter: @@ -56,8 +58,7 @@ *

* Every time a new update of the record is successfully written to the database, the counter will be updated automatically. * By default, the counter starts at 0 and increments by 1 for each update. The tags provide the capability of adjusting - * the counter start and increment/decrement values such as described in - * {@link DynamoDbAtomicCounter}. + * the counter start and increment/decrement values such as described in {@link DynamoDbAtomicCounter}. *

* Example 1: Using a bean based table schema *

@@ -86,10 +87,18 @@
  * }
  * 
*

- * NOTE: When using putItem, the counter will be reset to its start value. + * NOTES: + *

*/ @SdkPublicApi public final class AtomicCounterExtension implements DynamoDbEnhancedClientExtension { + + private static final Logger log = Logger.loggerFor(AtomicCounterExtension.class); + private AtomicCounterExtension() { } @@ -118,6 +127,7 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex break; case UPDATE_ITEM: modificationBuilder.updateExpression(createUpdateExpression(counters)); + modificationBuilder.transformedItem(filterFromItem(counters, context.items())); break; default: break; } @@ -136,6 +146,22 @@ private Map addToItem(Map counter return Collections.unmodifiableMap(itemToTransform); } + private Map filterFromItem(Map counters, Map items) { + Map itemToTransform = new HashMap<>(items); + List removedAttributes = new ArrayList<>(); + for (String attributeName : counters.keySet()) { + if (itemToTransform.containsKey(attributeName)) { + itemToTransform.remove(attributeName); + removedAttributes.add(attributeName); + } + } + if (!removedAttributes.isEmpty()) { + log.debug(() -> String.format("Filtered atomic counter attributes from existing update item to avoid collisions: %s", + String.join(",", removedAttributes))); + } + return Collections.unmodifiableMap(itemToTransform); + } + private SetAction counterAction(Map.Entry e) { String attributeName = e.getKey(); AtomicCounter counter = e.getValue(); diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AtomicCounterExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AtomicCounterExtensionTest.java index 4ca347f038b..6ee6cf915d7 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AtomicCounterExtensionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AtomicCounterExtensionTest.java @@ -87,7 +87,10 @@ public void beforeWrite_updateItemOperation_hasCounters_createsUpdateExpression( .operationName(OperationName.UPDATE_ITEM) .operationContext(PRIMARY_CONTEXT).build()); - assertThat(result.transformedItem()).isNull(); + Map transformedItem = result.transformedItem(); + assertThat(transformedItem).isNotNull().hasSize(1); + assertThat(transformedItem).containsEntry("id", AttributeValue.fromS(RECORD_ID)); + assertThat(result.updateExpression()).isNotNull(); List setActions = result.updateExpression().setActions(); @@ -112,11 +115,39 @@ public void beforeWrite_updateItemOperation_noCounters_noChanges() { .tableMetadata(SIMPLE_ITEM_MAPPER.tableMetadata()) .operationName(OperationName.UPDATE_ITEM) .operationContext(PRIMARY_CONTEXT).build()); - assertThat(result.transformedItem()).isNull(); assertThat(result.updateExpression()).isNull(); } + @Test + public void beforeWrite_updateItemOperation_hasCountersInItem_createsUpdateExpressionAndFilters() { + AtomicCounterItem atomicCounterItem = new AtomicCounterItem(); + atomicCounterItem.setId(RECORD_ID); + atomicCounterItem.setCustomCounter(255L); + + Map items = ITEM_MAPPER.itemToMap(atomicCounterItem, true); + assertThat(items).hasSize(2); + + WriteModification result = + atomicCounterExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(ITEM_MAPPER.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + + Map transformedItem = result.transformedItem(); + assertThat(transformedItem).isNotNull().hasSize(1); + assertThat(transformedItem).containsEntry("id", AttributeValue.fromS(RECORD_ID)); + + assertThat(result.updateExpression()).isNotNull(); + + List setActions = result.updateExpression().setActions(); + assertThat(setActions).hasSize(2); + + verifyAction(setActions, "customCounter", "5", "5"); + verifyAction(setActions, "defaultCounter", "-1", "1"); + } + @Test public void beforeWrite_putItemOperation_hasCounters_createsItemTransform() { AtomicCounterItem atomicCounterItem = new AtomicCounterItem(); diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AtomicCounterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AtomicCounterTest.java index a07d16a8f5d..9b3d12e6d55 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AtomicCounterTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AtomicCounterTest.java @@ -112,15 +112,35 @@ public void createViaPut_incrementsCorrectly() { } @Test - public void createViaUpdate_settingCounterInPojo_throwsException() { + public void createViaUpdate_settingCounterInPojo_hasNoEffect() { AtomicCounterRecord record = new AtomicCounterRecord(); record.setId(RECORD_ID); record.setDefaultCounter(10L); record.setAttribute1(STRING_VALUE); - assertThatThrownBy(() -> mappedTable.updateItem(record)) - .isInstanceOf(DynamoDbException.class) - .hasMessageContaining("Two document paths"); + mappedTable.updateItem(record); + AtomicCounterRecord persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getAttribute1()).isEqualTo(STRING_VALUE); + assertThat(persistedRecord.getDefaultCounter()).isEqualTo(0L); + assertThat(persistedRecord.getCustomCounter()).isEqualTo(10L); + assertThat(persistedRecord.getDecreasingCounter()).isEqualTo(-20L); + } + + @Test + public void updateItem_retrievedFromDb_shouldNotThrowException() { + AtomicCounterRecord record = new AtomicCounterRecord(); + record.setId(RECORD_ID); + record.setAttribute1(STRING_VALUE); + mappedTable.updateItem(record); + + AtomicCounterRecord retrievedRecord = mappedTable.getItem(record); + retrievedRecord.setAttribute1("ChangingThisAttribute"); + + retrievedRecord = mappedTable.updateItem(retrievedRecord); + assertThat(retrievedRecord).isNotNull(); + assertThat(retrievedRecord.getDefaultCounter()).isEqualTo(1L); + assertThat(retrievedRecord.getCustomCounter()).isEqualTo(15L); + assertThat(retrievedRecord.getDecreasingCounter()).isEqualTo(-21L); } @Test