diff --git a/.changes/next-release/feature-AWSDynamoDBEnhancedClient-faf3991.json b/.changes/next-release/feature-AWSDynamoDBEnhancedClient-faf3991.json new file mode 100644 index 000000000000..c735d40e8f35 --- /dev/null +++ b/.changes/next-release/feature-AWSDynamoDBEnhancedClient-faf3991.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS DynamoDB Enhanced Client", + "contributor": "", + "description": "Added support for `@DynamoDBAutoGeneratedUUID` to facilitate the automatic updating of DynamoDB attributes with random UUID." +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedUuidExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedUuidExtension.java new file mode 100644 index 000000000000..d92db8c60bbd --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedUuidExtension.java @@ -0,0 +1,163 @@ +/* + * 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.extensions; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.Validate; + + +/** + * This extension facilitates the automatic generation of a unique UUID (Universally Unique Identifier) for a specified attribute + * every time a new record is written to the database. The generated UUID is obtained using the + * {@link java.util.UUID#randomUUID()} method. + *

+ * This extension is not loaded by default when you instantiate a + * {@link software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient}. Therefore, you need to specify it in a custom + * extension when creating the enhanced client. + *

+ * Example to add AutoGeneratedUuidExtension along with default extensions is + * {@snippet : + * DynamoDbEnhancedClient.builder().extensions(Stream.concat(ExtensionResolver.defaultExtensions().stream(), + * Stream.of(AutoGeneratedUuidExtension.create())).collect(Collectors.toList())).build(); + *} + *

+ *

+ * Example to just add AutoGeneratedUuidExtension without default extensions is + * {@snippet : + * DynamoDbEnhancedClient.builder().extensions(AutoGeneratedUuidExtension.create()).build(); + *} + *

+ *

+ * To utilize the auto-generated UUID feature, first, create a field in your model that will store the UUID for the attribute. + * This class field must be of type {@link java.lang.String}, and you need to tag it as the autoGeneratedUuidAttribute. If you are + * using the {@link software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema}, then you should use the + * {@link software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedUuid} annotation. If you are using + * the {@link software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema}, then you should use the + * {@link + * software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension.AttributeTags#autoGeneratedUuidAttribute()} + * static attribute tag. + *

+ *

+ * Every time a new record is successfully put into the database, the specified attribute will be automatically populated with a + * unique UUID generated using {@link java.util.UUID#randomUUID()}. If the UUID needs to be created only for `putItem` and should + * not be generated for an `updateItem`, then + * {@link software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior#WRITE_IF_NOT_EXISTS} must be along with + * {@link DynamoDbUpdateBehavior} + * + *

+ */ +@SdkPublicApi +@ThreadSafe +public final class AutoGeneratedUuidExtension implements DynamoDbEnhancedClientExtension { + private static final String CUSTOM_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension:AutoGeneratedUuidAttribute"; + private static final AutoGeneratedUuidAttribute AUTO_GENERATED_UUID_ATTRIBUTE = new AutoGeneratedUuidAttribute(); + + private AutoGeneratedUuidExtension() { + } + + /** + * @return an Instance of {@link AutoGeneratedUuidExtension} + */ + public static AutoGeneratedUuidExtension create() { + return new AutoGeneratedUuidExtension(); + } + + /** + * Modifies the WriteModification UUID string with the attribute updated with the extension. + * + * @param context The {@link DynamoDbExtensionContext.BeforeWrite} context containing the state of the execution. + * @return WriteModification String updated with attribute updated with Extension. + */ + @Override + public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + + + Collection customMetadataObject = context.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) + .orElse(null); + + if (customMetadataObject == null) { + return WriteModification.builder().build(); + } + + Map itemToTransform = new HashMap<>(context.items()); + customMetadataObject.forEach(key -> insertUuidInItemToTransform(itemToTransform, key)); + return WriteModification.builder() + .transformedItem(Collections.unmodifiableMap(itemToTransform)) + .build(); + } + + private void insertUuidInItemToTransform(Map itemToTransform, + String key) { + itemToTransform.put(key, AttributeValue.builder().s(UUID.randomUUID().toString()).build()); + } + + public static final class AttributeTags { + + private AttributeTags() { + } + + /** + * Tags which indicate that the given attribute is supported wih Auto Generated UUID Record Extension. + * + * @return Tag name for AutoGenerated UUID Records + */ + public static StaticAttributeTag autoGeneratedUuidAttribute() { + return AUTO_GENERATED_UUID_ATTRIBUTE; + } + } + + private static class AutoGeneratedUuidAttribute implements StaticAttributeTag { + + @Override + public void validateType(String attributeName, EnhancedType type, + AttributeValueType attributeValueType) { + + Validate.notNull(type, "type is null"); + Validate.notNull(type.rawClass(), "rawClass is null"); + Validate.notNull(attributeValueType, "attributeValueType is null"); + + if (!type.rawClass().equals(String.class)) { + throw new IllegalArgumentException(String.format( + "Attribute '%s' of Class type %s is not a suitable Java Class type to be used as a Auto Generated " + + "Uuid attribute. Only String Class type is supported.", attributeName, type.rawClass())); + } + } + + @Override + public Consumer modifyMetadata(String attributeName, + AttributeValueType attributeValueType) { + return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, Collections.singleton(attributeName)) + .markAttributeAsKey(attributeName, attributeValueType); + } + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedUuid.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedUuid.java new file mode 100644 index 000000000000..6df85903c20a --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedUuid.java @@ -0,0 +1,36 @@ +/* + * 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.extensions.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.UUID; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.AutoGeneratedUuidTag; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.BeanTableSchemaAttributeTag; + +/** + * Denotes this attribute as recording the auto generated UUID string for the record. Every time a record with this + * attribute is written to the database it will update the attribute with a {@link UUID#randomUUID} string. + */ +@SdkPublicApi +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@BeanTableSchemaAttributeTag(AutoGeneratedUuidTag.class) +public @interface DynamoDbAutoGeneratedUuid { +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/AutoGeneratedUuidTag.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/AutoGeneratedUuidTag.java new file mode 100644 index 000000000000..17872e71954b --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/AutoGeneratedUuidTag.java @@ -0,0 +1,33 @@ +/* + * 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.extensions; + +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedUuid; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; + +@SdkInternalApi +public final class AutoGeneratedUuidTag { + + private AutoGeneratedUuidTag() { + } + + public static StaticAttributeTag attributeTagFor(DynamoDbAutoGeneratedUuid annotation) { + return AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute(); + } + +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedUuidExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedUuidExtensionTest.java new file mode 100644 index 000000000000..cc69f503d50f --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedUuidExtensionTest.java @@ -0,0 +1,222 @@ +/* + * 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.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.OperationContext; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.OperationName; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class AutoGeneratedUuidExtensionTest { + + private static final String UUID_REGEX = + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + + private static final Pattern UUID_PATTERN = Pattern.compile(UUID_REGEX); + + private static final String RECORD_ID = "id123"; + + private static final String TABLE_NAME = "table-name"; + private static final OperationContext PRIMARY_CONTEXT = + DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + private final AutoGeneratedUuidExtension atomicCounterExtension = AutoGeneratedUuidExtension.create(); + + + private static final StaticTableSchema ITEM_WITH_UUID_MAPPER = + StaticTableSchema.builder(ItemWithUuid.class) + .newItemSupplier(ItemWithUuid::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithUuid::getId) + .setter(ItemWithUuid::setId) + .addTag(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("uuidAttribute") + .getter(ItemWithUuid::getUuidAttribute) + .setter(ItemWithUuid::setUuidAttribute) + .addTag(AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute()) + ) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(ItemWithUuid::getSimpleString) + .setter(ItemWithUuid::setSimpleString)) + .build(); + + @Test + public void beforeWrite_updateItemOperation_hasUuidInItem_doesNotCreateUpdateExpressionAndFilters() { + ItemWithUuid SimpleItem = new ItemWithUuid(); + SimpleItem.setId(RECORD_ID); + String uuidAttribute = String.valueOf(UUID.randomUUID()); + SimpleItem.setUuidAttribute(uuidAttribute); + + Map items = ITEM_WITH_UUID_MAPPER.itemToMap(SimpleItem, true); + assertThat(items).hasSize(2); + + WriteModification result = + atomicCounterExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(ITEM_WITH_UUID_MAPPER.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + + Map transformedItem = result.transformedItem(); + assertThat(transformedItem).isNotNull().hasSize(2); + assertThat(transformedItem).containsEntry("id", AttributeValue.fromS(RECORD_ID)); + isValidUuid(transformedItem.get("uuidAttribute").s()); + assertThat(result.updateExpression()).isNull(); + + } + + @Test + public void beforeWrite_updateItemOperation_hasNoUuidInItem_doesNotCreatesUpdateExpressionAndFilters() { + ItemWithUuid SimpleItem = new ItemWithUuid(); + SimpleItem.setId(RECORD_ID); + + Map items = ITEM_WITH_UUID_MAPPER.itemToMap(SimpleItem, true); + assertThat(items).hasSize(1); + + WriteModification result = + atomicCounterExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(ITEM_WITH_UUID_MAPPER.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + + Map transformedItem = result.transformedItem(); + assertThat(transformedItem).isNotNull().hasSize(2); + assertThat(transformedItem).containsEntry("id", AttributeValue.fromS(RECORD_ID)); + isValidUuid(transformedItem.get("uuidAttribute").s()); + assertThat(result.updateExpression()).isNull(); + } + + @Test + public void beforeWrite_updateItemOperation_UuidNotPresent_newUuidCreated() { + ItemWithUuid item = new ItemWithUuid(); + item.setId(RECORD_ID); + + Map items = ITEM_WITH_UUID_MAPPER.itemToMap(item, true); + assertThat(items).hasSize(1); + + WriteModification result = + atomicCounterExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(ITEM_WITH_UUID_MAPPER.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + assertThat(result.transformedItem()).isNotNull(); + assertThat(result.updateExpression()).isNull(); + assertThat(result.transformedItem()).hasSize(2); + assertThat(isValidUuid(result.transformedItem().get("uuidAttribute").s())).isTrue(); + } + + @Test + void IllegalArgumentException_for_AutogeneratedUuid_withNonStringType() { + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> StaticTableSchema.builder(ItemWithUuid.class) + .newItemSupplier(ItemWithUuid::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithUuid::getId) + .setter(ItemWithUuid::setId) + .addTag(primaryPartitionKey())) + .addAttribute(Integer.class, a -> a.name("intAttribute") + .getter(ItemWithUuid::getIntAttribute) + .setter(ItemWithUuid::setIntAttribute) + .addTag(AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute()) + ) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(ItemWithUuid::getSimpleString) + .setter(ItemWithUuid::setSimpleString)) + .build()) + + .withMessage("Attribute 'intAttribute' of Class type class java.lang.Integer is not a suitable Java Class type" + + " to be used as a Auto Generated Uuid attribute. Only String Class type is supported."); + } + + public static boolean isValidUuid(String uuid) { + return UUID_PATTERN.matcher(uuid).matches(); + } + + private static class ItemWithUuid { + + private String id; + private String uuidAttribute; + private String simpleString; + private Integer intAttribute; + + public Integer getIntAttribute() { + return intAttribute; + } + + public void setIntAttribute(Integer intAttribute) { + this.intAttribute = intAttribute; + } + + public ItemWithUuid() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUuidAttribute() { + return uuidAttribute; + } + + public void setUuidAttribute(String uuidAttribute) { + this.uuidAttribute = uuidAttribute; + } + + public String getSimpleString() { + return simpleString; + } + + public void setSimpleString(String simpleString) { + this.simpleString = simpleString; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ItemWithUuid that = (ItemWithUuid) o; + return Objects.equals(id, that.id) && Objects.equals(uuidAttribute, that.uuidAttribute) && Objects.equals(simpleString, that.simpleString); + } + + @Override + public int hashCode() { + return Objects.hash(id, uuidAttribute, simpleString); + } + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedUuidRecordTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedUuidRecordTest.java new file mode 100644 index 000000000000..12ca834b0f8c --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedUuidRecordTest.java @@ -0,0 +1,488 @@ +/* + * 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 java.util.stream.Collectors.toList; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.updateBehavior; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.IntStream; +import org.assertj.core.api.Assertions; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +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.OperationContext; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedUuid; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; +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; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; +import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +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.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; +import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; + +@RunWith(Parameterized.class) +public class AutoGeneratedUuidRecordTest extends LocalDynamoDbSyncTestBase{ + + private static final String UUID_REGEX = + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + + private static final Pattern UUID_PATTERN = Pattern.compile(UUID_REGEX); + + public static void assertValidUuid(String uuid) { + Assertions.assertThat(UUID_PATTERN.matcher(uuid).matches()).isTrue(); + } + + private static final String TABLE_NAME = "table-name"; + private static final OperationContext PRIMARY_CONTEXT = + DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + + public AutoGeneratedUuidRecordTest(String testName, TableSchema recordTableSchema) { + this.mappedTable = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedUuidExtension.create()) + .build().table(getConcreteTableName("table-name"), + recordTableSchema); + this.testCaseName = testName; + } + + private static final TableSchema FLATTENED_TABLE_SCHEMA = + StaticTableSchema.builder(FlattenedRecord.class) + .newItemSupplier(FlattenedRecord::new) + .addAttribute(String.class, a -> a.name("generated") + .getter(FlattenedRecord::getGenerated) + .setter(FlattenedRecord::generated) + .tags(autoGeneratedUuidAttribute())) + .build(); + + 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::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attribute") + .getter(Record::getAttribute) + .setter(Record::attribute)) + .addAttribute(String.class, a -> a.name("lastUpdatedUuid") + .getter(Record::getLastUpdatedUuid) + .setter(Record::lastUpdatedUuid) + .tags(autoGeneratedUuidAttribute())) + .addAttribute(String.class, a -> a.name("createdUuid") + .getter(Record::getCreatedUuid) + .setter(Record::createdUuid) + .tags(autoGeneratedUuidAttribute(), + updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .flatten(FLATTENED_TABLE_SCHEMA, Record::getFlattenedRecord, Record::flattenedRecord) + .build(); + + + private final List> fakeItems = + IntStream.range(0, 4) + .mapToObj($ -> createUniqueFakeItem()) + .map(fakeItem -> TABLE_SCHEMA.itemToMap(fakeItem, true)) + .collect(toList()); + private DynamoDbTable mappedTable; + + private final String concreteTableName; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + { + concreteTableName = getConcreteTableName("table-name"); + } + + private String testCaseName; + + + @Parameters(name = "{index}; {0}") + public static Collection data() { + return Arrays.asList(new Object[][] { + { + "StaticTableSchema Schema assigned", TABLE_SCHEMA + }, + { + "Bean Schema assigned", + TableSchema.fromBean(Record.class) + } + }); + } + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName("table-name")) + .build()); + } + + + @Test + public void putNewRecordSetsInitialAutoGeneratedUuid() { + Record item = new Record().id("id").attribute("one"); + mappedTable.putItem(r -> r.item(item)); + Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); + // All UUID generated are unique + Assertions.assertThat(result.getCreatedUuid()).isNotEqualTo(result.lastUpdatedUuid); + Assertions.assertThat(result.getLastUpdatedUuid()).isNotEqualTo(result.flattenedRecord.getGenerated()); + + // Al uuid generated match the UUID pattern + assertRecordHasValidUuid(result); + + } + + private static void assertRecordHasValidUuid(Record result) { + assertValidUuid(result.getCreatedUuid()); + assertValidUuid(result.getLastUpdatedUuid()); + assertValidUuid(result.getFlattenedRecord().getGenerated()); + } + + @Test + public void putItemFollowedByUpdates() { + + mappedTable.putItem(r -> r.item(new Record().id("id").attribute("newItem"))); + Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); + + String createdUuidAfterPut = result.getCreatedUuid(); + String lastUpdatedUuiAfterPut = result.getLastUpdatedUuid(); + String flattenedRecordAfterPut = result.getFlattenedRecord().getGenerated(); + + assertRecordHasValidUuid(result); + + // All UUID generated are unique + Assertions.assertThat(result.getCreatedUuid()).isNotEqualTo(result.lastUpdatedUuid); + Assertions.assertThat(result.getLastUpdatedUuid()).isNotEqualTo(result.flattenedRecord.getGenerated()); + + // UPDATE + mappedTable.updateItem(r -> r.item(new Record().id("id").attribute("UpdatedItem"))); + + Record afterUpdate = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); + assertRecordHasValidUuid(afterUpdate); + + // All UUID generated are unique + Assertions.assertThat(afterUpdate.getCreatedUuid()).isNotEqualTo(afterUpdate.lastUpdatedUuid); + Assertions.assertThat(afterUpdate.getLastUpdatedUuid()).isNotEqualTo(afterUpdate.flattenedRecord.getGenerated()); + + // UpdateBehavior.WRITE_IF_NOT_EXISTS , the old UUID is not changed + Assertions.assertThat(afterUpdate.getCreatedUuid()).isEqualTo(createdUuidAfterPut); + + // UpdateBehavior.WRITE_ALWAYS + Assertions.assertThat(afterUpdate.getLastUpdatedUuid()).isNotEqualTo(lastUpdatedUuiAfterPut); + Assertions.assertThat(afterUpdate.getFlattenedRecord().getGenerated()).isNotEqualTo(flattenedRecordAfterPut); + Assertions.assertThat(afterUpdate.getAttribute()).isEqualTo("UpdatedItem"); + Assertions.assertThat(afterUpdate.getId()).isEqualTo("id"); + } + + @Test + public void putExistingRecordWithConditionExpressions() { + mappedTable.putItem(r -> r.item(new Record().id("newId").attribute("one"))); + Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("newId"))); + assertRecordHasValidUuid(result); + String createdUuidAfterPut = result.getCreatedUuid(); + String lastUpdatedUuiAfterPut = result.getLastUpdatedUuid(); + String flattenedRecordAfterPut = result.getFlattenedRecord().getGenerated(); + Expression conditionExpression = Expression.builder() + .expression("#k = :v OR #k = :v1") + .putExpressionName("#k", "attribute") + .putExpressionValue(":v", stringValue("one")) + .putExpressionValue(":v1", stringValue("wrong2")) + .build(); + + mappedTable.putItem(PutItemEnhancedRequest.builder(Record.class) + .item(new Record().id("newId").attribute("conditionalUpdate")) + .conditionExpression(conditionExpression) + .build()); + + Record afterUpdate = mappedTable.getItem(r -> r.key(k -> k.partitionValue("newId"))); + + // UpdateBehavior.WRITE_IF_NOT_EXISTS , this gets changed because this is a put + Assertions.assertThat(afterUpdate.getCreatedUuid()).isNotEqualTo(createdUuidAfterPut); + + // UpdateBehavior.WRITE_ALWAYS + Assertions.assertThat(afterUpdate.getLastUpdatedUuid()).isNotEqualTo(lastUpdatedUuiAfterPut); + Assertions.assertThat(afterUpdate.getFlattenedRecord().getGenerated()).isNotEqualTo(flattenedRecordAfterPut); + Assertions.assertThat(afterUpdate.getAttribute()).isEqualTo("conditionalUpdate"); + Assertions.assertThat(afterUpdate.getId()).isEqualTo("newId"); + } + + @Test + public void updateExistingRecordWithConditionExpressions() { + mappedTable.putItem(r -> r.item(new Record().id("id").attribute("one"))); + Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); + assertRecordHasValidUuid(result); + String createdUuidAfterPut = result.getCreatedUuid(); + String lastUpdatedUuiAfterPut = result.getLastUpdatedUuid(); + String flattenedRecordAfterPut = result.getFlattenedRecord().getGenerated(); + Expression conditionExpression = Expression.builder() + .expression("#k = :v OR #k = :v1") + .putExpressionName("#k", "attribute") + .putExpressionValue(":v", stringValue("one")) + .putExpressionValue(":v1", stringValue("wrong2")) + .build(); + + mappedTable.updateItem(r -> r.item(new Record().id("id").attribute("conditionalUpdate")) + .conditionExpression(conditionExpression)); + Record afterUpdate = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); + // UpdateBehavior.WRITE_IF_NOT_EXISTS , this gets changed because this is a put + Assertions.assertThat(afterUpdate.getCreatedUuid()).isEqualTo(createdUuidAfterPut); + // UpdateBehavior.WRITE_ALWAYS + Assertions.assertThat(afterUpdate.getLastUpdatedUuid()).isNotEqualTo(lastUpdatedUuiAfterPut); + Assertions.assertThat(afterUpdate.getFlattenedRecord().getGenerated()).isNotEqualTo(flattenedRecordAfterPut); + Assertions.assertThat(afterUpdate.getAttribute()).isEqualTo("conditionalUpdate"); + Assertions.assertThat(afterUpdate.getId()).isEqualTo("id"); + } + + @Test + public void putItemConditionTestFailure() { + mappedTable.putItem(r -> r.item(new Record().id("id").attribute("one"))); + Expression conditionExpression = Expression.builder() + .expression("#k = :v OR #k = :v1") + .putExpressionName("#k", "attribute") + .putExpressionValue(":v", stringValue("wrong1")) + .putExpressionValue(":v1", stringValue("wrong2")) + .build(); + Assertions.assertThatExceptionOfType(ConditionalCheckFailedException.class) + .isThrownBy(() -> mappedTable.putItem(PutItemEnhancedRequest.builder(Record.class) + .item(new Record().id("id").attribute("one")) + .conditionExpression(conditionExpression) + .build())) + .withMessageContaining("The conditional request failed"); + } + + @Test + public void updateItemConditionTestFailure() { + Record updated = mappedTable.updateItem(r -> r.item(new Record().id("id").attribute("one"))); + assertRecordHasValidUuid(updated); + Expression conditionExpression = Expression.builder() + .expression("#k = :v OR #k = :v1") + .putExpressionName("#k", "attribute") + .putExpressionValue(":v", stringValue("wrong1")) + .putExpressionValue(":v1", stringValue("wrong2")) + .build(); + Assertions.assertThatExceptionOfType(ConditionalCheckFailedException.class) + .isThrownBy(() -> mappedTable.updateItem(r -> r.item(new Record().id("id").attribute("conditionalUpdate")) + .conditionExpression(conditionExpression))) + .withMessageContaining("The conditional request failed"); + + } + + private GetItemResponse getItemAsStoredFromDDB() { + Map key = new HashMap<>(); + key.put("id", AttributeValue.builder().s("id").build()); + return getDynamoDbClient().getItem(GetItemRequest + .builder().tableName(concreteTableName) + .key(key) + .consistentRead(true).build()); + } + + public static Record createUniqueFakeItem() { + Record record = new Record(); + record.setId(UUID.randomUUID().toString()); + return record; + } + + @DynamoDbBean + public static class Record { + public Record() { + } + private String id; + private String attribute; + private String createdUuid; + private String lastUpdatedUuid; + + + private FlattenedRecord flattenedRecord; + + @DynamoDbPartitionKey + public String getId() { + return this.id; + } + public void setId(String id) { + this.id = id; + } + + public String getAttribute() { + return attribute; + } + + public void setAttribute(String attribute) { + this.attribute = attribute; + } + + public Record attribute(String attribute) { + this.attribute = attribute; + return this; + } + + @DynamoDbAutoGeneratedUuid + public String getLastUpdatedUuid() { + return lastUpdatedUuid; + } + + public void setLastUpdatedUuid(String lastUpdatedUuid) { + this.lastUpdatedUuid = lastUpdatedUuid; + } + + public Record lastUpdatedUuid(String lastUpdatedUuid) { + this.lastUpdatedUuid = lastUpdatedUuid; + return this; + } + + @DynamoDbAutoGeneratedUuid + @DynamoDbUpdateBehavior(value = UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getCreatedUuid() { + return createdUuid; + } + + public void setCreatedUuid(String createdUuid) { + this.createdUuid = createdUuid; + } + + public Record createdUuid(String createdUuid) { + this.createdUuid = createdUuid; + return this; + } + + @DynamoDbFlatten + public FlattenedRecord getFlattenedRecord() { + return flattenedRecord; + } + + public void setFlattenedRecord(FlattenedRecord flattenedRecord) { + this.flattenedRecord = flattenedRecord; + } + + public Record flattenedRecord(FlattenedRecord flattenedRecord) { + this.flattenedRecord = flattenedRecord; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Record record = (Record) o; + return Objects.equals(id, record.id) && + Objects.equals(attribute, record.attribute) && + Objects.equals(lastUpdatedUuid, record.lastUpdatedUuid) && + Objects.equals(createdUuid, record.createdUuid) && + Objects.equals(flattenedRecord, record.flattenedRecord); + } + + @Override + public int hashCode() { + return Objects.hash(id, attribute, lastUpdatedUuid, createdUuid, flattenedRecord); + } + + @Override + public String toString() { + return "Record{" + + "id='" + id + '\'' + + ", attribute='" + attribute + '\'' + + ", createdUuid=" + createdUuid + + ", lastUpdatedUuid=" + lastUpdatedUuid + + ", flattenedRecord=" + flattenedRecord + + '}'; + } + + public Record id(String id) { + this.id = id; + return this; + } + } + + @DynamoDbBean + public static class FlattenedRecord { + private String generated; + + @DynamoDbAutoGeneratedUuid + public String getGenerated() { + return generated; + } + + public void setGenerated(String generated) { + this.generated = generated; + } + + public FlattenedRecord generated(String generated) { + this.generated = generated; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FlattenedRecord that = (FlattenedRecord) o; + return Objects.equals(generated, that.generated); + } + + @Override + public int hashCode() { + return Objects.hash(generated); + } + + @Override + public String toString() { + return "FlattenedRecord{" + + "generated=" + generated + + '}'; + } + } +}