diff --git a/.changes/next-release/bugfix-AWSSDKforJavav2-2a80679.json b/.changes/next-release/bugfix-AWSSDKforJavav2-2a80679.json new file mode 100644 index 000000000000..f8c315dfb1ff --- /dev/null +++ b/.changes/next-release/bugfix-AWSSDKforJavav2-2a80679.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "AWS SDK for Java v2", + "contributor": "anirudh9391", + "description": "Introduce a new method to transform input to be able to perform update operations on nested DynamoDB object attributes." +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java index afd719d5a82a..61d750e98a7e 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java @@ -15,6 +15,8 @@ package software.amazon.awssdk.enhanced.dynamodb.internal; +import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; + import java.util.Collections; import java.util.List; import java.util.Map; @@ -22,6 +24,7 @@ import java.util.Set; import java.util.function.Function; import java.util.function.Supplier; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import software.amazon.awssdk.annotations.SdkInternalApi; @@ -40,6 +43,7 @@ public final class EnhancedClientUtils { private static final Set SPECIAL_CHARACTERS = Stream.of( '*', '.', '-', '#', '+', ':', '/', '(', ')', ' ', '&', '<', '>', '?', '=', '!', '@', '%', '$', '|').collect(Collectors.toSet()); + private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); private EnhancedClientUtils() { @@ -67,18 +71,30 @@ public static String cleanAttributeName(String key) { return somethingChanged ? new String(chars) : key; } + private static boolean isNestedAttribute(String key) { + return key.contains(NESTED_OBJECT_UPDATE); + } + /** * Creates a key token to be used with an ExpressionNames map. */ public static String keyRef(String key) { - return "#AMZN_MAPPED_" + cleanAttributeName(key); + String cleanAttributeName = cleanAttributeName(key); + cleanAttributeName = isNestedAttribute(cleanAttributeName) ? + NESTED_OBJECT_PATTERN.matcher(cleanAttributeName).replaceAll(".#AMZN_MAPPED_") + : cleanAttributeName; + return "#AMZN_MAPPED_" + cleanAttributeName; } /** * Creates a value token to be used with an ExpressionValues map. */ public static String valueRef(String value) { - return ":AMZN_MAPPED_" + cleanAttributeName(value); + String cleanAttributeName = cleanAttributeName(value); + cleanAttributeName = isNestedAttribute(cleanAttributeName) ? + NESTED_OBJECT_PATTERN.matcher(cleanAttributeName).replaceAll("_") + : cleanAttributeName; + return ":AMZN_MAPPED_" + cleanAttributeName; } public static T readAndTransformSingleItem(Map itemMap, diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java index 87a1dcdee9e1..0ffe361b5aed 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java @@ -15,11 +15,13 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.operations; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.readAndTransformSingleItem; import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.operationExpression; import static software.amazon.awssdk.utils.CollectionUtils.filterMap; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -34,6 +36,7 @@ import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification; import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter; +import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode; import software.amazon.awssdk.enhanced.dynamodb.model.TransactUpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; @@ -53,7 +56,8 @@ public class UpdateItemOperation implements TableOperation>, TransactableWriteOperation { - + + public static final String NESTED_OBJECT_UPDATE = "_NESTED_ATTR_UPDATE_"; private final Either, TransactUpdateItemEnhancedRequest> request; private UpdateItemOperation(UpdateItemEnhancedRequest request) { @@ -90,7 +94,22 @@ public UpdateItemRequest generateRequest(TableSchema tableSchema, r -> Optional.ofNullable(r.ignoreNulls())) .orElse(null); - Map itemMap = tableSchema.itemToMap(item, Boolean.TRUE.equals(ignoreNulls)); + IgnoreNullsMode ignoreNullsMode = request.map(r -> Optional.ofNullable(r.ignoreNullsMode()), + r -> Optional.ofNullable(r.ignoreNullsMode())) + .orElse(IgnoreNullsMode.DEFAULT); + + if (ignoreNullsMode == IgnoreNullsMode.SCALAR_ONLY + || ignoreNullsMode == IgnoreNullsMode.MAPS_ONLY) { + ignoreNulls = true; + } + Map itemMapImmutable = tableSchema.itemToMap(item, Boolean.TRUE.equals(ignoreNulls)); + + // If ignoreNulls is set to true, check for nested params to be updated + // If needed, Transform itemMap for it to be able to handle them. + + Map itemMap = ignoreNullsMode == IgnoreNullsMode.SCALAR_ONLY ? + transformItemToMapForUpdateExpression(itemMapImmutable) : itemMapImmutable; + TableMetadata tableMetadata = tableSchema.tableMetadata(); WriteModification transformation = @@ -141,6 +160,58 @@ public UpdateItemRequest generateRequest(TableSchema tableSchema, return requestBuilder.build(); } + + /** + * Method checks if a nested object parameter requires an update + * If so flattens out nested params separated by "_NESTED_ATTR_UPDATE_" + * this is consumed by @link EnhancedClientUtils to form the appropriate UpdateExpression + */ + public Map transformItemToMapForUpdateExpression(Map itemToMap) { + + Map nestedAttributes = new HashMap<>(); + + itemToMap.forEach((key, value) -> { + if (value.hasM() && isNotEmptyMap(value.m())) { + nestedAttributes.put(key, value); + } + }); + + if (!nestedAttributes.isEmpty()) { + Map itemToMapMutable = new HashMap<>(itemToMap); + nestedAttributes.forEach((key, value) -> { + itemToMapMutable.remove(key); + nestedItemToMap(itemToMapMutable, key, value); + }); + return itemToMapMutable; + } + + return itemToMap; + } + + private Map nestedItemToMap(Map itemToMap, + String key, + AttributeValue attributeValue) { + attributeValue.m().forEach((mapKey, mapValue) -> { + String nestedAttributeKey = key + NESTED_OBJECT_UPDATE + mapKey; + if (attributeValueNonNullOrShouldWriteNull(mapValue)) { + if (mapValue.hasM()) { + nestedItemToMap(itemToMap, nestedAttributeKey, mapValue); + } else { + itemToMap.put(nestedAttributeKey, mapValue); + } + } + }); + return itemToMap; + } + + private boolean isNotEmptyMap(Map map) { + return !map.isEmpty() && map.values().stream() + .anyMatch(this::attributeValueNonNullOrShouldWriteNull); + } + + private boolean attributeValueNonNullOrShouldWriteNull(AttributeValue attributeValue) { + return !isNullAttributeValue(attributeValue); + } @Override public UpdateItemEnhancedResponse transformResponse(UpdateItemResponse response, diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java index 41991e0e2865..1d47400ab2e6 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java @@ -18,6 +18,7 @@ import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef; +import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; import static software.amazon.awssdk.utils.CollectionUtils.filterMap; import java.util.Arrays; @@ -25,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Collectors; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; @@ -39,6 +41,8 @@ @SdkInternalApi public final class UpdateExpressionUtils { + private static final Pattern PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); + private UpdateExpressionUtils() { } @@ -136,9 +140,12 @@ private static Function behaviorBasedValue(UpdateBehavior update /** * Simple utility method that can create an ExpressionNames map based on a list of attribute names. */ - private static Map expressionNamesFor(String... attributeNames) { - return Arrays.stream(attributeNames) - .collect(Collectors.toMap(EnhancedClientUtils::keyRef, Function.identity())); - } + private static Map expressionNamesFor(String attributeNames) { + if (attributeNames.contains(NESTED_OBJECT_UPDATE)) { + return Arrays.stream(PATTERN.split(attributeNames)).distinct() + .collect(Collectors.toMap(EnhancedClientUtils::keyRef, Function.identity())); + } + return Collections.singletonMap(keyRef(attributeNames), attributeNames); + } } \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/IgnoreNullsMode.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/IgnoreNullsMode.java new file mode 100644 index 000000000000..a960fc794b1a --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/IgnoreNullsMode.java @@ -0,0 +1,37 @@ +/* + * 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 software.amazon.awssdk.annotations.SdkPublicApi; + +/** + *

+ * The SCALAR_ONLY mode supports updates to scalar attributes to any level (top level, first nested level, second nested level, + * etc.) when the user wants to update scalar attributes by providing only the delta of changes to be updated. This mode + * does not support updates to maps and is expected to throw a 4xx DynamoDB exception if done so. + *

+ * In the MAPS_ONLY mode, creation of new map/bean structures through update statements are supported, i.e. setting + * null/non-existent maps to non-null values. If users try to update scalar attributes in this mode, it will overwrite + * existing values in the table. + *

+ * The DEFAULT mode disables any special handling around null values in the update query expression + */ +@SdkPublicApi +public enum IgnoreNullsMode { + SCALAR_ONLY, + MAPS_ONLY, + DEFAULT +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java index 7fec8372ba2e..4f163992f6e8 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java @@ -40,12 +40,14 @@ public class TransactUpdateItemEnhancedRequest { private final T item; private final Boolean ignoreNulls; + private final IgnoreNullsMode ignoreNullsMode; private final Expression conditionExpression; private final String returnValuesOnConditionCheckFailure; private TransactUpdateItemEnhancedRequest(Builder builder) { this.item = builder.item; this.ignoreNulls = builder.ignoreNulls; + this.ignoreNullsMode = builder.ignoreNullsMode; this.conditionExpression = builder.conditionExpression; this.returnValuesOnConditionCheckFailure = builder.returnValuesOnConditionCheckFailure; } @@ -67,6 +69,7 @@ public static Builder builder(Class itemClass) { public Builder toBuilder() { return new Builder().item(item) .ignoreNulls(ignoreNulls) + .ignoreNullsMode(ignoreNullsMode) .conditionExpression(conditionExpression) .returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure); } @@ -80,11 +83,20 @@ public T item() { /** * Returns if the update operation should ignore attributes with null values, or false if it has not been set. + * This is deprecated in favour of ignoreNullsMode() */ + @Deprecated public Boolean ignoreNulls() { return ignoreNulls; } + /** + * Returns the mode of update to be performed + */ + public IgnoreNullsMode ignoreNullsMode() { + return ignoreNullsMode; + } + /** * Returns the condition {@link Expression} set on this request object, or null if it doesn't exist. */ @@ -161,6 +173,7 @@ public int hashCode() { public static final class Builder { private T item; private Boolean ignoreNulls; + private IgnoreNullsMode ignoreNullsMode; private Expression conditionExpression; private String returnValuesOnConditionCheckFailure; @@ -178,11 +191,17 @@ private Builder() { * @param ignoreNulls the boolean value * @return a builder of this type */ + @Deprecated public Builder ignoreNulls(Boolean ignoreNulls) { this.ignoreNulls = ignoreNulls; return this; } + public Builder ignoreNullsMode(IgnoreNullsMode ignoreNullsMode) { + this.ignoreNullsMode = ignoreNullsMode; + return this; + } + /** * Defines a logical expression on an item's attribute values which, if evaluating to true, * will allow the update operation to succeed. If evaluating to false, the operation will not succeed. diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java index 80f07679c738..f7e714c7a690 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java @@ -47,6 +47,7 @@ public final class UpdateItemEnhancedRequest { private final T item; private final Boolean ignoreNulls; + private final IgnoreNullsMode ignoreNullsMode; private final Expression conditionExpression; private final String returnValues; private final String returnConsumedCapacity; @@ -58,6 +59,7 @@ private UpdateItemEnhancedRequest(Builder builder) { this.item = builder.item; this.ignoreNulls = builder.ignoreNulls; this.conditionExpression = builder.conditionExpression; + this.ignoreNullsMode = builder.ignoreNullsMode; this.returnValues = builder.returnValues; this.returnConsumedCapacity = builder.returnConsumedCapacity; this.returnItemCollectionMetrics = builder.returnItemCollectionMetrics; @@ -81,6 +83,7 @@ public static Builder builder(Class itemClass) { public Builder toBuilder() { return new Builder().item(item) .ignoreNulls(ignoreNulls) + .ignoreNullsMode(ignoreNullsMode) .conditionExpression(conditionExpression) .returnValues(returnValues) .returnConsumedCapacity(returnConsumedCapacity) @@ -97,11 +100,20 @@ public T item() { /** * Returns if the update operation should ignore attributes with null values, or false if it has not been set. + * This is deprecated in favour of ignoreNullsMode() */ + @Deprecated public Boolean ignoreNulls() { return ignoreNulls; } + /** + * Returns the mode of update to be performed + */ + public IgnoreNullsMode ignoreNullsMode() { + return ignoreNullsMode; + } + /** * Returns the condition {@link Expression} set on this request object, or null if it doesn't exist. */ @@ -225,6 +237,7 @@ public int hashCode() { public static final class Builder { private T item; private Boolean ignoreNulls; + private IgnoreNullsMode ignoreNullsMode; private Expression conditionExpression; private String returnValues; private String returnConsumedCapacity; @@ -244,11 +257,17 @@ private Builder() { * @param ignoreNulls the boolean value * @return a builder of this type */ + @Deprecated public Builder ignoreNulls(Boolean ignoreNulls) { this.ignoreNulls = ignoreNulls; return this; } + public Builder ignoreNullsMode(IgnoreNullsMode ignoreNullsMode) { + this.ignoreNullsMode = ignoreNullsMode; + return this; + } + /** * Defines a logical expression on an item's attribute values which, if evaluating to true, * will allow the update operation to succeed. If evaluating to false, the operation will not succeed. diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java index fdbe05fb87ee..196d38282277 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java @@ -1,8 +1,10 @@ package software.amazon.awssdk.enhanced.dynamodb.functionaltests; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.Instant; +import java.util.Collections; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.After; @@ -12,17 +14,29 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.CompositeRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FlattenRecord; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordWithUpdateBehavior; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithUpdateBehaviors; import software.amazon.awssdk.enhanced.dynamodb.internal.client.ExtensionResolver; +import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DynamoDbException; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; public class UpdateBehaviorTest extends LocalDynamoDbSyncTestBase { private static final Instant INSTANT_1 = Instant.parse("2020-05-03T10:00:00Z"); private static final Instant INSTANT_2 = Instant.parse("2020-05-03T10:05:00Z"); private static final Instant FAR_FUTURE_INSTANT = Instant.parse("9999-05-03T10:05:00Z"); + private static final String TEST_BEHAVIOUR_ATTRIBUTE = "testBehaviourAttribute"; + private static final String TEST_ATTRIBUTE = "testAttribute"; private static final TableSchema TABLE_SCHEMA = TableSchema.fromClass(RecordWithUpdateBehaviors.class); + + private static final TableSchema TABLE_SCHEMA_FLATTEN_RECORD = + TableSchema.fromClass(FlattenRecord.class); private final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()).extensions( @@ -32,6 +46,9 @@ public class UpdateBehaviorTest extends LocalDynamoDbSyncTestBase { private final DynamoDbTable mappedTable = enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + + private final DynamoDbTable flattenedMappedTable = + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA_FLATTEN_RECORD); @Before public void createTable() { @@ -145,6 +162,401 @@ public void updateBehaviors_transactWriteItems_secondUpdate() { assertThat(persistedRecord.getCreatedAutoUpdateOn()).isEqualTo(firstUpdatedRecord.getCreatedAutoUpdateOn()); } + @Test + public void when_updatingNestedObjectWithSingleLevel_existingInformationIsPreserved_scalar_only_update() { + + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord); + + mappedTable.putItem(record); + + NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); + long updatedNestedCounter = 10L; + updatedNestedRecord.setNestedCounter(updatedNestedCounter); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setNestedRecord(updatedNestedRecord); + + mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, + TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + } + + @Test + public void when_updatingNestedObjectWithSingleLevel_default_mode_update_newMapCreated() { + + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord); + + mappedTable.putItem(record); + + NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); + long updatedNestedCounter = 10L; + updatedNestedRecord.setNestedCounter(updatedNestedCounter); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setNestedRecord(updatedNestedRecord); + + mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.DEFAULT)); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null); + } + + @Test + public void when_updatingNestedObjectWithSingleLevel_with_no_mode_update_newMapCreated() { + + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord); + + mappedTable.putItem(record); + + NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); + long updatedNestedCounter = 10L; + updatedNestedRecord.setNestedCounter(updatedNestedCounter); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setNestedRecord(updatedNestedRecord); + + mappedTable.updateItem(r -> r.item(update_record)); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null); + } + + @Test + public void when_updatingNestedObjectToEmptyWithSingleLevel_existingInformationIsPreserved_scalar_only_update() { + + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); + nestedRecord.setAttribute(TEST_ATTRIBUTE); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord); + + mappedTable.putItem(record); + + NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setNestedRecord(updatedNestedRecord); + + mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + assertThat(persistedRecord.getNestedRecord()).isNull(); + } + + private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long counter) { + NestedRecordWithUpdateBehavior nestedRecordWithDefaults = new NestedRecordWithUpdateBehavior(); + nestedRecordWithDefaults.setId(id); + nestedRecordWithDefaults.setNestedCounter(counter); + nestedRecordWithDefaults.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE); + nestedRecordWithDefaults.setNestedTimeAttribute(INSTANT_1); + + return nestedRecordWithDefaults; + } + + private void verifyMultipleLevelNestingTargetedUpdateBehavior(NestedRecordWithUpdateBehavior nestedRecord, + long updatedOuterNestedCounter, + long updatedInnerNestedCounter, + String test_behav_attribute, + Instant expected_time) { + assertThat(nestedRecord).isNotNull(); + assertThat(nestedRecord.getNestedRecord()).isNotNull(); + + assertThat(nestedRecord.getNestedCounter()).isEqualTo(updatedOuterNestedCounter); + assertThat(nestedRecord.getNestedRecord()).isNotNull(); + assertThat(nestedRecord.getNestedRecord().getNestedCounter()).isEqualTo(updatedInnerNestedCounter); + assertThat(nestedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo( + test_behav_attribute); + assertThat(nestedRecord.getNestedRecord().getNestedTimeAttribute()).isEqualTo(expected_time); + } + + private void verifySingleLevelNestingTargetedUpdateBehavior(NestedRecordWithUpdateBehavior nestedRecord, + long updatedNestedCounter, String expected_behav_attr, + Instant expected_time) { + assertThat(nestedRecord).isNotNull(); + assertThat(nestedRecord.getNestedCounter()).isEqualTo(updatedNestedCounter); + assertThat(nestedRecord.getNestedUpdateBehaviorAttribute()).isEqualTo(expected_behav_attr); + assertThat(nestedRecord.getNestedTimeAttribute()).isEqualTo(expected_time); + } + + @Test + public void when_updatingNestedObjectWithMultipleLevels_inScalarOnlyMode_existingInformationIsPreserved() { + + NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L); + + NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L); + nestedRecord2.setNestedRecord(nestedRecord1); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord2); + + mappedTable.putItem(record); + + NestedRecordWithUpdateBehavior updatedNestedRecord2 = new NestedRecordWithUpdateBehavior(); + long innerNestedCounter = 100L; + updatedNestedRecord2.setNestedCounter(innerNestedCounter); + + NestedRecordWithUpdateBehavior updatedNestedRecord1 = new NestedRecordWithUpdateBehavior(); + updatedNestedRecord1.setNestedRecord(updatedNestedRecord2); + long outerNestedCounter = 200L; + updatedNestedRecord1.setNestedCounter(outerNestedCounter); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setNestedRecord(updatedNestedRecord1); + + mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, + innerNestedCounter, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + } + + @Test + public void when_updatingNestedObjectWithMultipleLevels_inMapsOnlyMode_existingInformationIsPreserved() { + + NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L); + + NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord2); + + mappedTable.putItem(record); + + NestedRecordWithUpdateBehavior updatedNestedRecord1 = new NestedRecordWithUpdateBehavior(); + updatedNestedRecord1.setNestedRecord(nestedRecord1); + long outerNestedCounter = 200L; + updatedNestedRecord1.setNestedCounter(outerNestedCounter); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setNestedRecord(updatedNestedRecord1); + + mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY)); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, + 50L, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + } + + @Test + public void when_updatingNestedObjectWithMultipleLevels_default_mode_existingInformationIsErased() { + + NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L); + + NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L); + nestedRecord2.setNestedRecord(nestedRecord1); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord2); + + mappedTable.putItem(record); + + NestedRecordWithUpdateBehavior updatedNestedRecord2 = new NestedRecordWithUpdateBehavior(); + long innerNestedCounter = 100L; + updatedNestedRecord2.setNestedCounter(innerNestedCounter); + + NestedRecordWithUpdateBehavior updatedNestedRecord1 = new NestedRecordWithUpdateBehavior(); + updatedNestedRecord1.setNestedRecord(updatedNestedRecord2); + long outerNestedCounter = 200L; + updatedNestedRecord1.setNestedCounter(outerNestedCounter); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setNestedRecord(updatedNestedRecord1); + + mappedTable.updateItem(r -> r.item(update_record)); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, innerNestedCounter, null, + null); + } + + @Test + public void when_updatingNestedNonScalarObject_scalar_only_update_throwsDynamoDBException() { + + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); + nestedRecord.setAttribute(TEST_ATTRIBUTE); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + + mappedTable.putItem(record); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setKey("abc"); + update_record.setNestedRecord(nestedRecord); + + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY))) + .isInstanceOf(DynamoDbException.class); + } + + @Test + public void when_updatingNestedMap_mapsOnlyMode_newMapIsCreatedAndStored() { + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + + mappedTable.putItem(record); + + RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); + update_record.setId("id123"); + update_record.setVersion(1L); + update_record.setKey("abc"); + + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); + nestedRecord.setAttribute(TEST_ATTRIBUTE); + update_record.setNestedRecord(nestedRecord); + + RecordWithUpdateBehaviors persistedRecord = + mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY)); + + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), 5L, TEST_BEHAVIOUR_ATTRIBUTE, + INSTANT_1); + assertThat(persistedRecord.getNestedRecord().getAttribute()).isEqualTo(TEST_ATTRIBUTE); + } + + @Test + public void when_emptyNestedRecordIsSet_emptyMapIsStoredInTable() { + String key = "id123"; + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId(key); + record.setNestedRecord(new NestedRecordWithUpdateBehavior()); + + mappedTable.updateItem(r -> r.item(record)); + + GetItemResponse getItemResponse = getDynamoDbClient().getItem(GetItemRequest.builder() + .key(Collections.singletonMap("id", + AttributeValue.fromS(key))) + .tableName(getConcreteTableName("table-name")) + .build()); + + assertThat(getItemResponse.item().get("nestedRecord")).isNotNull(); + assertThat(getItemResponse.item().get("nestedRecord").toString()).isEqualTo("AttributeValue(M={nestedTimeAttribute" + + "=AttributeValue(NUL=true), " + + "nestedRecord=AttributeValue(NUL=true), " + + "attribute=AttributeValue(NUL=true), " + + "id=AttributeValue(NUL=true), " + + "nestedUpdateBehaviorAttribute=AttributeValue" + + "(NUL=true), nestedCounter=AttributeValue" + + "(NUL=true), nestedVersionedAttribute" + + "=AttributeValue(NUL=true)})"); + } + + + @Test + public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformationIsPreserved_scalar_only_update() { + + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id123", 10L); + + CompositeRecord compositeRecord = new CompositeRecord(); + compositeRecord.setNestedRecord(nestedRecord); + + FlattenRecord flattenRecord = new FlattenRecord(); + flattenRecord.setCompositeRecord(compositeRecord); + flattenRecord.setId("id456"); + + flattenedMappedTable.putItem(r -> r.item(flattenRecord)); + + NestedRecordWithUpdateBehavior updateNestedRecord = new NestedRecordWithUpdateBehavior(); + updateNestedRecord.setNestedCounter(100L); + + CompositeRecord updateCompositeRecord = new CompositeRecord(); + updateCompositeRecord.setNestedRecord(updateNestedRecord); + + FlattenRecord updatedFlattenRecord = new FlattenRecord(); + updatedFlattenRecord.setId("id456"); + updatedFlattenRecord.setCompositeRecord(updateCompositeRecord); + + FlattenRecord persistedFlattenedRecord = + flattenedMappedTable.updateItem(r -> r.item(updatedFlattenRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + assertThat(persistedFlattenedRecord.getCompositeRecord()).isNotNull(); + verifySingleLevelNestingTargetedUpdateBehavior(persistedFlattenedRecord.getCompositeRecord().getNestedRecord(), 100L, + TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + } + + + + @Test + public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformationIsPreserved_scalar_only_update() { + + NestedRecordWithUpdateBehavior outerNestedRecord = createNestedWithDefaults("id123", 10L); + NestedRecordWithUpdateBehavior innerNestedRecord = createNestedWithDefaults("id456", 5L); + outerNestedRecord.setNestedRecord(innerNestedRecord); + + CompositeRecord compositeRecord = new CompositeRecord(); + compositeRecord.setNestedRecord(outerNestedRecord); + + FlattenRecord flattenRecord = new FlattenRecord(); + flattenRecord.setCompositeRecord(compositeRecord); + flattenRecord.setId("id789"); + + flattenedMappedTable.putItem(r -> r.item(flattenRecord)); + + NestedRecordWithUpdateBehavior updateOuterNestedRecord = new NestedRecordWithUpdateBehavior(); + updateOuterNestedRecord.setNestedCounter(100L); + + NestedRecordWithUpdateBehavior updateInnerNestedRecord = new NestedRecordWithUpdateBehavior(); + updateInnerNestedRecord.setNestedCounter(50L); + + updateOuterNestedRecord.setNestedRecord(updateInnerNestedRecord); + + CompositeRecord updateCompositeRecord = new CompositeRecord(); + updateCompositeRecord.setNestedRecord(updateOuterNestedRecord); + + FlattenRecord updateFlattenRecord = new FlattenRecord(); + updateFlattenRecord.setCompositeRecord(updateCompositeRecord); + updateFlattenRecord.setId("id789"); + + FlattenRecord persistedFlattenedRecord = + flattenedMappedTable.updateItem(r -> r.item(updateFlattenRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + assertThat(persistedFlattenedRecord.getCompositeRecord()).isNotNull(); + verifyMultipleLevelNestingTargetedUpdateBehavior(persistedFlattenedRecord.getCompositeRecord().getNestedRecord(), 100L, + 50L, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedCounter()).isEqualTo(100L); + assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedRecord().getNestedCounter()).isEqualTo(50L); + } + /** * Currently, nested records are not updated through extensions. */ diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/CompositeRecord.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/CompositeRecord.java new file mode 100644 index 000000000000..ad9ac6405a58 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/CompositeRecord.java @@ -0,0 +1,49 @@ +/* + * 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.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class CompositeRecord { + private NestedRecordWithUpdateBehavior nestedRecord; + + public void setNestedRecord(NestedRecordWithUpdateBehavior nestedRecord) { + this.nestedRecord = nestedRecord; + } + + public NestedRecordWithUpdateBehavior getNestedRecord() { + return nestedRecord; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CompositeRecord that = (CompositeRecord) o; + return Objects.equals(that, this); + } + + @Override + public int hashCode() { + return Objects.hash(nestedRecord); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FlattenRecord.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FlattenRecord.java new file mode 100644 index 000000000000..8506fdf5468f --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FlattenRecord.java @@ -0,0 +1,51 @@ +/* + * 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 software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; + +@DynamoDbBean +public class FlattenRecord { + private String id; + private String flattenBehaviourAttribute; + private CompositeRecord compositeRecord; + + @DynamoDbPartitionKey + public String getId() { + return this.id; + } + public void setId(String id) { + this.id = id; + } + + public String getFlattenBehaviourAttribute() { + return flattenBehaviourAttribute; + } + public void setFlattenBehaviourAttribute(String flattenBehaviourAttribute) { + this.flattenBehaviourAttribute = flattenBehaviourAttribute; + } + + @DynamoDbFlatten + public CompositeRecord getCompositeRecord() { + return compositeRecord; + } + public void setCompositeRecord(CompositeRecord compositeRecord) { + this.compositeRecord = compositeRecord; + } +} + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java index 9e31533d97bd..883a89813c1a 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java @@ -32,6 +32,8 @@ public class NestedRecordWithUpdateBehavior { private Long nestedVersionedAttribute; private Instant nestedTimeAttribute; private Long nestedCounter; + private NestedRecordWithUpdateBehavior nestedRecord; + private String attribute; @DynamoDbPartitionKey public String getId() { @@ -77,4 +79,20 @@ public Long getNestedCounter() { public void setNestedCounter(Long nestedCounter) { this.nestedCounter = nestedCounter; } + + public NestedRecordWithUpdateBehavior getNestedRecord() { + return nestedRecord; + } + + public void setNestedRecord(NestedRecordWithUpdateBehavior nestedRecord) { + this.nestedRecord = nestedRecord; + } + + public String getAttribute() { + return attribute; + } + + public void setAttribute(String attribute) { + this.attribute = attribute; + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java index 9b2921b74464..8bd874fee002 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java @@ -39,6 +39,7 @@ public class RecordWithUpdateBehaviors { private Instant lastAutoUpdatedOnMillis; private Instant formattedLastAutoUpdatedOn; private NestedRecordWithUpdateBehavior nestedRecord; + private String key; @DynamoDbPartitionKey public String getId() { @@ -49,6 +50,15 @@ public void setId(String id) { this.id = id; } + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + @DynamoDbUpdateBehavior(WRITE_IF_NOT_EXISTS) @DynamoDbAttribute("created-on") // Forces a test on attribute name cleaning public Instant getCreatedOn() {