diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-cbcc2bb.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-cbcc2bb.json new file mode 100644 index 000000000000..81c91da75550 --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-cbcc2bb.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Added 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..4c3ec6e5aa27 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtension.java @@ -0,0 +1,254 @@ +/* + * 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 java.util.Collections.newSetFromMap; + +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.concurrent.ConcurrentHashMap; +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 attributes tagged with {@code @DynamoDbAutoGeneratedKey} + * when the attribute value is missing or empty during write operations (put, update, batch write, or transact write). + * + *

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

Conflict Detection: + * {@code @DynamoDbAutoGeneratedKey} and {@code @DynamoDbAutoGeneratedUuid} cannot be applied to the same attribute. If both + * annotations are present, an {@link IllegalArgumentException} is thrown during schema validation. + * + *

Supported Attributes: + * The annotation may only be applied to key attributes: + *

+ * + *

Validation Behavior: + * Annotation conflict detection and key-placement validation are performed once per {@link TableMetadata} + * instance and cached to avoid repeated validation on subsequent writes. + * + *

UpdateBehavior Limitation: + * {@code @DynamoDbUpdateBehavior} does not apply to primary keys due to DynamoDB API constraints. + * It only affects secondary index keys. + */ +@SdkPublicApi +@ThreadSafe +public final class AutoGeneratedKeyExtension implements DynamoDbEnhancedClientExtension { + + /** + * Metadata keys used to record attributes annotated with {@code @DynamoDbAutoGeneratedKey} and + * {@code @DynamoDbAutoGeneratedUuid}. These are used during schema validation to detect annotation conflicts. + */ + private static final String CUSTOM_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension:AutoGeneratedKeyAttribute"; + private static final String UUID_EXTENSION_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension:AutoGeneratedUuidAttribute"; + + private static final StaticAttributeTag AUTO_GENERATED_KEY_ATTRIBUTE = new AutoGeneratedKeyAttribute(); + private static final String AUTOGENERATED_KEY_ANNOTATION = "@DynamoDbAutoGeneratedKey"; + private static final String AUTOGENERATED_UUID_ANNOTATION = "@DynamoDbAutoGeneratedUuid"; + + /** + * Stores the TableMetadata instances that have already been validated by this extension. Uses a ConcurrentHashMap to ensure + * thread-safe access during concurrent write operations. + */ + private final Set validatedSchemas = newSetFromMap(new ConcurrentHashMap<>()); + + /** + * Caches the set of valid key attribute names per TableMetadata instance. Computed once per schema. + */ + private final Map> allowedKeysCache = new ConcurrentHashMap<>(); + + private AutoGeneratedKeyExtension() { + } + + /** + * @return an Instance of {@link AutoGeneratedKeyExtension} + */ + public static AutoGeneratedKeyExtension create() { + return new AutoGeneratedKeyExtension(); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Inserts a UUID for attributes tagged with {@code @DynamoDbAutoGeneratedKey} when the attribute is null or empty, preserving + * existing values. + * + *

Schema-level validation (annotation conflict detection and key-placement checks) + * is executed once per schema instance and cached. + */ + @Override + public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + + + Collection taggedAttributes = context.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) + .orElse(null); + + if (taggedAttributes == null) { + return WriteModification.builder().build(); + } + + TableMetadata tableMetadata = context.tableMetadata(); + if (validatedSchemas.add(tableMetadata)) { + validateNoAutoGeneratedAnnotationConflict(tableMetadata); + validateAutoGeneratedKeyPlacement(tableMetadata, taggedAttributes); + } + + Map itemToTransform = new HashMap<>(context.items()); + taggedAttributes.forEach(attr -> insertUuidIfMissing(itemToTransform, attr)); + + return WriteModification.builder() + .transformedItem(Collections.unmodifiableMap(itemToTransform)) + .build(); + } + + /** + * Validates (once per TableMetadata instance) that @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid are not applied + * to the same attribute. + */ + private static void validateNoAutoGeneratedAnnotationConflict(TableMetadata tableMetadata) { + ExtensionsValidationUtils.validateNoAnnotationConflict( + tableMetadata, + CUSTOM_METADATA_KEY, + UUID_EXTENSION_METADATA_KEY, + AUTOGENERATED_KEY_ANNOTATION, + AUTOGENERATED_UUID_ANNOTATION + ); + } + + /** + * Validates that all attributes tagged with @DynamoDbAutoGeneratedKey are either primary keys or secondary index keys. + **/ + private void validateAutoGeneratedKeyPlacement(TableMetadata tableMetadata, + Collection taggedAttributeNames) { + + Set allowedKeyAttributes = allowedKeysCache.computeIfAbsent(tableMetadata, metadata -> { + + // Add primary keys + Set keyAttributes = new HashSet<>(metadata.primaryKeys()); + + // Add all secondary index keys + metadata.indices().stream().map(IndexMetadata::name).forEach(indexName -> { + keyAttributes.addAll(metadata.indexPartitionKeys(indexName)); + keyAttributes.addAll(metadata.indexSortKeys(indexName)); + }); + + return keyAttributes; + }); + + taggedAttributeNames.stream() + .filter(attrName -> !allowedKeyAttributes.contains(attrName)) + .findFirst() + .ifPresent(invalidAttribute -> { + 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: " + invalidAttribute); + }); + } + + 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) { + return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, Collections.singleton(attributeName)) + .markAttributeAsKey(attributeName, attributeValueType); + } + } +} 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..9c8e55d82a10 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 @@ -15,11 +15,15 @@ package software.amazon.awssdk.enhanced.dynamodb.extensions; +import static java.util.Collections.newSetFromMap; + import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.ThreadSafe; @@ -27,6 +31,7 @@ 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.TableMetadata; 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; @@ -39,6 +44,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. @@ -77,9 +90,25 @@ @SdkPublicApi @ThreadSafe public final class AutoGeneratedUuidExtension implements DynamoDbEnhancedClientExtension { + + /** + * Custom metadata keys under which AutoGeneratedUuidExtension/AutoGeneratedKeyExtension record attributes annotated with + * {@code @DynamoDbAutoGeneratedUuid}/{@code @DynamoDbAutoGeneratedKey}. Used to detect conflicts during schema validation. + */ private static final String CUSTOM_METADATA_KEY = "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension:AutoGeneratedUuidAttribute"; + private static final String AUTOGENERATED_KEY_EXTENSION_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension:AutoGeneratedKeyAttribute"; + private static final AutoGeneratedUuidAttribute AUTO_GENERATED_UUID_ATTRIBUTE = new AutoGeneratedUuidAttribute(); + private static final String AUTOGENERATED_KEY_ANNOTATION = "@DynamoDbAutoGeneratedKey"; + private static final String AUTOGENERATED_UUID_ANNOTATION = "@DynamoDbAutoGeneratedUuid"; + + /** + * Stores the TableMetadata instances that have already been validated by this extension. Uses a ConcurrentHashMap to ensure + * thread-safe access during concurrent write operations. + */ + private final Set validatedSchemas = newSetFromMap(new ConcurrentHashMap<>()); private AutoGeneratedUuidExtension() { } @@ -109,6 +138,9 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex return WriteModification.builder().build(); } + TableMetadata metadata = context.tableMetadata(); + validateNoAnnotationConflict(metadata); + Map itemToTransform = new HashMap<>(context.items()); customMetadataObject.forEach(key -> insertUuidInItemToTransform(itemToTransform, key)); return WriteModification.builder() @@ -116,6 +148,21 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .build(); } + /** + * Validates (once per TableMetadata instance) that @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid are not applied + * to the same attribute. + */ + private void validateNoAnnotationConflict(TableMetadata tableMetadata) { + if (validatedSchemas.add(tableMetadata)) { + ExtensionsValidationUtils.validateNoAnnotationConflict( + tableMetadata, + AUTOGENERATED_KEY_EXTENSION_METADATA_KEY, + CUSTOM_METADATA_KEY, + AUTOGENERATED_KEY_ANNOTATION, + AUTOGENERATED_UUID_ANNOTATION); + } + } + private void insertUuidInItemToTransform(Map itemToTransform, String key) { itemToTransform.put(key, AttributeValue.builder().s(UUID.randomUUID().toString()).build()); diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ExtensionsValidationUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ExtensionsValidationUtils.java new file mode 100644 index 000000000000..4a04286e2ece --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ExtensionsValidationUtils.java @@ -0,0 +1,74 @@ +/* + * 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 software.amazon.awssdk.annotations.SdkProtectedApi; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; + +/** + * Provides shared schema validation utilities for DynamoDB enhanced client extensions. + */ +@SdkProtectedApi +public final class ExtensionsValidationUtils { + + private ExtensionsValidationUtils() { + } + + /** + * Validates that there are no attributes that have both annotations. These annotations have conflicting behaviors and cannot + * be used together on the same attribute. If an attribute is found with both annotations, an IllegalArgumentException is + * thrown with a message indicating the attribute and the conflicting annotations. + * + * @param tableMetadata The metadata of the table to validate. + * @param firstAnnotationMetadataKey The metadata key for the first annotation to check for. + * @param secondAnnotationMetadataKey The metadata key for the second annotation to check for. + * @param firstAnnotationName The name of the first annotation to use in the error message if a conflict is found. + * @param secondAnnotationName The name of the second annotation to use in the error message if a conflict is found. + */ + public static void validateNoAnnotationConflict(TableMetadata tableMetadata, + String firstAnnotationMetadataKey, + String secondAnnotationMetadataKey, + String firstAnnotationName, + String secondAnnotationName) { + + Collection attributesHavingFirstAnnotation = + tableMetadata.customMetadataObject(firstAnnotationMetadataKey, Collection.class).orElse(Collections.emptyList()); + + if (attributesHavingFirstAnnotation.isEmpty()) { + return; + } + + Collection attributesHavingSecondAnnotation = + tableMetadata.customMetadataObject(secondAnnotationMetadataKey, Collection.class).orElse(Collections.emptyList()); + + if (attributesHavingSecondAnnotation.isEmpty()) { + return; + } + + attributesHavingFirstAnnotation + .stream() + .filter(attributesHavingSecondAnnotation::contains) + .findFirst() + .ifPresent(attribute -> { + throw new IllegalArgumentException( + "Attribute '" + attribute + "' cannot have both " + firstAnnotationName + + " and " + secondAnnotationName + " annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + }); + } +} \ No newline at end of file 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..302a72df76c4 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedKey.java @@ -0,0 +1,49 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.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.annotations.BeanTableSchemaAttributeTag; + +/** + * Marks a key attribute to be automatically populated with a UUID when the value is null or empty during a write operation. + * + *

Usage: May only be applied to key attributes: primary partition key, primary sort key, or secondary index + * (GSI/LSI) keys. If used on a non-key attribute, {@code AutoGeneratedKeyExtension} throws an {@link IllegalArgumentException} + * during schema validation. + * + *

Semantics: Generates a UUID using {@link java.util.UUID#randomUUID()} + * only when the attribute is absent. Existing values are preserved. + * + *

Difference from {@code @DynamoDbAutoGeneratedUuid}: + * This annotation is intended for key attributes and generates a value only when missing. {@code @DynamoDbAutoGeneratedUuid} can + * be applied to any attribute and always generates a new UUID on every write. + * + *

Valid only for {@link String} attributes. + */ +@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/extensions/annotations/DynamoDbAutoGeneratedUuid.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedUuid.java index 6df85903c20a..5381cd77227b 100644 --- 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 @@ -19,14 +19,26 @@ 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. + * Marks an attribute to be automatically populated with a new UUID on every write operation. + * + *

Intended Usage: + * This annotation is generic and may be applied to any {@link String} attribute, not only key attributes. + * + *

Generation Semantics:

+ * On every write (put, update, batch write, or transaction write), the attribute is replaced with a new value generated using + * {@link java.util.UUID#randomUUID()}, regardless of any existing value. + * + *

Difference from {@code @DynamoDbAutoGeneratedKey}: + * {@code @DynamoDbAutoGeneratedKey} is intended for key attributes and generates UUID only when the value is absent. + * {@code @DynamoDbAutoGeneratedUuid} annotation always regenerates the UUID on each write.

+ * + *

Type Restriction:

+ * This annotation may only be applied to attributes of type {@link String}. */ @SdkPublicApi @Target(ElementType.METHOD) 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..0720ffa99d8c --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtensionTest.java @@ -0,0 +1,457 @@ +/* + * 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 = "1"; + 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.create(); + + /** + * Schema that places @DynamoDbAutoGeneratedKey on GSI key ("keyAttribute") -> the validation passes. + */ + private static final StaticTableSchema ITEM_WITH_KEY_SCHEMA = + StaticTableSchema.builder(AutogeneratedKeyItem.class) + .newItemSupplier(AutogeneratedKeyItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(AutogeneratedKeyItem::getId) + .setter(AutogeneratedKeyItem::setId) + .addTag(primaryPartitionKey()) + .addTag(autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("keyAttribute") + .getter(AutogeneratedKeyItem::getKeyAttribute) + .setter(AutogeneratedKeyItem::setKeyAttribute) + .tags( + secondaryPartitionKey("gsi_keys_only"), + autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(AutogeneratedKeyItem::getSimpleString) + .setter(AutogeneratedKeyItem::setSimpleString)) + .build(); + + /** + * Schema that places @DynamoDbAutoGeneratedKey on a NON-KEY attribute -> triggers the exception. + */ + private static final StaticTableSchema INVALID_NONKEY_AUTOGEN_SCHEMA = + StaticTableSchema.builder(AutogeneratedKeyItem.class) + .newItemSupplier(AutogeneratedKeyItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(AutogeneratedKeyItem::getId) + .setter(AutogeneratedKeyItem::setId) + .addTag(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("keyAttribute") + .getter(AutogeneratedKeyItem::getKeyAttribute) + .setter(AutogeneratedKeyItem::setKeyAttribute) + // index tags not defined — autogen on non-key fails at beforeWrite() + .addTag(autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(AutogeneratedKeyItem::getSimpleString) + .setter(AutogeneratedKeyItem::setSimpleString)) + .build(); + + /** + * Schema that places @DynamoDbAutoGeneratedKey on LSI key ("simpleString") -> the validation passes. + */ + private static final StaticTableSchema LSI_SK_AUTOGEN_SCHEMA = + StaticTableSchema.builder(AutogeneratedKeyItem.class) + .newItemSupplier(AutogeneratedKeyItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(AutogeneratedKeyItem::getId) + .setter(AutogeneratedKeyItem::setId) + .addTag(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("keyAttribute") + .getter(AutogeneratedKeyItem::getKeyAttribute) + .setter(AutogeneratedKeyItem::setKeyAttribute)) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(AutogeneratedKeyItem::getSimpleString) + .setter(AutogeneratedKeyItem::setSimpleString) + .tags( + secondarySortKey("lsi1"), + autoGeneratedKeyAttribute())) + .build(); + + @Test + public void updateItem_withExistingKey_preservesValueAndDoesNotGenerateNewOne() { + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + 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)); + + assertThat(isValidUuid(transformed.get("keyAttribute").s())).isTrue(); + assertThat(result.updateExpression()).isNull(); + } + + @Test + public void updateItem_withoutExistingKey_generatesNewUuid() { + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + 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() { + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + 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 nonStringAttributeAnnotatedWithAutoGeneratedKey_throwsIllegalArgumentException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> + StaticTableSchema.builder(AutogeneratedKeyItem.class) + .newItemSupplier(AutogeneratedKeyItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(AutogeneratedKeyItem::getId) + .setter(AutogeneratedKeyItem::setId) + .addTag(primaryPartitionKey())) + .addAttribute(Integer.class, a -> a.name("intAttribute") + .getter(AutogeneratedKeyItem::getIntAttribute) + .setter(AutogeneratedKeyItem::setIntAttribute) + .addTag(autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(AutogeneratedKeyItem::getSimpleString) + .setter(AutogeneratedKeyItem::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() { + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + item.setId(RECORD_ID); + + 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(); + } + + @Test + public void autoGeneratedKey_onSecondarySortKey_generatesUuid() { + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + item.setId(RECORD_ID); + + 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(); + } + + @Test + public void autoGeneratedKey_onNonKeyAttribute_throwsIllegalArgumentException() { + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + item.setId(RECORD_ID); + + 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()) + ) + .withMessage("@DynamoDbAutoGeneratedKey can only be applied to key attributes: " + + "primary partition key, primary sort key, or GSI/LSI partition/sort keys. " + + "Invalid placement on attribute: keyAttribute"); + } + + @Test + public void conflictingAnnotations_onSameAttribute_throwsIllegalArgumentException() { + // Create a schema with both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid on the same attribute + StaticTableSchema conflictingSchema = + StaticTableSchema + .builder(AutogeneratedKeyItem.class) + .newItemSupplier(AutogeneratedKeyItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(AutogeneratedKeyItem::getId) + .setter(AutogeneratedKeyItem::setId) + .addTag(primaryPartitionKey()) + // Both annotations on the same attribute + .addTag(autoGeneratedKeyAttribute()) + .addTag(autoGeneratedUuidAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(AutogeneratedKeyItem::getSimpleString) + .setter(AutogeneratedKeyItem::setSimpleString)) + .build(); + + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + 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(AutogeneratedKeyItem.class) + .newItemSupplier(AutogeneratedKeyItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(AutogeneratedKeyItem::getId) + .setter(AutogeneratedKeyItem::setId) + .addTag(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("keyAttribute") + .getter(AutogeneratedKeyItem::getKeyAttribute) + .setter(AutogeneratedKeyItem::setKeyAttribute) + .addTag(secondaryPartitionKey("gsi1")) + // Both annotations on the same GSI key + .addTag(autoGeneratedKeyAttribute()) + .addTag(autoGeneratedUuidAttribute())) + .addAttribute(String.class, a -> a.name("simpleString") + .getter(AutogeneratedKeyItem::getSimpleString) + .setter(AutogeneratedKeyItem::setSimpleString)) + .build(); + + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + 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() { + StaticTableSchema conflictingSchema = + StaticTableSchema + .builder(AutogeneratedKeyItem.class) + .newItemSupplier(AutogeneratedKeyItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(AutogeneratedKeyItem::getId) + .setter(AutogeneratedKeyItem::setId) + .addTag(primaryPartitionKey()) + .addTag(autoGeneratedKeyAttribute()) + .addTag(autoGeneratedUuidAttribute())) + .build(); + + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + 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."); + } + + @Test + public void beforeWrite_noAttributesTaggedWithAutogeneratedKey_returnsEmptyModification() { + StaticTableSchema schemaWithoutTags = + StaticTableSchema.builder(AutogeneratedKeyItem.class) + .newItemSupplier(AutogeneratedKeyItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(AutogeneratedKeyItem::getId) + .setter(AutogeneratedKeyItem::setId) + .addTag(primaryPartitionKey())) + .build(); + + AutogeneratedKeyItem item = new AutogeneratedKeyItem(); + item.setId(RECORD_ID); + Map items = schemaWithoutTags.itemToMap(item, true); + + WriteModification result = extension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(schemaWithoutTags.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()); + + assertThat(result).isEqualTo(WriteModification.builder().build()); + } + + private static class AutogeneratedKeyItem { + + private String id; + private String keyAttribute; + private String simpleString; + private Integer intAttribute; + + AutogeneratedKeyItem() { + } + + 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 AutogeneratedKeyItem)) { + return false; + } + AutogeneratedKeyItem that = (AutogeneratedKeyItem) 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..33da228c5472 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 @@ -65,6 +65,34 @@ public class AutoGeneratedUuidExtensionTest { .setter(ItemWithUuid::setSimpleString)) .build(); + @Test + public void beforeWrite_withNoMetadata_returnsNoWriteModifications() { + StaticTableSchema schemaWithoutUuidAttribute = + StaticTableSchema.builder(ItemWithUuid.class) + .newItemSupplier(ItemWithUuid::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithUuid::getId) + .setter(ItemWithUuid::setId) + .addTag(primaryPartitionKey())) + .build(); + + ItemWithUuid item = new ItemWithUuid(); + item.setId(RECORD_ID); + Map items = schemaWithoutUuidAttribute.itemToMap(item, true); + + WriteModification result = atomicCounterExtension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(schemaWithoutUuidAttribute.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result).isNotNull(); + assertThat(result.transformedItem()).isNull(); + assertThat(result.updateExpression()).isNull(); + assertThat(result.additionalConditionalExpression()).isNull(); + } + @Test public void beforeWrite_updateItemOperation_hasUuidInItem_doesNotCreateUpdateExpressionAndFilters() { ItemWithUuid SimpleItem = new ItemWithUuid(); @@ -152,8 +180,35 @@ void IllegalArgumentException_for_AutogeneratedUuid_withNonStringType() { .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."); + .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."); + } + + @Test + public void beforeWrite_withoutMetadata_returnsEmptyModification() { + StaticTableSchema schemaWithoutMetadata = + StaticTableSchema.builder(ItemWithUuid.class) + .newItemSupplier(ItemWithUuid::new) + .addAttribute(String.class, a -> a.name("id") + .getter(ItemWithUuid::getId) + .setter(ItemWithUuid::setId) + .addTag(primaryPartitionKey())) + .build(); + + ItemWithUuid item = new ItemWithUuid(); + item.setId(RECORD_ID); + Map items = schemaWithoutMetadata.itemToMap(item, true); + + WriteModification result = atomicCounterExtension.beforeWrite( + DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableMetadata(schemaWithoutMetadata.tableMetadata()) + .operationName(OperationName.PUT_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build()); + + assertThat(result).isEqualTo(WriteModification.builder().build()); } public static boolean isValidUuid(String uuid) { diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutogeneratedConflictingAnnotationsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutogeneratedConflictingAnnotationsTest.java new file mode 100644 index 000000000000..933bc9f64f86 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutogeneratedConflictingAnnotationsTest.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 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 AutogeneratedConflictingAnnotationsTest { + + private static final String RECORD_ID = "1"; + private static final String TABLE_NAME = "table-name"; + private static final OperationContext PRIMARY_CONTEXT = + DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + private final AutoGeneratedKeyExtension keyExtension = AutoGeneratedKeyExtension.create(); + 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(RecordWithAutogenerated.class) + .newItemSupplier(RecordWithAutogenerated::new) + .addAttribute(String.class, a -> a.name("autogeneratedKeyField") + .getter(RecordWithAutogenerated::getAutogeneratedKeyField) + .setter(RecordWithAutogenerated::setAutogeneratedKeyField) + .addTag(primaryPartitionKey()) + // Both annotations on the same attribute + .addTag(autoGeneratedKeyAttribute()) + .addTag(autoGeneratedUuidAttribute())) + .build(); + + @Test + public void autogeneratedKeyExtensionFirst_detectsConflictWithUuidExtension() { + RecordWithAutogenerated item = new RecordWithAutogenerated(); + item.setAutogeneratedKeyField(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 'autogeneratedKeyField' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid " + + "annotations. These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + + @Test + public void autogeneratedUuidExtensionFirst_detectsConflictWithKeyExtension() { + RecordWithAutogenerated item = new RecordWithAutogenerated(); + item.setAutogeneratedKeyField(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 'autogeneratedKeyField' cannot have both " + + "@DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + + @Test + public void autogeneratedAnnotations_onSeparateAttributes_doesNotDetectConflict() { + // Schema with annotations on different attributes - does not detect conflict + StaticTableSchema separateSchema = + StaticTableSchema.builder(RecordWithAutogenerated.class) + .newItemSupplier(RecordWithAutogenerated::new) + .addAttribute(String.class, a -> a.name("autogeneratedKeyField") + .getter(RecordWithAutogenerated::getAutogeneratedKeyField) + .setter(RecordWithAutogenerated::setAutogeneratedKeyField) + .addTag(primaryPartitionKey()) + .addTag(autoGeneratedKeyAttribute())) + .addAttribute(String.class, a -> a.name("autogeneratedUuidField") + .getter(RecordWithAutogenerated::getAutogeneratedUuidField) + .setter(RecordWithAutogenerated::setAutogeneratedUuidField) + .addTag(autoGeneratedUuidAttribute())) + .build(); + + RecordWithAutogenerated item = new RecordWithAutogenerated(); + + 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 RecordWithAutogenerated { + private String autogeneratedKeyField; + private String autogeneratedUuidField; + + public String getAutogeneratedKeyField() { + return autogeneratedKeyField; + } + + public void setAutogeneratedKeyField(String autogeneratedKeyField) { + this.autogeneratedKeyField = autogeneratedKeyField; + } + + public String getAutogeneratedUuidField() { + return autogeneratedUuidField; + } + + public void setAutogeneratedUuidField(String autogeneratedUuidField) { + this.autogeneratedUuidField = autogeneratedUuidField; + } + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ExtensionsValidationUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ExtensionsValidationUtilsTest.java new file mode 100644 index 000000000000..bc78b5b45f06 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/ExtensionsValidationUtilsTest.java @@ -0,0 +1,92 @@ +package software.amazon.awssdk.enhanced.dynamodb.extensions; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.ExtensionsValidationUtils.validateNoAnnotationConflict; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; + +@RunWith(MockitoJUnitRunner.class) +public class ExtensionsValidationUtilsTest { + + @Mock + private TableMetadata metadata; + + private static final String AUTOGENERATED_KEY_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension:AutoGeneratedKeyAttribute"; + private static final String AUTOGENERATED_UUID_METADATA_KEY = + "software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension:AutoGeneratedUuidAttribute"; + + private static final String AUTOGENERATED_KEY_ANNOTATION = "@DynamoDbAutoGeneratedKey"; + private static final String AUTOGENERATED_UUID_ANNOTATION = "@DynamoDbAutoGeneratedUuid"; + + @Test + public void validateNoAnnotationConflict_whenAnnotationsOverlap_throwsException() { + when(metadata.customMetadataObject(AUTOGENERATED_KEY_METADATA_KEY, Collection.class)) + .thenReturn(Optional.of(Collections.singleton("sharedAttribute"))); + when(metadata.customMetadataObject(AUTOGENERATED_UUID_METADATA_KEY, Collection.class)) + .thenReturn(Optional.of(Arrays.asList("sharedAttribute", "otherUuidAttribute"))); + + assertThatThrownBy(() -> validateNoAnnotationConflict( + metadata, + AUTOGENERATED_KEY_METADATA_KEY, + AUTOGENERATED_UUID_METADATA_KEY, + AUTOGENERATED_KEY_ANNOTATION, + AUTOGENERATED_UUID_ANNOTATION + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Attribute 'sharedAttribute' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid " + + "annotations. These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } + + @Test + public void validateNoAnnotationConflict_whenNoAnnotatedFields_doesNotThrowException() { + assertThatCode(() -> validateNoAnnotationConflict( + metadata, + AUTOGENERATED_KEY_METADATA_KEY, + AUTOGENERATED_UUID_METADATA_KEY, + AUTOGENERATED_KEY_ANNOTATION, + AUTOGENERATED_UUID_ANNOTATION + )).doesNotThrowAnyException(); + } + + @Test + public void validateNoAnnotationConflict_whenAnnotationsDontOverlap_doesNotThrowException() { + when(metadata.customMetadataObject(AUTOGENERATED_KEY_METADATA_KEY, Collection.class)) + .thenReturn(Optional.of(Collections.singleton("keyAttribute"))); + when(metadata.customMetadataObject(AUTOGENERATED_UUID_METADATA_KEY, Collection.class)) + .thenReturn(Optional.of(Collections.singleton("uuidAttribute"))); + + assertThatCode(() -> validateNoAnnotationConflict( + metadata, + AUTOGENERATED_KEY_METADATA_KEY, + AUTOGENERATED_UUID_METADATA_KEY, + AUTOGENERATED_KEY_ANNOTATION, + AUTOGENERATED_UUID_ANNOTATION + )).doesNotThrowAnyException(); + } + + @Test + public void validateNoAnnotationConflict_whenSecondAnnotationIsNotUsed_doesNotThrow() { + when(metadata.customMetadataObject(AUTOGENERATED_KEY_METADATA_KEY, Collection.class)) + .thenReturn(Optional.empty()); + + assertThatCode(() -> validateNoAnnotationConflict( + metadata, + AUTOGENERATED_KEY_METADATA_KEY, + AUTOGENERATED_UUID_METADATA_KEY, + AUTOGENERATED_KEY_ANNOTATION, + AUTOGENERATED_UUID_ANNOTATION + )).doesNotThrowAnyException(); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java index 3f30fdc8ecdf..e84378201b20 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java @@ -265,9 +265,9 @@ public static Stream customStartAtAndIncrementValues() { @MethodSource("customFailingStartAtAndIncrementValues") public void customStartingValueAndIncrement_shouldThrow(Long startAt, Long incrementBy) { assertThrows(IllegalArgumentException.class, () -> VersionedRecordExtension.builder() - .startAt(startAt) - .incrementBy(incrementBy) - .build()); + .startAt(startAt) + .incrementBy(incrementBy) + .build()); } public static Stream customFailingStartAtAndIncrementValues() { @@ -375,9 +375,9 @@ public void customStartingValueAndIncrementWithAnnotation_worksAsExpected() { @Test public void customAnnotationValuesAndBuilderValues_annotationShouldTakePrecedence() { VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() - .startAt(5L) - .incrementBy(2L) - .build(); + .startAt(5L) + .incrementBy(2L) + .build(); FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); item.setId(UUID.randomUUID().toString()); @@ -516,7 +516,7 @@ public void customIncrementForExistingVersion_worksAsExpected(Long startAt, Long @ParameterizedTest @MethodSource("customIncrementForExistingVersionValues") public void customIncrementForExistingVersion_withImmutableSchema_worksAsExpected(Long startAt, Long incrementBy, - Long existingVersion, String expectedNextVersion) { + Long existingVersion, String expectedNextVersion) { VersionedRecordExtension.Builder recordExtensionBuilder = VersionedRecordExtension.builder(); if (startAt != null) { recordExtensionBuilder.startAt(startAt); 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..2902465297f2 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedKeyRecordTest.java @@ -0,0 +1,537 @@ +/* + * 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 java.util.List; +import java.util.stream.Collectors; +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; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.WriteBatch; + +/** + * 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.create()) + .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(); + 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 unique keys + assertThat(retrieved.getId()).isNotEqualTo(retrieved.getSortKey()); + assertThat(retrieved.getGsiPk()).isNotEqualTo(retrieved.getGsiSk()); + assertThat(retrieved.getId()).isNotEqualTo(retrieved.getGsiPk()); + } + + @Test + public void updateItem_respectsUpdateBehaviorForSecondaryIndexKeys() { + // Put 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 putItem_withAutogeneratedKeySetOnNonKeyAttribute_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)) + .withMessage( + "@DynamoDbAutoGeneratedKey can only be applied to key attributes: " + + "primary partition key, primary sort key, or GSI/LSI partition/sort keys. " + + "Invalid placement on attribute: 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 autogeneratedConflictingAnnotations_throwsException() { + String tableName = getConcreteTableName("conflicting-annotations-test"); + DynamoDbEnhancedClient client = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions( + AutoGeneratedKeyExtension.create(), + 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); + } + } + + + // ========== Transactional Write Operations Tests ========== + @Test + public void transactWrite_generatesKeysForMultipleOperations() { + TestRecord putRecord = new TestRecord(); + putRecord.setPayload("transact-put"); + TestRecord updateRecord = new TestRecord(); + updateRecord.setPayload("transact-update"); + + DynamoDbEnhancedClient client = createClient(); + client.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addPutItem(mappedTable, putRecord) + .addUpdateItem(mappedTable, updateRecord) + .build()); + + List allRecords = mappedTable.scan().items().stream().collect(Collectors.toList()); + assertThat(allRecords).hasSize(2); + allRecords.forEach(this::assertAllKeysGenerated); + } + + @Test + public void transactWrite_preservesExistingKeysAndGeneratesMissing() { + TestRecord record = new TestRecord(); + record.setId("existing-id"); + record.setGsiPk("existing-gsi-pk"); + record.setPayload("transact-preserve"); + + DynamoDbEnhancedClient client = createClient(); + client.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addPutItem(mappedTable, record) + .build()); + + TestRecord retrieved = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(retrieved.getId()).isEqualTo("existing-id"); + assertThat(retrieved.getGsiPk()).isEqualTo("existing-gsi-pk"); + assertValidUuid(retrieved.getSortKey()); + assertValidUuid(retrieved.getGsiSk()); + } + + + // ========== Batch Write Operations Tests ========== + @Test + public void batchWrite_generatesAllMissingKeys() { + TestRecord record1 = new TestRecord(); + record1.setPayload("batch1"); + TestRecord record2 = new TestRecord(); + record2.setPayload("batch2"); + + DynamoDbEnhancedClient client = createClient(); + client.batchWriteItem(r -> r.addWriteBatch( + WriteBatch.builder(TestRecord.class) + .mappedTableResource(mappedTable) + .addPutItem(record1) + .addPutItem(record2) + .build())); + + List allRecords = mappedTable.scan().items().stream().collect(Collectors.toList()); + assertThat(allRecords).hasSize(2); + allRecords.forEach(this::assertAllKeysGenerated); + } + + @Test + public void batchWrite_preservesExistingKeysAndGeneratesMissing() { + TestRecord record = new TestRecord(); + record.setId("existing-id"); + record.setSortKey("existing-sort"); + record.setPayload("batch-preserve"); + + DynamoDbEnhancedClient client = createClient(); + client.batchWriteItem(r -> r.addWriteBatch( + WriteBatch.builder(TestRecord.class) + .mappedTableResource(mappedTable) + .addPutItem(record) + .build())); + + TestRecord retrieved = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(retrieved.getId()).isEqualTo("existing-id"); + assertThat(retrieved.getSortKey()).isEqualTo("existing-sort"); + assertValidUuid(retrieved.getGsiPk()); + assertValidUuid(retrieved.getGsiSk()); + } + + @Test + public void batchWrite_mixedPutDeleteOperations() { + TestRecord existingRecord = new TestRecord(); + existingRecord.setId("to-delete"); + existingRecord.setSortKey("to-delete-sort"); + mappedTable.putItem(existingRecord); + + TestRecord newRecord = new TestRecord(); + newRecord.setPayload("batch-mixed"); + + DynamoDbEnhancedClient client = createClient(); + client.batchWriteItem(r -> r.addWriteBatch( + WriteBatch.builder(TestRecord.class) + .mappedTableResource(mappedTable) + .addPutItem(newRecord) + .addDeleteItem(b -> b.key(k -> k.partitionValue("to-delete").sortValue("to-delete-sort"))) + .build())); + + List allRecords = mappedTable.scan().items().stream().collect(Collectors.toList()); + assertThat(allRecords).hasSize(1); + + TestRecord remaining = allRecords.get(0); + assertAllKeysGenerated(remaining); + assertThat(remaining.getPayload()).isEqualTo("batch-mixed"); + } + + private DynamoDbEnhancedClient createClient() { + return DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedKeyExtension.create()) + .build(); + } + + private void deleteTableByName(String tableName) { + getDynamoDbClient().deleteTable(b -> b.tableName(tableName)); + } + + private void assertAllKeysGenerated(TestRecord record) { + assertValidUuid(record.getId()); + assertValidUuid(record.getSortKey()); + assertValidUuid(record.getGsiPk()); + assertValidUuid(record.getGsiSk()); + } + + @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..71f79bf7851e 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.functionaltests; import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; 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; @@ -23,7 +24,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 +45,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 +57,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 +312,33 @@ public void updateItemConditionTestFailure() { } + @Test + public void putItem_onRecordWithAutogeneratedConflictingAnnotations_throwsException() { + String tableName = getConcreteTableName("conflicting-annotations-record-table"); + DynamoDbEnhancedClient client = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedUuidExtension.create(), AutoGeneratedKeyExtension.create()) + .build(); + + try { + DynamoDbTable table = + client.table(tableName, TableSchema.fromBean(ConflictingAnnotationsRecord.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + ConflictingAnnotationsRecord record = new ConflictingAnnotationsRecord(); + record.setPayload("payload"); + + 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 +501,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 + @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; + } + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedKeyCompositeGsiTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedKeyCompositeGsiTest.java new file mode 100644 index 000000000000..3b872f994ca2 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedKeyCompositeGsiTest.java @@ -0,0 +1,410 @@ +/* + * 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.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.awssdk.enhanced.dynamodb.UuidTestUtils.isValidUuid; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.Order.FIRST; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.Order.SECOND; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedKey; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.LocalDynamoDbSyncTestBase; +import software.amazon.awssdk.enhanced.dynamodb.internal.client.ExtensionResolver; +import software.amazon.awssdk.enhanced.dynamodb.mapper.Order; +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.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; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.WriteBatch; + +public class AutoGeneratedKeyCompositeGsiTest extends LocalDynamoDbSyncTestBase { + + private static final TableSchema TABLE_SCHEMA = + TableSchema.fromClass(BeanWithMixedCompositeGsi.class); + + private final DynamoDbEnhancedClient enhancedClient = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(Stream.concat( + ExtensionResolver.defaultExtensions().stream(), + Stream.of(AutoGeneratedKeyExtension.create())) + .collect(Collectors.toList())) + .build(); + + private final DynamoDbTable mappedTable = + enhancedClient.table(getConcreteTableName("mixed-gsi-autogenerated-key-table"), TABLE_SCHEMA); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(r -> r.tableName( + getConcreteTableName("mixed-gsi-autogenerated-key-table"))); + } + + @Test + public void putItem_whenKeysNotPopulated_generatesNewUuids() { + BeanWithMixedCompositeGsi record = new BeanWithMixedCompositeGsi(); + + mappedTable.putItem(record); + BeanWithMixedCompositeGsi result = mappedTable.scan().items().stream() + .findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertAllKeysAreValidUuids(result); + } + + @Test + public void putItem_whenKeysAlreadyPopulated_preservesExistingUuids() { + BeanWithMixedCompositeGsi record = buildBeanWithCompositeGsiAndKeysPopulated(); + mappedTable.putItem(record); + + BeanWithMixedCompositeGsi result = mappedTable.scan().items().stream() + .findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertCompositeKeyValuesArePreserved(result); + } + + @Test + public void updateItem_respectsUpdateBehavior() { + BeanWithMixedCompositeGsi record = buildBeanWithCompositeGsiAndKeysPopulated(); + + // put item with all keys populated + mappedTable.putItem(record); + BeanWithMixedCompositeGsi afterPut = + mappedTable.scan().items().stream() + .findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + String originalPk = afterPut.getId(); + String originalSk = afterPut.getSort(); + String originalRootPartitionKey1 = afterPut.getRootPartitionKey1(); + String originalRootPartitionKey2 = afterPut.getRootPartitionKey2(); + String originalRootSortKey1 = afterPut.getRootSortKey1(); + String originalRootSortKey2 = afterPut.getRootSortKey2(); + String originalFlattenedPartitionKey1 = afterPut.getFlattenedKeys().flattenedPartitionKey1; + String originalFlattenedPartitionKey2 = afterPut.getFlattenedKeys().flattenedPartitionKey2; + String originalFlattenedSortKey1 = afterPut.getFlattenedKeys().flattenedSortKey1; + String originalFlattenedSortKey2 = afterPut.getFlattenedKeys().flattenedSortKey2; + + // update item + BeanWithMixedCompositeGsi update = new BeanWithMixedCompositeGsi(); + update.setId(afterPut.getId()); + update.setSort(afterPut.getSort()); + + mappedTable.updateItem(update); + BeanWithMixedCompositeGsi afterUpdate = + mappedTable.getItem(r -> r.key(k -> k.partitionValue(afterPut.getId()).sortValue(afterPut.getSort()))); + + // id and sort preserve original values as DynamoDbUpdateBehavior has no effect on primary partition keys or sort keys + assertThat(afterUpdate.getId()).isEqualTo(originalPk); + assertThat(afterUpdate.getSort()).isEqualTo(originalSk); + + // rootPartitionKey1, rootSortKey1, flattenedPartitionKey1, flattenedSortKey1 have WRITE_ALWAYS + // -> regenerates UUID on every update + assertThat(afterUpdate.getRootPartitionKey1()).isNotEqualTo(originalRootPartitionKey1); + assertThat(afterUpdate.getRootSortKey1()).isNotEqualTo(originalRootSortKey1); + assertThat(afterUpdate.getFlattenedKeys().flattenedPartitionKey1).isNotEqualTo(originalFlattenedPartitionKey1); + assertThat(afterUpdate.getFlattenedKeys().flattenedSortKey1).isNotEqualTo(originalFlattenedSortKey1); + + // rootPartitionKey2, rootSortKey2, flattenedPartitionKey2, flattenedSortKey2 have WRITE_IF_NOT_EXISTS + // -> preserves original UUID, only writes if null + assertThat(afterUpdate.getRootPartitionKey2()).isEqualTo(originalRootPartitionKey2); + assertThat(afterUpdate.getRootSortKey2()).isEqualTo(originalRootSortKey2); + assertThat(afterUpdate.getFlattenedKeys().getFlattenedPartitionKey2()).isEqualTo(originalFlattenedPartitionKey2); + assertThat(afterUpdate.getFlattenedKeys().getFlattenedSortKey2()).isEqualTo(originalFlattenedSortKey2); + } + + @Test + public void batchWrite_whenKeysNotAlreadyPopulated_generatesNewUuids() { + BeanWithMixedCompositeGsi firstRecord = new BeanWithMixedCompositeGsi(); + BeanWithMixedCompositeGsi secondRecord = new BeanWithMixedCompositeGsi(); + + enhancedClient.batchWriteItem(req -> req.addWriteBatch( + WriteBatch.builder(BeanWithMixedCompositeGsi.class) + .mappedTableResource(mappedTable) + .addPutItem(firstRecord) + .addPutItem(secondRecord) + .build())); + + List results = mappedTable.scan().items().stream().collect(Collectors.toList()); + + assertThat(results.size()).isEqualTo(2); + assertAllKeysAreValidUuids(results.get(0)); + assertAllKeysAreValidUuids(results.get(1)); + } + + @Test + public void batchWrite_whenKeysAlreadyPopulated_preservesExistingUuids() { + BeanWithMixedCompositeGsi firstRecord = buildBeanWithCompositeGsiAndKeysPopulated(1); + BeanWithMixedCompositeGsi secondRecord = buildBeanWithCompositeGsiAndKeysPopulated(2); + + enhancedClient.batchWriteItem(req -> req.addWriteBatch(WriteBatch.builder(BeanWithMixedCompositeGsi.class) + .mappedTableResource(mappedTable) + .addPutItem(firstRecord) + .addPutItem(secondRecord) + .build())); + + BeanWithMixedCompositeGsi firstSavedRecord = + mappedTable.getItem(r -> r.key(k -> k.partitionValue("existing-id_1").sortValue("existing-sort_1"))); + assertCompositeKeyValuesArePreserved(firstSavedRecord, 1); + + BeanWithMixedCompositeGsi secondSavedRecord = + mappedTable.getItem(r -> r.key(k -> k.partitionValue("existing-id_2").sortValue("existing-sort_2"))); + assertCompositeKeyValuesArePreserved(secondSavedRecord, 2); + } + + @Test + public void transactWrite_whenKeysNotAlreadyPopulated_generatesNewUuids() { + BeanWithMixedCompositeGsi record = new BeanWithMixedCompositeGsi(); + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addPutItem(mappedTable, record) + .build()); + + BeanWithMixedCompositeGsi result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertAllKeysAreValidUuids(result); + } + + @Test + public void transactWrite_whenKeysAlreadyPopulated_preservesExistingUuids() { + BeanWithMixedCompositeGsi record = buildBeanWithCompositeGsiAndKeysPopulated(); + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addPutItem(mappedTable, record) + .build()); + + BeanWithMixedCompositeGsi result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertCompositeKeyValuesArePreserved(result); + } + + private static void assertAllKeysAreValidUuids(BeanWithMixedCompositeGsi record) { + assertThat(isValidUuid(record.getId())).isTrue(); + assertThat(isValidUuid(record.getSort())).isTrue(); + assertThat(isValidUuid(record.getRootPartitionKey1())).isTrue(); + assertThat(isValidUuid(record.getRootPartitionKey2())).isTrue(); + assertThat(isValidUuid(record.getRootSortKey1())).isTrue(); + assertThat(isValidUuid(record.getRootSortKey2())).isTrue(); + assertThat(isValidUuid(record.getFlattenedKeys().flattenedPartitionKey1)).isTrue(); + assertThat(isValidUuid(record.getFlattenedKeys().flattenedPartitionKey2)).isTrue(); + assertThat(isValidUuid(record.getFlattenedKeys().flattenedSortKey1)).isTrue(); + assertThat(isValidUuid(record.getFlattenedKeys().flattenedSortKey2)).isTrue(); + } + + private static void assertCompositeKeyValuesArePreserved(BeanWithMixedCompositeGsi actual) { + assertCompositeKeyValuesArePreserved(actual, null); + } + + private static void assertCompositeKeyValuesArePreserved(BeanWithMixedCompositeGsi actual, Integer recordIndex) { + String suffix = recordIndex == null ? "" : "_" + recordIndex; + + assertThat(actual.getId()).isEqualTo("existing-id" + suffix); + assertThat(actual.getSort()).isEqualTo("existing-sort" + suffix); + assertThat(actual.getRootPartitionKey1()).isEqualTo("existing-rootPk1" + suffix); + assertThat(actual.getRootPartitionKey2()).isEqualTo("existing-rootPk2" + suffix); + assertThat(actual.getRootSortKey1()).isEqualTo("existing-rootSk1" + suffix); + assertThat(actual.getRootSortKey2()).isEqualTo("existing-rootSk2" + suffix); + + assertThat(actual.getFlattenedKeys().flattenedPartitionKey1).isEqualTo("existing-flattenedPk1" + suffix); + assertThat(actual.getFlattenedKeys().flattenedPartitionKey2).isEqualTo("existing-flattenedPk2" + suffix); + assertThat(actual.getFlattenedKeys().flattenedSortKey1).isEqualTo("existing-flattenedSk1" + suffix); + assertThat(actual.getFlattenedKeys().flattenedSortKey2).isEqualTo("existing-flattenedSk2" + suffix); + } + + private static BeanWithMixedCompositeGsi buildBeanWithCompositeGsiAndKeysPopulated() { + return buildBeanWithCompositeGsiAndKeysPopulated(null); + } + + private static BeanWithMixedCompositeGsi buildBeanWithCompositeGsiAndKeysPopulated(Integer index) { + String suffix = index == null ? "" : "_" + index; + + BeanWithMixedCompositeGsi record = new BeanWithMixedCompositeGsi(); + record.setId("existing-id" + suffix); + record.setSort("existing-sort" + suffix); + record.setRootPartitionKey1("existing-rootPk1" + suffix); + record.setRootPartitionKey2("existing-rootPk2" + suffix); + record.setRootSortKey1("existing-rootSk1" + suffix); + record.setRootSortKey2("existing-rootSk2" + suffix); + + BeanWithMixedCompositeGsi.FlattenedKeys flattenedKeys = new BeanWithMixedCompositeGsi.FlattenedKeys(); + flattenedKeys.setFlattenedPartitionKey1("existing-flattenedPk1" + suffix); + flattenedKeys.setFlattenedPartitionKey2("existing-flattenedPk2" + suffix); + flattenedKeys.setFlattenedSortKey1("existing-flattenedSk1" + suffix); + flattenedKeys.setFlattenedSortKey2("existing-flattenedSk2" + suffix); + record.setFlattenedKeys(flattenedKeys); + + return record; + } + + @DynamoDbBean + public static class BeanWithMixedCompositeGsi { + private String id; + private String sort; + private String rootPartitionKey1; + private String rootPartitionKey2; + private String rootSortKey1; + private String rootSortKey2; + private FlattenedKeys flattenedKeys; + + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbSortKey + @DynamoDbAutoGeneratedKey + public String getSort() { + return sort; + } + + public void setSort(String sort) { + this.sort = sort; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + @DynamoDbSecondaryPartitionKey(indexNames = {"mixed_partition_gsi", "full_mixed_gsi", "mixed_sort_gsi"}, order = FIRST) + public String getRootPartitionKey1() { + return rootPartitionKey1; + } + + public void setRootPartitionKey1(String rootPartitionKey1) { + this.rootPartitionKey1 = rootPartitionKey1; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + @DynamoDbSecondaryPartitionKey(indexNames = {"mixed_partition_gsi", "full_mixed_gsi", "mixed_sort_gsi"}, order = SECOND) + public String getRootPartitionKey2() { + return rootPartitionKey2; + } + + public void setRootPartitionKey2(String rootPartitionKey2) { + this.rootPartitionKey2 = rootPartitionKey2; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + @DynamoDbSecondarySortKey(indexNames = {"mixed_sort_gsi", "full_mixed_gsi"}, order = FIRST) + public String getRootSortKey1() { + return rootSortKey1; + } + + public void setRootSortKey1(String rootSortKey1) { + this.rootSortKey1 = rootSortKey1; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + @DynamoDbSecondarySortKey(indexNames = {"mixed_sort_gsi", "full_mixed_gsi"}, order = SECOND) + public String getRootSortKey2() { + return rootSortKey2; + } + + public void setRootSortKey2(String rootSortKey2) { + this.rootSortKey2 = rootSortKey2; + } + + @DynamoDbFlatten + public FlattenedKeys getFlattenedKeys() { + return flattenedKeys; + } + + public void setFlattenedKeys(FlattenedKeys flattenedKeys) { + this.flattenedKeys = flattenedKeys; + } + + @DynamoDbBean + public static class FlattenedKeys { + private String flattenedPartitionKey1; + private String flattenedPartitionKey2; + private String flattenedSortKey1; + private String flattenedSortKey2; + + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + @DynamoDbSecondaryPartitionKey(indexNames = {"mixed_partition_gsi", "full_mixed_gsi"}, order = Order.THIRD) + public String getFlattenedPartitionKey1() { + return flattenedPartitionKey1; + } + + public void setFlattenedPartitionKey1(String flattenedPartitionKey1) { + this.flattenedPartitionKey1 = flattenedPartitionKey1; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + @DynamoDbSecondaryPartitionKey(indexNames = {"mixed_partition_gsi", "full_mixed_gsi"}, order = Order.FOURTH) + public String getFlattenedPartitionKey2() { + return flattenedPartitionKey2; + } + + public void setFlattenedPartitionKey2(String flattenedPartitionKey2) { + this.flattenedPartitionKey2 = flattenedPartitionKey2; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + @DynamoDbSecondarySortKey(indexNames = {"mixed_sort_gsi", "full_mixed_gsi"}, order = Order.THIRD) + public String getFlattenedSortKey1() { + return flattenedSortKey1; + } + + public void setFlattenedSortKey1(String flattenedSortKey1) { + this.flattenedSortKey1 = flattenedSortKey1; + } + + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + @DynamoDbSecondarySortKey(indexNames = {"mixed_sort_gsi", "full_mixed_gsi"}, order = Order.FOURTH) + public String getFlattenedSortKey2() { + return flattenedSortKey2; + } + + public void setFlattenedSortKey2(String flattenedSortKey2) { + this.flattenedSortKey2 = flattenedSortKey2; + } + } + } +} + + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedKeyExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedKeyExtensionTest.java new file mode 100644 index 000000000000..bc9d4cee5d29 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedKeyExtensionTest.java @@ -0,0 +1,593 @@ +/* + * 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.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.UuidTestUtils.isValidUuid; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension; +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.functionaltests.LocalDynamoDbSyncTestBase; +import software.amazon.awssdk.enhanced.dynamodb.internal.client.ExtensionResolver; +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; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.WriteBatch; + +public class AutoGeneratedKeyExtensionTest extends LocalDynamoDbSyncTestBase { + + private static final TableSchema TABLE_SCHEMA = + TableSchema.fromClass(BeanWithAutogeneratedKey.class); + + private final DynamoDbEnhancedClient enhancedClient = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(Stream.concat( + ExtensionResolver.defaultExtensions().stream(), + Stream.of(AutoGeneratedKeyExtension.create())) + .collect(Collectors.toList())) + .build(); + + private final DynamoDbTable mappedTable = + enhancedClient.table(getConcreteTableName("autogenerated-key-table"), TABLE_SCHEMA); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(r -> r.tableName(getConcreteTableName("autogenerated-key-table"))); + } + + @Test + public void putItem_whenKeysNotPopulated_generatesNewUuids() { + BeanWithAutogeneratedKey record = new BeanWithAutogeneratedKey(); + + mappedTable.putItem(record); + BeanWithAutogeneratedKey result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(isValidUuid(result.getId())).isTrue(); + assertThat(isValidUuid(result.getSortKey())).isTrue(); + assertThat(isValidUuid(result.getGsiPk())).isTrue(); + assertThat(isValidUuid(result.getGsiSk())).isTrue(); + } + + @Test + public void putItem_whenKeysAlreadyPopulated_preservesExistingUuids() { + BeanWithAutogeneratedKey record = buildBeanWithAutogeneratedKeyAndKeysPopulated(); + + mappedTable.putItem(record); + BeanWithAutogeneratedKey result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(result.getId()).isEqualTo("existing-id"); + assertThat(result.getSortKey()).isEqualTo("existing-sortKey"); + assertThat(result.getGsiPk()).isEqualTo("existing-gsiPk"); + assertThat(result.getGsiSk()).isEqualTo("existing-gsiSk"); + } + + @Test + public void updateItem_respectsUpdateBehavior() { + // put initial item + BeanWithAutogeneratedKey record = new BeanWithAutogeneratedKey(); + mappedTable.putItem(record); + BeanWithAutogeneratedKey afterPut = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + String originalPk = afterPut.getId(); + String originalSk = afterPut.getSortKey(); + String originalGsiPk = afterPut.getGsiPk(); + String originalGsiSk = afterPut.getGsiSk(); + + + // update item + BeanWithAutogeneratedKey update = new BeanWithAutogeneratedKey(); + update.setId(afterPut.getId()); + update.setSortKey(afterPut.getSortKey()); + + mappedTable.updateItem(update); + BeanWithAutogeneratedKey afterUpdate = + mappedTable.getItem(r -> r.key(k -> k.partitionValue(afterPut.getId()).sortValue(afterPut.getSortKey()))); + + // id and sortKey preserve original values as DynamoDbUpdateBehavior has no effect on primary partition keys or sort keys + assertThat(afterUpdate.getId()).isEqualTo(originalPk); + assertThat(afterUpdate.getSortKey()).isEqualTo(originalSk); + + // gsiPk has WRITE_ALWAYS -> regenerates UUID on every update + assertThat(isValidUuid(afterUpdate.getGsiPk())).isTrue(); + assertThat(afterUpdate.getGsiPk()).isNotEqualTo(originalGsiPk); + + // gsiSk has WRITE_IF_NOT_EXISTS -> preserves original UUID, only writes if null + assertThat(afterUpdate.getGsiSk()).isEqualTo(originalGsiSk); + } + + @Test + public void batchWrite_whenKeysNotAlreadyPopulated_generatesNewUuids() { + BeanWithAutogeneratedKey firstRecord = new BeanWithAutogeneratedKey(); + BeanWithAutogeneratedKey secondRecord = new BeanWithAutogeneratedKey(); + + enhancedClient.batchWriteItem(req -> req.addWriteBatch( + WriteBatch.builder(BeanWithAutogeneratedKey.class) + .mappedTableResource(mappedTable) + .addPutItem(firstRecord) + .addPutItem(secondRecord) + .build())); + List results = mappedTable.scan().items().stream().collect(Collectors.toList()); + + assertThat(results.size()).isEqualTo(2); + assertThat(isValidUuid(results.get(0).getId())).isTrue(); + assertThat(isValidUuid(results.get(1).getId())).isTrue(); + assertThat(isValidUuid(results.get(0).getSortKey())).isTrue(); + assertThat(isValidUuid(results.get(1).getSortKey())).isTrue(); + } + + @Test + public void batchWrite_whenKeysAlreadyPopulated_preservesExistingUuids() { + BeanWithAutogeneratedKey firstRecord = buildBeanWithAutogeneratedKeyAndKeysPopulated(1); + BeanWithAutogeneratedKey secondRecord = buildBeanWithAutogeneratedKeyAndKeysPopulated(2); + + enhancedClient.batchWriteItem(req -> req.addWriteBatch( + WriteBatch.builder(BeanWithAutogeneratedKey.class) + .mappedTableResource(mappedTable) + .addPutItem(firstRecord) + .addPutItem(secondRecord) + .build())); + + BeanWithAutogeneratedKey savedRecord1 = + mappedTable.getItem(r -> r.key(k -> k.partitionValue("existing-id_1").sortValue("existing-sortKey_1"))); + assertThat(savedRecord1.getId()).isEqualTo("existing-id_1"); + assertThat(savedRecord1.getSortKey()).isEqualTo("existing-sortKey_1"); + assertThat(savedRecord1.getGsiPk()).isEqualTo("existing-gsiPk_1"); + assertThat(savedRecord1.getGsiSk()).isEqualTo("existing-gsiSk_1"); + + BeanWithAutogeneratedKey savedRecord2 = + mappedTable.getItem(r -> r.key(k -> k.partitionValue("existing-id_2").sortValue("existing-sortKey_2"))); + assertThat(savedRecord2.getId()).isEqualTo("existing-id_2"); + assertThat(savedRecord2.getSortKey()).isEqualTo("existing-sortKey_2"); + assertThat(savedRecord2.getGsiPk()).isEqualTo("existing-gsiPk_2"); + assertThat(savedRecord2.getGsiSk()).isEqualTo("existing-gsiSk_2"); + } + + @Test + public void transactWrite_whenKeysNotAlreadyPopulated_generatesNewUuids() { + BeanWithAutogeneratedKey record = new BeanWithAutogeneratedKey(); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addPutItem(mappedTable, record) + .build()); + + BeanWithAutogeneratedKey result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(isValidUuid(result.getId())).isTrue(); + assertThat(isValidUuid(result.getSortKey())).isTrue(); + assertThat(isValidUuid(result.getGsiPk())).isTrue(); + assertThat(isValidUuid(result.getGsiSk())).isTrue(); + } + + @Test + public void transactWrite_whenKeysAlreadyPopulated_preservesExistingUuids() { + BeanWithAutogeneratedKey record = buildBeanWithAutogeneratedKeyAndKeysPopulated(); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addPutItem(mappedTable, record) + .build()); + + BeanWithAutogeneratedKey result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(result.getId()).isEqualTo("existing-id"); + assertThat(result.getSortKey()).isEqualTo("existing-sortKey"); + assertThat(result.getGsiPk()).isEqualTo("existing-gsiPk"); + assertThat(result.getGsiSk()).isEqualTo("existing-gsiSk"); + } + + @Test + public void putItem_onVersionedRecord_andKeyNotPopulated_worksWithAutoGeneratedKeyAndGeneratesNewUuid() { + String tableName = "versioned-record-autogenerated-key-table"; + DynamoDbTable mappedTable = + enhancedClient.table(tableName, TableSchema.fromClass(BeanWithAutogeneratedKeyAndVersion.class)); + + try { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + BeanWithAutogeneratedKeyAndVersion record = new BeanWithAutogeneratedKeyAndVersion(); + record.setData("data-v1"); + mappedTable.putItem(record); // id not set, should be auto generated + + BeanWithAutogeneratedKeyAndVersion retrieved = + mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + String generatedUuid = retrieved.getId(); + assertThat(isValidUuid(generatedUuid)).isTrue(); + assertThat(retrieved.getData()).isEqualTo("data-v1"); + assertThat(retrieved.getVersion()).isEqualTo(1L); + + retrieved.setData("data-v2"); + BeanWithAutogeneratedKeyAndVersion updated = mappedTable.updateItem(retrieved); + assertThat(updated.getId()).isEqualTo(generatedUuid); + assertThat(updated.getData()).isEqualTo("data-v2"); + assertThat(updated.getVersion()).isEqualTo(2L); + + } finally { + try { + mappedTable.deleteTable(); + } catch (Exception ignored) { + } + } + } + + @Test + public void putItem_onVersionedRecord_andAlreadyPopulatedKey_worksWithAutoGeneratedKeyAndPreservesInitialValue() { + String tableName = "versioned-record-autogenerated-key-table"; + DynamoDbTable mappedTable = + enhancedClient.table(tableName, TableSchema.fromClass(BeanWithAutogeneratedKeyAndVersion.class)); + + try { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + BeanWithAutogeneratedKeyAndVersion record = new BeanWithAutogeneratedKeyAndVersion(); + record.setId("id"); + record.setData("data-v1"); + mappedTable.putItem(record); + + BeanWithAutogeneratedKeyAndVersion retrieved = + mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(retrieved.getId()).isEqualTo("id"); + assertThat(retrieved.getData()).isEqualTo("data-v1"); + assertThat(retrieved.getVersion()).isEqualTo(1L); + + retrieved.setData("data-v2"); + BeanWithAutogeneratedKeyAndVersion updated = mappedTable.updateItem(retrieved); + assertThat(updated.getId()).isEqualTo("id"); + assertThat(updated.getData()).isEqualTo("data-v2"); + assertThat(updated.getVersion()).isEqualTo(2L); + + } finally { + try { + mappedTable.deleteTable(); + } catch (Exception ignored) { + } + } + } + + @Test + public void putItem_whenAnnotationInConflictWithAutogeneratedUuidAnnotation_throwsException() { + String tableName = "conflicting-annotations-record-autogenerated-key-table"; + DynamoDbTable mappedTable = + enhancedClient.table(tableName, TableSchema.fromClass(BeanWithConflictingAnnotations.class)); + + try { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + assertThatThrownBy(() -> mappedTable.putItem(new BeanWithConflictingAnnotations())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid annotations. " + + "These annotations have conflicting behaviors and cannot be used together on the same attribute."); + } finally { + try { + mappedTable.deleteTable(); + } catch (Exception ignored) { + } + } + } + + @Test + public void putItem_whenAnnotationUsedOnNonKeyAttribute_throwsException() { + String tableName = "annotation-on-non-key-attribute-record-autogenerated-key-table"; + DynamoDbTable mappedTable = + enhancedClient.table(tableName, TableSchema.fromClass(BeanWithAutogeneratedKeyOnNonKeyAttribute.class)); + + try { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + BeanWithAutogeneratedKeyOnNonKeyAttribute bean = new BeanWithAutogeneratedKeyOnNonKeyAttribute(); + bean.setId("id"); + + assertThatThrownBy(() -> mappedTable.putItem(bean)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "@DynamoDbAutoGeneratedKey can only be applied to key attributes: " + + "primary partition key, primary sort key, or GSI/LSI partition/sort keys. " + + "Invalid placement on attribute: nonKeyAttribute"); + } finally { + try { + mappedTable.deleteTable(); + } catch (Exception ignored) { + } + } + } + + @Test + public void putItem_whenNoAutogeneratedKeyAnnotationIsPresent_doesNotRegenerateUuids() { + String tableName = "no-annotation-record-autogenerated-key-table"; + DynamoDbTable mappedTable = + enhancedClient.table(tableName, TableSchema.fromClass(BeanWithoutAutogeneratedKey.class)); + + try { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + BeanWithoutAutogeneratedKey record = new BeanWithoutAutogeneratedKey(); + record.setId("existing-id"); + record.setSortKey("existing-sortKey"); + record.setGsiPk("existing-gsiPk"); + record.setGsiSk("existing-gsiSk"); + record.setData("test"); + + mappedTable.putItem(record); + BeanWithoutAutogeneratedKey retrieved = mappedTable.getItem( + r -> r.key(k -> k.partitionValue("existing-id").sortValue("existing-sortKey"))); + assertThat(retrieved.getId()).isEqualTo("existing-id"); + assertThat(retrieved.getSortKey()).isEqualTo("existing-sortKey"); + assertThat(retrieved.getGsiPk()).isEqualTo("existing-gsiPk"); + assertThat(retrieved.getGsiSk()).isEqualTo("existing-gsiSk"); + assertThat(retrieved.getData()).isEqualTo("test"); + } finally { + try { + mappedTable.deleteTable(); + } catch (Exception ignored) { + } + } + } + + @Test + public void createBean_givenAutogeneratedKeyAnnotationAppliedOnNonStringAttributeType_throwsException() { + assertThatThrownBy(() -> TableSchema.fromBean(BeanWithAutogeneratedKeyOnAttributeWithInvalidType.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Attribute 'id' 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."); + } + + + private static BeanWithAutogeneratedKey buildBeanWithAutogeneratedKeyAndKeysPopulated() { + return buildBeanWithAutogeneratedKeyAndKeysPopulated(null); + } + + private static BeanWithAutogeneratedKey buildBeanWithAutogeneratedKeyAndKeysPopulated(Integer index) { + String suffix = index == null ? "" : "_" + index; + + BeanWithAutogeneratedKey record = new BeanWithAutogeneratedKey(); + record.setId("existing-id" + suffix); + record.setSortKey("existing-sortKey" + suffix); + record.setGsiPk("existing-gsiPk" + suffix); + record.setGsiSk("existing-gsiSk" + suffix); + + return record; + } + + + @DynamoDbBean + public static class BeanWithoutAutogeneratedKey { + private String id; + private String sortKey; + private String gsiPk; + private String gsiSk; + private String data; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbSortKey + public String getSortKey() { + return sortKey; + } + + public void setSortKey(String sortKey) { + this.sortKey = sortKey; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "gsi1") + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getGsiPk() { + return gsiPk; + } + + public void setGsiPk(String gsiPk) { + this.gsiPk = gsiPk; + } + + @DynamoDbSecondarySortKey(indexNames = "gsi1") + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getGsiSk() { + return gsiSk; + } + + public void setGsiSk(String gsiSk) { + this.gsiSk = gsiSk; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + } + + @DynamoDbBean + public static class BeanWithAutogeneratedKey { + private String id; + private String sortKey; + private String gsiPk; + private String gsiSk; + + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbSortKey + @DynamoDbAutoGeneratedKey + public String getSortKey() { + return sortKey; + } + + public void setSortKey(String sortKey) { + this.sortKey = sortKey; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "gsi1") + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getGsiPk() { + return gsiPk; + } + + public void setGsiPk(String gsiPk) { + this.gsiPk = gsiPk; + } + + @DynamoDbSecondarySortKey(indexNames = "gsi1") + @DynamoDbAutoGeneratedKey + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getGsiSk() { + return gsiSk; + } + + public void setGsiSk(String gsiSk) { + this.gsiSk = gsiSk; + } + } + + @DynamoDbBean + public static class BeanWithAutogeneratedKeyAndVersion { + private String id; + private Long version; + private String data; + + @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 getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + } + + @DynamoDbBean + public static class BeanWithAutogeneratedKeyOnAttributeWithInvalidType { + private Integer id; + + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedKey + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + } + + @DynamoDbBean + public static class BeanWithAutogeneratedKeyOnNonKeyAttribute { + private String id; + private String nonKeyAttribute; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbAutoGeneratedKey + public String getNonKeyAttribute() { + return nonKeyAttribute; + } + + public void setNonKeyAttribute(String nonKeyAttribute) { + this.nonKeyAttribute = nonKeyAttribute; + } + } + + @DynamoDbBean + public static class BeanWithConflictingAnnotations { + private String id; + + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedKey + @DynamoDbAutoGeneratedUuid + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + } +} + + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedUuidExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedUuidExtensionTest.java new file mode 100644 index 000000000000..458e31368d30 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/extensions/AutoGeneratedUuidExtensionTest.java @@ -0,0 +1,324 @@ +/* + * 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.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.UuidTestUtils.isValidUuid; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedUuid; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.LocalDynamoDbSyncTestBase; +import software.amazon.awssdk.enhanced.dynamodb.internal.client.ExtensionResolver; +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; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.WriteBatch; + +public class AutoGeneratedUuidExtensionTest extends LocalDynamoDbSyncTestBase { + + private static final TableSchema TABLE_SCHEMA = + TableSchema.fromClass(RecordWithAutogeneratedUuid.class); + + private final DynamoDbEnhancedClient enhancedClient = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(Stream.concat( + ExtensionResolver.defaultExtensions().stream(), + Stream.of(AutoGeneratedUuidExtension.create())) + .collect(Collectors.toList())) + .build(); + + private final DynamoDbTable mappedTable = + enhancedClient.table(getConcreteTableName("autogenerated-uuid-table"), TABLE_SCHEMA); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(r -> r.tableName(getConcreteTableName("autogenerated-uuid-table"))); + } + + @Test + public void putItem_whenKeysNotAlreadyPopulated_generatesNewUuids() { + RecordWithAutogeneratedUuid record = new RecordWithAutogeneratedUuid(); + record.setId("existing-id"); + + mappedTable.putItem(record); + RecordWithAutogeneratedUuid result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(isValidUuid(result.getId())).isTrue(); + assertThat(isValidUuid(result.getSortKey())).isTrue(); + } + + @Test + public void putItem_whenKeysAlreadyPopulated_replacesExistingUuids() { + RecordWithAutogeneratedUuid record = new RecordWithAutogeneratedUuid(); + record.setId("existing-id"); + record.setSortKey("existing-sk"); + + mappedTable.putItem(record); + RecordWithAutogeneratedUuid result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(isValidUuid(result.getId())).isTrue(); + assertThat(isValidUuid(result.getSortKey())).isTrue(); + assertThat(result.getId()).isNotEqualTo("existing-id"); + assertThat(result.getSortKey()).isNotEqualTo("existing-sk"); + } + + @Test + public void batchWrite_whenKeysNotAlreadyPopulated_generatesNewUuids() { + RecordWithAutogeneratedUuid record1 = new RecordWithAutogeneratedUuid(); + RecordWithAutogeneratedUuid record2 = new RecordWithAutogeneratedUuid(); + + enhancedClient.batchWriteItem(req -> req.addWriteBatch( + WriteBatch.builder(RecordWithAutogeneratedUuid.class) + .mappedTableResource(mappedTable) + .addPutItem(record1) + .addPutItem(record2) + .build())); + List results = mappedTable.scan().items().stream().collect(Collectors.toList()); + + assertThat(results.size()).isEqualTo(2); + assertThat(isValidUuid(results.get(0).getId())).isTrue(); + assertThat(isValidUuid(results.get(1).getId())).isTrue(); + assertThat(isValidUuid(results.get(0).getSortKey())).isTrue(); + assertThat(isValidUuid(results.get(1).getSortKey())).isTrue(); + } + + @Test + public void batchWrite_whenKeysAlreadyPopulated_generatesNewUuids() { + RecordWithAutogeneratedUuid record1 = new RecordWithAutogeneratedUuid(); + record1.setId("existing-id-1"); + record1.setSortKey("existing-sk-1"); + + RecordWithAutogeneratedUuid record2 = new RecordWithAutogeneratedUuid(); + record2.setId("existing-id-2"); + record2.setSortKey("existing-sk-2"); + + enhancedClient.batchWriteItem(req -> req.addWriteBatch( + WriteBatch.builder(RecordWithAutogeneratedUuid.class) + .mappedTableResource(mappedTable) + .addPutItem(record1) + .addPutItem(record2) + .build())); + + List results = mappedTable.scan().items().stream().collect(Collectors.toList()); + + assertThat(results.size()).isEqualTo(2); + assertThat(results.size()).isEqualTo(2); + assertThat(isValidUuid(results.get(0).getId())).isTrue(); + assertThat(isValidUuid(results.get(1).getId())).isTrue(); + assertThat(isValidUuid(results.get(0).getSortKey())).isTrue(); + assertThat(isValidUuid(results.get(1).getSortKey())).isTrue(); + + assertThat(results.get(0).getId()).isNotEqualTo("existing-id-1"); + assertThat(results.get(1).getId()).isNotEqualTo("existing-id-2"); + assertThat(results.get(0).getSortKey()).isNotEqualTo("existing-sk-1"); + assertThat(results.get(1).getSortKey()).isNotEqualTo("existing-sk-2"); + } + + @Test + public void transactWrite_whenKeysNotAlreadyPopulated_generatesNewUuids() { + RecordWithAutogeneratedUuid record = new RecordWithAutogeneratedUuid(); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addPutItem(mappedTable, record) + .build()); + RecordWithAutogeneratedUuid result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(isValidUuid(result.getId())).isTrue(); + assertThat(isValidUuid(result.getSortKey())).isTrue(); + } + + @Test + public void transactWrite_whenKeysAlreadyPopulated_generatesNewUuids() { + RecordWithAutogeneratedUuid record = new RecordWithAutogeneratedUuid(); + record.setId("existing-id"); + record.setSortKey("existing-sk"); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addPutItem(mappedTable, record) + .build()); + RecordWithAutogeneratedUuid result = mappedTable.scan().items().stream().findFirst() + .orElseThrow(() -> new AssertionError("No record found")); + + assertThat(isValidUuid(result.getId())).isTrue(); + assertThat(isValidUuid(result.getSortKey())).isTrue(); + assertThat(result.getId()).isNotEqualTo("existing-id"); + assertThat(result.getSortKey()).isNotEqualTo("existing-sk"); + } + + @Test + public void putItem_whenAutogeneratedUuidAnnotationIsNotPresent_doesNotRegenerateUuids() { + String tableName = "no-autogenerated-uuid-table"; + DynamoDbTable mappedTable = + enhancedClient.table(tableName, TableSchema.fromClass(RecordWithoutAutogeneratedUuid.class)); + + try { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + RecordWithoutAutogeneratedUuid record = new RecordWithoutAutogeneratedUuid(); + record.setId("existing-id"); + record.setSortKey("existing-sk"); + record.setGsiPk("existing-gsiPk"); + record.setGsiSk("existing-gsiSk"); + record.setData("data"); + + mappedTable.putItem(record); + RecordWithoutAutogeneratedUuid retrieved = mappedTable.getItem( + r -> r.key(k -> k.partitionValue("existing-id").sortValue("existing-sk"))); + + assertThat(retrieved.getId()).isEqualTo("existing-id"); + assertThat(retrieved.getSortKey()).isEqualTo("existing-sk"); + assertThat(retrieved.getGsiPk()).isEqualTo("existing-gsiPk"); + assertThat(retrieved.getGsiSk()).isEqualTo("existing-gsiSk"); + assertThat(retrieved.getData()).isEqualTo("data"); + } finally { + try { + mappedTable.deleteTable(); + } catch (Exception ignored) { + } + } + } + + @Test + public void createBean_whenAutogeneratedUuidAnnotationIsAppliedOnNonStringAttribute_throwsException() { + assertThatThrownBy(() -> TableSchema.fromBean(AutogeneratedUuidInvalidTypeRecord.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Attribute 'id' 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."); + } + + @DynamoDbBean + public static class RecordWithAutogeneratedUuid { + private String id; + private String sortKey; + + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedUuid + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbSortKey + @DynamoDbAutoGeneratedUuid + public String getSortKey() { + return sortKey; + } + + public void setSortKey(String sortKey) { + this.sortKey = sortKey; + } + } + + @DynamoDbBean + public static class RecordWithoutAutogeneratedUuid { + private String id; + private String sortKey; + private String gsiPk; + private String gsiSk; + private String data; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbSortKey + public String getSortKey() { + return sortKey; + } + + public void setSortKey(String sortKey) { + this.sortKey = sortKey; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "gsi1") + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getGsiPk() { + return gsiPk; + } + + public void setGsiPk(String gsiPk) { + this.gsiPk = gsiPk; + } + + @DynamoDbSecondarySortKey(indexNames = "gsi1") + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getGsiSk() { + return gsiSk; + } + + public void setGsiSk(String gsiSk) { + this.gsiSk = gsiSk; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + } + + @DynamoDbBean + public static class AutogeneratedUuidInvalidTypeRecord { + private Integer id; + + @DynamoDbPartitionKey + @DynamoDbAutoGeneratedUuid + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + } +} \ No newline at end of file