From 7cec4a2e6be008f424dba515154e551f07b84f15 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Thu, 6 Nov 2025 09:41:11 +0200 Subject: [PATCH] Added support for DynamoDbAutoGeneratedKey annotation --- ...-AmazonDynamoDBEnhancedClient-cbcc2bb.json | 6 + .../extensions/AutoGeneratedKeyExtension.java | 219 +++++++++ .../AutoGeneratedUuidExtension.java | 30 ++ .../annotations/DynamoDbAutoGeneratedKey.java | 77 ++++ .../extensions/AutoGeneratedKeyTag.java | 33 ++ .../enhanced/dynamodb/UuidTestUtils.java | 33 ++ .../AutoGeneratedKeyExtensionTest.java | 434 ++++++++++++++++++ .../AutoGeneratedUuidExtensionTest.java | 39 ++ .../ConflictingAnnotationsTest.java | 173 +++++++ .../AutoGeneratedKeyRecordTest.java | 410 +++++++++++++++++ .../AutoGeneratedUuidRecordTest.java | 60 ++- 11 files changed, 1509 insertions(+), 5 deletions(-) create mode 100644 .changes/next-release/feature-AmazonDynamoDBEnhancedClient-cbcc2bb.json create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtension.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedKey.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/AutoGeneratedKeyTag.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/UuidTestUtils.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtensionTest.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ConflictingAnnotationsTest.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedKeyRecordTest.java diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-cbcc2bb.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-cbcc2bb.json new file mode 100644 index 000000000000..b8c7700af796 --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-cbcc2bb.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Added the support for DynamoDbAutoGeneratedKey annotation" +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtension.java new file mode 100644 index 000000000000..0898bb7e57d1 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtension.java @@ -0,0 +1,219 @@ +/* + * 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.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +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.IndexMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.StringUtils; +import software.amazon.awssdk.utils.Validate; + +/** + * Generates a random UUID (via {@link java.util.UUID#randomUUID()}) for any attribute tagged with + * {@code @DynamoDbAutoGeneratedKey} when that attribute is missing or empty on a write (put/update). + *

+ * Key Difference from @DynamoDbAutoGeneratedUuid: This extension only generates UUIDs when the + * attribute value is null or empty, preserving existing values. In contrast, {@code @DynamoDbAutoGeneratedUuid} always generates + * new UUIDs regardless of existing values. + *

+ * Conflict Detection: This extension cannot be used together with {@code @DynamoDbAutoGeneratedUuid} on the same + * attribute. If both annotations are applied to the same field, an {@link IllegalArgumentException} will be thrown at runtime to + * prevent unpredictable behavior based on extension load order. + *

+ * The annotation may be placed only on key attributes: + *

+ * + *

Validation: The extension enforces this at runtime during {@link #beforeWrite} by comparing the + * annotated attributes against the table's known key attributes. If an annotated attribute + * is not a PK/SK or an GSI/LSI, an {@link IllegalArgumentException} is thrown.

+ * + *

UpdateBehavior Limitations: {@code @DynamoDbUpdateBehavior} has no effect on primary keys due to + * DynamoDB's UpdateItem API requirements. It only affects secondary index keys.

+ */ +@SdkPublicApi +@ThreadSafe +public final class AutoGeneratedKeyExtension implements DynamoDbEnhancedClientExtension { + + /** + * Custom metadata key under which we store the set of annotated attribute names. + */ + private static final String CUSTOM_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension:AutoGeneratedKeyAttribute"; + + /** + * Metadata key used by AutoGeneratedUuidExtension to detect conflicts. + */ + private static final String UUID_EXTENSION_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension:AutoGeneratedUuidAttribute"; + + private static final AutoGeneratedKeyAttribute AUTO_GENERATED_KEY_ATTRIBUTE = new AutoGeneratedKeyAttribute(); + + private AutoGeneratedKeyExtension() { + } + + public static Builder builder() { + return new Builder(); + } + + /** + * If this table has attributes tagged for auto-generation, insert a UUID value into the outgoing item for any such attribute + * that is currently missing/empty. Unlike {@code @DynamoDbAutoGeneratedUuid}, this preserves existing values. + *

+ * Also validates that the annotation is only used on PK/SK/GSI/LSI key attributes and that there are no conflicts with + * + * @DynamoDbAutoGeneratedUuid. + */ + @Override + public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + Collection taggedAttributes = context.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) + .orElse(null); + + if (taggedAttributes == null || taggedAttributes.isEmpty()) { + return WriteModification.builder().build(); + } + + // Check for conflicts with @DynamoDbAutoGeneratedUuid + Collection uuidTaggedAttributes = context.tableMetadata() + .customMetadataObject(UUID_EXTENSION_METADATA_KEY, Collection.class) + .orElse(Collections.emptyList()); + + taggedAttributes.stream() + .filter(uuidTaggedAttributes::contains) + .findFirst() + .ifPresent(attribute -> { + throw new IllegalArgumentException( + "Attribute '" + attribute + "' cannot have both @DynamoDbAutoGeneratedKey and " + + "@DynamoDbAutoGeneratedUuid annotations. These annotations have conflicting behaviors " + + "and cannot be used together on the same attribute."); + }); + + TableMetadata meta = context.tableMetadata(); + Set allowedKeys = new HashSet<>(); + + // ensure every @DynamoDbAutoGeneratedKey attribute is a PK/SK or GSI/LSI. If not, throw IllegalArgumentException + allowedKeys.add(meta.primaryPartitionKey()); + meta.primarySortKey().ifPresent(allowedKeys::add); + + for (IndexMetadata idx : meta.indices()) { + String indexName = idx.name(); + allowedKeys.add(meta.indexPartitionKey(indexName)); + meta.indexSortKey(indexName).ifPresent(allowedKeys::add); + } + + taggedAttributes.stream() + .filter(attr -> !allowedKeys.contains(attr)) + .findFirst() + .ifPresent(attr -> { + throw new IllegalArgumentException( + "@DynamoDbAutoGeneratedKey can only be applied to key attributes: " + + "primary partition key, primary sort key, or GSI/LSI partition/sort keys." + + "Invalid placement on attribute: " + attr); + }); + + // Generate UUIDs for missing/empty annotated attributes + Map itemToTransform = new HashMap<>(context.items()); + taggedAttributes.forEach(attr -> insertUuidIfMissing(itemToTransform, attr)); + + return WriteModification.builder() + .transformedItem(Collections.unmodifiableMap(itemToTransform)) + .build(); + } + + private void insertUuidIfMissing(Map itemToTransform, String key) { + AttributeValue existing = itemToTransform.get(key); + if (Objects.isNull(existing) || StringUtils.isBlank(existing.s())) { + itemToTransform.put(key, AttributeValue.builder().s(UUID.randomUUID().toString()).build()); + } + } + + /** + * Static helpers used by the {@code @BeanTableSchemaAttributeTag}-based annotation tag. + */ + public static final class AttributeTags { + private AttributeTags() { + } + + /** + * @return a {@link StaticAttributeTag} that marks the attribute for auto-generated key behavior. + */ + public static StaticAttributeTag autoGeneratedKeyAttribute() { + return AUTO_GENERATED_KEY_ATTRIBUTE; + } + } + + /** + * Stateless builder. + */ + public static final class Builder { + private Builder() { + } + + public AutoGeneratedKeyExtension build() { + return new AutoGeneratedKeyExtension(); + } + } + + /** + * Validates Java type and records the tagged attribute into table metadata so {@link #beforeWrite} can find it at runtime. + */ + private static final class AutoGeneratedKeyAttribute 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 " + + "Key attribute. Only String Class type is supported.", attributeName, type.rawClass())); + } + } + + @Override + public Consumer modifyMetadata(String attributeName, + AttributeValueType attributeValueType) { + // Record the names of the attributes annotated with @DynamoDbAutoGeneratedKey for later lookup in beforeWrite() + return metadata -> metadata.addCustomMetadataObject( + CUSTOM_METADATA_KEY, Collections.singleton(attributeName)); + } + } +} 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 index d92db8c60bbd..5a4712a177d3 100644 --- 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 @@ -39,6 +39,14 @@ * every time a new record is written to the database. The generated UUID is obtained using the * {@link java.util.UUID#randomUUID()} method. *

+ * Key Difference from @DynamoDbAutoGeneratedKey: This extension always generates new UUIDs on every write, + * regardless of existing values. In contrast, {@code @DynamoDbAutoGeneratedKey} only generates UUIDs when the + * attribute value is null or empty, preserving existing values. + *

+ * Conflict Detection: This extension cannot be used together with {@code @DynamoDbAutoGeneratedKey} on the same + * attribute. If both annotations are applied to the same field, an {@link IllegalArgumentException} will be thrown + * at runtime to prevent unpredictable behavior. + *

* 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. @@ -79,6 +87,13 @@ public final class AutoGeneratedUuidExtension implements DynamoDbEnhancedClientExtension { private static final String CUSTOM_METADATA_KEY = "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension:AutoGeneratedUuidAttribute"; + + /** + * Metadata key used by AutoGeneratedKeyExtension to detect conflicts. + */ + private static final String KEY_EXTENSION_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension:AutoGeneratedKeyAttribute"; + private static final AutoGeneratedUuidAttribute AUTO_GENERATED_UUID_ATTRIBUTE = new AutoGeneratedUuidAttribute(); private AutoGeneratedUuidExtension() { @@ -109,6 +124,21 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex return WriteModification.builder().build(); } + // Check for conflicts with @DynamoDbAutoGeneratedKey + Collection keyTaggedAttributes = context.tableMetadata() + .customMetadataObject(KEY_EXTENSION_METADATA_KEY, Collection.class) + .orElse(Collections.emptyList()); + + customMetadataObject.stream() + .filter(keyTaggedAttributes::contains) + .findFirst() + .ifPresent(attribute -> { + throw new IllegalArgumentException( + "Attribute '" + attribute + "' cannot have both @DynamoDbAutoGeneratedKey and " + + "@DynamoDbAutoGeneratedUuid annotations. These annotations have conflicting behaviors " + + "and cannot be used together on the same attribute."); + }); + Map itemToTransform = new HashMap<>(context.items()); customMetadataObject.forEach(key -> insertUuidInItemToTransform(itemToTransform, key)); return WriteModification.builder() diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedKey.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedKey.java new file mode 100644 index 000000000000..3fe249b5eb0f --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedKey.java @@ -0,0 +1,77 @@ +/* + * 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.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.AutoGeneratedKeyTag; +import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.BeanTableSchemaAttributeTag; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; + +/** + * Annotation that marks a key attribute to be automatically populated with a random UUID if no value is provided during a write + * operation (put or update). This annotation is intended to work specifically with key attributes. + * + *

This annotation is designed for use with the V2 {@link software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema}. + * It is registered via {@link BeanTableSchemaAttributeTag} and its behavior is implemented by + * {@link software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension}.

+ * + *

Where this annotation can be applied

+ * This annotation is only valid on attributes that serve as keys: + * + * If applied to any other attribute, the {@code AutoGeneratedKeyExtension} will throw an + * {@link IllegalArgumentException} at runtime. + * + *

How values are generated

+ * + * + *

Behavior with UpdateBehavior

+ *

Primary Keys: {@link DynamoDbUpdateBehavior} has no effect on primary partition keys + * or primary sort keys. Primary keys are required for UpdateItem operations in DynamoDB and cannot be conditionally + * updated. UUIDs will be generated whenever the primary key attribute is missing or empty, regardless of any + * {@code UpdateBehavior} setting.

+ * + *

Secondary Index Keys: For GSI/LSI keys, {@link DynamoDbUpdateBehavior} can be used: + *

+ * + *

Type restriction

+ * This annotation is only valid on attributes of type {@link String}. + */ +@SdkPublicApi +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target( {ElementType.METHOD, ElementType.FIELD}) +@BeanTableSchemaAttributeTag(AutoGeneratedKeyTag.class) +public @interface DynamoDbAutoGeneratedKey { +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/AutoGeneratedKeyTag.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/AutoGeneratedKeyTag.java new file mode 100644 index 000000000000..815e15bccd7a --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/AutoGeneratedKeyTag.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.AutoGeneratedKeyExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; + +@SdkInternalApi +public final class AutoGeneratedKeyTag { + + private AutoGeneratedKeyTag() { + } + + public static StaticAttributeTag attributeTagFor(DynamoDbAutoGeneratedKey annotation) { + return AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute(); + } + +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/UuidTestUtils.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/UuidTestUtils.java new file mode 100644 index 000000000000..6b4314bc5668 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/UuidTestUtils.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; + +import java.util.UUID; + +public final class UuidTestUtils { + + private UuidTestUtils() { + } + + public static boolean isValidUuid(String uuid) { + try { + UUID.fromString(uuid); + return true; + } catch (Exception e) { + return false; + } + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtensionTest.java new file mode 100644 index 000000000000..ee053f1a16fe --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtensionTest.java @@ -0,0 +1,434 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (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.UuidTestUtils.isValidUuid; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondarySortKey; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +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 AutoGeneratedKeyExtensionTest { + + 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 AutoGeneratedKeyExtension extension = AutoGeneratedKeyExtension.builder().build(); + + /** + * Schema that places @DynamoDbAutoGeneratedKey on GSI key ("keyAttribute") so the validation passes. + */ + private static final StaticTableSchema ITEM_WITH_KEY_SCHEMA = + StaticTableSchema.builder(ItemWithKey.class) + .newItemSupplier(ItemWithKey::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithKey::getId) + .setter(ItemWithKey::setId) + .addTag(primaryPartitionKey()) // PK + .addTag(autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("keyAttribute") + .getter(ItemWithKey::getKeyAttribute) + .setter(ItemWithKey::setKeyAttribute) + .tags( + secondaryPartitionKey("gsi_keys_only"), // GSI + autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(ItemWithKey::getSimpleString) + .setter(ItemWithKey::setSimpleString)) + .build(); + + /** + * Schema that places @DynamoDbAutoGeneratedKey on a NON-KEY attribute to trigger the exception. + */ + private static final StaticTableSchema INVALID_NONKEY_AUTOGEN_SCHEMA = + StaticTableSchema.builder(ItemWithKey.class) + .newItemSupplier(ItemWithKey::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithKey::getId) + .setter(ItemWithKey::setId) + .addTag(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("keyAttribute") + .getter(ItemWithKey::getKeyAttribute) + .setter(ItemWithKey::setKeyAttribute) + // No index tags here — autogen on non-key fails at beforeWrite() + .addTag(autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(ItemWithKey::getSimpleString) + .setter(ItemWithKey::setSimpleString)) + .build(); + + /** + * Schema that places @DynamoDbAutoGeneratedKey on LSI key ("simpleString") so the validation passes. + */ + private static final StaticTableSchema LSI_SK_AUTOGEN_SCHEMA = + StaticTableSchema.builder(ItemWithKey.class) + .newItemSupplier(ItemWithKey::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithKey::getId) + .setter(ItemWithKey::setId) + .addTag(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("keyAttribute") + .getter(ItemWithKey::getKeyAttribute) + .setter(ItemWithKey::setKeyAttribute)) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(ItemWithKey::getSimpleString) + .setter(ItemWithKey::setSimpleString) + .tags( + secondarySortKey("lsi1"), // LSI + autoGeneratedKeyAttribute())) + .build(); + + @Test + public void updateItem_withExistingKey_preservesValueAndDoesNotGenerateNew() { + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); + String preset = UUID.randomUUID().toString(); + item.setKeyAttribute(preset); + + Map items = ITEM_WITH_KEY_SCHEMA.itemToMap(item, true); + assertThat(items).hasSize(2); + + WriteModification result = + extension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(ITEM_WITH_KEY_SCHEMA.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()); + + Map transformed = result.transformedItem(); + assertThat(transformed).isNotNull().hasSize(2); + assertThat(transformed).containsEntry("id", AttributeValue.fromS(RECORD_ID)); + + // Ensures the attribute remains a valid UUID without altering the preset value + assertThat(isValidUuid(transformed.get("keyAttribute").s())).isTrue(); + assertThat(result.updateExpression()).isNull(); + } + + @Test + public void updateItem_withoutKey_generatesNewUuid() { + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); + + Map items = ITEM_WITH_KEY_SCHEMA.itemToMap(item, true); + assertThat(items).hasSize(1); + + WriteModification result = + extension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(ITEM_WITH_KEY_SCHEMA.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()); + + Map transformed = result.transformedItem(); + assertThat(transformed).isNotNull().hasSize(2); + assertThat(transformed).containsEntry("id", AttributeValue.fromS(RECORD_ID)); + assertThat(isValidUuid(transformed.get("keyAttribute").s())).isTrue(); + assertThat(result.updateExpression()).isNull(); + } + + @Test + public void updateItem_withMissingKeyAttribute_insertsGeneratedUuid() { + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); + + Map items = ITEM_WITH_KEY_SCHEMA.itemToMap(item, true); + assertThat(items).hasSize(1); + + WriteModification result = + extension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(ITEM_WITH_KEY_SCHEMA.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("keyAttribute").s())).isTrue(); + } + + @Test + public void nonStringTypeAnnotatedWithAutoGeneratedKey_throwsIllegalArgumentException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> + StaticTableSchema.builder(ItemWithKey.class) + .newItemSupplier(ItemWithKey::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithKey::getId) + .setter(ItemWithKey::setId) + .addTag(primaryPartitionKey())) + .addAttribute(Integer.class, a -> a.name("intAttribute") + .getter(ItemWithKey::getIntAttribute) + .setter(ItemWithKey::setIntAttribute) + .addTag(autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(ItemWithKey::getSimpleString) + .setter(ItemWithKey::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 Key attribute. Only String Class type is supported."); + } + + @Test + public void autoGeneratedKey_onSecondaryPartitionKey_generatesUuid() { + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); // keyAttribute (GSI PK) is missing → should be generated + + Map items = ITEM_WITH_KEY_SCHEMA.itemToMap(item, true); + + WriteModification result = + extension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(ITEM_WITH_KEY_SCHEMA.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()); + + Map transformed = result.transformedItem(); + assertThat(transformed).isNotNull(); + assertThat(isValidUuid(transformed.get("keyAttribute").s())).isTrue(); // generated for GSI PK + } + + @Test + public void autoGeneratedKey_onSecondarySortKey_generatesUuid() { + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); // simpleString (GSI/LSI) is missing → should be generated + + Map items = LSI_SK_AUTOGEN_SCHEMA.itemToMap(item, true); + + WriteModification result = + extension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(LSI_SK_AUTOGEN_SCHEMA.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()); + + Map transformed = result.transformedItem(); + assertThat(transformed).isNotNull(); + assertThat(isValidUuid(transformed.get("simpleString").s())).isTrue(); // generated for index SK + } + + @Test + public void autoGeneratedKey_onNonKey_throwsIllegalArgumentException() { + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); // keyAttribute is annotated but NOT a key in this schema → should fail at beforeWrite() + + Map items = INVALID_NONKEY_AUTOGEN_SCHEMA.itemToMap(item, true); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> + extension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(INVALID_NONKEY_AUTOGEN_SCHEMA.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()) + ) + .withMessageContaining("@DynamoDbAutoGeneratedKey can only be applied to key attributes: " + + "primary partition key, primary sort key, or GSI/LSI partition/sort keys.") + .withMessageContaining("keyAttribute"); + } + + @Test + public void conflictingAnnotations_throwsIllegalArgumentException() { + // Create a schema with both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid on the same attribute + StaticTableSchema conflictingSchema = + StaticTableSchema + .builder(ItemWithKey.class) + .newItemSupplier(ItemWithKey::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithKey::getId) + .setter(ItemWithKey::setId) + .addTag(primaryPartitionKey()) + // Both annotations on the same attribute + .addTag(autoGeneratedKeyAttribute()) + .addTag(autoGeneratedUuidAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(ItemWithKey::getSimpleString) + .setter(ItemWithKey::setSimpleString)) + .build(); + + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); + + Map items = conflictingSchema.itemToMap(item, true); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> + extension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(conflictingSchema.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()) + ) + .withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + + @Test + public void conflictingAnnotations_onSecondaryKey_throwsIllegalArgumentException() { + // Create a schema with both annotations on a GSI key + StaticTableSchema conflictingGsiSchema = + StaticTableSchema + .builder(ItemWithKey.class) + .newItemSupplier(ItemWithKey::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithKey::getId) + .setter(ItemWithKey::setId) + .addTag(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("keyAttribute") + .getter(ItemWithKey::getKeyAttribute) + .setter(ItemWithKey::setKeyAttribute) + .addTag(secondaryPartitionKey("gsi1")) + // Both annotations on the same GSI key + .addTag(autoGeneratedKeyAttribute()) + .addTag(autoGeneratedUuidAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(ItemWithKey::getSimpleString) + .setter(ItemWithKey::setSimpleString)) + .build(); + + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); + + Map items = conflictingGsiSchema.itemToMap(item, true); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> + extension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(conflictingGsiSchema.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build())) + .withMessage("Attribute 'keyAttribute' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid " + + "annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + + @Test + public void conflictDetection_worksRegardlessOfExtensionOrder() { + // Verify that AutoGeneratedKeyExtension detects conflicts even when + // AutoGeneratedUuidExtension metadata is already present + StaticTableSchema conflictingSchema = + StaticTableSchema + .builder(ItemWithKey.class) + .newItemSupplier(ItemWithKey::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithKey::getId) + .setter(ItemWithKey::setId) + .addTag(primaryPartitionKey()) + .addTag(autoGeneratedKeyAttribute()) + .addTag(autoGeneratedUuidAttribute())) + .build(); + + ItemWithKey item = new ItemWithKey(); + item.setId(RECORD_ID); + + Map items = conflictingSchema.itemToMap(item, true); + + // Test that the conflict is detected regardless of which extension runs first + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> + extension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(conflictingSchema.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build())) + .withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid " + + "annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + + private static class ItemWithKey { + + private String id; + private String keyAttribute; + private String simpleString; + private Integer intAttribute; + + ItemWithKey() { + } + + public Integer getIntAttribute() { + return intAttribute; + } + + public void setIntAttribute(Integer intAttribute) { + this.intAttribute = intAttribute; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getKeyAttribute() { + return keyAttribute; + } + + public void setKeyAttribute(String keyAttribute) { + this.keyAttribute = keyAttribute; + } + + 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 instanceof ItemWithKey)) { + return false; + } + ItemWithKey that = (ItemWithKey) o; + return Objects.equals(id, that.id) + && Objects.equals(keyAttribute, that.keyAttribute) + && Objects.equals(simpleString, that.simpleString); + } + + @Override + public int hashCode() { + return Objects.hash(id, keyAttribute, simpleString); + } + } +} 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 index cc69f503d50f..cd92fa5fb063 100644 --- 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 @@ -156,6 +156,45 @@ void IllegalArgumentException_for_AutogeneratedUuid_withNonStringType() { + " to be used as a Auto Generated Uuid attribute. Only String Class type is supported."); } + @Test + public void conflictingAnnotations_throwsIllegalArgumentException() { + // Create a schema with both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid on the same attribute + StaticTableSchema conflictingSchema = + StaticTableSchema.builder(ItemWithUuid.class) + .newItemSupplier(ItemWithUuid::new) + .addAttribute(String.class, + a -> a.name("id") + .getter(ItemWithUuid::getId) + .setter(ItemWithUuid::setId) + .addTag(primaryPartitionKey()) + // Both annotations on the same attribute + .addTag(AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute()) + .addTag(AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute())) + .addAttribute(String.class, + a -> a.name("simpleString") + .getter(ItemWithUuid::getSimpleString) + .setter(ItemWithUuid::setSimpleString)) + .build(); + + ItemWithUuid item = new ItemWithUuid(); + item.setId(RECORD_ID); + + Map items = conflictingSchema.itemToMap(item, true); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> + atomicCounterExtension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(conflictingSchema.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()) + ) + .withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + public static boolean isValidUuid(String uuid) { return UUID_PATTERN.matcher(uuid).matches(); } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ConflictingAnnotationsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ConflictingAnnotationsTest.java new file mode 100644 index 000000000000..c2ed794ee310 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ConflictingAnnotationsTest.java @@ -0,0 +1,173 @@ +/* + * 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.assertThatExceptionOfType; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.util.Map; +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; + +/** + * Tests to verify that conflicting annotations (@DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid) are properly detected + * and throw exceptions regardless of extension load order. + */ +public class ConflictingAnnotationsTest { + + private static final String RECORD_ID = "test-id"; + private static final String TABLE_NAME = "test-table"; + private static final OperationContext PRIMARY_CONTEXT = + DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + private final AutoGeneratedKeyExtension keyExtension = AutoGeneratedKeyExtension.builder().build(); + private final AutoGeneratedUuidExtension uuidExtension = AutoGeneratedUuidExtension.create(); + + /** + * Schema with both annotations on the same attribute to test conflict detection. + */ + private static final StaticTableSchema CONFLICTING_SCHEMA = + StaticTableSchema.builder(TestItem.class) + .newItemSupplier(TestItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(TestItem::getId) + .setter(TestItem::setId) + .addTag(primaryPartitionKey()) + // Both annotations on the same attribute + .addTag(autoGeneratedKeyAttribute()) + .addTag(autoGeneratedUuidAttribute())) + .build(); + + @Test + public void keyExtensionFirst_detectsConflictWithUuidExtension() { + TestItem item = new TestItem(); + item.setId(RECORD_ID); + + Map items = CONFLICTING_SCHEMA.itemToMap(item, true); + + // AutoGeneratedKeyExtension runs first and detects conflict + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> keyExtension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(CONFLICTING_SCHEMA.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()) + ) + .withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + + @Test + public void uuidExtensionFirst_detectsConflictWithKeyExtension() { + TestItem item = new TestItem(); + item.setId(RECORD_ID); + + Map items = CONFLICTING_SCHEMA.itemToMap(item, true); + + // AutoGeneratedUuidExtension runs first and detects conflict + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> uuidExtension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(CONFLICTING_SCHEMA.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()) + ) + .withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + + @Test + public void separateAttributes_noConflict() { + // Schema with annotations on different attributes - should work fine + StaticTableSchema separateSchema = + StaticTableSchema.builder(TestItemSeparate.class) + .newItemSupplier(TestItemSeparate::new) + .addAttribute(String.class, a -> a.name("id") + .getter(TestItemSeparate::getId) + .setter(TestItemSeparate::setId) + .addTag(primaryPartitionKey()) + .addTag(autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("uuidField") + .getter(TestItemSeparate::getUuidField) + .setter(TestItemSeparate::setUuidField) + .addTag(autoGeneratedUuidAttribute())) + .build(); + + TestItemSeparate item = new TestItemSeparate(); + + Map items = separateSchema.itemToMap(item, true); + + // Both extensions should work without conflict when on different attributes + keyExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(separateSchema.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()); + + uuidExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(separateSchema.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()); + } + + public static class TestItem { + private String id; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + } + + public static class TestItemSeparate { + private String id; + private String uuidField; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUuidField() { + return uuidField; + } + + public void setUuidField(String uuidField) { + this.uuidField = uuidField; + } + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedKeyRecordTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedKeyRecordTest.java new file mode 100644 index 000000000000..7416fd92e5d2 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedKeyRecordTest.java @@ -0,0 +1,410 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. + * Licensed under the Apache License, Version 2.0. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.AutoGeneratedUuidRecordTest.assertValidUuid; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primarySortKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondarySortKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.updateBehavior; + +import java.util.Arrays; +import java.util.Collection; +import org.assertj.core.api.Assertions; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +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.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedKey; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedUuid; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +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.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; + +/** + * Tests for @DynamoDbAutoGeneratedKey annotation functionality. + *

+ * Tests cover: - Basic UUID generation on all 4 key types (primary PK/SK, GSI PK/SK) - UpdateBehavior control (WRITE_ALWAYS vs + * WRITE_IF_NOT_EXISTS) for secondary index keys - Primary key limitations (UpdateBehavior has no effect) - Error handling for + * invalid usage - Integration with other extensions (VersionedRecord) + */ +@RunWith(Parameterized.class) +public class AutoGeneratedKeyRecordTest extends LocalDynamoDbSyncTestBase { + + private final DynamoDbTable mappedTable; + + public AutoGeneratedKeyRecordTest(String testName, TableSchema schema) { + this.mappedTable = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedKeyExtension.builder().build()) + .build() + .table(getConcreteTableName("AutoGenKey-table"), schema); + } + + @Parameters(name = "{index}: {0}") + public static Collection data() { + return Arrays.asList(new Object[][] { + {"StaticTableSchema", createStaticSchema()}, + {"BeanTableSchema", TableSchema.fromBean(TestRecord.class)} + }); + } + + private static TableSchema createStaticSchema() { + return StaticTableSchema.builder(TestRecord.class) + .newItemSupplier(TestRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(TestRecord::getId) + .setter(TestRecord::setId) + .tags(primaryPartitionKey(), + AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("sortKey") + .getter(TestRecord::getSortKey) + .setter(TestRecord::setSortKey) + .tags(primarySortKey(), + AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("gsiPk") + .getter(TestRecord::getGsiPk) + .setter(TestRecord::setGsiPk) + .tags(secondaryPartitionKey("gsi1"), + AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("gsiSk") + .getter(TestRecord::getGsiSk) + .setter(TestRecord::setGsiSk) + .tags(secondarySortKey("gsi1"), + AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute(), + updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(String.class, a -> a.name("payload") + .getter(TestRecord::getPayload) + .setter(TestRecord::setPayload)) + .build(); + } + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + mappedTable.deleteTable(); + } + + @Test + public void putItem_generatesUuidsForAllFourKeyTypes() { + TestRecord record = new TestRecord(); + // Don't set any keys - they should all be auto-generated + mappedTable.putItem(record); + + TestRecord retrieved = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + // Verify all 4 key types are generated + assertValidUuid(retrieved.getId()); // Primary partition key + assertValidUuid(retrieved.getSortKey()); // Primary sort key + assertValidUuid(retrieved.getGsiPk()); // GSI partition key + assertValidUuid(retrieved.getGsiSk()); // GSI sort key + + // Verify they're all different + assertThat(retrieved.getId()).isNotEqualTo(retrieved.getSortKey()); + assertThat(retrieved.getGsiPk()).isNotEqualTo(retrieved.getGsiSk()); + assertThat(retrieved.getId()).isNotEqualTo(retrieved.getGsiPk()); + } + + @Test + public void updateItem_respectsUpdateBehaviorForSecondaryIndexKeys() { + // Put initial record + TestRecord record = new TestRecord(); + mappedTable.putItem(record); + + TestRecord afterPut = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + String id = afterPut.getId(); + String sortKey = afterPut.getSortKey(); + String originalGsiPk = afterPut.getGsiPk(); // WRITE_ALWAYS (default) → should change + String originalGsiSk = afterPut.getGsiSk(); // WRITE_IF_NOT_EXISTS → should preserve + + // Update record + TestRecord updateRecord = new TestRecord(); + updateRecord.setId(id); + updateRecord.setSortKey(sortKey); + updateRecord.setPayload("updated"); + mappedTable.updateItem(updateRecord); + + TestRecord afterUpdate = mappedTable.getItem(r -> r.key(k -> k.partitionValue(id).sortValue(sortKey))); + assertThat(afterUpdate.getGsiPk()).isNotEqualTo(originalGsiPk); // Regenerated + assertThat(afterUpdate.getGsiSk()).isEqualTo(originalGsiSk); // Preserved + assertEquals("updated", afterUpdate.getPayload()); + } + + @Test + public void nonKeyAttribute_throwsException() { + String tableName = getConcreteTableName("invalid-usage-test"); + DynamoDbEnhancedClient client = createClient(); + + TableSchema schema = StaticTableSchema.builder(InvalidUsageRecord.class) + .newItemSupplier(InvalidUsageRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(InvalidUsageRecord::getId) + .setter(InvalidUsageRecord::setId) + .addTag(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("notAKey") + .getter(InvalidUsageRecord::getNotAKey) + .setter(InvalidUsageRecord::setNotAKey) + .addTag(AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute())) + .build(); + + DynamoDbTable table = client.table(tableName, schema); + + try { + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + InvalidUsageRecord record = new InvalidUsageRecord(); + record.setId("test-id"); + + Assertions.assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> table.putItem(record)) + .withMessageContaining("@DynamoDbAutoGeneratedKey can only be applied to key attributes") + .withMessageContaining("notAKey"); + } finally { + deleteTableByName(tableName); + } + } + + @Test + public void versionedRecord_worksWithAutoGeneratedKeys() { + // NOTE: UpdateBehavior has no effect on primary keys since they cannot be null in DynamoDB + String tableName = getConcreteTableName("versioned-test"); + DynamoDbEnhancedClient client = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedKeyExtension.builder().build(), + VersionedRecordExtension.builder().build()) + .build(); + + DynamoDbTable table = client.table(tableName, TableSchema.fromBean(VersionedRecord.class)); + + try { + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + VersionedRecord record = new VersionedRecord(); + record.setPayload("initial"); + table.putItem(record); + + VersionedRecord retrieved = table.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertValidUuid(retrieved.getId()); + assertEquals("initial", retrieved.getPayload()); + assertThat(retrieved.getVersion()).isEqualTo(1L); + + // Update to test versioning still works + retrieved.setPayload("updated"); + VersionedRecord updated = table.updateItem(retrieved); + assertEquals("updated", updated.getPayload()); + assertThat(updated.getVersion()).isEqualTo(2L); + } finally { + deleteTableByName(tableName); + } + } + + @Test + public void conflictingAnnotations_throwsException() { + String tableName = getConcreteTableName("conflicting-annotations-test"); + DynamoDbEnhancedClient client = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedKeyExtension.builder().build(), + AutoGeneratedUuidExtension.create()) + .build(); + + try { + DynamoDbTable table = + client.table(tableName, TableSchema.fromBean(ConflictingAnnotationsRecord.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + ConflictingAnnotationsRecord record = new ConflictingAnnotationsRecord(); + record.setPayload("test"); + + Assertions + .assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> table.putItem(record)) + .withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid " + + "annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } finally { + deleteTableByName(tableName); + } + } + + + private DynamoDbEnhancedClient createClient() { + return DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedKeyExtension.builder().build()) + .build(); + } + + private void deleteTableByName(String tableName) { + getDynamoDbClient().deleteTable(b -> b.tableName(tableName)); + } + + @DynamoDbBean + public static class TestRecord { + private String id; + private String sortKey; + private String gsiPk; + private String gsiSk; + private String payload; + + // NOTE: UpdateBehavior has no effect on primary keys since they cannot be null in DynamoDB + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + // NOTE: UpdateBehavior has no effect on primary keys since they cannot be null in DynamoDB + @DynamoDbSortKey + @DynamoDbAutoGeneratedKey + public String getSortKey() { + return sortKey; + } + + public void setSortKey(String sortKey) { + this.sortKey = sortKey; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbSecondaryPartitionKey(indexNames = "gsi1") + public String getGsiPk() { + return gsiPk; + } + + public void setGsiPk(String gsiPk) { + this.gsiPk = gsiPk; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbSecondarySortKey(indexNames = "gsi1") + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getGsiSk() { + return gsiSk; + } + + public void setGsiSk(String gsiSk) { + this.gsiSk = gsiSk; + } + + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + } + + public static class InvalidUsageRecord { + private String id; + private String notAKey; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getNotAKey() { + return notAKey; + } + + public void setNotAKey(String notAKey) { + this.notAKey = notAKey; + } + } + + @DynamoDbBean + public static class VersionedRecord { + private String id; + private Long version; + private String payload; + + // NOTE: UpdateBehavior has no effect on primary keys since they cannot be null in DynamoDB + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbVersionAttribute + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } + + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + } + + @DynamoDbBean + public static class ConflictingAnnotationsRecord { + private String id; + private String payload; + + // Both annotations on the same field - should cause an exception + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedKey + @DynamoDbAutoGeneratedUuid + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + } +} \ 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 index e59ea214399b..3bdc7a5eb218 100644 --- 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 @@ -23,7 +23,6 @@ import java.util.Arrays; import java.util.Collection; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -45,7 +44,9 @@ 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.AutoGeneratedKeyExtension; import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedKey; 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; @@ -55,13 +56,9 @@ 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{ @@ -314,6 +311,33 @@ public void updateItemConditionTestFailure() { } + @Test + public void conflictingAnnotations_throwsException() { + String tableName = getConcreteTableName("conflicting-annotations-test"); + DynamoDbEnhancedClient client = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedUuidExtension.create(), AutoGeneratedKeyExtension.builder().build()) + .build(); + + try { + DynamoDbTable table = + client.table(tableName, TableSchema.fromBean(ConflictingAnnotationsRecord.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + ConflictingAnnotationsRecord record = new ConflictingAnnotationsRecord(); + record.setPayload("test"); + + Assertions.assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> table.putItem(record)) + .withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid " + + "annotations. These annotations have conflicting behaviors and cannot be used together " + + "on the same attribute."); + } finally { + getDynamoDbClient().deleteTable(DeleteTableRequest.builder().tableName(tableName).build()); + } + } + public static Record createUniqueFakeItem() { Record record = new Record(); record.setId(UUID.randomUUID().toString()); @@ -476,4 +500,30 @@ public String toString() { '}'; } } + + @DynamoDbBean + public static class ConflictingAnnotationsRecord { + private String id; + private String payload; + + // Both annotations on the same field - should cause an exception + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedUuid + @DynamoDbAutoGeneratedKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + } }